From 42be39b343ec9d0dfb4568d4a43d00ab2005f3bf Mon Sep 17 00:00:00 2001 From: liuyh Date: Thu, 13 Nov 2025 15:24:54 +0800 Subject: [PATCH] =?UTF-8?q?Hair=20Keeper=20v1.0.0=EF=BC=9A=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E9=AB=98=E5=BA=A6=E9=9B=86=E6=88=90=E3=80=81=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E5=AE=9A=E5=88=B6=E3=80=81=E7=BA=A6=E5=AE=9A=E4=BC=98?= =?UTF-8?q?=E4=BA=8E=E9=85=8D=E7=BD=AE=E7=9A=84=E5=85=A8=E6=A0=88Web?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=A8=A1=E6=9D=BF=EF=BC=8C=E6=97=A8=E5=9C=A8?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E7=81=B5=E6=B4=BB=E6=80=A7=E7=9A=84=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E6=8F=90=E4=BE=9B=E4=B8=80=E5=A5=97=E5=9F=BA=E4=BA=8E?= =?UTF-8?q?=E6=88=90=E7=86=9F=E6=9E=B6=E6=9E=84=E7=9A=84=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E5=BA=95=E5=BA=A7=EF=BC=8C=E8=87=AA=E5=B8=A6=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E3=80=81=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E3=80=81=E4=B8=B0=E5=AF=8C=E5=89=8D=E7=AB=AF=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E3=80=81=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E3=80=81=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=BB=BB=E5=8A=A1=E3=80=81=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E7=AD=89=E4=B8=B0=E5=AF=8C=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E4=BE=9BAI=E5=BC=80=E5=8F=91=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=EF=BC=8C=E5=85=8D=E4=BA=8E=E7=BA=A0=E7=BB=93=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=A6=82=E4=BD=95=E5=AE=9E=E7=8E=B0=EF=BC=8C=E5=8F=AF?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E4=B8=8A=E6=89=8B=E4=B8=93=E6=B3=A8=E4=BA=8E?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cloud-dev/.dockerignore | 55 + .cloud-dev/Dockerfile | 141 ++ .cloud-dev/README.md | 335 +++++ .cloud-dev/docker-compose.yml | 42 + .cloud-dev/entrypoint.sh | 46 + .env.example | 61 + .gitignore | 53 + .roo/mcp.json | 12 + README.md | 87 ++ components.json | 83 ++ docker-compose.yml | 43 + eslint.config.mjs | 35 + instrumentation.ts | 17 + next.config.ts | 18 + package.json | 118 ++ postcss.config.mjs | 5 + prisma/init_data/院系.json | 1102 +++++++++++++++ .../20251113071821_init/migration.sql | 223 +++ .../migration.sql | 9 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 243 ++++ prisma/seed.ts | 180 +++ public/js/browser-image-compression.js | 9 + public/pku_icon.png | Bin 0 -> 25869 bytes public/pku_logo.png | Bin 0 -> 22464 bytes public/pku_logo2.png | Bin 0 -> 15316 bytes src/app/(auth)/login/page.tsx | 122 ++ src/app/(main)/[...notFound]/page.tsx | 5 + src/app/(main)/dev/arch/layout.dev.tsx | 9 + .../components/PackageAnalyzeDialog.tsx | 189 +++ .../package/components/PackageDetailSheet.tsx | 209 +++ src/app/(main)/dev/arch/package/page.dev.tsx | 297 ++++ src/app/(main)/dev/arch/page.dev.tsx | 5 + src/app/(main)/dev/dev-theme.css | 68 + src/app/(main)/dev/file/columns.tsx | 387 ++++++ .../dev/file/components/FileAnalyzeDialog.tsx | 192 +++ .../file/components/FileDependencyGraph.tsx | 210 +++ .../dev/file/components/FileDetailPanel.tsx | 432 ++++++ .../dev/file/components/FileDetailSheet.tsx | 66 + .../file/components/GitCommitViewDialog.tsx | 129 ++ .../components/FolderAnalyzeDialog.tsx | 180 +++ .../components/FolderDetailPanel.tsx | 149 +++ .../components/SearchDirectoryTree.tsx | 312 +++++ .../dev/file/directory-tree/page.dev.tsx | 169 +++ src/app/(main)/dev/file/graph/page.dev.tsx | 61 + src/app/(main)/dev/file/layout.dev.tsx | 9 + src/app/(main)/dev/file/list/page.dev.tsx | 289 ++++ src/app/(main)/dev/file/page.dev.tsx | 5 + .../(main)/dev/frontend-design/layout.dev.tsx | 9 + .../(main)/dev/frontend-design/page.dev.tsx | 5 + .../dev/frontend-design/page/page.dev.tsx | 19 + .../ui/components/AddComponentSheet.tsx | 372 ++++++ .../ui/components/ComponentDetailDialog.tsx | 174 +++ .../dev/frontend-design/ui/page.dev.tsx | 411 ++++++ src/app/(main)/dev/layout.dev.tsx | 15 + src/app/(main)/dev/panel/agents-config.tsx | 88 ++ .../dev/panel/components/version-control.tsx | 584 ++++++++ src/app/(main)/dev/panel/dev-ai-chat.tsx | 407 ++++++ src/app/(main)/dev/panel/dev-checklist.tsx | 30 + .../(main)/dev/panel/dev-panel-provider.tsx | 59 + src/app/(main)/dev/panel/dev-panel.tsx | 47 + src/app/(main)/dev/panel/dev-tools.tsx | 252 ++++ src/app/(main)/dev/panel/index.ts | 5 + src/app/(main)/dev/run/container/page.dev.tsx | 19 + src/app/(main)/dev/run/layout.dev.tsx | 9 + src/app/(main)/dev/run/page.dev.tsx | 5 + src/app/(main)/error/403/page.tsx | 26 + src/app/(main)/layout.tsx | 32 + src/app/(main)/not-found.tsx | 26 + src/app/(main)/page.tsx | 375 ++++++ src/app/(main)/settings/page.tsx | 35 + src/app/(main)/users/columns.tsx | 260 ++++ .../components/BatchAuthorizationDialog.tsx | 199 +++ .../users/components/RoleManagementDialog.tsx | 397 ++++++ .../users/components/UserCreateDialog.tsx | 214 +++ .../users/components/UserDeleteDialog.tsx | 87 ++ .../users/components/UserUpdateDialog.tsx | 229 ++++ src/app/(main)/users/page.tsx | 150 +++ src/app/(main)/welcome.tsx | 43 + src/app/api/auth/[...nextauth]/route.ts | 6 + src/app/api/dev/ai-chat/route.dev.ts | 165 +++ src/app/api/trpc/[trpc]/route.ts | 13 + src/app/favicon.ico | Bin 0 -> 33326 bytes src/app/globals.css | 176 +++ src/app/layout.tsx | 51 + src/components/ai-elements/actions.tsx | 65 + src/components/ai-elements/conversation.tsx | 97 ++ src/components/ai-elements/message.tsx | 451 +++++++ src/components/ai-elements/model-selector.tsx | 205 +++ src/components/ai-elements/prompt-input.tsx | 1190 +++++++++++++++++ src/components/ai-elements/reasoning.tsx | 178 +++ src/components/ai-elements/response.tsx | 22 + src/components/ai-elements/shimmer.tsx | 64 + .../common/advanced-select-provider.tsx | 215 +++ src/components/common/advanced-select.tsx | 399 ++++++ src/components/common/card-select.tsx | 271 ++++ src/components/common/checkbox-group.tsx | 67 + src/components/common/date-picker.tsx | 121 ++ src/components/common/date-range-picker.tsx | 118 ++ src/components/common/file-preview.tsx | 968 ++++++++++++++ .../common/file-upload-provider.tsx | 508 +++++++ src/components/common/file-upload.tsx | 335 +++++ src/components/common/form-dialog.tsx | 316 +++++ .../common/multi-step-form-dialog.tsx | 326 +++++ src/components/common/number-range-input.tsx | 88 ++ src/components/common/preview-card.tsx | 68 + src/components/common/responsive-tabs.tsx | 254 ++++ src/components/common/search-input.tsx | 61 + src/components/common/stats-card-group.tsx | 166 +++ src/components/common/task-dialog.tsx | 322 +++++ src/components/common/theme-toggle-button.tsx | 221 +++ .../common/triple-column-adaptive-drawer.tsx | 93 ++ .../data-details/detail-badge-list.tsx | 91 ++ .../data-details/detail-code-block.tsx | 184 +++ .../data-details/detail-copyable.tsx | 93 ++ .../data-details/detail-field-group.tsx | 32 + src/components/data-details/detail-field.tsx | 91 ++ src/components/data-details/detail-header.tsx | 47 + src/components/data-details/detail-list.tsx | 105 ++ .../data-details/detail-section.tsx | 90 ++ src/components/data-details/detail-sheet.tsx | 65 + .../data-details/detail-timeline.tsx | 394 ++++++ src/components/data-details/index.ts | 67 + src/components/data-table/action-bar.tsx | 178 +++ src/components/data-table/column-header.tsx | 99 ++ src/components/data-table/data-table.tsx | 143 ++ .../data-table/filters/date-filter.tsx | 245 ++++ .../data-table/filters/faceted-filter.tsx | 249 ++++ .../data-table/filters/range-filter.tsx | 122 ++ .../data-table/filters/slider-filter.tsx | 251 ++++ src/components/data-table/pagination.tsx | 128 ++ src/components/data-table/sort-list.tsx | 408 ++++++ src/components/data-table/table-skeleton.tsx | 118 ++ src/components/data-table/toolbar.tsx | 165 +++ src/components/data-table/view-options.tsx | 84 ++ src/components/features/adaptive-graph.tsx | 499 +++++++ .../features/code-editor-preview.tsx | 301 +++++ src/components/icons/code-lang.tsx | 187 +++ src/components/icons/pku.tsx | 169 +++ src/components/layout/app-sidebar.tsx | 157 +++ src/components/layout/breadcrumb-nav.tsx | 89 ++ src/components/layout/carousel-layout.tsx | 176 +++ .../layout/change-password-dialog.tsx | 99 ++ src/components/layout/header.tsx | 134 ++ src/components/layout/main-layout.tsx | 32 + src/components/layout/sub-menu-layout.tsx | 69 + src/components/layout/sub-menu-redirect.tsx | 28 + src/components/layout/user-profile-dialog.tsx | 246 ++++ src/components/providers/session-provider.tsx | 7 + src/components/providers/theme-provider.tsx | 17 + src/components/providers/trpc-provider.tsx | 45 + src/components/ui/alert-dialog.tsx | 157 +++ src/components/ui/alert.tsx | 66 + src/components/ui/avatar.tsx | 53 + src/components/ui/badge.tsx | 248 ++++ src/components/ui/breadcrumb.tsx | 109 ++ src/components/ui/button-group.tsx | 83 ++ src/components/ui/button.tsx | 470 +++++++ src/components/ui/calendar.tsx | 213 +++ src/components/ui/card.tsx | 92 ++ src/components/ui/carousel.tsx | 241 ++++ src/components/ui/checkbox.tsx | 47 + src/components/ui/collapsible.tsx | 33 + src/components/ui/command.tsx | 184 +++ src/components/ui/dialog.tsx | 139 ++ src/components/ui/drawer.tsx | 135 ++ src/components/ui/dropdown-menu.tsx | 257 ++++ src/components/ui/faceted.tsx | 286 ++++ src/components/ui/form.tsx | 167 +++ src/components/ui/input-group.tsx | 170 +++ src/components/ui/input.tsx | 21 + src/components/ui/kbd.tsx | 28 + src/components/ui/label.tsx | 24 + src/components/ui/popover.tsx | 48 + src/components/ui/progress.tsx | 31 + src/components/ui/radio-group.tsx | 45 + src/components/ui/scroll-area.tsx | 53 + src/components/ui/select.tsx | 185 +++ src/components/ui/separator.tsx | 28 + src/components/ui/sheet.tsx | 139 ++ src/components/ui/sidebar.tsx | 726 ++++++++++ src/components/ui/skeleton.tsx | 13 + src/components/ui/slider.tsx | 63 + src/components/ui/sortable.tsx | 587 ++++++++ src/components/ui/svg-text.tsx | 161 +++ src/components/ui/table.tsx | 116 ++ src/components/ui/tabs.tsx | 196 +++ src/components/ui/textarea.tsx | 18 + src/components/ui/tooltip.tsx | 94 ++ src/components/ui/tree.tsx | 160 +++ src/constants/data-table.ts | 38 + src/constants/menu-icons.ts | 75 ++ src/constants/menu.ts | 326 +++++ src/constants/permissions.ts | 58 + src/constants/site.ts | 6 + src/hooks/use-callback-ref.ts | 29 + src/hooks/use-composed-refs.ts | 64 + src/hooks/use-data-table.ts | 252 ++++ src/hooks/use-debounced-callback.ts | 31 + src/hooks/use-minio-upload.ts | 621 +++++++++ src/hooks/use-mobile.ts | 24 + src/hooks/use-select.ts | 250 ++++ src/hooks/use-smart-select-options.ts | 67 + src/lib/algorithm.ts | 197 +++ src/lib/format.ts | 88 ++ src/lib/schema/data-table.ts | 140 ++ src/lib/schema/user.ts | 51 + src/lib/trpc.ts | 9 + src/lib/utils.ts | 147 ++ src/middleware.ts | 73 + src/server/agents/code-analyzer.ts | 319 +++++ src/server/agents/folder-analyzer.ts | 110 ++ src/server/agents/package-analyzer.ts | 135 ++ src/server/agents/ui-demo-generator.ts | 118 ++ src/server/auth.ts | 123 ++ src/server/cron.ts | 35 + src/server/db.ts | 15 + src/server/minio.ts | 351 +++++ src/server/queues/analyze-files.queue.ts | 51 + src/server/queues/analyze-files.worker.ts | 336 +++++ src/server/queues/analyze-folders.queue.ts | 51 + src/server/queues/analyze-folders.worker.ts | 273 ++++ src/server/queues/analyze-packages.queue.ts | 51 + src/server/queues/analyze-packages.worker.ts | 322 +++++ src/server/queues/index.ts | 16 + src/server/redis.ts | 28 + src/server/routers/_app.ts | 30 + src/server/routers/common.ts | 8 + src/server/routers/dev/arch.ts | 68 + src/server/routers/dev/file.ts | 602 +++++++++ src/server/routers/dev/frontend-design.ts | 221 +++ src/server/routers/dev/panel.ts | 229 ++++ src/server/routers/global.ts | 37 + src/server/routers/jobs.ts | 205 +++ src/server/routers/selection.ts | 60 + src/server/routers/upload.ts | 255 ++++ src/server/routers/users.ts | 380 ++++++ src/server/service/dev/terminal.ts | 175 +++ src/server/trpc.ts | 63 + src/server/utils/ast-helper.ts | 292 ++++ src/server/utils/data-table-helper.ts | 168 +++ src/server/utils/git-helper.ts | 634 +++++++++ src/server/utils/node-helper.ts | 141 ++ src/server/utils/request-helper.ts | 54 + src/types/data-table.ts | 59 + src/types/next-auth.d.ts | 17 + src/types/user.ts | 14 + tsconfig.json | 27 + update-env-example.sh | 37 + 249 files changed, 38843 insertions(+) create mode 100644 .cloud-dev/.dockerignore create mode 100644 .cloud-dev/Dockerfile create mode 100644 .cloud-dev/README.md create mode 100644 .cloud-dev/docker-compose.yml create mode 100644 .cloud-dev/entrypoint.sh create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .roo/mcp.json create mode 100644 README.md create mode 100644 components.json create mode 100644 docker-compose.yml create mode 100644 eslint.config.mjs create mode 100644 instrumentation.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 prisma/init_data/院系.json create mode 100644 prisma/migrations/20251113071821_init/migration.sql create mode 100644 prisma/migrations/20251114032620_add_kv_config/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 public/js/browser-image-compression.js create mode 100644 public/pku_icon.png create mode 100644 public/pku_logo.png create mode 100644 public/pku_logo2.png create mode 100644 src/app/(auth)/login/page.tsx create mode 100644 src/app/(main)/[...notFound]/page.tsx create mode 100644 src/app/(main)/dev/arch/layout.dev.tsx create mode 100644 src/app/(main)/dev/arch/package/components/PackageAnalyzeDialog.tsx create mode 100644 src/app/(main)/dev/arch/package/components/PackageDetailSheet.tsx create mode 100644 src/app/(main)/dev/arch/package/page.dev.tsx create mode 100644 src/app/(main)/dev/arch/page.dev.tsx create mode 100644 src/app/(main)/dev/dev-theme.css create mode 100644 src/app/(main)/dev/file/columns.tsx create mode 100644 src/app/(main)/dev/file/components/FileAnalyzeDialog.tsx create mode 100644 src/app/(main)/dev/file/components/FileDependencyGraph.tsx create mode 100644 src/app/(main)/dev/file/components/FileDetailPanel.tsx create mode 100644 src/app/(main)/dev/file/components/FileDetailSheet.tsx create mode 100644 src/app/(main)/dev/file/components/GitCommitViewDialog.tsx create mode 100644 src/app/(main)/dev/file/directory-tree/components/FolderAnalyzeDialog.tsx create mode 100644 src/app/(main)/dev/file/directory-tree/components/FolderDetailPanel.tsx create mode 100644 src/app/(main)/dev/file/directory-tree/components/SearchDirectoryTree.tsx create mode 100644 src/app/(main)/dev/file/directory-tree/page.dev.tsx create mode 100644 src/app/(main)/dev/file/graph/page.dev.tsx create mode 100644 src/app/(main)/dev/file/layout.dev.tsx create mode 100644 src/app/(main)/dev/file/list/page.dev.tsx create mode 100644 src/app/(main)/dev/file/page.dev.tsx create mode 100644 src/app/(main)/dev/frontend-design/layout.dev.tsx create mode 100644 src/app/(main)/dev/frontend-design/page.dev.tsx create mode 100644 src/app/(main)/dev/frontend-design/page/page.dev.tsx create mode 100644 src/app/(main)/dev/frontend-design/ui/components/AddComponentSheet.tsx create mode 100644 src/app/(main)/dev/frontend-design/ui/components/ComponentDetailDialog.tsx create mode 100644 src/app/(main)/dev/frontend-design/ui/page.dev.tsx create mode 100644 src/app/(main)/dev/layout.dev.tsx create mode 100644 src/app/(main)/dev/panel/agents-config.tsx create mode 100644 src/app/(main)/dev/panel/components/version-control.tsx create mode 100644 src/app/(main)/dev/panel/dev-ai-chat.tsx create mode 100644 src/app/(main)/dev/panel/dev-checklist.tsx create mode 100644 src/app/(main)/dev/panel/dev-panel-provider.tsx create mode 100644 src/app/(main)/dev/panel/dev-panel.tsx create mode 100644 src/app/(main)/dev/panel/dev-tools.tsx create mode 100644 src/app/(main)/dev/panel/index.ts create mode 100644 src/app/(main)/dev/run/container/page.dev.tsx create mode 100644 src/app/(main)/dev/run/layout.dev.tsx create mode 100644 src/app/(main)/dev/run/page.dev.tsx create mode 100644 src/app/(main)/error/403/page.tsx create mode 100644 src/app/(main)/layout.tsx create mode 100644 src/app/(main)/not-found.tsx create mode 100644 src/app/(main)/page.tsx create mode 100644 src/app/(main)/settings/page.tsx create mode 100644 src/app/(main)/users/columns.tsx create mode 100644 src/app/(main)/users/components/BatchAuthorizationDialog.tsx create mode 100644 src/app/(main)/users/components/RoleManagementDialog.tsx create mode 100644 src/app/(main)/users/components/UserCreateDialog.tsx create mode 100644 src/app/(main)/users/components/UserDeleteDialog.tsx create mode 100644 src/app/(main)/users/components/UserUpdateDialog.tsx create mode 100644 src/app/(main)/users/page.tsx create mode 100644 src/app/(main)/welcome.tsx create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/dev/ai-chat/route.dev.ts create mode 100644 src/app/api/trpc/[trpc]/route.ts create mode 100644 src/app/favicon.ico create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/components/ai-elements/actions.tsx create mode 100644 src/components/ai-elements/conversation.tsx create mode 100644 src/components/ai-elements/message.tsx create mode 100644 src/components/ai-elements/model-selector.tsx create mode 100644 src/components/ai-elements/prompt-input.tsx create mode 100644 src/components/ai-elements/reasoning.tsx create mode 100644 src/components/ai-elements/response.tsx create mode 100644 src/components/ai-elements/shimmer.tsx create mode 100644 src/components/common/advanced-select-provider.tsx create mode 100644 src/components/common/advanced-select.tsx create mode 100644 src/components/common/card-select.tsx create mode 100644 src/components/common/checkbox-group.tsx create mode 100644 src/components/common/date-picker.tsx create mode 100644 src/components/common/date-range-picker.tsx create mode 100644 src/components/common/file-preview.tsx create mode 100644 src/components/common/file-upload-provider.tsx create mode 100644 src/components/common/file-upload.tsx create mode 100644 src/components/common/form-dialog.tsx create mode 100644 src/components/common/multi-step-form-dialog.tsx create mode 100644 src/components/common/number-range-input.tsx create mode 100644 src/components/common/preview-card.tsx create mode 100644 src/components/common/responsive-tabs.tsx create mode 100644 src/components/common/search-input.tsx create mode 100644 src/components/common/stats-card-group.tsx create mode 100644 src/components/common/task-dialog.tsx create mode 100644 src/components/common/theme-toggle-button.tsx create mode 100644 src/components/common/triple-column-adaptive-drawer.tsx create mode 100644 src/components/data-details/detail-badge-list.tsx create mode 100644 src/components/data-details/detail-code-block.tsx create mode 100644 src/components/data-details/detail-copyable.tsx create mode 100644 src/components/data-details/detail-field-group.tsx create mode 100644 src/components/data-details/detail-field.tsx create mode 100644 src/components/data-details/detail-header.tsx create mode 100644 src/components/data-details/detail-list.tsx create mode 100644 src/components/data-details/detail-section.tsx create mode 100644 src/components/data-details/detail-sheet.tsx create mode 100644 src/components/data-details/detail-timeline.tsx create mode 100644 src/components/data-details/index.ts create mode 100644 src/components/data-table/action-bar.tsx create mode 100644 src/components/data-table/column-header.tsx create mode 100644 src/components/data-table/data-table.tsx create mode 100644 src/components/data-table/filters/date-filter.tsx create mode 100644 src/components/data-table/filters/faceted-filter.tsx create mode 100644 src/components/data-table/filters/range-filter.tsx create mode 100644 src/components/data-table/filters/slider-filter.tsx create mode 100644 src/components/data-table/pagination.tsx create mode 100644 src/components/data-table/sort-list.tsx create mode 100644 src/components/data-table/table-skeleton.tsx create mode 100644 src/components/data-table/toolbar.tsx create mode 100644 src/components/data-table/view-options.tsx create mode 100644 src/components/features/adaptive-graph.tsx create mode 100644 src/components/features/code-editor-preview.tsx create mode 100644 src/components/icons/code-lang.tsx create mode 100644 src/components/icons/pku.tsx create mode 100644 src/components/layout/app-sidebar.tsx create mode 100644 src/components/layout/breadcrumb-nav.tsx create mode 100644 src/components/layout/carousel-layout.tsx create mode 100644 src/components/layout/change-password-dialog.tsx create mode 100644 src/components/layout/header.tsx create mode 100644 src/components/layout/main-layout.tsx create mode 100644 src/components/layout/sub-menu-layout.tsx create mode 100644 src/components/layout/sub-menu-redirect.tsx create mode 100644 src/components/layout/user-profile-dialog.tsx create mode 100644 src/components/providers/session-provider.tsx create mode 100644 src/components/providers/theme-provider.tsx create mode 100644 src/components/providers/trpc-provider.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/ui/button-group.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/carousel.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/faceted.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/input-group.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/kbd.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/radio-group.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/sortable.tsx create mode 100644 src/components/ui/svg-text.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/components/ui/tree.tsx create mode 100644 src/constants/data-table.ts create mode 100644 src/constants/menu-icons.ts create mode 100644 src/constants/menu.ts create mode 100644 src/constants/permissions.ts create mode 100644 src/constants/site.ts create mode 100644 src/hooks/use-callback-ref.ts create mode 100644 src/hooks/use-composed-refs.ts create mode 100644 src/hooks/use-data-table.ts create mode 100644 src/hooks/use-debounced-callback.ts create mode 100644 src/hooks/use-minio-upload.ts create mode 100644 src/hooks/use-mobile.ts create mode 100644 src/hooks/use-select.ts create mode 100644 src/hooks/use-smart-select-options.ts create mode 100644 src/lib/algorithm.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/schema/data-table.ts create mode 100644 src/lib/schema/user.ts create mode 100644 src/lib/trpc.ts create mode 100644 src/lib/utils.ts create mode 100644 src/middleware.ts create mode 100644 src/server/agents/code-analyzer.ts create mode 100644 src/server/agents/folder-analyzer.ts create mode 100644 src/server/agents/package-analyzer.ts create mode 100644 src/server/agents/ui-demo-generator.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/cron.ts create mode 100644 src/server/db.ts create mode 100644 src/server/minio.ts create mode 100644 src/server/queues/analyze-files.queue.ts create mode 100644 src/server/queues/analyze-files.worker.ts create mode 100644 src/server/queues/analyze-folders.queue.ts create mode 100644 src/server/queues/analyze-folders.worker.ts create mode 100644 src/server/queues/analyze-packages.queue.ts create mode 100644 src/server/queues/analyze-packages.worker.ts create mode 100644 src/server/queues/index.ts create mode 100644 src/server/redis.ts create mode 100644 src/server/routers/_app.ts create mode 100644 src/server/routers/common.ts create mode 100644 src/server/routers/dev/arch.ts create mode 100644 src/server/routers/dev/file.ts create mode 100644 src/server/routers/dev/frontend-design.ts create mode 100644 src/server/routers/dev/panel.ts create mode 100644 src/server/routers/global.ts create mode 100644 src/server/routers/jobs.ts create mode 100644 src/server/routers/selection.ts create mode 100644 src/server/routers/upload.ts create mode 100644 src/server/routers/users.ts create mode 100644 src/server/service/dev/terminal.ts create mode 100644 src/server/trpc.ts create mode 100644 src/server/utils/ast-helper.ts create mode 100644 src/server/utils/data-table-helper.ts create mode 100644 src/server/utils/git-helper.ts create mode 100644 src/server/utils/node-helper.ts create mode 100644 src/server/utils/request-helper.ts create mode 100644 src/types/data-table.ts create mode 100644 src/types/next-auth.d.ts create mode 100644 src/types/user.ts create mode 100644 tsconfig.json create mode 100644 update-env-example.sh diff --git a/.cloud-dev/.dockerignore b/.cloud-dev/.dockerignore new file mode 100644 index 0000000..30f8843 --- /dev/null +++ b/.cloud-dev/.dockerignore @@ -0,0 +1,55 @@ +# 依赖目录 +node_modules +.pnpm-store + +# 构建输出 +.next +dist +build +out + +# 日志文件 +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# 环境变量文件 +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# 编辑器和 IDE +.vscode +.idea +*.swp +*.swo +*~ + +# 操作系统文件 +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# 测试覆盖率 +coverage + +# 临时文件 +tmp +temp +*.tmp + +# Docker 相关 +Dockerfile +docker-compose.yml +.dockerignore + +# 其他 +.cache +.turbo \ No newline at end of file diff --git a/.cloud-dev/Dockerfile b/.cloud-dev/Dockerfile new file mode 100644 index 0000000..ccb810d --- /dev/null +++ b/.cloud-dev/Dockerfile @@ -0,0 +1,141 @@ +FROM ubuntu:22.04 + +# 设置环境变量 +ENV DEBIAN_FRONTEND=noninteractive \ + NODE_VERSION=22.14.0 \ + PYTHON_VERSION=3.12 \ + CODE_SERVER_VERSION=4.96.2 \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + TZ=Asia/Shanghai \ + DEV_PASSWORD=clouddev + +# 安装基础工具和依赖 +RUN apt-get update && apt-get install -y \ + curl \ + wget \ + git \ + openssh-server \ + tmux \ + tree \ + pwgen \ + zip \ + unzip \ + net-tools \ + fontconfig \ + ffmpeg \ + ca-certificates \ + gnupg \ + lsb-release \ + build-essential \ + libssl-dev \ + zlib1g-dev \ + libbz2-dev \ + libreadline-dev \ + libsqlite3-dev \ + libncursesw5-dev \ + xz-utils \ + tk-dev \ + libxml2-dev \ + libxmlsec1-dev \ + libffi-dev \ + liblzma-dev \ + ttyd \ + cmake \ + telnet \ + redis-tools \ + potrace \ + imagemagick \ + zsh \ + && rm -rf /var/lib/apt/lists/* + +# 安装 Python 3.12 +RUN apt-get update && apt-get install -y software-properties-common && \ + add-apt-repository ppa:deadsnakes/ppa && \ + apt-get update && \ + apt-get install -y python3.12 python3.12-venv python3.12-dev python3-pip && \ + update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 && \ + update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 && \ + rm -rf /var/lib/apt/lists/* + +# 安装 Node.js 22.14 +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* + +# 安装 pnpm +RUN npm install -g pnpm + +# 安装 uv (Python package manager) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +# 安装 oh-my-zsh +RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended + +# 设置 zsh 为默认 shell +RUN chsh -s $(which zsh) + +# 配置 zsh +RUN echo 'export ZSH="$HOME/.oh-my-zsh"' > /root/.zshrc && \ + echo 'ZSH_THEME="robbyrussell"' >> /root/.zshrc && \ + echo 'plugins=(git node npm docker python)' >> /root/.zshrc && \ + echo 'source $ZSH/oh-my-zsh.sh' >> /root/.zshrc && \ + echo '' >> /root/.zshrc && \ + echo '# 添加 uv 到 PATH' >> /root/.zshrc && \ + echo 'export PATH="$HOME/.local/bin:$PATH"' >> /root/.zshrc + +# 安装 MinIO Client (mc) +RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc && \ + chmod +x /usr/local/bin/mc + +# 安装 code-server +RUN curl -fsSL https://code-server.dev/install.sh | sh -s -- --version=${CODE_SERVER_VERSION} + +# 安装 npm 全局包 +RUN npm install -g \ + @anthropic-ai/claude-code \ + @musistudio/claude-code-router + +# 创建工作目录 +RUN mkdir -p /workspace /root/.local/share/code-server/User + +# 配置 SSH +RUN mkdir -p /var/run/sshd && \ + sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \ + sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config + +# 安装 code-server 插件 +RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \ + && code-server --install-extension bierner.markdown-mermaid \ + && code-server --install-extension ms-python.python \ + && code-server --install-extension rooveterinaryinc.roo-cline \ + && code-server --install-extension dbaeumer.vscode-eslint \ + && code-server --install-extension prisma.prisma \ + && code-server --install-extension ecmel.vscode-html-css \ + && code-server --install-extension cweijan.vscode-redis-client + +# 配置 code-server (密码将在启动时设置) +RUN mkdir -p /root/.config/code-server && \ + echo 'bind-addr: 0.0.0.0:8080' > /root/.config/code-server/config.yaml && \ + echo 'auth: password' >> /root/.config/code-server/config.yaml && \ + echo 'cert: false' >> /root/.config/code-server/config.yaml + +# 配置 tmux +RUN echo 'set -g mouse on' > /root/.tmux.conf && \ + echo 'set -g history-limit 10000' >> /root/.tmux.conf && \ + echo 'set -g default-terminal "screen-256color"' >> /root/.tmux.conf + +# 复制启动脚本 +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# 暴露端口 +# 22: SSH +# 8080: code-server +# 7681: ttyd +# 3000: Next.js dev server +EXPOSE 22 8080 7681 3000 + +WORKDIR /workspace + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/.cloud-dev/README.md b/.cloud-dev/README.md new file mode 100644 index 0000000..e4d0012 --- /dev/null +++ b/.cloud-dev/README.md @@ -0,0 +1,335 @@ +# 云开发容器 + +本目录包含 Hair Keeper 项目的云开发容器配置,提供完整的开发环境,无需在本地安装任何开发工具。 + +## 📦 容器内容 + +### 开发环境 +- **Node.js**: 22.14.0 +- **pnpm**: 最新版本 +- **Python**: 3.12 +- **Code Server**: 4.96.2 (基于 VS Code 的 Web IDE) +- **Git**: 版本控制工具 + +### Shell 环境 +- **zsh**: 默认 Shell +- **oh-my-zsh**: zsh 配置框架 +- **tmux**: 终端复用器 + +### 命令行工具 +- **ttyd**: Web 终端(可选启动) +- **tree**: 目录树显示 +- **pwgen**: 密码生成器 +- **curl/wget**: 下载工具 +- **ffmpeg**: 多媒体处理 +- **mc**: MinIO 客户端 +- **zip/unzip**: 压缩工具 +- **net-tools**: 网络工具 +- **ssh**: 远程连接 +- **cmake**: 构建工具 +- **telnet**: 网络调试 +- **redis-tools**: Redis 命令行工具 +- **potrace**: 位图转矢量图 +- **imagemagick**: 图像处理工具 +- **uv**: 快速 Python 包管理器 + +### NPM 全局包 +- `@anthropic-ai/claude-code`: Claude AI 代码助手 +- `@musistudio/claude-code-router`: Claude 代码路由器 + +### VS Code 插件 +- **vscode-pdf**: PDF 查看器 +- **Markdown Preview Mermaid Support**: Mermaid 图表支持 +- **Python**: Python 语言支持 +- **Roo Code**: Roo 代码助手 +- **ESLint**: JavaScript/TypeScript 代码检查 +- **HTML Preview**: HTML 预览 +- **Prisma**: Prisma ORM 支持 +- **HTML CSS Support**: HTML/CSS 智能提示 +- **Redis**: Redis 客户端 + +## 🚀 使用方法 + +### 方式一:使用 Docker Compose(推荐) + +#### 使用默认密码启动 +```zsh +cd .cloud-dev +docker compose up -d +``` + +#### 自定义密码启动 +修改 `docker-compose.yml` 中的 `DEV_PASSWORD` 环境变量,或使用环境变量覆盖: +```zsh +DEV_PASSWORD=your_password docker compose up -d +``` + +停止容器: +```zsh +docker compose down +``` + +查看日志: +```zsh +docker compose logs -f +``` + +重新构建: +```zsh +docker compose up -d --build +``` + +### 方式二:使用 Docker 命令 + +#### 1. 构建镜像 + +```zsh +cd .cloud-dev +docker build -t hair-keeper-dev . +``` + +#### 2. 运行容器 + +使用默认密码: +```zsh +docker run -d \ + --name hair-keeper-dev \ + -p 2222:22 \ + -p 8080:8080 \ + -p 7681:7681 \ + -p 3000:3000 \ + -v $(pwd)/..:/workspace:cached \ + -v hair-keeper-node-modules:/workspace/node_modules \ + -v hair-keeper-pnpm-store:/root/.local/share/pnpm/store \ + hair-keeper-dev +``` + +使用自定义密码: +```zsh +docker run -d \ + --name hair-keeper-dev \ + -e DEV_PASSWORD=your_password \ + -p 2222:22 \ + -p 8080:8080 \ + -p 7681:7681 \ + -p 3000:3000 \ + -v $(pwd)/..:/workspace:cached \ + -v hair-keeper-node-modules:/workspace/node_modules \ + -v hair-keeper-pnpm-store:/root/.local/share/pnpm/store \ + hair-keeper-dev +``` + +### 3. 访问开发环境 + +容器启动后,可以通过以下方式访问: + +#### Code Server (Web IDE) +- **地址**: http://localhost:8080 +- **默认密码**: `clouddev`(可通过 `DEV_PASSWORD` 环境变量自定义) +- 提供完整的 VS Code 开发体验 +- 内置终端使用 zsh + oh-my-zsh + +#### SSH 连接 +```zsh +ssh root@localhost -p 2222 +# 默认密码: clouddev(可通过 DEV_PASSWORD 环境变量自定义) +``` + +#### Next.js 开发服务器 +- **地址**: http://localhost:3000 +- 在容器内运行 `pnpm run dev` 启动 + +## 📝 端口映射 + +| 服务 | 容器端口 | 主机端口 | 说明 | +|------|---------|---------|------| +| SSH | 22 | 2222 | SSH 远程连接 | +| Code Server | 8080 | 8080 | Web IDE | +| Next.js | 3000 | 3000 | 开发服务器 | + +**注意**: ttyd (Web 终端) 默认不启动,如需使用可在容器内手动运行: +```zsh +ttyd -p 7681 zsh +``` +然后通过 http://localhost:7681 访问(需要在 docker run 或 docker-compose.yml 中映射 7681 端口) + +## 🔧 常用操作 + +### 进入容器 +```zsh +docker exec -it hair-keeper-dev zsh +``` + +### 安装项目依赖 +```zsh +docker exec -it hair-keeper-dev pnpm install +``` + +### 启动开发服务器 +```zsh +docker exec -it hair-keeper-dev pnpm run dev +``` + +### 查看容器日志 +```zsh +docker logs -f hair-keeper-dev +``` + +### 查看 Code Server 日志 +Code Server 的日志位于容器内的 `/root/.local/share/code-server/coder-logs/` 目录,包含两个日志文件: + +```zsh +# 查看日志文件列表 +docker exec -it hair-keeper-dev ls -lt /root/.local/share/code-server/coder-logs/ + +# 实时查看标准输出日志 +docker exec -it hair-keeper-dev tail -f /root/.local/share/code-server/coder-logs/code-server-stdout.log + +# 实时查看错误日志 +docker exec -it hair-keeper-dev tail -f /root/.local/share/code-server/coder-logs/code-server-stderr.log + +# 同时查看两个日志文件 +docker exec -it hair-keeper-dev tail -f /root/.local/share/code-server/coder-logs/code-server-*.log + +# 或者在容器内查看 +docker exec -it hair-keeper-dev zsh +cd /root/.local/share/code-server/coder-logs/ +tail -f code-server-stdout.log code-server-stderr.log +``` + +### 停止容器 +```zsh +docker stop hair-keeper-dev +``` + +### 删除容器 +```zsh +docker rm hair-keeper-dev +``` + +## 🎯 工作流程 + +1. **启动容器**: 运行上述 docker run 命令 +2. **访问 Code Server**: 在浏览器打开 http://localhost:8080 +3. **打开项目**: Code Server 会自动打开 /workspace 目录(映射到项目根目录) +4. **安装依赖**: 在终端运行 `pnpm install` +5. **启动开发**: 运行 `pnpm run dev` +6. **开始开发**: 在浏览器访问 http://localhost:3000 + +## 💡 提示 + +### Shell 环境 +容器默认使用 **zsh** 作为 Shell,配置了 **oh-my-zsh** 框架: +- 主题:robbyrussell +- 插件:git, node, npm, docker, python +- 自动补全和语法高亮 +- 更友好的命令行体验 + +### Python 包管理 +推荐使用 **uv** 进行 Python 包管理,它比 pip 快得多: +```zsh +# 创建虚拟环境 +uv venv + +# 安装包 +uv pip install package-name + +# 从 requirements.txt 安装 +uv pip install -r requirements.txt +``` + +### 数据持久化 +- 项目代码通过 volume 映射,修改会实时同步到主机 +- node_modules 建议使用 Docker volume 以提高性能 + +### 性能优化 +如果需要更好的性能,可以使用命名卷: + +```zsh +docker volume create hair-keeper-node-modules + +docker run -d \ + --name hair-keeper-dev \ + -p 2222:22 \ + -p 8080:8080 \ + -p 7681:7681 \ + -p 3000:3000 \ + -v $(pwd)/..:/workspace \ + -v hair-keeper-node-modules:/workspace/node_modules \ + hair-keeper-dev +``` + +### 运行时修改密码 +如果需要在容器运行时修改密码: + +**方法一:重启容器并设置新密码** +```zsh +docker stop hair-keeper-dev +docker rm hair-keeper-dev +# 使用新密码重新启动 +DEV_PASSWORD=new_password docker compose up -d +``` + +**方法二:在容器内手动修改** +```zsh +# SSH 密码 +docker exec -it hair-keeper-dev passwd + +# Code Server 密码 +docker exec -it hair-keeper-dev zsh -c "echo 'password: new_password' >> /root/.config/code-server/config.yaml" +# 然后重启 code-server +``` + +## 🔐 密码配置 + +### 默认密码 +容器默认密码为 `clouddev`,用于: +- SSH 登录(root 用户) +- Code Server Web IDE + +### 自定义密码 +通过环境变量 `DEV_PASSWORD` 设置自定义密码: + +**Docker Compose 方式**: +编辑 `docker-compose.yml`: +```yaml +environment: + - DEV_PASSWORD=your_secure_password +``` + +**Docker 命令方式**: +```zsh +docker run -e DEV_PASSWORD=your_secure_password ... +``` + +### 安全建议 +- ⚠️ 生产环境务必修改默认密码 +- 建议使用 SSH 密钥认证替代密码 +- 不要将容器直接暴露到公网 +- 使用反向代理(如 Nginx)并配置 HTTPS +- 定期更换密码 + +## 📚 相关资源 + +- [Code Server 文档](https://coder.com/docs/code-server) +- [Docker 文档](https://docs.docker.com/) +- [tmux 快捷键](https://tmuxcheatsheet.com/) + +## 🐛 故障排除 + +### 端口被占用 +如果端口被占用,可以修改主机端口映射: +```zsh +-p 8081:8080 # 将 Code Server 映射到 8081 +``` + +### 权限问题 +如果遇到文件权限问题,确保容器内的用户有正确的权限: +```zsh +docker exec -it hair-keeper-dev chown -R root:root /workspace +``` + +### 容器无法启动 +查看容器日志排查问题: +```zsh +docker logs hair-keeper-dev \ No newline at end of file diff --git a/.cloud-dev/docker-compose.yml b/.cloud-dev/docker-compose.yml new file mode 100644 index 0000000..ca25a48 --- /dev/null +++ b/.cloud-dev/docker-compose.yml @@ -0,0 +1,42 @@ +services: + cloud-dev: + build: + context: . + dockerfile: Dockerfile + container_name: hair-keeper-dev + hostname: hair-keeper-dev + ports: + - "2222:22" # SSH + - "8080:8080" # Code Server + - "7681:7681" # ttyd (Web Terminal) + - "3000:3000" # Next.js Dev Server + volumes: + # 项目代码映射(使用 cached 模式提高性能) + - ../:/workspace:cached + # node_modules 使用命名卷以提高性能 + - node_modules:/workspace/node_modules + # pnpm store 缓存 + - pnpm_store:/root/.local/share/pnpm/store + # Git 配置(可选,如果需要保留 Git 配置) + - ~/.gitconfig:/root/.gitconfig:ro + environment: + - NODE_ENV=development + - TZ=Asia/Shanghai + # 开发环境密码,可自定义修改 + - DEV_PASSWORD=clouddev + restart: unless-stopped + # 资源限制(可选,根据需要调整) + deploy: + resources: + limits: + cpus: '4' + memory: 8G + reservations: + cpus: '2' + memory: 4G + +volumes: + # node_modules 卷,避免主机和容器之间的文件系统差异 + node_modules: + # pnpm store 卷,加速依赖安装 + pnpm_store: \ No newline at end of file diff --git a/.cloud-dev/entrypoint.sh b/.cloud-dev/entrypoint.sh new file mode 100644 index 0000000..de523b4 --- /dev/null +++ b/.cloud-dev/entrypoint.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# 设置默认密码 +DEV_PASSWORD=${DEV_PASSWORD:-clouddev} + +# 设置 root 密码 +echo "root:${DEV_PASSWORD}" | chpasswd + +# 确保 code-server 配置目录存在 +mkdir -p /root/.local/share/code-server + +# 创建 code-server 配置文件 +cat > /root/.config/code-server/config.yaml << EOF +bind-addr: 0.0.0.0:8080 +auth: password +password: ${DEV_PASSWORD} +cert: false +EOF + +# 启动 SSH 服务 +echo "Starting SSH service..." +service ssh start + +# 启动 code-server +echo "Starting code-server on port 8080..." +code-server --bind-addr 0.0.0.0:8080 /workspace & + + +# 输出服务信息 +echo "" +echo "==========================================" +echo "Cloud Development Container Started" +echo "==========================================" +echo "SSH: Port 22 (user: root, password: ${DEV_PASSWORD})" +echo "Code Server: Port 8080 (password: ${DEV_PASSWORD})" +echo "Next.js Dev: Port 3000 (run 'pnpm run dev' to start)" +echo "==========================================" +echo "" +echo "Workspace: /workspace" +echo "Default Shell: zsh with oh-my-zsh" +echo "" +echo "提示: 可通过环境变量 DEV_PASSWORD 自定义密码" +echo "" + +# 保持容器运行 +tail -f /dev/null \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9baef90 --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +# ============================================ +# AUTO-GENERATED FILE - DO NOT EDIT MANUALLY +# 自动生成的文件 - 请勿手动修改 +# ============================================ +# This file is automatically generated by .env to help developers or AI understand the environment variables in the project +# 此文件由 .env 自动生成,用于帮助开发人员或者AI了解项目中有哪些环境变量 +# +# Purpose: Template for environment variables +# 用途:环境变量配置模板 +# +# Usage: +# 使用方法: +# 1. Copy this file: cp .env.example .env +# 复制此文件:cp .env.example .env +# 2. Fill in your actual values in .env +# 在 .env 中填写实际的配置值 +# 3. Never commit .env to Git! +# 永远不要将 .env 提交到 Git! +# ============================================ + +# 默认配置文件,在所有环境下都会加载 + +# 容器相关 +POSTGRESQL_USERNAME= +POSTGRESQL_PASSWORD= +POSTGRESQL_PORT= +DATABASE_URL= + +REDIS_PORT= +REDIS_PASSWORD= + +MINIO_ENDPOINT= +MINIO_API_PORT= +MINIO_CONSOLE_PORT= +MINIO_USE_SSL= +MINIO_ROOT_USER= +MINIO_ROOT_PASSWORD= +MINIO_SERVER_URL= +MINIO_BUCKET= + +# 应用相关 +SUPER_ADMIN_PASSWORD= + +# NextAuth.js Configuration +NEXTAUTH_SECRET= +NEXTAUTH_URL= + +PKUAI_API_KEY= +PKUAI_API_BASE= + + + + +# 仅在开发环境加载(写在.env.development中) +NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT= +DEV_TERMINAL= + + + + +# 仅在生产环境加载(写在.env.production中) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac730c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env*.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/postgresql +tasks.md +/prisma/zod + +# pnpm +.pnpm-store/ + +# lock 一般的项目需要用git管理,但这个是模板项目就不管理 +package-lock.json +pnpm-lock.yaml diff --git a/.roo/mcp.json b/.roo/mcp.json new file mode 100644 index 0000000..7a61061 --- /dev/null +++ b/.roo/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers":{ + "ai-elements": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://registry.ai-sdk.dev/api/mcp" + ] + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c61bbb --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +## 项目说明 +本项目模板(Hair Keeper v1.0.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。 + +Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。 + +开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。 + +## 主要依赖库 +- 基础:next + react + trpc + prisma +- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast) +- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable +- 用户交互增强:motion(动画) + framer-motion(动画) + use-gesture/react(手势) +- Headless UI:react-hook-form + tanstack/react-table + headless-tree/react +- 数据和存储:pg(PostgreSQL) + ioredis + minio +- 后台任务及消息队列:bullmq +- AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件) +- 辅助性库:lodash + zod + date-fns + nanoid +- 其他:next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard + +## 项目约定 +### 前端 +- 可使用`pnpx shadcn@latest add`添加UI控件(会添加到components/ui中) +- 表单输入组件提供value、onChange和disabled三种属性的支持,可方便的集成到react-hook-form中 +- z-index约定:常规对话框及其遮罩z-50,表单控件的Popover为z-60,全屏预览及遮罩z-70 +- tailwindcss v4支持直接编写如 w-[10px] 这样的任意值,非必要不写style,支持样式类合并`import { cn } from "@/lib/utils"` +- 用`import { useCallbackRef } from "@/hooks/use-callback-ref"`这个钩子构建引用不变但逻辑总是最新的函数,解决闭包陷阱 + +### 后端 +- tRPC接口报错时可使用TRPCError,例如throw new TRPCError({ code: 'NOT_FOUND', message: '' }) +- server/trpc.ts中预定义了publicProcedure用于创建无权限限制、也不需要登录的api +- server/trpc.ts中预定义了publicProcedurepermissionRequiredProcedure,用来创建限制特定权限访问的路由,例如permissionRequiredProcedure(Permissions.USER_MANAGE);空字符串表示无权限要求,但是需要用户登录;约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问,该权限不在Permissions中定义,只有超级管理员才能绕过授权限制访问所有接口,因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。 + +### 数据和存储 +- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client` +- 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient +- 时间字段统一使用 `@db.Timestamptz` 类型 +- 前后端参数传递尽量使用扁平结构而非嵌套结构 +- 文件的上传和下载采用“客户端直传”架构(基于MinIO),服务器端只负责授权和生成预签名URL + +### 开发模式 +为了方便开发,本项目模板内置了在开发模式下可用的大量页面和功能 +- 仅在开发阶段使用的页面、布局、api等会被NextJS识别并处理的文件,以dev.tsx、dev.ts、dev.jsx、dev.js为后缀,不会被打包到生产环境 +- 仅在开发阶段使用的数据模型以Dev开头,对应的数据表以dev_开头 + +## 重要目录和文件 +### 前端 +- `components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件 +- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用 +- `components/ai-elements/`:ai对话相关的组件 +- `components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件 +- `components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能 +- `components/icons/`:项目的自定义图标可以写在这个文件夹 +- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器 +- `components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库 +- `app/(main)/`:开发者在这里自行实现的所有业务的页面 +- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考 +- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现 +- `app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现 +- `hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这 +- `lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信 + +### 后端 +- `server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API +- `server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册 +- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由 +- `server/routers/jobs.ts`:tRPC任务进度订阅路由 +- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验 +- `server/routers/global.ts`:系统全局和特定业务关联不大的一些api +- `server/routers/dev/`:开发模式下的辅助功能需要的trpc api +- `server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中 +- `server/agents`:LLM的对接和使用 +- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务 +- `server/service/dev/`:开发模式下的辅助功能需要的后台服务 +- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持 +- `api/dev/`:开发模式下的辅助功能需要的api + +### 其他 +- `constants/`:项目全局常量管理 +- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)") +- `lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型 +- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序 +- `lib/format.ts`:数据格式化工具函数库 + +## 非标准命令 +- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互 +- `pnpm run db:seed`:数据库种子数据,`prisma/init_data/`存放种子初始化数据 +- `pnpm run build:analyze`:打包项目并使用next/bundle-analyzer进行分析 diff --git a/components.json b/components.json new file mode 100644 index 0000000..6165388 --- /dev/null +++ b/components.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json", + "@reui": "https://reui.io/r/{name}.json", + "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json", + "@paceui-ui": "https://ui.paceui.com/r/{name}.json", + "@heseui": "https://www.heseui.com/r/{name}.json", + "@blocks": "https://blocks.so/r/{name}.json", + "@elements": "https://tryelements.dev/r/{name}.json", + "@smoothui": "https://smoothui.dev/r/{name}.json", + "@formcn": "https://formcn.dev/r/{name}.json", + "@limeplay": "https://limeplay.winoffrg.dev/r/{name}.json", + "@rigidui": "https://rigidui.com/r/{name}.json", + "@retroui": "https://retroui.dev/r/{name}.json", + "@wds": "https://wds-shadcn-registry.netlify.app/r/{name}.json", + "@aceternity": "https://ui.aceternity.com/registry/{name}.json", + "@alexcarpenter": "https://ui.alexcarpenter.me/r/{name}.json", + "@algolia": "https://sitesearch.algolia.com/r/{name}.json", + "@aliimam": "https://aliimam.in/r/{name}.json", + "@animate-ui": "https://animate-ui.com/r/{name}.json", + "@assistant-ui": "https://r.assistant-ui.com/{name}.json", + "@austin-ui": "https://austin-ui.netlify.app/r/{name}.json", + "@better-upload": "https://better-upload.com/r/{name}.json", + "@billingsdk": "https://billingsdk.com/r/{name}.json", + "@bucharitesh": "https://bucharitesh.in/r/{name}.json", + "@clerk": "https://clerk.com/r/{name}.json", + "@coss": "https://coss.com/ui/r/{name}.json", + "@chisom-ui": "https://chisom-ui.netlify.app/r/{name}.json", + "@creative-tim": "https://www.creative-tim.com/ui/r/{name}.json", + "@cult-ui": "https://cult-ui.com/r/{name}.json", + "@diceui": "https://diceui.com/r/{name}.json", + "@eldoraui": "https://eldoraui.site/r/{name}.json", + "@elevenlabs-ui": "https://ui.elevenlabs.io/r/{name}.json", + "@fancy": "https://fancycomponents.dev/r/{name}.json", + "@kokonutui": "https://kokonutui.com/r/{name}.json", + "@lens-blocks": "https://lensblocks.com/r/{name}.json", + "@lytenyte": "https://www.1771technologies.com/r/{name}.json", + "@magicui": "https://magicui.design/r/{name}.json", + "@magicui-pro": "https://pro.magicui.design/registry/{name}", + "@motion-primitives": "https://motion-primitives.com/c/{name}.json", + "@nuqs": "https://nuqs.dev/r/{name}.json", + "@paceui": "https://ui.paceui.com/r/{name}.json", + "@plate": "https://platejs.org/r/{name}.json", + "@prompt-kit": "https://prompt-kit.com/c/{name}.json", + "@prosekit": "https://prosekit.dev/r/{name}.json", + "@react-bits": "https://reactbits.dev/r/{name}.json", + "@react-market": "https://www.react-market.com/get/{name}.json", + "@solaceui": "https://www.solaceui.com/r/{name}.json", + "@scrollxui": "https://www.scrollxui.dev/registry/{name}.json", + "@shadcn-studio": "https://shadcnstudio.com/r/{name}.json", + "@shadcnblocks": "https://shadcnblocks.com/r/{name}.json", + "@simple-ai": "https://simple-ai.dev/r/{name}.json", + "@skyr": "https://ui-play.skyroc.me/r/{name}.json", + "@spectrumui": "https://ui.spectrumhq.in/r/{name}.json", + "@supabase": "https://supabase.com/ui/r/{name}.json", + "@svgl": "https://svgl.app/r/{name}.json", + "@tailark": "https://tailark.com/r/{name}.json", + "@tweakcn": "https://tweakcn.com/r/themes/{name}.json", + "@paykit-sdk": "https://www.usepaykit.dev/r/{name}.json", + "@pixelact-ui": "https://www.pixelactui.com/r/{name}.json", + "@zippystarter": "https://zippystarter.com/r/{name}.json", + "@ha-components": "https://hacomponents.keshuac.com/r/{name}.json" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0fa09b9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + postgresql: + image: bitnami/postgresql:17 # https://hub.docker.com/r/bitnami/postgresql + container_name: hair-keeper-dev-pg + restart: always + ports: + - "${POSTGRESQL_PORT}:5432" + environment: + POSTGRESQL_USERNAME: ${POSTGRESQL_USERNAME} + POSTGRESQL_PASSWORD: ${POSTGRESQL_PASSWORD} + POSTGRESQL_INITDB_ARGS: "--encoding=UTF-8 --locale=C" + volumes: + - hair_keeper_postgresql_data:/bitnami/postgresql + + redis: + image: redis:8-alpine # https://hub.docker.com/_/redis + container_name: hair-keeper-dev-redis + ports: + - "${REDIS_PORT}:6379" + command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes + volumes: + - hair_keeper_redis_data:/data + restart: always + + minio: + image: minio/minio:RELEASE.2025-07-23T15-54-02Z + container_name: hair-keeper-dev-minio + ports: + - "${MINIO_API_PORT}:9000" # API端口 + - "${MINIO_CONSOLE_PORT}:9001" # Console端口 + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_SERVER_URL: ${MINIO_SERVER_URL} + volumes: + - hair_keeper_minio_data:/data + command: server /data --console-address ":9001" + restart: always + +volumes: + hair_keeper_postgresql_data: + hair_keeper_redis_data: + hair_keeper_minio_data: diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..6e8754c --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,35 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + "public/**", + ], + }, + { + rules: { + "@typescript-eslint/no-unused-vars": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-explicit-any": "off", + "prefer-const": "off", + "@next/next/no-img-element": "off" + }, + }, +]; + +export default eslintConfig; diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..f19f9a2 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,17 @@ +// 这里的代码只会在服务器启动时执行一次 + + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + // 初始化定时任务 + const { initCronJobs } = await import('@/server/cron') + await initCronJobs() + + if (process.env.NODE_ENV === 'development') { + const { startTerminalService } = await import('@/server/service/dev/terminal'); + startTerminalService() // 开发环境下启动一个基于ttyd可嵌入在网页上的终端服务 + } + } else { + + } +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..07f18a0 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,18 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + +}; + + +if (process.env.NODE_ENV === 'development') { + const pageExtensions = nextConfig.pageExtensions || ['ts', 'tsx', 'js', 'jsx']; + nextConfig.pageExtensions = ['dev.ts', 'dev.tsx', 'dev.js', 'dev.jsx', ...pageExtensions]; +} + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const withBundleAnalyzer = require('@next/bundle-analyzer')({ + enabled: process.env.ANALYZE === 'true', +}) + +export default withBundleAnalyzer(nextConfig); diff --git a/package.json b/package.json new file mode 100644 index 0000000..c544a63 --- /dev/null +++ b/package.json @@ -0,0 +1,118 @@ +{ + "name": "hair-keeper", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 3000 --turbo", + "dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M", + "build": "next build", + "start": "next start -p 3000", + "lint": "eslint", + "db:seed": "tsx prisma/seed.ts", + "build:analyze": "ANALYZE=true next build" + }, + "dependencies": { + "@ai-sdk/anthropic": "^2.0.29", + "@ai-sdk/openai": "^2.0.52", + "@ai-sdk/react": "^2.0.71", + "@auth/prisma-adapter": "^2.10.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@headless-tree/core": "^1.5.1", + "@headless-tree/react": "^1.5.1", + "@hookform/resolvers": "^5.2.2", + "@next/bundle-analyzer": "^15.5.6", + "@prisma/client": "^6.15.0", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-direction": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-use-controllable-state": "^1.2.2", + "@radix-ui/react-visually-hidden": "^1.2.3", + "@tanstack/react-query": "^5.87.1", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", + "@trpc/client": "^11.5.1", + "@trpc/next": "^11.5.1", + "@trpc/react-query": "^11.5.1", + "@trpc/server": "^11.5.1", + "@types/dagre": "^0.7.53", + "@types/pg": "^8.15.5", + "@use-gesture/react": "^10.3.1", + "@xyflow/react": "^12.8.6", + "ai": "^5.0.71", + "bcryptjs": "^3.0.2", + "browser-image-compression": "^2.0.2", + "bullmq": "^5.61.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "copy-to-clipboard": "^3.3.3", + "dagre": "^0.8.5", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.23.24", + "ioredis": "^5.8.1", + "lodash": "^4.17.21", + "lucide-react": "^0.543.0", + "minio": "^8.0.6", + "motion": "^12.23.22", + "nanoid": "^5.1.6", + "next": "~15.4.0", + "next-auth": "^4.24.11", + "next-themes": "^0.4.6", + "nuqs": "^2.6.0", + "pg": "^8.16.3", + "prism-react-renderer": "^2.4.1", + "prisma": "^6.15.0", + "radix-ui": "^1.4.3", + "react": "~19.1.0", + "react-day-picker": "^9.11.0", + "react-dom": "~19.1.0", + "react-dropzone": "^14.3.8", + "react-hook-form": "^7.62.0", + "recharts": "^3.2.0", + "shiki": "^3.15.0", + "sonner": "^2.0.7", + "streamdown": "^1.4.0", + "superjson": "^2.2.2", + "tailwind-merge": "^3.3.1", + "use-stick-to-bottom": "^1.1.1", + "vaul": "^1.1.2", + "zod": "^4.1.9" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", + "@types/lodash": "^4.17.20", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.5.2", + "react-icons": "^5.5.0", + "react-live": "^4.1.8", + "shadcn": "^3.5.0", + "tailwindcss": "^4", + "tsx": "^4.20.5", + "tw-animate-css": "^1.3.8", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/prisma/init_data/院系.json b/prisma/init_data/院系.json new file mode 100644 index 0000000..1654eb2 --- /dev/null +++ b/prisma/init_data/院系.json @@ -0,0 +1,1102 @@ +[ + { + "id": "00001", + "name": "数学学院", + "full_name": "数学科学学院" + }, + { + "id": "00004", + "name": "物理学院", + "full_name": "物理学院" + }, + { + "id": "00010", + "name": "化学学院", + "full_name": "化学与分子工程学院" + }, + { + "id": "00011", + "name": "生命学院", + "full_name": "生命科学学院" + }, + { + "id": "00012", + "name": "地空学院", + "full_name": "地球与空间科学学院" + }, + { + "id": "00013", + "name": "环科学院", + "full_name": "环境科学与工程学院" + }, + { + "id": "00016", + "name": "心理学院", + "full_name": "心理与认知科学学院" + }, + { + "id": "00017", + "name": "软件学院", + "full_name": "软件与微电子学院" + }, + { + "id": "00018", + "name": "新闻学院", + "full_name": "新闻与传播学院" + }, + { + "id": "00020", + "name": "中文系", + "full_name": "中国语言文学系" + }, + { + "id": "00021", + "name": "历史系", + "full_name": "历史学系" + }, + { + "id": "00022", + "name": "考古文博学院", + "full_name": "考古文博学院" + }, + { + "id": "00023", + "name": "哲学宗教学系", + "full_name": "哲学系、宗教学系" + }, + { + "id": "00024", + "name": "国际关系学院", + "full_name": "国际关系学院" + }, + { + "id": "00025", + "name": "经济学院", + "full_name": "经济学院" + }, + { + "id": "00028", + "name": "光华管理学院", + "full_name": "光华管理学院" + }, + { + "id": "00029", + "name": "法学院", + "full_name": "法学院" + }, + { + "id": "00030", + "name": "信息管理系", + "full_name": "信息管理系" + }, + { + "id": "00031", + "name": "社会学系", + "full_name": "社会学系" + }, + { + "id": "00032", + "name": "政府管理学院", + "full_name": "政府管理学院" + }, + { + "id": "00033", + "name": "核磁中心", + "full_name": "北京核磁共振中心" + }, + { + "id": "00034", + "name": "成人教育学院", + "full_name": "成人教育学院" + }, + { + "id": "00039", + "name": "外国语学院", + "full_name": "外国语学院" + }, + { + "id": "00040", + "name": "马克思学院", + "full_name": "马克思主义学院" + }, + { + "id": "00041", + "name": "体育教研部", + "full_name": "体育教研部" + }, + { + "id": "00042", + "name": "科学与社会研究中心", + "full_name": "科学与社会研究中心" + }, + { + "id": "00043", + "name": "艺术学院", + "full_name": "艺术学院" + }, + { + "id": "00044", + "name": "对外汉语学院", + "full_name": "对外汉语教育学院" + }, + { + "id": "00045", + "name": "教师教学发展中心", + "full_name": "教师教学发展中心" + }, + { + "id": "00046", + "name": "元培学院", + "full_name": "元培学院" + }, + { + "id": "00047", + "name": "深圳研究生院", + "full_name": "深圳研究生院" + }, + { + "id": "00048", + "name": "信息学院", + "full_name": "信息科学技术学院" + }, + { + "id": "00049", + "name": "景观设计学研究院", + "full_name": "景观设计学研究院" + }, + { + "id": "00055", + "name": "王选所", + "full_name": "王选计算机研究所" + }, + { + "id": "00058", + "name": "电镜实验室", + "full_name": "物理学院电子显微镜室" + }, + { + "id": "00062", + "name": "国发院", + "full_name": "国家发展研究院" + }, + { + "id": "00063", + "name": "马列所", + "full_name": "马列主义研究所" + }, + { + "id": "00067", + "name": "教育学院", + "full_name": "教育学院" + }, + { + "id": "00068", + "name": "人口所", + "full_name": "人口研究所" + }, + { + "id": "00069", + "name": "社会学所", + "full_name": "社会学人类学研究所" + }, + { + "id": "00074", + "name": "现代物理中心", + "full_name": "北京现代物理研究中心" + }, + { + "id": "00082", + "name": "计算中心", + "full_name": "计算中心" + }, + { + "id": "00084", + "name": "交叉学科院", + "full_name": "前沿交叉学科研究院" + }, + { + "id": "00086", + "name": "工学院", + "full_name": "工学院" + }, + { + "id": "00087", + "name": "中古史中心", + "full_name": "中国中古史研究中心" + }, + { + "id": "00099", + "name": "图书馆", + "full_name": "图书馆" + }, + { + "id": "00100", + "name": "集成电路学院", + "full_name": "集成电路学院" + }, + { + "id": "00101", + "name": "计算机学院", + "full_name": "计算机学院" + }, + { + "id": "00106", + "name": "智能学院", + "full_name": "智能学院" + }, + { + "id": "00107", + "name": "电子学院", + "full_name": "电子学院" + }, + { + "id": "00126", + "name": "城环学院", + "full_name": "城市与环境学院" + }, + { + "id": "00127", + "name": "环境学院", + "full_name": "环境科学与工程学院" + }, + { + "id": "00152", + "name": "经济研究所", + "full_name": "经济研究所" + }, + { + "id": "00160", + "name": "亚太研究院", + "full_name": "亚太研究院" + }, + { + "id": "00165", + "name": "首都发展研究院", + "full_name": "首都发展研究院" + }, + { + "id": "00166", + "name": "国学研究院", + "full_name": "国学研究院" + }, + { + "id": "00167", + "name": "民营经济院", + "full_name": "民营经济研究院" + }, + { + "id": "00168", + "name": "中古文献中心", + "full_name": "中国古文献研究中心" + }, + { + "id": "00169", + "name": "中国古代史研究中心", + "full_name": "中国古代史研究中心" + }, + { + "id": "00170", + "name": "汉语语言学研究中心", + "full_name": "汉语语言学研究中心" + }, + { + "id": "00171", + "name": "东方文学中心", + "full_name": "东方文学研究中心" + }, + { + "id": "00172", + "name": "中国考古学研究中心", + "full_name": "中国考古学研究中心" + }, + { + "id": "00173", + "name": "外国哲学研究所", + "full_name": "外国哲学研究所" + }, + { + "id": "00174", + "name": "政治发展与政府管理研究所", + "full_name": "政治发展与政府管理研究所" + }, + { + "id": "00175", + "name": "社会发展中心", + "full_name": "中国社会与发展研究中心" + }, + { + "id": "00176", + "name": "邓小平理论研究院", + "full_name": "邓小平理论研究院" + }, + { + "id": "00177", + "name": "教育经济研究所", + "full_name": "教育经济研究所" + }, + { + "id": "00178", + "name": "经济法研究所", + "full_name": "经济法研究所" + }, + { + "id": "00179", + "name": "产学研基地", + "full_name": "深港产学研基地" + }, + { + "id": "00180", + "name": "医学部教学办", + "full_name": "医学部教学办" + }, + { + "id": "00181", + "name": "科学计算中心", + "full_name": "科学与工程计算中心" + }, + { + "id": "00182", + "name": "分子医学所", + "full_name": "分子医学研究所" + }, + { + "id": "00183", + "name": "软件工程中心", + "full_name": "软件工程国家工程研究中心" + }, + { + "id": "00184", + "name": "实验动物中心", + "full_name": "实验动物中心" + }, + { + "id": "00185", + "name": "先进技术院", + "full_name": "先进技术研究院" + }, + { + "id": "00187", + "name": "社科调查中心", + "full_name": "中国社会科学调查中心" + }, + { + "id": "00188", + "name": "财政所", + "full_name": "中国教育财政科学研究所" + }, + { + "id": "00189", + "name": "科维理天文所", + "full_name": "科维理天文与天体物理研究所" + }, + { + "id": "00190", + "name": "中外妇女中心", + "full_name": "中外妇女问题研究中心" + }, + { + "id": "00191", + "name": "数学中心", + "full_name": "北京国际数学研究中心" + }, + { + "id": "00192", + "name": "歌剧研究院", + "full_name": "歌剧研究院" + }, + { + "id": "00195", + "name": "景观学院", + "full_name": "建筑与景观设计学院" + }, + { + "id": "00197", + "name": "中国画法研究院", + "full_name": "中国画法研究院" + }, + { + "id": "00198", + "name": "西方古典学中心", + "full_name": "西方古典学中心" + }, + { + "id": "00201", + "name": "校办", + "full_name": "校长办公室" + }, + { + "id": "00202", + "name": "人文院", + "full_name": "高等人文研究院" + }, + { + "id": "00203", + "name": "麦戈文脑科学研究所", + "full_name": "北京大学麦戈文脑科学研究所" + }, + { + "id": "00204", + "name": "继续教育学院", + "full_name": "继续教育学院" + }, + { + "id": "00205", + "name": "国际战略研究院", + "full_name": "国际战略研究院" + }, + { + "id": "00206", + "name": "新媒体研究院", + "full_name": "北京大学新媒体研究院" + }, + { + "id": "00207", + "name": "海洋研究院", + "full_name": "海洋研究院" + }, + { + "id": "00208", + "name": "燕京学堂", + "full_name": "燕京学堂" + }, + { + "id": "00210", + "name": "监察室", + "full_name": "监察室" + }, + { + "id": "00211", + "name": "现代农学院", + "full_name": "现代农学院" + }, + { + "id": "00212", + "name": "新结构经济学研究中心", + "full_name": "新结构经济学研究中心" + }, + { + "id": "00213", + "name": "中国政治学研究中心", + "full_name": "北京大学中国政治学研究中心" + }, + { + "id": "00214", + "name": "儒藏编纂中心", + "full_name": "儒藏编纂与研究中心" + }, + { + "id": "00215", + "name": "基金会", + "full_name": "基金会" + }, + { + "id": "00216", + "name": "人文社会科学研究院", + "full_name": "人文社会科学研究院" + }, + { + "id": "00220", + "name": "微米纳米加工技术全国重点实验室", + "full_name": "微米纳米加工技术全国重点实验室" + }, + { + "id": "00221", + "name": "新时代研究院", + "full_name": "北京大学习近平新时代中国特色社会主义思想研究院" + }, + { + "id": "00222", + "name": "生物医学前沿创新中心", + "full_name": "北京大学生物医学前沿创新中心" + }, + { + "id": "00223", + "name": "天然气水合物国际研究中心", + "full_name": "北京大学天然气水合物国际研究中心" + }, + { + "id": "00225", + "name": "人工智能研究院", + "full_name": "北京大学人工智能研究院" + }, + { + "id": "00228", + "name": "能源研究院", + "full_name": "北京大学能源研究院" + }, + { + "id": "00230", + "name": "大数据实验室", + "full_name": "北京大学大数据分析与应用技术国家工程实验室" + }, + { + "id": "00231", + "name": "全健院", + "full_name": "北京大学全球健康发展研究院" + }, + { + "id": "00232", + "name": "材料科学与工程学院", + "full_name": "北京大学材料科学与工程学院" + }, + { + "id": "00233", + "name": "未来技术学院", + "full_name": "北京大学未来技术学院" + }, + { + "id": "00234", + "name": "文学讲习所", + "full_name": "北京大学文学讲习所" + }, + { + "id": "00236", + "name": "现代中国人文研究所", + "full_name": "北京大学现代中国人文研究所" + }, + { + "id": "00237", + "name": "碳中和研究院", + "full_name": "北京大学碳中和研究院" + }, + { + "id": "00301", + "name": "教务长办", + "full_name": "教务长办公室" + }, + { + "id": "00302", + "name": "成人教育部", + "full_name": "成人教育部" + }, + { + "id": "00303", + "name": "教务处", + "full_name": "教务处" + }, + { + "id": "00304", + "name": "自然科学处", + "full_name": "自然科学处" + }, + { + "id": "00305", + "name": "社科处", + "full_name": "社会科学处" + }, + { + "id": "00307", + "name": "科技开发部", + "full_name": "科技开发部" + }, + { + "id": "00308", + "name": "研究生院", + "full_name": "研究生院" + }, + { + "id": "00309", + "name": "昌平园区", + "full_name": "昌平园区" + }, + { + "id": "00310", + "name": "海外教育学院", + "full_name": "海外教育学院" + }, + { + "id": "00311", + "name": "境外办", + "full_name": "境外办学办公室" + }, + { + "id": "00312", + "name": "知识产权学院", + "full_name": "知识产权学院第一届董事会" + }, + { + "id": "00320", + "name": "学报", + "full_name": "学报" + }, + { + "id": "00322", + "name": "环境保护办公", + "full_name": "环境保护办公室" + }, + { + "id": "00351", + "name": "学位办", + "full_name": "学位办公室" + }, + { + "id": "00352", + "name": "研管处", + "full_name": "研究生管理处" + }, + { + "id": "00353", + "name": "研培处", + "full_name": "研究生培养处" + }, + { + "id": "00360", + "name": "成人教育", + "full_name": "成人教育学院" + }, + { + "id": "00381", + "name": "考试中心", + "full_name": "国外考试中心" + }, + { + "id": "00390", + "name": "专利事务所", + "full_name": "专利事务所" + }, + { + "id": "00398", + "name": "口腔实验室", + "full_name": "口腔数字化医疗技术和材料国家工程实验室" + }, + { + "id": "00399", + "name": "生命科学中心", + "full_name": "北京大学生命科学联合中心" + }, + { + "id": "00401", + "name": "总务长办", + "full_name": "总务长办公室" + }, + { + "id": "00402", + "name": "财务处", + "full_name": "财务处" + }, + { + "id": "00403", + "name": "基建处", + "full_name": "基建处" + }, + { + "id": "00405", + "name": "动力中心", + "full_name": "动力中心" + }, + { + "id": "00406", + "name": "餐饮中心", + "full_name": "餐饮中心" + }, + { + "id": "00407", + "name": "校园管理中心", + "full_name": "校园管理服务中心" + }, + { + "id": "00408", + "name": "会议中心", + "full_name": "会议中心" + }, + { + "id": "00409", + "name": "校医院", + "full_name": "校医院" + }, + { + "id": "00410", + "name": "运输中心", + "full_name": "运输中心" + }, + { + "id": "00411", + "name": "学生宿舍中心", + "full_name": "学生宿舍服务中心" + }, + { + "id": "00412", + "name": "社区服务中心", + "full_name": "社区服务中心" + }, + { + "id": "00413", + "name": "幼教中心", + "full_name": "幼教中心" + }, + { + "id": "00414", + "name": "电话室", + "full_name": "电话室" + }, + { + "id": "00415", + "name": "水电中心", + "full_name": "水电中心" + }, + { + "id": "00416", + "name": "供暖中心", + "full_name": "供暖中心" + }, + { + "id": "00418", + "name": "特殊用房中心", + "full_name": "特殊用房管理中心" + }, + { + "id": "00420", + "name": "肖家河建设办", + "full_name": "肖家河项目建设办公室" + }, + { + "id": "00422", + "name": "校园服务中心", + "full_name": "校园服务中心" + }, + { + "id": "00423", + "name": "公寓服务中心", + "full_name": "公寓服务中心" + }, + { + "id": "00424", + "name": "北京大学附属幼儿园", + "full_name": "北京大学附属幼儿园" + }, + { + "id": "00500", + "name": "校庆办公室", + "full_name": "百年校庆筹备委员会办公室" + }, + { + "id": "00501", + "name": "职工学校", + "full_name": "职工业余学校" + }, + { + "id": "00503", + "name": "一附中", + "full_name": "北大附中" + }, + { + "id": "00504", + "name": "二附中", + "full_name": "北大第二附属中学" + }, + { + "id": "00505", + "name": "附小", + "full_name": "北大附小" + }, + { + "id": "00510", + "name": "印刷厂", + "full_name": "印刷厂" + }, + { + "id": "00511", + "name": "出版社", + "full_name": "出版社" + }, + { + "id": "00551", + "name": "街道办", + "full_name": "燕园街道办事处" + }, + { + "id": "00552", + "name": "派出所", + "full_name": "燕园派出所" + }, + { + "id": "00601", + "name": "党办校办", + "full_name": "党委办公室校长办公室" + }, + { + "id": "00602", + "name": "政策法规研究室", + "full_name": "政策法规研究室" + }, + { + "id": "00603", + "name": "纪委监察室", + "full_name": "纪律检查委员会监察室" + }, + { + "id": "00604", + "name": "组织部", + "full_name": "党委组织部" + }, + { + "id": "00605", + "name": "宣传部", + "full_name": "党委宣传部" + }, + { + "id": "00606", + "name": "统战部", + "full_name": "党委统战部" + }, + { + "id": "00607", + "name": "武装部学工部", + "full_name": "武装部学生工作部" + }, + { + "id": "00608", + "name": "保卫部", + "full_name": "保卫部" + }, + { + "id": "00609", + "name": "人事部", + "full_name": "人事部" + }, + { + "id": "00610", + "name": "国际合作部", + "full_name": "国际合作部" + }, + { + "id": "00611", + "name": "财务部", + "full_name": "财务部" + }, + { + "id": "00612", + "name": "教务部", + "full_name": "教务部" + }, + { + "id": "00613", + "name": "科研部", + "full_name": "科学研究部" + }, + { + "id": "00614", + "name": "研究生院", + "full_name": "研究生院" + }, + { + "id": "00615", + "name": "继续教育部", + "full_name": "继续教育部" + }, + { + "id": "00616", + "name": "房产部", + "full_name": "房地产管理部" + }, + { + "id": "00617", + "name": "基建部", + "full_name": "基建工程部" + }, + { + "id": "00618", + "name": "总务部", + "full_name": "总务部" + }, + { + "id": "00619", + "name": "产业管理部", + "full_name": "产业管理部" + }, + { + "id": "00620", + "name": "社科部", + "full_name": "社会科学部" + }, + { + "id": "00621", + "name": "审计室", + "full_name": "审计室" + }, + { + "id": "00622", + "name": "科技开发部", + "full_name": "科技开发部" + }, + { + "id": "00623", + "name": "设备部", + "full_name": "实验室与设备管理部" + }, + { + "id": "00626", + "name": "保密办", + "full_name": "保密委员会办公室" + }, + { + "id": "00628", + "name": "校友办公室", + "full_name": "校友工作办公室" + }, + { + "id": "00629", + "name": "离退休工作部", + "full_name": "离退休工作部" + }, + { + "id": "00632", + "name": "学科办", + "full_name": "学科建设办公室" + }, + { + "id": "00633", + "name": "国内合作办", + "full_name": "北京大学国内合作委员会办公室" + }, + { + "id": "00634", + "name": "巡察办", + "full_name": "北京大学党委巡察办公室" + }, + { + "id": "00636", + "name": "网信办", + "full_name": "网络安全和信息化委员会办公室" + }, + { + "id": "00637", + "name": "怀柔科学城校区筹建办公室", + "full_name": "怀柔科学城校区筹建办公室" + }, + { + "id": "00638", + "name": "北京大学新校区管理委员会办公室", + "full_name": "北京大学新校区管理委员会办公室" + }, + { + "id": "00639", + "name": "教务长办公室", + "full_name": "教务长办公室" + }, + { + "id": "00651", + "name": "校团委", + "full_name": "校团委" + }, + { + "id": "00652", + "name": "工会", + "full_name": "工会" + }, + { + "id": "00653", + "name": "学位办公室", + "full_name": "学位办公室" + }, + { + "id": "00654", + "name": "昌平园区", + "full_name": "昌平园区" + }, + { + "id": "00655", + "name": "教育基金会", + "full_name": "教育基金会" + }, + { + "id": "00657", + "name": "附中", + "full_name": "北京大学附属中学" + }, + { + "id": "00660", + "name": "出版社", + "full_name": "出版社" + }, + { + "id": "00661", + "name": "档案馆", + "full_name": "档案馆" + }, + { + "id": "00662", + "name": "燕园办事处", + "full_name": "燕园街道办事处" + }, + { + "id": "00664", + "name": "校史馆", + "full_name": "校史馆" + }, + { + "id": "00665", + "name": "校友会", + "full_name": "北京大学校友会" + }, + { + "id": "00666", + "name": "信息办", + "full_name": "信息化办公室" + }, + { + "id": "00668", + "name": "体育馆", + "full_name": "北大体育馆临时管理小组" + }, + { + "id": "00669", + "name": "昌平校区", + "full_name": "昌平校区管理办公室" + }, + { + "id": "00670", + "name": "学生资助中心", + "full_name": "学生资助中心" + }, + { + "id": "00671", + "name": "创新创业学院", + "full_name": "北京大学创新创业学院" + }, + { + "id": "00701", + "name": "校产办", + "full_name": "校办产业管理办公室" + }, + { + "id": "00702", + "name": "仪器厂", + "full_name": "仪器厂" + }, + { + "id": "00703", + "name": "电子仪器厂", + "full_name": "电子仪器厂" + }, + { + "id": "00704", + "name": "无线电厂", + "full_name": "无线电工厂" + }, + { + "id": "00705", + "name": "北佳", + "full_name": "北佳新技术有限公司" + }, + { + "id": "00706", + "name": "方正集团", + "full_name": "方正集团" + }, + { + "id": "00707", + "name": "麦普", + "full_name": "麦普微波器件有限公司" + }, + { + "id": "00708", + "name": "未名公司", + "full_name": "未名生物工程总公司" + }, + { + "id": "00790", + "name": "生物城委员会", + "full_name": "生物城建设委员会" + } +] \ No newline at end of file diff --git a/prisma/migrations/20251113071821_init/migration.sql b/prisma/migrations/20251113071821_init/migration.sql new file mode 100644 index 0000000..a1fc864 --- /dev/null +++ b/prisma/migrations/20251113071821_init/migration.sql @@ -0,0 +1,223 @@ +-- CreateTable +CREATE TABLE "user" ( + "id" TEXT NOT NULL, + "name" TEXT, + "status" TEXT, + "dept_code" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL, + "password" TEXT NOT NULL, + "is_super_admin" BOOLEAN NOT NULL DEFAULT false, + "last_login_at" TIMESTAMPTZ, + + CONSTRAINT "user_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dept" ( + "code" TEXT NOT NULL, + "name" TEXT NOT NULL DEFAULT '', + "full_name" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "dept_pkey" PRIMARY KEY ("code") +); + +-- CreateTable +CREATE TABLE "role" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "role_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "permission" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "selection_log" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "context" TEXT NOT NULL, + "option_id" TEXT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "selection_log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dev_file_type" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "order" INTEGER NOT NULL, + + CONSTRAINT "dev_file_type_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dev_pkg_type" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "order" INTEGER NOT NULL, + + CONSTRAINT "dev_pkg_type_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dev_analyzed_file" ( + "id" SERIAL NOT NULL, + "path" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "commit_id" TEXT NOT NULL DEFAULT '', + "content" TEXT, + "file_type_id" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "description" TEXT NOT NULL, + "exportedMembers" JSONB, + "tags" TEXT[], + "lastAnalyzedAt" TIMESTAMPTZ NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dev_analyzed_file_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dev_analyzed_pkg" ( + "name" TEXT NOT NULL, + "version" TEXT NOT NULL, + "modifiedAt" TIMESTAMP(3) NOT NULL, + "description" TEXT NOT NULL, + "homepage" TEXT, + "repository_url" TEXT, + "pkg_type_id" TEXT NOT NULL, + "projectRoleSummary" TEXT NOT NULL, + "primaryUsagePattern" TEXT NOT NULL, + "relatedFiles" TEXT[], + "relatedFileCount" INTEGER NOT NULL, + "last_analyzed_at" TIMESTAMPTZ NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dev_analyzed_pkg_pkey" PRIMARY KEY ("name") +); + +-- CreateTable +CREATE TABLE "dev_file_dependency" ( + "id" SERIAL NOT NULL, + "source_file_id" INTEGER NOT NULL, + "target_file_path" TEXT NOT NULL, + "usage_description" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dev_file_dependency_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dev_file_pkg_dependency" ( + "id" SERIAL NOT NULL, + "source_file_id" INTEGER NOT NULL, + "package_name" TEXT NOT NULL, + "usage_description" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dev_file_pkg_dependency_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dev_analyzed_folder" ( + "path" TEXT NOT NULL, + "name" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "description" TEXT NOT NULL, + "last_analyzed_at" TIMESTAMPTZ NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dev_analyzed_folder_pkey" PRIMARY KEY ("path") +); + +-- CreateTable +CREATE TABLE "_RoleToUser" ( + "A" INTEGER NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_RoleToUser_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_PermissionToRole" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL, + + CONSTRAINT "_PermissionToRole_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "role_name_key" ON "role"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "permission_name_key" ON "permission"("name"); + +-- CreateIndex +CREATE INDEX "selection_log_userId_context_idx" ON "selection_log"("userId", "context"); + +-- CreateIndex +CREATE INDEX "selection_log_context_option_id_idx" ON "selection_log"("context", "option_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "dev_analyzed_file_path_commit_id_key" ON "dev_analyzed_file"("path", "commit_id"); + +-- CreateIndex +CREATE INDEX "dev_analyzed_pkg_pkg_type_id_idx" ON "dev_analyzed_pkg"("pkg_type_id"); + +-- CreateIndex +CREATE INDEX "dev_file_dependency_target_file_path_idx" ON "dev_file_dependency"("target_file_path"); + +-- CreateIndex +CREATE UNIQUE INDEX "dev_file_dependency_source_file_id_target_file_path_key" ON "dev_file_dependency"("source_file_id", "target_file_path"); + +-- CreateIndex +CREATE INDEX "dev_file_pkg_dependency_package_name_idx" ON "dev_file_pkg_dependency"("package_name"); + +-- CreateIndex +CREATE UNIQUE INDEX "dev_file_pkg_dependency_source_file_id_package_name_key" ON "dev_file_pkg_dependency"("source_file_id", "package_name"); + +-- CreateIndex +CREATE INDEX "_RoleToUser_B_index" ON "_RoleToUser"("B"); + +-- CreateIndex +CREATE INDEX "_PermissionToRole_B_index" ON "_PermissionToRole"("B"); + +-- AddForeignKey +ALTER TABLE "user" ADD CONSTRAINT "user_dept_code_fkey" FOREIGN KEY ("dept_code") REFERENCES "dept"("code") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "selection_log" ADD CONSTRAINT "selection_log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dev_analyzed_file" ADD CONSTRAINT "dev_analyzed_file_file_type_id_fkey" FOREIGN KEY ("file_type_id") REFERENCES "dev_file_type"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dev_analyzed_pkg" ADD CONSTRAINT "dev_analyzed_pkg_pkg_type_id_fkey" FOREIGN KEY ("pkg_type_id") REFERENCES "dev_pkg_type"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dev_file_dependency" ADD CONSTRAINT "dev_file_dependency_source_file_id_fkey" FOREIGN KEY ("source_file_id") REFERENCES "dev_analyzed_file"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dev_file_pkg_dependency" ADD CONSTRAINT "dev_file_pkg_dependency_source_file_id_fkey" FOREIGN KEY ("source_file_id") REFERENCES "dev_analyzed_file"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RoleToUser" ADD CONSTRAINT "_RoleToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RoleToUser" ADD CONSTRAINT "_RoleToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_PermissionToRole" ADD CONSTRAINT "_PermissionToRole_A_fkey" FOREIGN KEY ("A") REFERENCES "permission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_PermissionToRole" ADD CONSTRAINT "_PermissionToRole_B_fkey" FOREIGN KEY ("B") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251114032620_add_kv_config/migration.sql b/prisma/migrations/20251114032620_add_kv_config/migration.sql new file mode 100644 index 0000000..093016c --- /dev/null +++ b/prisma/migrations/20251114032620_add_kv_config/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "kv_config" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL, + + CONSTRAINT "kv_config_pkey" PRIMARY KEY ("key") +); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..8ed525a --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,243 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// 用户表 +model User { + id String @id + name String? + status String? // 在校/减离/NULL + deptCode String? @map("dept_code") // 所属院系代码(外键) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + password String + isSuperAdmin Boolean @default(false) @map("is_super_admin") + lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz + + // 关联 + dept Dept? @relation(fields: [deptCode], references: [code]) + roles Role[] // 多对多关联角色 + selectionLogs SelectionLog[] // 选择日志 + + @@map("user") +} + +// 院系表 +model Dept { + code String @id + name String @default("") + fullName String @default("") @map("full_name") + + // 关联 + users User[] + + @@map("dept") +} + +// 角色表 +model Role { + id Int @id @default(autoincrement()) + name String @unique + users User[] // 多对多关联用户 + permissions Permission[] // 多对多关联权限 + + @@map("role") +} + +// 权限表 +model Permission { + id Int @id @default(autoincrement()) + name String @unique + roles Role[] // 多对多关联角色 + + @@map("permission") +} + +// 选择日志表 +model SelectionLog { + id Int @id @default(autoincrement()) + userId String // 关联到用户 + user User @relation(fields: [userId], references: [id]) + + // 用于标识是哪个的选项,用.进行分隔,例如"user.filter.dept" + context String + + // 关键字段:被选中的选项的值 + optionId String @map("option_id") + + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + + // 建立索引,提升查询性能 + @@index([userId, context]) + @@index([context, optionId]) + @@map("selection_log") +} + +// KV配置表 - 用于存储各种键值对配置 +model KVConfig { + key String @id // 配置键 + value String @db.Text // 配置值 + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + @@map("kv_config") +} + + + + + + + + + +/*********************************************** 仅在开发阶段可用的数据表放在这下面 ***********************************************/ +model DevFileType { + id String @id // 文件类型ID,如 "COMPONENT_UI" + name String // 文件类型名称,如 "UI组件" + description String // 文件类型描述 + order Int // 用来进行排序方便展示 + + // 关联 + files DevAnalyzedFile[] + + @@map("dev_file_type") +} + +// 依赖包类型表 +model DevPkgType { + id String @id // 依赖包类型ID,如 "CORE_FRAMEWORK" + name String // 分类名称,如 "核心框架 (Core Framework)" + description String @db.Text // 职责描述 + order Int // 用来进行排序方便展示 + + packages DevAnalyzedPkg[] + @@map("dev_pkg_type") +} + +// 分析得到的项目文件信息 +model DevAnalyzedFile { + id Int @id @default(autoincrement()) + path String // 文件相对路径,如 "src/app/api/trpc/[trpc]/route.ts" + fileName String // 文件名,如 "route.ts" + commitId String @default("") @map("commit_id") // Git commit ID (前7位,修改过的文件后面加*) + content String? @db.Text // 文件内容(不超过100K的非二进制文件) + + fileTypeId String @map("file_type_id") // 文件类型ID(外键) + summary String // 主要功能一句话总结 (LLM生成) + description String // 详细功能描述 (LLM生成) + + // 关键代码信息 + exportedMembers Json? // 导出的函数、组件、类型、对象、列表、其他 { name, type }[] + + // 元数据 + tags String[] // 标签 (用于快速筛选和分类) + lastAnalyzedAt DateTime @updatedAt @db.Timestamptz + createdAt DateTime @default(now()) @db.Timestamptz // 创建时间 + + // 关联 + fileType DevFileType @relation(fields: [fileTypeId], references: [id]) + dependencies DevFileDependency[] // 该文件依赖的其他文件 + pkgDependencies DevFilePkgDependency[] // 该文件依赖的包 + + // path 和 commitId 构成唯一键 + @@unique([path, commitId], name: "uidx_path_commit") + @@map("dev_analyzed_file") +} + +// 分析得到的依赖包信息 +model DevAnalyzedPkg { + name String @id // 包名,如 '@tanstack/react-table' + + // -- 静态信息,通过读取node_modules中包的package.json获取,内置包则为node的信息 + version String // 版本号,如 '8.17.3' + modifiedAt DateTime // 该版本的发布时间,通过命令获取 npm view @tanstack/react-table "time[8.17.3]" + description String @db.Text // 从 package.json 中获取的官方描述 + homepage String? // 包的主页URL + repositoryUrl String? @map("repository_url") // 包的仓库URL,例如git+https://github.com/shadcn/ui.git + + // -- 调用AI获取 + pkgTypeId String @map("pkg_type_id") // 依赖包类型ID (外键) + projectRoleSummary String @db.Text // AI总结的,该包的核心功能 + primaryUsagePattern String @db.Text // AI总结的主要使用模式,分成多点描述 + + // -- 统计获得 + relatedFiles String[] // path[] 本次分析中,认为和该库有关联的文件(统计一定引用层数) + relatedFileCount Int // 该包在项目中被多少个文件直接或引用 + + // --- 时间戳 --- + lastAnalyzedAt DateTime @updatedAt @map("last_analyzed_at") @db.Timestamptz + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + + // --- 关联 --- + pkgType DevPkgType @relation(fields: [pkgTypeId], references: [id]) + + @@index([pkgTypeId]) + @@map("dev_analyzed_pkg") +} + +// 文件依赖关系表 +model DevFileDependency { + id Int @id @default(autoincrement()) + sourceFileId Int @map("source_file_id") // 源文件ID(依赖方) + targetFilePath String @map("target_file_path") // 目标文件路径(被依赖方) + + // AI生成的依赖用途描述 + usageDescription String? @map("usage_description") @db.Text // 描述该依赖在源文件中的用途 + + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + + // 关联 + sourceFile DevAnalyzedFile @relation(fields: [sourceFileId], references: [id], onDelete: Cascade) + + // 确保同一个源文件不会重复依赖同一个目标文件 + @@unique([sourceFileId, targetFilePath], name: "uidx_source_target") + @@index([targetFilePath]) // 加速按目标文件路径查询 + @@map("dev_file_dependency") +} + +// 包依赖关系表 +model DevFilePkgDependency { + id Int @id @default(autoincrement()) + sourceFileId Int @map("source_file_id") // 源文件ID(依赖方) + packageName String @map("package_name") // 包名(如 'react' 或 '@tanstack/react-table') + + // AI生成的依赖用途描述 + usageDescription String? @map("usage_description") @db.Text // 描述该包在源文件中的用途和使用的功能 + + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + + // 关联 + sourceFile DevAnalyzedFile @relation(fields: [sourceFileId], references: [id], onDelete: Cascade) + + // 确保同一个源文件不会重复依赖同一个包 + @@unique([sourceFileId, packageName], name: "uidx_source_package") + @@index([packageName]) // 加速按包名查询 + @@map("dev_file_pkg_dependency") +} + +// 分析得到的项目文件夹信息 +model DevAnalyzedFolder { + path String @id // 文件夹相对路径,如 "src/app/api" + name String // 文件夹名,如 "api" + + summary String // 主要功能一句话总结 (LLM生成) + description String @db.Text // 详细功能描述 (LLM生成) + + // 元数据 + lastAnalyzedAt DateTime @updatedAt @map("last_analyzed_at") @db.Timestamptz + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + + @@map("dev_analyzed_folder") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..3901899 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,180 @@ +import { PrismaClient } from '@prisma/client' +import bcrypt from 'bcryptjs' +import { Permissions, ALL_PERMISSIONS } from '../src/constants/permissions' +import fs from 'fs' +import path from 'path' + +const prisma = new PrismaClient() + +// 解析 JSON 文件并导入院系数据 +async function importDepartments() { + const jsonPath = path.join(__dirname, 'init_data', '院系.json') + const jsonContent = fs.readFileSync(jsonPath, 'utf-8') + const departments = JSON.parse(jsonContent) + + console.log(`开始导入 ${departments.length} 个院系...`) + + await Promise.all( + departments.map((dept: any) => { + return prisma.dept.upsert({ + where: { code: dept.id }, + update: { + name: dept.name, + fullName: dept.full_name, + }, + create: { + code: dept.id, + name: dept.name, + fullName: dept.full_name, + }, + }) + }) + ) + console.log('院系数据导入完成') +} + +async function main() { + console.log('开始数据库初始化...') + + // 插入权限 + for (const permName of ALL_PERMISSIONS) { + await prisma.permission.upsert({ + where: { name: permName }, + update: {}, + create: { name: permName }, + }) + } + + // 角色与权限映射 + const rolePermissionsMap: Record = { + 系统管理员: ALL_PERMISSIONS, + } + + // 插入角色 + for (const [roleName, perms] of Object.entries(rolePermissionsMap)) { + await prisma.role.upsert({ + where: { name: roleName }, + update: {}, + create: { + name: roleName, + permissions: { + connect: perms.map((name) => ({ name })), + }, + }, + }) + } + + await importDepartments() + + // 创建测试用户 + const usersToCreate = [ + { id: 'user1', name: '用户甲', status: '在校', deptCode: '00001', roleNames: [] }, + { id: 'sys_admin', name: '系统管理员', status: '在校', deptCode: '00001', roleNames: ['系统管理员'] }, + { id: 'super_admin', password: process.env.SUPER_ADMIN_PASSWORD, name: '超级管理员', status: '在校', deptCode: '00001', roleNames: [], isSuperAdmin: true }, + { id: 'unknown', name: '未知用户', status: '在校', deptCode: '00001', roleNames: [] }, + ] + + for (const u of usersToCreate) { + const password = await bcrypt.hash(u.password ?? '123456', 12) + await prisma.user.upsert({ + where: { id: u.id }, + update: { + name: u.name, + status: u.status, + deptCode: u.deptCode, + password, + isSuperAdmin: u.isSuperAdmin ?? false, + roles: { + set: u.roleNames.map((name) => ({ name })), + }, + }, + create: { + id: u.id, + name: u.name, + status: u.status, + deptCode: u.deptCode, + password, + isSuperAdmin: u.isSuperAdmin ?? false, + roles: { + connect: u.roleNames.map((name) => ({ name })), + }, + }, + }) + } + + // 插入文件类型(仅开发环境) + const fileTypes = [ + { id: 'COMPONENT_UI', name: 'UI组件', description: '使用"use client",不涉及复杂数据获取,专注于UI和交互,通用性强' }, + { id: 'COMPONENT_FEATURE', name: '业务组件', description: '使用"use client",不涉及复杂数据获取,专注于UI和交互,与业务关联性强' }, + { id: 'COMPONENT_PAGE', name: '页面组件', description: 'src/app下的page.tsx文件,是页面的主入口' }, + { id: 'COMPONENT_LAYOUT', name: '布局组件', description: '不对应某个特定页面的前端组件,例如定义页面布局的文件' }, + { id: 'COMPONENT_REF', name: '组件关联文件', description: '前端组件相关的其他文件,例如定义表格列属性的columns.tsx文件' }, + { id: 'API_TRPC', name: 'tRPC API', description: '基于tRPC的API' }, + { id: 'API_NEXT', name: 'NextJS原生API', description: '直接基于NextJS框架构建的API' }, + { id: 'BACKGROUND_JOB', name: '后台任务', description: '执行后台任务的文件(worker/queue)' }, + { id: 'HOOK', name: 'React Hook', description: '文件名以use开头,导出自定义React Hook' }, + { id: 'UTIL', name: '工具函数', description: '提供纯函数、常量等通用工具' }, + { id: 'SCHEMA', name: '数据模式', description: '定义数据长什么样的文件,通常用于保证前后端数据的一致性,对数据进行校验' }, + { id: 'STYLES', name: '样式文件', description: '全局或局部样式文件' }, + { id: 'ASSET', name: '资源文件', description: '图片(包括svg)、视频、音频、文本等' }, + { id: 'TYPE_DEFINITION', name: '类型定义', description: '主要用于导出TypeScript类型和常量' }, + { id: 'GENERATE', name: '自动生成', description: '自动生成的文件' }, + { id: 'SCRIPT', name: '脚本文件', description: '独立于项目,单独运行的文件,例如prisma/seed.ts' }, + { id: 'FRAMEWORK', name: '框架配置', description: '各种前端库约定俗成的配置文件,例如schema.prisma' }, + { id: 'CONFIG', name: '项目配置', description: '项目级别的配置文件,通常位于项目根目录下,例如package.json' }, + { id: 'OTHER', name: '其他', description: '无法归入以上分类的文件' }, + ] + + for (let index = 0; index < fileTypes.length; index++) { + const fileType = fileTypes[index] + await prisma.devFileType.upsert({ + where: { id: fileType.id }, + update: { + name: fileType.name, + description: fileType.description, + order: (index + 1) * 10, + }, + create: {...fileType, order: (index + 1) * 10}, + }) + } + console.log('文件类型数据初始化完成') + + // 插入依赖包类型(仅开发环境) + const pkgTypes = [ + { id: 'CORE_FRAMEWORK', name: '核心框架', description: '构成应用程序骨架的基础技术,决定了项目的基本结构和运行方式,例如next、react、react-dom、vue、angular、svelte、remix、solid-js、nuxt、gatsby。' }, + { id: 'UI_INTERACTION', name: 'UI & 交互', description: '负责构建用户界面、处理用户交互和视觉呈现的所有库,例如radix-ui/react-xxx、shadcn/ui、@dnd-kit/core、@tanstack/react-table、react-hook-form、lucide-react、framer-motion、antd、mui、chakra-ui、bootstrap、tailwindcss、emotion、styled-components、react-select、react-datepicker、react-toastify、react-icons。' }, + { id: 'API_DATA_COMMS', name: 'API & 数据通信', description: '负责前后端数据交换、API定义和请求,例如@trpc/server、@trpc/client、@tanstack/react-query、axios、graphql-request、apollo-client、ws。' }, + { id: 'DATA_LAYER', name: '数据层', description: '负责与数据库、缓存、对象存储等进行交互,例如@prisma/client、ioredis、minio、mongoose、typeorm、sequelize、knex、redis、mongodb、pg、mysql、sqlite3、firebase、supabase。' }, + { id: 'BACKGROUND_JOBS', name: '后台任务', description: '用于处理异步、长时间运行或计划任务,例如bullmq、agenda、node-cron、bree、bull、kue、bee-queue、sqs-consumer、rabbitmq、bull-board、bull-arena。' }, + { id: 'SECURITY_AUTH', name: '安全 & 认证', description: '负责用户身份验证、授权和数据加密,例如next-auth、bcryptjs、jsonwebtoken、passport、oauth2orize、casl、argon2、express-session、helmet、csrf、@auth0/auth0-react。' }, + { id: 'AI_LLM', name: 'AI & 大模型', description: '专门用于与大型语言模型或其他AI服务进行交互,例如ai、@ai-sdk/openai、@ai-sdk/anthropic、openai、@huggingface/inference、langchain、replicate、cohere-ai、stability-sdk、transformers、vertex-ai。' }, + { id: 'UTILITIES', name: '通用工具', description: '提供特定功能的辅助函数库,如日期处理、状态管理、验证等,例如zod、date-fns、nanoid、lodash、ramda、moment、uuid、joi、yup、clsx、tailwind-merge、deepmerge、numeral、dayjs、chalk、debug、dotenv。' }, + { id: 'NODEJS_CORE', name: 'Node.js核心', description: 'Node.js运行时内置的模块,用于底层操作如文件系统、路径处理等,例如fs、path、child_process、util、module、os、http、crypto、events、stream、process、net、url、assert。' }, + ] + + for (let index = 0; index < pkgTypes.length; index++) { + const pkgType = pkgTypes[index] + await prisma.devPkgType.upsert({ + where: { id: pkgType.id }, + update: { + name: pkgType.name, + description: pkgType.description, + order: (index + 1) * 10, + }, + create: {...pkgType, order: (index + 1) * 10}, + }) + } + console.log('依赖包类型数据初始化完成') + + console.log('数据库初始化完成') +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) \ No newline at end of file diff --git a/public/js/browser-image-compression.js b/public/js/browser-image-compression.js new file mode 100644 index 0000000..c7fe920 --- /dev/null +++ b/public/js/browser-image-compression.js @@ -0,0 +1,9 @@ +/** + * Browser Image Compression + * v2.0.2 + * by Donald + * https://github.com/Donaldcwl/browser-image-compression + */ + +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).imageCompression=t()}(this,(function(){"use strict";function _mergeNamespaces(e,t){return t.forEach((function(t){t&&"string"!=typeof t&&!Array.isArray(t)&&Object.keys(t).forEach((function(r){if("default"!==r&&!(r in e)){var i=Object.getOwnPropertyDescriptor(t,r);Object.defineProperty(e,r,i.get?i:{enumerable:!0,get:function(){return t[r]}})}}))})),Object.freeze(e)}function copyExifWithoutOrientation(e,t){return new Promise((function(r,i){let o;return getApp1Segment(e).then((function(e){try{return o=e,r(new Blob([t.slice(0,2),o,t.slice(2)],{type:"image/jpeg"}))}catch(e){return i(e)}}),i)}))}const getApp1Segment=e=>new Promise(((t,r)=>{const i=new FileReader;i.addEventListener("load",(({target:{result:e}})=>{const i=new DataView(e);let o=0;if(65496!==i.getUint16(o))return r("not a valid JPEG");for(o+=2;;){const a=i.getUint16(o);if(65498===a)break;const s=i.getUint16(o+2);if(65505===a&&1165519206===i.getUint32(o+4)){const a=o+10;let f;switch(i.getUint16(a)){case 18761:f=!0;break;case 19789:f=!1;break;default:return r("TIFF header contains invalid endian")}if(42!==i.getUint16(a+2,f))return r("TIFF header contains invalid version");const l=i.getUint32(a+4,f),c=a+l+2+12*i.getUint16(a+l,f);for(let e=a+l+2;e>>24&255,i[r+1]=o>>>16&255,i[r+2]=o>>>8&255,i[r+3]=o>>>0&255,new Uint8Array(i.buffer,0,r+4)},UZIP.deflateRaw=function(e,t){null==t&&(t={level:6});var r=new Uint8Array(50+Math.floor(1.1*e.length)),i=UZIP.F.deflateRaw(e,r,i,t.level);return new Uint8Array(r.buffer,0,i)},UZIP.encode=function(e,t){null==t&&(t=!1);var r=0,i=UZIP.bin.writeUint,o=UZIP.bin.writeUshort,a={};for(var s in e){var f=!UZIP._noNeed(s)&&!t,l=e[s],c=UZIP.crc.crc(l,0,l.length);a[s]={cpr:f,usize:l.length,crc:c,file:f?UZIP.deflateRaw(l):l}}for(var s in a)r+=a[s].file.length+30+46+2*UZIP.bin.sizeUTF8(s);r+=22;var u=new Uint8Array(r),h=0,d=[];for(var s in a){var A=a[s];d.push(h),h=UZIP._writeHeader(u,h,s,A,0)}var g=0,p=h;for(var s in a){A=a[s];d.push(h),h=UZIP._writeHeader(u,h,s,A,1,d[g++])}var m=h-p;return i(u,h,101010256),h+=4,o(u,h+=4,g),o(u,h+=2,g),i(u,h+=2,m),i(u,h+=4,p),h+=4,h+=2,u.buffer},UZIP._noNeed=function(e){var t=e.split(".").pop().toLowerCase();return-1!="png,jpg,jpeg,zip".indexOf(t)},UZIP._writeHeader=function(e,t,r,i,o,a){var s=UZIP.bin.writeUint,f=UZIP.bin.writeUshort,l=i.file;return s(e,t,0==o?67324752:33639248),t+=4,1==o&&(t+=2),f(e,t,20),f(e,t+=2,0),f(e,t+=2,i.cpr?8:0),s(e,t+=2,0),s(e,t+=4,i.crc),s(e,t+=4,l.length),s(e,t+=4,i.usize),f(e,t+=4,UZIP.bin.sizeUTF8(r)),f(e,t+=2,0),t+=2,1==o&&(t+=2,t+=2,s(e,t+=6,a),t+=4),t+=UZIP.bin.writeUTF8(e,t,r),0==o&&(e.set(l,t),t+=l.length),t},UZIP.crc={table:function(){for(var e=new Uint32Array(256),t=0;t<256;t++){for(var r=t,i=0;i<8;i++)1&r?r=3988292384^r>>>1:r>>>=1;e[t]=r}return e}(),update:function(e,t,r,i){for(var o=0;o>>8;return e},crc:function(e,t,r){return 4294967295^UZIP.crc.update(4294967295,e,t,r)}},UZIP.adler=function(e,t,r){for(var i=1,o=0,a=t,s=t+r;a>8&255},readUint:function(e,t){return 16777216*e[t+3]+(e[t+2]<<16|e[t+1]<<8|e[t])},writeUint:function(e,t,r){e[t]=255&r,e[t+1]=r>>8&255,e[t+2]=r>>16&255,e[t+3]=r>>24&255},readASCII:function(e,t,r){for(var i="",o=0;o>6,e[t+o+1]=128|s>>0&63,o+=2;else if(0==(4294901760&s))e[t+o]=224|s>>12,e[t+o+1]=128|s>>6&63,e[t+o+2]=128|s>>0&63,o+=3;else{if(0!=(4292870144&s))throw"e";e[t+o]=240|s>>18,e[t+o+1]=128|s>>12&63,e[t+o+2]=128|s>>6&63,e[t+o+3]=128|s>>0&63,o+=4}}return o},sizeUTF8:function(e){for(var t=e.length,r=0,i=0;i>>3}var d=a.lits,A=a.strt,g=a.prev,p=0,m=0,w=0,v=0,b=0,y=0;for(h>2&&(A[y=UZIP.F._hash(e,0)]=0),l=0;l14e3||m>26697)&&h-l>100&&(u>>16,B=65535&F;if(0!=F){B=65535&F;var U=s(_=F>>>16,a.of0);a.lhst[257+U]++;var C=s(B,a.df0);a.dhst[C]++,v+=a.exb[U]+a.dxb[C],d[p]=_<<23|l-u,d[p+1]=B<<16|U<<8|C,p+=2,u=l+_}else a.lhst[e[l]]++;m++}}for(w==l&&0!=e.length||(u>>3},UZIP.F._bestMatch=function(e,t,r,i,o,a){var s=32767&t,f=r[s],l=s-f+32768&32767;if(f==s||i!=UZIP.F._hash(e,t-l))return 0;for(var c=0,u=0,h=Math.min(32767,t);l<=h&&0!=--a&&f!=s;){if(0==c||e[t+c]==e[t+c-l]){var d=UZIP.F._howLong(e,t,l);if(d>c){if(u=l,(c=d)>=o)break;l+2A&&(A=m,f=p)}}}l+=(s=f)-(f=r[s])+32768&32767}return c<<16|u},UZIP.F._howLong=function(e,t,r){if(e[t]!=e[t-r]||e[t+1]!=e[t+1-r]||e[t+2]!=e[t+2-r])return 0;var i=t,o=Math.min(e.length,t+258);for(t+=3;t>>23,R=M+(8388607&T);M>16,H=O>>8&255,L=255&O;y(f,l=UZIP.F._writeLit(257+H,C,f,l),S-v.of0[H]),l+=v.exb[H],b(f,l=UZIP.F._writeLit(L,I,f,l),P-v.df0[L]),l+=v.dxb[L],M+=S}}l=UZIP.F._writeLit(256,C,f,l)}return l},UZIP.F._copyExact=function(e,t,r,i,o){var a=o>>>3;return i[a]=r,i[a+1]=r>>>8,i[a+2]=255-i[a],i[a+3]=255-i[a+1],a+=4,i.set(new Uint8Array(e.buffer,t,r),a),o+(r+4<<3)},UZIP.F.getTrees=function(){for(var e=UZIP.F.U,t=UZIP.F._hufTree(e.lhst,e.ltree,15),r=UZIP.F._hufTree(e.dhst,e.dtree,15),i=[],o=UZIP.F._lenCodes(e.ltree,i),a=[],s=UZIP.F._lenCodes(e.dtree,a),f=0;f4&&0==e.itree[1+(e.ordr[c-1]<<1)];)c--;return[t,r,l,o,s,c,i,a]},UZIP.F.getSecond=function(e){for(var t=[],r=0;r>1)+",");return t},UZIP.F.contSize=function(e,t){for(var r=0,i=0;i15&&(UZIP.F._putsE(r,i,s,f),i+=f)}return i},UZIP.F._lenCodes=function(e,t){for(var r=e.length;2!=r&&0==e[r-1];)r-=2;for(var i=0;i>>1,138))<11?t.push(17,c-3):t.push(18,c-11),i+=2*c-2}else if(o==f&&a==o&&s==o){for(l=i+5;l+2>>1,6);t.push(16,c-3),i+=2*c-2}else t.push(o,0)}return r>>>1},UZIP.F._hufTree=function(e,t,r){var i=[],o=e.length,a=t.length,s=0;for(s=0;sr&&(UZIP.F.restrictDepth(l,r,p),p=r),s=0;st;i++){var s=e[i].d;e[i].d=t,a+=o-(1<>>=r-t;a>0;){(s=e[i].d)=0;i--)e[i].d==t&&a<0&&(e[i].d--,a++);0!=a&&console.log("debt left")},UZIP.F._goodIndex=function(e,t){var r=0;return t[16|r]<=e&&(r|=16),t[8|r]<=e&&(r|=8),t[4|r]<=e&&(r|=4),t[2|r]<=e&&(r|=2),t[1|r]<=e&&(r|=1),r},UZIP.F._writeLit=function(e,t,r,i){return UZIP.F._putsF(r,i,t[e<<1]),i+t[1+(e<<1)]},UZIP.F.inflate=function(e,t){var r=Uint8Array;if(3==e[0]&&0==e[1])return t||new r(0);var i=UZIP.F,o=i._bitsF,a=i._bitsE,s=i._decodeTiny,f=i.makeCodes,l=i.codes2map,c=i._get17,u=i.U,h=null==t;h&&(t=new r(e.length>>>2<<3));for(var d,A,g=0,p=0,m=0,w=0,v=0,b=0,y=0,E=0,F=0;0==g;)if(g=o(e,F,1),p=o(e,F+1,2),F+=3,0!=p){if(h&&(t=UZIP.F._check(t,E+(1<<17))),1==p&&(d=u.flmap,A=u.fdmap,b=511,y=31),2==p){m=a(e,F,5)+257,w=a(e,F+5,5)+1,v=a(e,F+10,4)+4,F+=14;for(var _=0;_<38;_+=2)u.itree[_]=0,u.itree[_+1]=0;var B=1;for(_=0;_B&&(B=U)}F+=3*v,f(u.itree,B),l(u.itree,B,u.imap),d=u.lmap,A=u.dmap,F=s(u.imap,(1<>>4;if(M>>>8==0)t[E++]=M;else{if(256==M)break;var x=E+M-254;if(M>264){var T=u.ldef[M-257];x=E+(T>>>3)+a(e,F,7&T),F+=7&T}var S=A[c(e,F)&y];F+=15&S;var R=S>>>4,O=u.ddef[R],P=(O>>>4)+o(e,F,15&O);for(F+=15&O,h&&(t=UZIP.F._check(t,E+(1<<17)));E>>3),L=e[H-4]|e[H-3]<<8;h&&(t=UZIP.F._check(t,E+L)),t.set(new r(e.buffer,e.byteOffset+H,L),E),F=H+L<<3,E+=L}return t.length==E?t:t.slice(0,E)},UZIP.F._check=function(e,t){var r=e.length;if(t<=r)return e;var i=new Uint8Array(Math.max(r<<1,t));return i.set(e,0),i},UZIP.F._decodeTiny=function(e,t,r,i,o,a){for(var s=UZIP.F._bitsE,f=UZIP.F._get17,l=0;l>>4;if(u<=15)a[l]=u,l++;else{var h=0,d=0;16==u?(d=3+s(i,o,2),o+=2,h=a[l-1]):17==u?(d=3+s(i,o,3),o+=3):18==u&&(d=11+s(i,o,7),o+=7);for(var A=l+d;l>>1;ao&&(o=f),a++}for(;a>1,f=e[a+1],l=s<<4|f,c=t-f,u=e[a]<>>15-t]=l,u++}},UZIP.F.revCodes=function(e,t){for(var r=UZIP.F.U.rev15,i=15-t,o=0;o>>i}},UZIP.F._putsE=function(e,t,r){r<<=7&t;var i=t>>>3;e[i]|=r,e[i+1]|=r>>>8},UZIP.F._putsF=function(e,t,r){r<<=7&t;var i=t>>>3;e[i]|=r,e[i+1]|=r>>>8,e[i+2]|=r>>>16},UZIP.F._bitsE=function(e,t,r){return(e[t>>>3]|e[1+(t>>>3)]<<8)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)},UZIP.F._get25=function(e,t){return(e[t>>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16|e[3+(t>>>3)]<<24)>>>(7&t)},UZIP.F.U=(t=Uint16Array,r=Uint32Array,{next_code:new t(16),bl_count:new t(16),ordr:[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],of0:[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,999,999,999],exb:[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0],ldef:new t(32),df0:[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,65535,65535],dxb:[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0],ddef:new r(32),flmap:new t(512),fltree:[],fdmap:new t(32),fdtree:[],lmap:new t(32768),ltree:[],ttree:[],dmap:new t(32768),dtree:[],imap:new t(512),itree:[],rev15:new t(32768),lhst:new r(286),dhst:new r(30),ihst:new r(19),lits:new r(15e3),strt:new t(65536),prev:new t(32768)}),function(){for(var e=UZIP.F.U,t=0;t<32768;t++){var r=t;r=(4278255360&(r=(4042322160&(r=(3435973836&(r=(2863311530&r)>>>1|(1431655765&r)<<1))>>>2|(858993459&r)<<2))>>>4|(252645135&r)<<4))>>>8|(16711935&r)<<8,e.rev15[t]=(r>>>16|r<<16)>>>17}function pushV(e,t,r){for(;0!=t--;)e.push(0,r)}for(t=0;t<32;t++)e.ldef[t]=e.of0[t]<<3|e.exb[t],e.ddef[t]=e.df0[t]<<4|e.dxb[t];pushV(e.fltree,144,8),pushV(e.fltree,112,9),pushV(e.fltree,24,7),pushV(e.fltree,8,8),UZIP.F.makeCodes(e.fltree,9),UZIP.F.codes2map(e.fltree,9,e.flmap),UZIP.F.revCodes(e.fltree,9),pushV(e.fdtree,32,5),UZIP.F.makeCodes(e.fdtree,5),UZIP.F.codes2map(e.fdtree,5,e.fdmap),UZIP.F.revCodes(e.fdtree,5),pushV(e.itree,19,0),pushV(e.ltree,286,0),pushV(e.dtree,30,0),pushV(e.ttree,320,0)}()}({get exports(){return e},set exports(t){e=t}});var UZIP=_mergeNamespaces({__proto__:null,default:e},[e]);const UPNG=function(){var e={nextZero(e,t){for(;0!=e[t];)t++;return t},readUshort:(e,t)=>e[t]<<8|e[t+1],writeUshort(e,t,r){e[t]=r>>8&255,e[t+1]=255&r},readUint:(e,t)=>16777216*e[t]+(e[t+1]<<16|e[t+2]<<8|e[t+3]),writeUint(e,t,r){e[t]=r>>24&255,e[t+1]=r>>16&255,e[t+2]=r>>8&255,e[t+3]=255&r},readASCII(e,t,r){let i="";for(let o=0;oe.length<2?`0${e}`:e,readUTF8(t,r,i){let o,a="";for(let o=0;o>3)]>>7-((7&A)<<0)&1);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>2)]>>6-((3&A)<<1)&3);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>1)]>>4-((1&A)<<2)&15);l[m]=e[y],l[m+1]=e[y+1],l[m+2]=e[y+2],l[m+3]=E>>3)]>>>7-(7&B)&1))==255*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(2==h)for(B=0;B>>2)]>>>6-((3&B)<<1)&3))==85*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(4==h)for(B=0;B>>1)]>>>4-((1&B)<<2)&15))==17*p?0:255;c[i+B]=U<<24|F<<16|F<<8|F}else if(8==h)for(B=0;B>3,s=Math.ceil(r*o/8),f=new Uint8Array(i*s);let l=0;const c=[0,0,4,0,2,0,1],u=[0,4,0,2,0,1,0],h=[8,8,8,4,4,2,2],d=[8,8,4,4,2,2,1];let A=0;for(;A<7;){const p=h[A],m=d[A];let w=0,v=0,b=c[A];for(;b>3])>>7-(7&i)&1,f[_*s+(t>>3)]|=g<<7-((7&t)<<0);if(2==o)g=(g=e[i>>3])>>6-(7&i)&3,f[_*s+(t>>2)]|=g<<6-((3&t)<<1);if(4==o)g=(g=e[i>>3])>>4-(7&i)&15,f[_*s+(t>>1)]|=g<<4-((1&t)<<2);if(o>=8){const r=_*s+t*a;for(let t=0;t>3)+t]}i+=o,t+=m}F++,_+=p}w*v!=0&&(l+=v*(1+E)),A+=1}return f}(r,e)),r}function _inflate(e,r){return t(new Uint8Array(e.buffer,2,e.length-6),r)}var t=function(){const e={H:{}};return e.H.N=function(t,r){const i=Uint8Array;let o,a,s=0,f=0,l=0,c=0,u=0,h=0,d=0,A=0,g=0;if(3==t[0]&&0==t[1])return r||new i(0);const p=e.H,m=p.b,w=p.e,v=p.R,b=p.n,y=p.A,E=p.Z,F=p.m,_=null==r;for(_&&(r=new i(t.length>>>2<<5));0==s;)if(s=m(t,g,1),f=m(t,g+1,2),g+=3,0!=f){if(_&&(r=e.H.W(r,A+(1<<17))),1==f&&(o=F.J,a=F.h,h=511,d=31),2==f){l=w(t,g,5)+257,c=w(t,g+5,5)+1,u=w(t,g+10,4)+4,g+=14;let e=1;for(var B=0;B<38;B+=2)F.Q[B]=0,F.Q[B+1]=0;for(B=0;Be&&(e=r)}g+=3*u,b(F.Q,e),y(F.Q,e,F.u),o=F.w,a=F.d,g=v(F.u,(1<>>4;if(i>>>8==0)r[A++]=i;else{if(256==i)break;{let e=A+i-254;if(i>264){const r=F.q[i-257];e=A+(r>>>3)+w(t,g,7&r),g+=7&r}const o=a[E(t,g)&d];g+=15&o;const s=o>>>4,f=F.c[s],l=(f>>>4)+m(t,g,15&f);for(g+=15&f;A>>3),a=t[o-4]|t[o-3]<<8;_&&(r=e.H.W(r,A+a)),r.set(new i(t.buffer,t.byteOffset+o,a),A),g=o+a<<3,A+=a}return r.length==A?r:r.slice(0,A)},e.H.W=function(e,t){const r=e.length;if(t<=r)return e;const i=new Uint8Array(r<<1);return i.set(e,0),i},e.H.R=function(t,r,i,o,a,s){const f=e.H.e,l=e.H.Z;let c=0;for(;c>>4;if(i<=15)s[c]=i,c++;else{let e=0,t=0;16==i?(t=3+f(o,a,2),a+=2,e=s[c-1]):17==i?(t=3+f(o,a,3),a+=3):18==i&&(t=11+f(o,a,7),a+=7);const r=c+t;for(;c>>1;for(;ao&&(o=r),a++}for(;a>1,s=t[e+1],f=o<<4|s,l=r-s;let c=t[e]<>>15-r]=f,c++}}},e.H.l=function(t,r){const i=e.H.m.r,o=15-r;for(let e=0;e>>o}},e.H.M=function(e,t,r){r<<=7&t;const i=t>>>3;e[i]|=r,e[i+1]|=r>>>8},e.H.I=function(e,t,r){r<<=7&t;const i=t>>>3;e[i]|=r,e[i+1]|=r>>>8,e[i+2]|=r>>>16},e.H.e=function(e,t,r){return(e[t>>>3]|e[1+(t>>>3)]<<8)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)&(1<>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16)>>>(7&t)},e.H.i=function(e,t){return(e[t>>>3]|e[1+(t>>>3)]<<8|e[2+(t>>>3)]<<16|e[3+(t>>>3)]<<24)>>>(7&t)},e.H.m=function(){const e=Uint16Array,t=Uint32Array;return{K:new e(16),j:new e(16),X:[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],S:[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,999,999,999],T:[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0],q:new e(32),p:[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,65535,65535],z:[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0],c:new t(32),J:new e(512),_:[],h:new e(32),$:[],w:new e(32768),C:[],v:[],d:new e(32768),D:[],u:new e(512),Q:[],r:new e(32768),s:new t(286),Y:new t(30),a:new t(19),t:new t(15e3),k:new e(65536),g:new e(32768)}}(),function(){const t=e.H.m;for(var r=0;r<32768;r++){let e=r;e=(2863311530&e)>>>1|(1431655765&e)<<1,e=(3435973836&e)>>>2|(858993459&e)<<2,e=(4042322160&e)>>>4|(252645135&e)<<4,e=(4278255360&e)>>>8|(16711935&e)<<8,t.r[r]=(e>>>16|e<<16)>>>17}function n(e,t,r){for(;0!=t--;)e.push(0,r)}for(r=0;r<32;r++)t.q[r]=t.S[r]<<3|t.T[r],t.c[r]=t.p[r]<<4|t.z[r];n(t._,144,8),n(t._,112,9),n(t._,24,7),n(t._,8,8),e.H.n(t._,9),e.H.A(t._,9,t.J),e.H.l(t._,9),n(t.$,32,5),e.H.n(t.$,5),e.H.A(t.$,5,t.h),e.H.l(t.$,5),n(t.Q,19,0),n(t.C,286,0),n(t.D,30,0),n(t.v,320,0)}(),e.H.N}();function _getBPP(e){return[1,null,3,1,2,null,4][e.ctype]*e.depth}function _filterZero(e,t,r,i,o){let a=_getBPP(t);const s=Math.ceil(i*a/8);let f,l;a=Math.ceil(a/8);let c=e[r],u=0;if(c>1&&(e[r]=[0,0,1][c-2]),3==c)for(u=a;u>>1)&255;for(let t=0;t>>1);for(;u>>1)}else{for(;u=0&&f>=0?(h=r*t+a<<2,d=(f+r)*o+s+a<<2):(h=(-f+r)*t-s+a<<2,d=r*o+a<<2),0==l)i[d]=e[h],i[d+1]=e[h+1],i[d+2]=e[h+2],i[d+3]=e[h+3];else if(1==l){var A=e[h+3]*(1/255),g=e[h]*A,p=e[h+1]*A,m=e[h+2]*A,w=i[d+3]*(1/255),v=i[d]*w,b=i[d+1]*w,y=i[d+2]*w;const t=1-A,r=A+w*t,o=0==r?0:1/r;i[d+3]=255*r,i[d+0]=(g+v*t)*o,i[d+1]=(p+b*t)*o,i[d+2]=(m+y*t)*o}else if(2==l){A=e[h+3],g=e[h],p=e[h+1],m=e[h+2],w=i[d+3],v=i[d],b=i[d+1],y=i[d+2];A==w&&g==v&&p==b&&m==y?(i[d]=0,i[d+1]=0,i[d+2]=0,i[d+3]=0):(i[d]=g,i[d+1]=p,i[d+2]=m,i[d+3]=A)}else if(3==l){A=e[h+3],g=e[h],p=e[h+1],m=e[h+2],w=i[d+3],v=i[d],b=i[d+1],y=i[d+2];if(A==w&&g==v&&p==b&&m==y)continue;if(A<220&&w>20)return!1}return!0}return{decode:function decode(r){const i=new Uint8Array(r);let o=8;const a=e,s=a.readUshort,f=a.readUint,l={tabs:{},frames:[]},c=new Uint8Array(i.length);let u,h=0,d=0;const A=[137,80,78,71,13,10,26,10];for(var g=0;g<8;g++)if(i[g]!=A[g])throw"The input is not a PNG file!";for(;o>>1:r>>>=1;e[t]=r}return e}(),update(e,t,r,o){for(let a=0;a>>8;return e},crc:(e,t,r)=>4294967295^i.update(4294967295,e,t,r)};function addErr(e,t,r,i){t[r]+=e[0]*i>>4,t[r+1]+=e[1]*i>>4,t[r+2]+=e[2]*i>>4,t[r+3]+=e[3]*i>>4}function N(e){return Math.max(0,Math.min(255,e))}function D(e,t){const r=e[0]-t[0],i=e[1]-t[1],o=e[2]-t[2],a=e[3]-t[3];return r*r+i*i+o*o+a*a}function dither(e,t,r,i,o,a,s){null==s&&(s=1);const f=i.length,l=[];for(var c=0;c>>0&255,e>>>8&255,e>>>16&255,e>>>24&255])}for(c=0;c>2]=u,A[c>>2]=i[u]}}function _main(e,r,o,a,s){null==s&&(s={});const{crc:f}=i,l=t.writeUint,c=t.writeUshort,u=t.writeASCII;let h=8;const d=e.frames.length>1;let A,g=!1,p=33+(d?20:0);if(null!=s.sRGB&&(p+=13),null!=s.pHYs&&(p+=21),null!=s.iCCP&&(A=pako.deflate(s.iCCP),p+=21+A.length+4),3==e.ctype){for(var m=e.plte.length,w=0;w>>24!=255&&(g=!0);p+=8+3*m+4+(g?8+1*m+4:0)}for(var v=0;v>>8&255,a=r>>>16&255;b[h+t+0]=i,b[h+t+1]=o,b[h+t+2]=a}if(h+=3*m,l(b,h,f(b,h-3*m-4,3*m+4)),h+=4,g){l(b,h,m),h+=4,u(b,h,"tRNS"),h+=4;for(w=0;w>>24&255;h+=m,l(b,h,f(b,h-m-4,m+4)),h+=4}}let E=0;for(v=0;vc&&(c=t),eh&&(h=e))}-1==c&&(s=f=c=h=0),a&&(1==(1&s)&&s--,1==(1&f)&&f--);const v=(c-s+1)*(h-f+1);v>2,e>>2);F.push(_);const t=new Uint8Array(r.abuf,i,e);h&&dither(B.img,B.rect.width,B.rect.height,E,t,_),B.img.set(t),i+=e}}else for(p=0;pU&&t==e[w-U])_[w]=_[w-U];else{let e=y[t];if(null==e&&(y[t]=e=E.length,E.push(t),E.length>=300))break;_[w]=e}}}const C=E.length;C<=256&&0==u&&(A=C<=2?1:C<=4?2:C<=16?4:8,A=Math.max(A,c));for(p=0;p>1)]|=o[e+Q]<<4-4*(1&Q);else if(2==A)for(Q=0;Q>2)]|=o[e+Q]<<6-2*(3&Q);else if(1==A)for(Q=0;Q>3)]|=o[e+Q]<<7-1*(7&Q)}t=I,d=3,i=1}else if(0==v&&1==b.length){I=new Uint8Array(U*e*3);const o=U*e;for(w=0;ww&&(w=i),fv&&(v=f))}-1==w&&(p=m=w=v=0),f&&(1==(1&p)&&p--,1==(1&m)&&m--),s={x:p,y:m,width:w-p+1,height:v-m+1};const b=o[a];b.rect=s,b.blend=1,b.img=new Uint8Array(s.width*s.height*4),0==o[a-1].dispose?(e(u,r,i,b.img,s.width,s.height,-s.x,-s.y,0),_prepareDiff(A,r,i,b.img,s)):e(A,r,i,b.img,s.width,s.height,-s.x,-s.y,0)}function _prepareDiff(t,r,i,o,a){e(t,r,i,o,a.width,a.height,-a.x,-a.y,2)}function _filterZero(e,t,r,i,o,a,s){const f=[];let l,c=[0,1,2,3,4];-1!=a?c=[a]:(t*i>5e5||1==r)&&(c=[0]),s&&(l={level:0});const u=UZIP;for(var h=0;h>1)+256&255;if(4==s)for(c=a;c>1)&255;for(c=a;c>1)&255}if(4==s){for(c=0;c>2);let u;if(r.length<2e7)for(var h=0;h>2]=u.ind,o[h>>2]=u.est.rgba}else for(h=0;h>2]=u.ind,o[h>>2]=u.est.rgba}return{abuf:i.buffer,inds:c,plte:f}}function getKDtree(e,t,r){null==r&&(r=1e-4);const i=new Uint32Array(e.buffer),o={i0:0,i1:e.length,bst:null,est:null,tdst:0,left:null,right:null};o.bst=stats(e,o.i0,o.i1),o.est=estats(o.bst);const a=[o];for(;a.lengtht&&(t=a[s].est.L,o=s);if(t=l||f.i1<=l){f.est.L=0;continue}const c={i0:f.i0,i1:l,bst:null,est:null,tdst:0,left:null,right:null};c.bst=stats(e,c.i0,c.i1),c.est=estats(c.bst);const u={i0:l,i1:f.i1,bst:null,est:null,tdst:0,left:null,right:null};u.bst={R:[],m:[],N:f.bst.N-c.bst.N};for(s=0;s<16;s++)u.bst.R[s]=f.bst.R[s]-c.bst.R[s];for(s=0;s<4;s++)u.bst.m[s]=f.bst.m[s]-c.bst.m[s];u.est=estats(u.bst),f.left=c,f.right=u,a[o]=c,a.push(u)}a.sort(((e,t)=>t.bst.N-e.bst.N));for(s=0;s0&&(s=e.right,f=e.left);const l=getNearest(s,t,r,i,o);if(l.tdst<=a*a)return l;const c=getNearest(f,t,r,i,o);return c.tdsta;)i-=4;if(r>=i)break;const s=t[r>>2];t[r>>2]=t[i>>2],t[i>>2]=s,r+=4,i-=4}for(;vecDot(e,r,o)>a;)r-=4;return r+4}function vecDot(e,t,r){return e[t]*r[0]+e[t+1]*r[1]+e[t+2]*r[2]+e[t+3]*r[3]}function stats(e,t,r){const i=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],o=[0,0,0,0],a=r-t>>2;for(let a=t;a>>0}}var o={multVec:(e,t)=>[e[0]*t[0]+e[1]*t[1]+e[2]*t[2]+e[3]*t[3],e[4]*t[0]+e[5]*t[1]+e[6]*t[2]+e[7]*t[3],e[8]*t[0]+e[9]*t[1]+e[10]*t[2]+e[11]*t[3],e[12]*t[0]+e[13]*t[1]+e[14]*t[2]+e[15]*t[3]],dot:(e,t)=>e[0]*t[0]+e[1]*t[1]+e[2]*t[2]+e[3]*t[3],sml:(e,t)=>[e*t[0],e*t[1],e*t[2],e*t[3]]};UPNG.encode=function encode(e,t,r,i,o,a,s){null==i&&(i=0),null==s&&(s=!1);const f=compress(e,t,r,i,[!1,!1,!1,0,s,!1]);return compressPNG(f,-1),_main(f,t,r,o,a)},UPNG.encodeLL=function encodeLL(e,t,r,i,o,a,s,f){const l={ctype:0+(1==i?0:2)+(0==o?0:4),depth:a,frames:[]},c=(i+o)*a,u=c*t;for(let i=0;i>>0),set16(1),set16(32),set32(3),set32(c),set32(2835),set32(2835),seek(8),set32(16711680),set32(65280),set32(255),set32(4278190080),set32(1466527264),function convert(){for(;b0;){for(w=122+b*l,g=0;g>>24,d.setUint32(w+g,p<<8|m),g+=4;b++}E{t(new Blob([e],{type:"image/bmp"}))}))},_dly:9};var r={CHROME:"CHROME",FIREFOX:"FIREFOX",DESKTOP_SAFARI:"DESKTOP_SAFARI",IE:"IE",IOS:"IOS",ETC:"ETC"},i={[r.CHROME]:16384,[r.FIREFOX]:11180,[r.DESKTOP_SAFARI]:16384,[r.IE]:8192,[r.IOS]:4096,[r.ETC]:8192};const o="undefined"!=typeof window,a="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope,s=o&&window.cordova&&window.cordova.require&&window.cordova.require("cordova/modulemapper"),CustomFile=(o||a)&&(s&&s.getOriginalSymbol(window,"File")||"undefined"!=typeof File&&File),CustomFileReader=(o||a)&&(s&&s.getOriginalSymbol(window,"FileReader")||"undefined"!=typeof FileReader&&FileReader);function getFilefromDataUrl(e,t,r=Date.now()){return new Promise((i=>{const o=e.split(","),a=o[0].match(/:(.*?);/)[1],s=globalThis.atob(o[1]);let f=s.length;const l=new Uint8Array(f);for(;f--;)l[f]=s.charCodeAt(f);const c=new Blob([l],{type:a});c.name=t,c.lastModified=r,i(c)}))}function getDataUrlFromFile(e){return new Promise(((t,r)=>{const i=new CustomFileReader;i.onload=()=>t(i.result),i.onerror=e=>r(e),i.readAsDataURL(e)}))}function loadImage(e){return new Promise(((t,r)=>{const i=new Image;i.onload=()=>t(i),i.onerror=e=>r(e),i.src=e}))}function getBrowserName(){if(void 0!==getBrowserName.cachedResult)return getBrowserName.cachedResult;let e=r.ETC;const{userAgent:t}=navigator;return/Chrom(e|ium)/i.test(t)?e=r.CHROME:/iP(ad|od|hone)/i.test(t)&&/WebKit/i.test(t)?e=r.IOS:/Safari/i.test(t)?e=r.DESKTOP_SAFARI:/Firefox/i.test(t)?e=r.FIREFOX:(/MSIE/i.test(t)||!0==!!document.documentMode)&&(e=r.IE),getBrowserName.cachedResult=e,getBrowserName.cachedResult}function approximateBelowMaximumCanvasSizeOfBrowser(e,t){const r=getBrowserName(),o=i[r];let a=e,s=t,f=a*s;const l=a>s?s/a:a/s;for(;f>o*o;){const e=(o+a)/2,t=(o+s)/2;et.toBlob(e,r))).then(function(e){try{return l=e,l.name=i,l.lastModified=o,$If_5.call(this)}catch(e){return f(e)}}.bind(this),f);{if("function"==typeof OffscreenCanvas&&e instanceof OffscreenCanvas)return e.convertToBlob({type:r,quality:a}).then(function(e){try{return l=e,l.name=i,l.lastModified=o,$If_6.call(this)}catch(e){return f(e)}}.bind(this),f);{let d;return d=e.toDataURL(r,a),getFilefromDataUrl(d,i,o).then(function(e){try{return l=e,$If_6.call(this)}catch(e){return f(e)}}.bind(this),f)}function $If_6(){return $If_5.call(this)}}function $If_5(){return $If_4.call(this)}}function $If_4(){return s(l)}}))}function cleanupCanvasMemory(e){e.width=0,e.height=0}function isAutoOrientationInBrowser(){return new Promise((function(e,t){let r,i,o,a,s;return void 0!==isAutoOrientationInBrowser.cachedResult?e(isAutoOrientationInBrowser.cachedResult):(r="data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/xABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAAAAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==",getFilefromDataUrl("data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/xABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAAAAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==","test.jpg",Date.now()).then((function(r){try{return i=r,drawFileInCanvas(i).then((function(r){try{return o=r[1],canvasToFile(o,i.type,i.name,i.lastModified).then((function(r){try{return a=r,cleanupCanvasMemory(o),drawFileInCanvas(a).then((function(r){try{return s=r[0],isAutoOrientationInBrowser.cachedResult=1===s.width&&2===s.height,e(isAutoOrientationInBrowser.cachedResult)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t)}catch(e){return t(e)}}),t))}))}function getExifOrientation(e){return new Promise(((t,r)=>{const i=new CustomFileReader;i.onload=e=>{const r=new DataView(e.target.result);if(65496!=r.getUint16(0,!1))return t(-2);const i=r.byteLength;let o=2;for(;or(e),i.readAsArrayBuffer(e)}))}function handleMaxWidthOrHeight(e,t){const{width:r}=e,{height:i}=e,{maxWidthOrHeight:o}=t;let a,s=e;return isFinite(o)&&(r>o||i>o)&&([s,a]=getNewCanvasAndCtx(r,i),r>i?(s.width=o,s.height=i/r*o):(s.width=r/i*o,s.height=o),a.drawImage(e,0,0,s.width,s.height),cleanupCanvasMemory(e)),s}function followExifOrientation(e,t){const{width:r}=e,{height:i}=e,[o,a]=getNewCanvasAndCtx(r,i);switch(t>4&&t<9?(o.width=i,o.height=r):(o.width=r,o.height=i),t){case 2:a.transform(-1,0,0,1,r,0);break;case 3:a.transform(-1,0,0,-1,r,i);break;case 4:a.transform(1,0,0,-1,0,i);break;case 5:a.transform(0,1,1,0,0,0);break;case 6:a.transform(0,1,-1,0,i,0);break;case 7:a.transform(0,-1,-1,0,i,r);break;case 8:a.transform(0,-1,1,0,0,r)}return a.drawImage(e,0,0,r,i),cleanupCanvasMemory(e),o}function compress(e,t,r=0){return new Promise((function(i,o){let a,s,f,l,c,u,h,d,A,g,p,m,w,v,b,y,E,F,_,B;function incProgress(e=5){if(t.signal&&t.signal.aborted)throw t.signal.reason;a+=e,t.onProgress(Math.min(a,100))}function setProgress(e){if(t.signal&&t.signal.aborted)throw t.signal.reason;a=Math.min(Math.max(e,a),100),t.onProgress(a)}return a=r,s=t.maxIteration||10,f=1024*t.maxSizeMB*1024,incProgress(),drawFileInCanvas(e,t).then(function(r){try{return[,l]=r,incProgress(),c=handleMaxWidthOrHeight(l,t),incProgress(),new Promise((function(r,i){var o;if(!(o=t.exifOrientation))return getExifOrientation(e).then(function(e){try{return o=e,$If_2.call(this)}catch(e){return i(e)}}.bind(this),i);function $If_2(){return r(o)}return $If_2.call(this)})).then(function(r){try{return u=r,incProgress(),isAutoOrientationInBrowser().then(function(r){try{return h=r?c:followExifOrientation(c,u),incProgress(),d=t.initialQuality||1,A=t.fileType||e.type,canvasToFile(h,A,e.name,e.lastModified,d).then(function(r){try{{if(g=r,incProgress(),p=g.size>f,m=g.size>e.size,!p&&!m)return setProgress(100),i(g);var a;function $Loop_3(){if(s--&&(b>f||b>w)){let t,r;return t=B?.95*_.width:_.width,r=B?.95*_.height:_.height,[E,F]=getNewCanvasAndCtx(t,r),F.drawImage(_,0,0,t,r),d*="image/png"===A?.85:.95,canvasToFile(E,A,e.name,e.lastModified,d).then((function(e){try{return y=e,cleanupCanvasMemory(_),_=E,b=y.size,setProgress(Math.min(99,Math.floor((v-b)/(v-f)*100))),$Loop_3}catch(e){return o(e)}}),o)}return[1]}return w=e.size,v=g.size,b=v,_=h,B=!t.alwaysKeepResolution&&p,(a=function(e){for(;e;){if(e.then)return void e.then(a,o);try{if(e.pop){if(e.length)return e.pop()?$Loop_3_exit.call(this):e;e=$Loop_3}else e=e.call(this)}catch(e){return o(e)}}}.bind(this))($Loop_3);function $Loop_3_exit(){return cleanupCanvasMemory(_),cleanupCanvasMemory(E),cleanupCanvasMemory(c),cleanupCanvasMemory(h),cleanupCanvasMemory(l),setProgress(100),i(y)}}}catch(u){return o(u)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}catch(e){return o(e)}}.bind(this),o)}))}const f="\nlet scriptImported = false\nself.addEventListener('message', async (e) => {\n const { file, id, imageCompressionLibUrl, options } = e.data\n options.onProgress = (progress) => self.postMessage({ progress, id })\n try {\n if (!scriptImported) {\n // console.log('[worker] importScripts', imageCompressionLibUrl)\n self.importScripts(imageCompressionLibUrl)\n scriptImported = true\n }\n // console.log('[worker] self', self)\n const compressedFile = await imageCompression(file, options)\n self.postMessage({ file: compressedFile, id })\n } catch (e) {\n // console.error('[worker] error', e)\n self.postMessage({ error: e.message + '\\n' + e.stack, id })\n }\n})\n";let l;function compressOnWebWorker(e,t){return new Promise(((r,i)=>{l||(l=function createWorkerScriptURL(e){const t=[];return"function"==typeof e?t.push(`(${e})()`):t.push(e),URL.createObjectURL(new Blob(t))}(f));const o=new Worker(l);o.addEventListener("message",(function handler(e){if(t.signal&&t.signal.aborted)o.terminate();else if(void 0===e.data.progress){if(e.data.error)return i(new Error(e.data.error)),void o.terminate();r(e.data.file),o.terminate()}else t.onProgress(e.data.progress)})),o.addEventListener("error",i),t.signal&&t.signal.addEventListener("abort",(()=>{i(t.signal.reason),o.terminate()})),o.postMessage({file:e,imageCompressionLibUrl:t.libURL,options:{...t,onProgress:void 0,signal:void 0}})}))}function imageCompression(e,t){return new Promise((function(r,i){let o,a,s,f,l,c;if(o={...t},s=0,({onProgress:f}=o),o.maxSizeMB=o.maxSizeMB||Number.POSITIVE_INFINITY,l="boolean"!=typeof o.useWebWorker||o.useWebWorker,delete o.useWebWorker,o.onProgress=e=>{s=e,"function"==typeof f&&f(s)},!(e instanceof Blob||e instanceof CustomFile))return i(new Error("The file given is not an instance of Blob or File"));if(!/^image/.test(e.type))return i(new Error("The file given is not an image"));if(c="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope,!l||"function"!=typeof Worker||c)return compress(e,o).then(function(e){try{return a=e,$If_4.call(this)}catch(e){return i(e)}}.bind(this),i);var u=function(){try{return $If_4.call(this)}catch(e){return i(e)}}.bind(this),$Try_1_Catch=function(t){try{return compress(e,o).then((function(e){try{return a=e,u()}catch(e){return i(e)}}),i)}catch(e){return i(e)}};try{return o.libURL=o.libURL||"https://cdn.jsdelivr.net/npm/browser-image-compression@2.0.2/dist/browser-image-compression.js",compressOnWebWorker(e,o).then((function(e){try{return a=e,u()}catch(e){return $Try_1_Catch()}}),$Try_1_Catch)}catch(e){$Try_1_Catch()}function $If_4(){try{a.name=e.name,a.lastModified=e.lastModified}catch(e){}try{o.preserveExif&&"image/jpeg"===e.type&&(!o.fileType||o.fileType&&o.fileType===e.type)&&(a=copyExifWithoutOrientation(e,a))}catch(e){}return r(a)}}))}return imageCompression.getDataUrlFromFile=getDataUrlFromFile,imageCompression.getFilefromDataUrl=getFilefromDataUrl,imageCompression.loadImage=loadImage,imageCompression.drawImageInCanvas=drawImageInCanvas,imageCompression.drawFileInCanvas=drawFileInCanvas,imageCompression.canvasToFile=canvasToFile,imageCompression.getExifOrientation=getExifOrientation,imageCompression.handleMaxWidthOrHeight=handleMaxWidthOrHeight,imageCompression.followExifOrientation=followExifOrientation,imageCompression.cleanupCanvasMemory=cleanupCanvasMemory,imageCompression.isAutoOrientationInBrowser=isAutoOrientationInBrowser,imageCompression.approximateBelowMaximumCanvasSizeOfBrowser=approximateBelowMaximumCanvasSizeOfBrowser,imageCompression.copyExifWithoutOrientation=copyExifWithoutOrientation,imageCompression.getBrowserName=getBrowserName,imageCompression.version="2.0.2",imageCompression})); +//# sourceMappingURL=browser-image-compression.js.map diff --git a/public/pku_icon.png b/public/pku_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6ada1ba7967b35c7c8b2b0080985f58191d15008 GIT binary patch literal 25869 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 literal 0 HcmV?d00001 diff --git a/public/pku_logo.png b/public/pku_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d65972660d3de557f36d6d71ff9123ed204eb33c GIT binary patch literal 22464 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 mx5! + +export default function LoginPage() { + const router = useRouter() + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + id: "", + password: "", + }, + }) + + const onSubmit = async (data: LoginFormData) => { + try { + const result = await signIn("credentials", { + id: data.id, + password: data.password, + redirect: false, + }) + + if (result?.error) { + form.setError("root", { + type: "manual", + message: "用户ID或密码错误" + }) + } else if (result?.ok) { + // 登录成功,重定向到首页 + router.push("/") + router.refresh() + } + } catch (error) { + form.setError("root", { + type: "manual", + message: "登录失败,请重试" + }) + console.error("Login error:", error) + } + } + + return ( +
+ + + 登录 + + +
+ + ( + + 用户ID + + + + + + )} + /> + ( + + 密码 + + + + + + )} + /> + {form.formState.errors.root && ( +
+ {form.formState.errors.root.message} +
+ )} + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/(main)/[...notFound]/page.tsx b/src/app/(main)/[...notFound]/page.tsx new file mode 100644 index 0000000..e5b9c40 --- /dev/null +++ b/src/app/(main)/[...notFound]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function NotFoundCatchAll() { + notFound() +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/layout.dev.tsx b/src/app/(main)/dev/arch/layout.dev.tsx new file mode 100644 index 0000000..640f326 --- /dev/null +++ b/src/app/(main)/dev/arch/layout.dev.tsx @@ -0,0 +1,9 @@ +import { SubMenuLayout } from "@/components/layout/sub-menu-layout"; + +export default function ArchLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/package/components/PackageAnalyzeDialog.tsx b/src/app/(main)/dev/arch/package/components/PackageAnalyzeDialog.tsx new file mode 100644 index 0000000..a8a448e --- /dev/null +++ b/src/app/(main)/dev/arch/package/components/PackageAnalyzeDialog.tsx @@ -0,0 +1,189 @@ +'use client' + +import React from 'react' +import { trpc } from '@/lib/trpc' +import { Button } from '@/components/ui/button' +import { FileSearch } from 'lucide-react' +import { toast } from 'sonner' +import { TaskDialog, BaseTaskProgress } from '@/components/common/task-dialog' +import type { AnalyzePackagesProgress } from '@/server/queues' + +/** + * 扩展的分析进度类型 + */ +interface AnalyzeProgress extends BaseTaskProgress, AnalyzePackagesProgress {} + +interface PackageAnalyzeDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + jobId: string | null + onAnalyzeCompleted: () => void +} + +interface PackageAnalyzeTriggerProps { + onStartAnalyze: () => void + isStarting: boolean +} + +/** + * 依赖包分析触发器按钮 + */ +export function PackageAnalyzeTrigger({ + onStartAnalyze, + isStarting +}: PackageAnalyzeTriggerProps) { + return ( + + ) +} + +/** + * 依赖包分析进度对话框 + */ +export function PackageAnalyzeDialog({ + open, + onOpenChange, + jobId, + onAnalyzeCompleted +}: PackageAnalyzeDialogProps) { + // 停止分析任务 mutation + const cancelMutation = trpc.devArch!.cancelAnalyzePackagesJob.useMutation({ + onSuccess: () => { + toast.success('已发送停止请求') + }, + onError: (error) => { + toast.error(error.message || '停止任务失败') + }, + }) + + // 停止任务 + const handleCancelTask = async (taskJobId: string) => { + await cancelMutation.mutateAsync({ jobId: taskJobId }) + } + + // 自定义状态消息渲染 + const renderStatusMessage = (progress: AnalyzeProgress) => { + if (progress.state === 'waiting') { + return '任务等待中...' + } else if (progress.state === 'active') { + if (progress.currentPackage) { + return `正在分析: ${progress.currentPackage}` + } + return '正在分析依赖包...' + } else if (progress.state === 'completed') { + const successCount = (progress.analyzedPackages || 0) - (progress.failedPackages || 0) + const failedCount = progress.failedPackages || 0 + const skippedCount = progress.skippedPackages || 0 + + const parts = [`成功 ${successCount} 个`] + if (failedCount > 0) { + parts.push(`失败 ${failedCount} 个`) + } + if (skippedCount > 0) { + parts.push(`跳过 ${skippedCount} 个`) + } + parts.push(`共 ${progress.totalPackages || 0} 个依赖包`) + + return `分析完成!${parts.join(',')}` + } else if (progress.state === 'failed') { + return progress.error || '分析失败' + } + return '' + } + + // 自定义详细信息渲染 + const renderDetails = (progress: AnalyzeProgress) => { + if (progress.totalPackages === undefined && progress.analyzedPackages === undefined) { + return null + } + + const successCount = (progress.analyzedPackages || 0) - (progress.failedPackages || 0) + + return ( +
+ {/* 进度统计 */} +
+ {progress.totalPackages !== undefined && ( +
+ 依赖包总数: + {progress.totalPackages} +
+ )} + {progress.analyzedPackages !== undefined && ( +
+ 已分析: + {progress.analyzedPackages} +
+ )} + {successCount > 0 && ( +
+ 成功: + {successCount} +
+ )} + {progress.failedPackages !== undefined && progress.failedPackages > 0 && ( +
+ 失败: + {progress.failedPackages} +
+ )} + {progress.skippedPackages !== undefined && progress.skippedPackages > 0 && ( +
+ 跳过: + {progress.skippedPackages} +
+ )} +
+ + {/* 当前处理的依赖包 */} + {progress.currentPackage && progress.state === 'active' && ( +
+
当前依赖包:
+
{progress.currentPackage}
+
+ )} + + {/* 最近的错误信息 */} + {progress.recentErrors && progress.recentErrors.length > 0 && ( +
+
最近的错误 (最多显示10条):
+
+ {progress.recentErrors.map((err, index) => ( +
+
{err.packageName}
+
{err.error}
+ {index < progress.recentErrors!.length - 1 && ( +
+ )} +
+ ))} +
+
+ )} +
+ ) + } + + return ( + + open={open} + onOpenChange={onOpenChange} + useSubscription={trpc.jobs.subscribeAnalyzePackagesProgress.useSubscription} + jobId={jobId} + title="依赖包分析进度" + description="正在使用AI分析项目依赖包,请稍候..." + onCancelTask={handleCancelTask} + onTaskCompleted={onAnalyzeCompleted} + isCancelling={cancelMutation.isPending} + renderStatusMessage={renderStatusMessage} + renderDetails={renderDetails} + /> + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/package/components/PackageDetailSheet.tsx b/src/app/(main)/dev/arch/package/components/PackageDetailSheet.tsx new file mode 100644 index 0000000..5126682 --- /dev/null +++ b/src/app/(main)/dev/arch/package/components/PackageDetailSheet.tsx @@ -0,0 +1,209 @@ +'use client' + +import * as React from 'react' +import { Package, Link as LinkIcon, Code2, FileCode, Globe, Code } from 'lucide-react' +import { + DetailSheet, + DetailHeader, + DetailSection, + DetailField, + DetailFieldGroup, + DetailList, + DetailCopyable, +} from '@/components/data-details' +import { Badge } from '@/components/ui/badge' +import { formatDate } from '@/lib/format' +import { SheetDescription, SheetTitle } from '@/components/ui/sheet' +import { VisuallyHidden } from '@radix-ui/react-visually-hidden' +import type { PackageData } from "@/server/routers/dev/arch"; + +export interface PackageDetailSheetProps { + pkg: PackageData | null + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * 依赖包详情展示Sheet + * 使用通用详情展示框架展示DevAnalyzedPkg对象的完整信息 + */ +export function PackageDetailSheet({ + pkg, + open, + onOpenChange, +}: PackageDetailSheetProps) { + if (!pkg) return null + + // 处理关联文件列表 + const relatedFileItems = (pkg.relatedFiles || []).map((filePath, index) => ({ + id: `file-${index}`, + label: filePath, + icon: FileCode, + })) + + // 解析主要使用模式(按行分割) + const usagePatterns = pkg.primaryUsagePattern + .split('\n') + .filter(line => line.trim()) + .map((pattern, index) => ({ + id: `pattern-${index}`, + label: pattern.trim(), + })) + + return ( + + + + v{pkg.version} + + + {pkg.pkgType.name} + + + } + icon={} + /> + + } + description={{pkg.description}} + > + {/* 基本信息 */} + + + } + /> + + v{pkg.version} + + } + /> + + {pkg.pkgType.name} +
+ } + /> + + + + + + + {/* 官方描述 */} + +
+ {pkg.description} +
+
+ + {/* 项目中的角色 */} + +
+ + {pkg.projectRoleSummary} +
+ } + /> + +
+ + {/* 主要使用模式 */} + {usagePatterns.length > 0 && ( + + + + )} + + {/* 关联文件 */} + {relatedFileItems.length > 0 && ( + + + + )} + + {/* 链接信息 */} + + + {pkg.homepage && ( + + + {pkg.homepage} + + } + /> + )} + {pkg.repositoryUrl && ( + + + {pkg.repositoryUrl} + + } + /> + )} + + + + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/package/page.dev.tsx b/src/app/(main)/dev/arch/package/page.dev.tsx new file mode 100644 index 0000000..a49b82e --- /dev/null +++ b/src/app/(main)/dev/arch/package/page.dev.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Package, FileCode, Code, FileClock } from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { SearchInput } from "@/components/common/search-input"; +import { ResponsiveTabs, type ResponsiveTabItem } from "@/components/common/responsive-tabs"; +import { trpc } from "@/lib/trpc"; +import { Skeleton } from "@/components/ui/skeleton"; +import { PackageAnalyzeDialog, PackageAnalyzeTrigger } from "./components/PackageAnalyzeDialog"; +import { PackageDetailSheet } from "./components/PackageDetailSheet"; +import { toast } from 'sonner' +import type { PackageData } from "@/server/routers/dev/arch"; +import { formatDate } from "@/lib/format"; + +// 依赖包卡片组件 +function PackageCard({ pkg, onClick }: { pkg: PackageData; onClick: () => void }) { + return ( + + +
+ + + {pkg.homepage ? ( + + {pkg.name} + + ) : ( + + {pkg.name} + + )} + {pkg.repositoryUrl && ( + + + + )} + + + + + v{pkg.version} + + + +
+
{pkg.name} v{pkg.version}
+
更新于 {formatDate(pkg.modifiedAt)}
+
+
+
+
+ + {pkg.description} + +
+ +
+
+ + 核心功能 +
+

{pkg.projectRoleSummary}

+
+
+
+ + {pkg.relatedFileCount} + 个文件 +
+ + +
+ + {formatDate(pkg.lastAnalyzedAt)} +
+
+ +
最近分析时间
+
+
+
+
+
+ ); +} + +export default function ArchPackagePage() { + const [searchQuery, setSearchQuery] = useState(''); + + // 用于刷新数据的 utils + const utils = trpc.useUtils() + + // 获取所有包类型 + const { data: pkgTypes, isLoading: isLoadingTypes } = trpc.devArch!.getAllPkgTypes.useQuery(); + + // 获取所有依赖包数据 + const { data: packagesByType, isLoading: isLoadingPackages } = trpc.devArch!.getAllPackages.useQuery(); + + // 刷新依赖包列表 + const handleRefreshPackages = useCallback(() => { + utils.devArch!.getAllPackages.invalidate() + utils.devArch!.getAllPkgTypes.invalidate() + }, [utils]) + + // 使用第一个包类型作为默认激活标签 + const [activeTab, setActiveTab] = useState(''); + + // 分析对话框状态 + const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false) + const [analyzeJobId, setAnalyzeJobId] = useState(null) + + // 详情Sheet状态 + const [selectedPackage, setSelectedPackage] = useState(null) + const [isDetailSheetOpen, setIsDetailSheetOpen] = useState(false) + + // 处理卡片点击 + const handleCardClick = useCallback((pkg: PackageData) => { + setSelectedPackage(pkg) + setIsDetailSheetOpen(true) + }, []) + + // 启动依赖包分析 mutation + const analyzeMutation = trpc.devArch!.startAnalyzePackages.useMutation({ + onSuccess: (data) => { + // 打开进度对话框 + setAnalyzeJobId(String(data.jobId)) + setIsAnalyzeDialogOpen(true) + }, + onError: (error) => { + toast.error(error.message || '启动依赖包分析失败') + }, + }) + + // 启动分析 + const handleStartAnalyze = () => { + analyzeMutation.mutate() + } + + // 当包类型加载完成后,设置默认激活标签 + useEffect(() => { + if (pkgTypes && pkgTypes.length > 0 && !activeTab) { + setActiveTab(pkgTypes[0].id); + } + }, [pkgTypes, activeTab]); + + const isLoading = isLoadingTypes || isLoadingPackages; + + // 按优先级搜索过滤:name > description > projectRoleSummary > primaryUsagePattern + const getFilteredPackages = useCallback((typeId: string) => { + const packages = packagesByType?.[typeId] || []; + if (!searchQuery) return packages; + + const query = searchQuery.toLowerCase(); + + // 计算每个包的匹配优先级 + const packagesWithPriority = packages.map((pkg) => { + let priority = 0; + if (pkg.name.toLowerCase().includes(query)) { + priority = 4; + } + else if (pkg.description.toLowerCase().includes(query)) { + priority = 3; + } + else if (pkg.projectRoleSummary.toLowerCase().includes(query)) { + priority = 2; + } + else if (pkg.primaryUsagePattern.toLowerCase().includes(query)) { + priority = 1; + } + return { pkg, priority }; + }); + + // 过滤出有匹配的包,并按优先级排序 + return packagesWithPriority + .filter(({ priority }) => priority > 0) + .sort((a, b) => b.priority - a.priority) + .map(({ pkg }) => pkg); + }, [packagesByType, searchQuery]); + + // 将包类型转换为标签项 + const tabItems: ResponsiveTabItem[] = pkgTypes?.map((type) => ({ + id: type.id, + name: type.name, + description: type.description, + count: packagesByType?.[type.id]?.length || 0, + })) || []; + + // 仅当pkgTypes完成加载且为空时才显示"暂无依赖包数据" + if (!isLoading && (!pkgTypes || pkgTypes.length === 0)) { + return ( +
+ +

暂无依赖包数据

+
+ ); + } + + return ( + <> + {isLoading ? ( + + +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ) : ( + + {tabItems.map((item) => { + const filteredPackages = getFilteredPackages(item.id); + + return ( + +
+ {/* 搜索栏和操作按钮 */} +
+ +
+ +
+ + {/* 包列表 */} + {filteredPackages.length > 0 ? ( +
+ {filteredPackages.map((pkg) => ( + handleCardClick(pkg)} + /> + ))} +
+ ) : ( +
+ +

+ {searchQuery ? '未找到匹配的依赖包' : '暂无依赖包数据'} +

+
+ )} +
+ + ); + })} + + )} + + {/* 依赖包分析进度对话框 */} + + + {/* 依赖包详情Sheet */} + + + ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/arch/page.dev.tsx b/src/app/(main)/dev/arch/page.dev.tsx new file mode 100644 index 0000000..f5a6cbc --- /dev/null +++ b/src/app/(main)/dev/arch/page.dev.tsx @@ -0,0 +1,5 @@ +import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect"; + +export default function ArchPage() { + return ; +} \ No newline at end of file diff --git a/src/app/(main)/dev/dev-theme.css b/src/app/(main)/dev/dev-theme.css new file mode 100644 index 0000000..768a0ac --- /dev/null +++ b/src/app/(main)/dev/dev-theme.css @@ -0,0 +1,68 @@ +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.623 0.214 259.815); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.623 0.214 259.815); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.488 0.243 264.376); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.379 0.146 265.522); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.488 0.243 264.376); +} diff --git a/src/app/(main)/dev/file/columns.tsx b/src/app/(main)/dev/file/columns.tsx new file mode 100644 index 0000000..037f3f7 --- /dev/null +++ b/src/app/(main)/dev/file/columns.tsx @@ -0,0 +1,387 @@ +'use client' + +import { ColumnDef } from '@tanstack/react-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { formatDate } from '@/lib/format' +import { Checkbox } from '@/components/ui/checkbox' +import { DataTableColumnHeader } from '@/components/data-table/column-header' +import { Eye } from 'lucide-react' +import type { DevAnalyzedFile } from '@/server/routers/dev/file' +import { Option } from '@/types/data-table' +import { SourceFileIcon } from '@/components/icons/code-lang' + +export interface DevAnalyzedFileColumnsOptions { + fileTypes: Array
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/components/FileDetailSheet.tsx b/src/app/(main)/dev/file/components/FileDetailSheet.tsx new file mode 100644 index 0000000..a38d5bd --- /dev/null +++ b/src/app/(main)/dev/file/components/FileDetailSheet.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import { FileCode } from 'lucide-react' +import { + DetailSheet, + DetailHeader, +} from '@/components/data-details' +import { Badge } from '@/components/ui/badge' +import type { DevAnalyzedFile } from '@/server/routers/dev/file' +import { SheetDescription, SheetTitle } from '@/components/ui/sheet' +import { VisuallyHidden } from '@radix-ui/react-visually-hidden' +import { FileDetailPanel } from './FileDetailPanel' + + +export interface FileDetailSheetProps { + file: DevAnalyzedFile | null + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * 文件详情展示Sheet + * 使用通用详情展示框架展示DevAnalyzedFile对象的完整信息 + */ +export function FileDetailSheet({ + file, + open, + onOpenChange, +}: FileDetailSheetProps) { + if (!file) return null + + return ( + + + + {file.fileType?.name || file.fileTypeId} + + + ID: {file.id} + + + } + icon={} + /> + + } + description={{ file.path }} + > + + + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/components/GitCommitViewDialog.tsx b/src/app/(main)/dev/file/components/GitCommitViewDialog.tsx new file mode 100644 index 0000000..ac1c04b --- /dev/null +++ b/src/app/(main)/dev/file/components/GitCommitViewDialog.tsx @@ -0,0 +1,129 @@ +'use client' + +import * as React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { trpc } from '@/lib/trpc' +import { DetailCodeBlock } from '@/components/data-details' +import { getLanguageFromPath } from '@/lib/utils' +import { Skeleton } from '@/components/ui/skeleton' + +export interface GitCommitViewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + fileId: number + filePath: string + commitId: string + previousCommitId?: string +} + +/** + * Git Commit查看对话框 + * 显示指定commit的文件内容和与上个版本的差异 + */ +export function GitCommitViewDialog({ + open, + onOpenChange, + fileId, + filePath, + commitId, + previousCommitId, +}: GitCommitViewDialogProps) { + // 获取当前commit的文件内容 + const { data: fileContent, isLoading: isContentLoading, isError: isContentError, error: contentError } = + trpc.devFile!.getFileContentAtCommit.useQuery( + { id: fileId, commitId }, + { enabled: open } + ) + + // 获取与上个版本的差异 + const { data: fileDiff, isLoading: isDiffLoading, isError: isDiffError, error: diffError } = + trpc.devFile!.getFileDiffBetweenCommits.useQuery( + { + id: fileId, + oldCommitId: previousCommitId || '', + newCommitId: commitId + }, + { enabled: open && !!previousCommitId } + ) + + return ( + + + + 查看 Commit {commitId} + {filePath} + + + + + 文件内容 + + 变更对比 + + + + + {isContentLoading ? ( +
+ + + +
+ ) : isContentError ? ( +
+ 加载失败: {contentError?.message || '未知错误'} +
+ ) : fileContent ? ( + + ) : ( +
+ 文件在此版本不存在 +
+ )} +
+ + + {!previousCommitId ? ( +
+ 这是第一个版本,没有可对比的内容 +
+ ) : isDiffLoading ? ( +
+ + + +
+ ) : isDiffError ? ( +
+ 加载失败: {diffError?.message || '未知错误'} +
+ ) : fileDiff ? ( + + ) : ( +
+ 无变更 +
+ )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/directory-tree/components/FolderAnalyzeDialog.tsx b/src/app/(main)/dev/file/directory-tree/components/FolderAnalyzeDialog.tsx new file mode 100644 index 0000000..4cdb6cd --- /dev/null +++ b/src/app/(main)/dev/file/directory-tree/components/FolderAnalyzeDialog.tsx @@ -0,0 +1,180 @@ +'use client' + +import React from 'react' +import { trpc } from '@/lib/trpc' +import { Button } from '@/components/ui/button' +import { FolderSearch } from 'lucide-react' +import { toast } from 'sonner' +import { TaskDialog, BaseTaskProgress } from '@/components/common/task-dialog' +import type { AnalyzeFoldersProgress } from '@/server/queues' + +/** + * 扩展的分析进度类型 + */ +interface AnalyzeProgress extends BaseTaskProgress, AnalyzeFoldersProgress {} + +interface FolderAnalyzeDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + jobId: string | null + onAnalyzeCompleted: () => void +} + +interface FolderAnalyzeTriggerProps { + onStartAnalyze: () => void + isStarting: boolean +} + +/** + * 文件夹分析触发器按钮 + */ +export function FolderAnalyzeTrigger({ + onStartAnalyze, + isStarting +}: FolderAnalyzeTriggerProps) { + return ( + + ) +} + +/** + * 文件夹分析进度对话框 + */ +export function FolderAnalyzeDialog({ + open, + onOpenChange, + jobId, + onAnalyzeCompleted +}: FolderAnalyzeDialogProps) { + // 停止分析任务 mutation + const cancelMutation = trpc.devFile!.cancelAnalyzeFoldersJob.useMutation({ + onSuccess: () => { + toast.success('已发送停止请求') + }, + onError: (error) => { + toast.error(error.message || '停止任务失败') + }, + }) + + // 停止任务 + const handleCancelTask = async (taskJobId: string) => { + await cancelMutation.mutateAsync({ jobId: taskJobId }) + } + + // 自定义状态消息渲染 + const renderStatusMessage = (progress: AnalyzeProgress) => { + if (progress.state === 'waiting') { + return '任务等待中...' + } else if (progress.state === 'active') { + if (progress.currentFolder) { + return `正在分析: ${progress.currentFolder}` + } + return '正在分析文件夹...' + } else if (progress.state === 'completed') { + const successCount = (progress.analyzedFolders || 0) - (progress.failedFolders || 0) + const failedCount = progress.failedFolders || 0 + + const parts = [`成功 ${successCount} 个`] + if (failedCount > 0) { + parts.push(`失败 ${failedCount} 个`) + } + parts.push(`共 ${progress.totalFolders || 0} 个文件夹`) + + return `分析完成!${parts.join(',')}` + } else if (progress.state === 'failed') { + return progress.error || '分析失败' + } + return '' + } + + // 自定义详细信息渲染 + const renderDetails = (progress: AnalyzeProgress) => { + if (progress.totalFolders === undefined && progress.analyzedFolders === undefined) { + return null + } + + const successCount = (progress.analyzedFolders || 0) - (progress.failedFolders || 0) + + return ( +
+ {/* 进度统计 */} +
+ {progress.totalFolders !== undefined && ( +
+ 文件夹总数: + {progress.totalFolders} +
+ )} + {progress.analyzedFolders !== undefined && ( +
+ 已分析: + {progress.analyzedFolders} +
+ )} + {successCount > 0 && ( +
+ 成功: + {successCount} +
+ )} + {progress.failedFolders !== undefined && progress.failedFolders > 0 && ( +
+ 失败: + {progress.failedFolders} +
+ )} +
+ + {/* 当前处理的文件夹 */} + {progress.currentFolder && progress.state === 'active' && ( +
+
当前文件夹:
+
{progress.currentFolder}
+
+ )} + + {/* 最近的错误信息 */} + {progress.recentErrors && progress.recentErrors.length > 0 && ( +
+
最近的错误 (最多显示10条):
+
+ {progress.recentErrors.map((err, index) => ( +
+
{err.folderPath}
+
{err.error}
+ {index < progress.recentErrors!.length - 1 && ( +
+ )} +
+ ))} +
+
+ )} +
+ ) + } + + return ( + + open={open} + onOpenChange={onOpenChange} + useSubscription={trpc.jobs.subscribeAnalyzeFoldersProgress.useSubscription} + jobId={jobId} + title="文件夹分析进度" + description="正在使用AI分析项目文件夹,请稍候..." + onCancelTask={handleCancelTask} + onTaskCompleted={onAnalyzeCompleted} + isCancelling={cancelMutation.isPending} + renderStatusMessage={renderStatusMessage} + renderDetails={renderDetails} + /> + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/directory-tree/components/FolderDetailPanel.tsx b/src/app/(main)/dev/file/directory-tree/components/FolderDetailPanel.tsx new file mode 100644 index 0000000..94733c3 --- /dev/null +++ b/src/app/(main)/dev/file/directory-tree/components/FolderDetailPanel.tsx @@ -0,0 +1,149 @@ +'use client'; + +import React from 'react'; +import { FolderIcon, Calendar, FileText } from 'lucide-react'; +import { + DetailSection, + DetailField, + DetailFieldGroup, +} from '@/components/data-details'; +import { Badge } from '@/components/ui/badge'; +import { formatDate } from '@/lib/format'; +import { trpc } from '@/lib/trpc'; +import { Skeleton } from '@/components/ui/skeleton'; +import { toast } from 'sonner'; + +export interface FolderDetailPanelProps { + /** 文件夹路径 */ + path: string; + /** 文件夹名称 */ + name: string; + /** 子项数量 */ + childrenCount?: number; +} + +/** + * 文件夹详情面板组件 + * 显示文件夹的详细信息,包括从数据库获取的分析结果 + */ +export function FolderDetailPanel({ + path, + name, + childrenCount = 0, +}: FolderDetailPanelProps) { + // 查询文件夹的详细信息 + const { data: folderDetail, isLoading, isError, error } = trpc.devFile!.getFolderDetail.useQuery( + { path }, + { enabled: !!path } + ); + if (error) { + toast.error("获取文件夹详情失败:" + error.toString().substring(0, 100)) + } + + if (isLoading) { + return ( +
+
+ + +
+ +
+ ); + } + + if (isError || !folderDetail) { + return ( +
+
+
+ +

{name}

+
+

+ {path} +

+
+ +
+

类型

+ 文件夹 +
+ + {childrenCount > 0 && ( +
+

+ 子项数量 +

+

{childrenCount} 项

+
+ )} + + {isError && ( +
+

+ 暂无详细分析信息 +

+
+ )} +
+ ); + } + + return ( +
+ {/* 标题区域 */} +
+
+ +

{name}

+
+

+ {path} +

+
+ + {/* 基本信息 */} + + + 文件夹} + /> + {childrenCount > 0 && ( + + )} + + + + + + {/* 功能描述 */} + +
+ + + {folderDetail.description} +
+ } + /> +
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/directory-tree/components/SearchDirectoryTree.tsx b/src/app/(main)/dev/file/directory-tree/components/SearchDirectoryTree.tsx new file mode 100644 index 0000000..6e38ddc --- /dev/null +++ b/src/app/(main)/dev/file/directory-tree/components/SearchDirectoryTree.tsx @@ -0,0 +1,312 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { Tree, TreeItem, TreeItemLabel } from '@/components/ui/tree'; +import { hotkeysCoreFeature, syncDataLoaderFeature, searchFeature, expandAllFeature } from '@headless-tree/core'; +import { useTree } from '@headless-tree/react'; +import { FolderIcon, FolderOpenIcon, FileIcon } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { SourceFileIcon } from '@/components/icons/code-lang'; +import { cn } from '@/lib/utils'; +import { useDebouncedCallback } from '@/hooks/use-debounced-callback'; +import type { FileTreeItem } from '@/server/routers/dev/file'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +// 将树形结构扁平化为 Record 格式 +function flattenTree(item: FileTreeItem, items: Record = {}): Record { + items[item.path] = item; + if (item.children) { + item.children.forEach(child => flattenTree(child, items)); + } + return items; +} + +// 获取文件扩展名 +function getFileExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + return lastDot > 0 ? filename.substring(lastDot + 1) : ''; +} + +const indent = 20; + +export interface SearchDirectoryTreeProps { + /** 树形数据根节点 */ + data: FileTreeItem; + /** 初始展开的节点路径列表 */ + initialExpandedItems?: string[]; + /** 是否显示摘要 */ + showSummary?: boolean; + /** 列表项被选中时的回调(点击即选中) */ + onItemSelect?: (item: FileTreeItem) => void; + /** 当前选中项的路径 */ + selectedItemPath?: string | null; + /** 自定义类名 */ + className?: string; +} + +/** + * 可搜索的目录树组件 + * + * 功能特性: + * - 支持搜索文件名、路径和摘要 + * - 搜索时自动展开匹配项的父节点 + * - 支持显示/隐藏摘要信息 + * - 支持点击选中回调 + * - 文件图标根据扩展名自动显示 + */ +export function SearchDirectoryTree({ + data, + initialExpandedItems = ['', 'src', 'src/app', 'src/components', 'src/server'], + showSummary: showSummaryProp = false, + onItemSelect, + selectedItemPath, + className, +}: SearchDirectoryTreeProps) { + const [showSummary, setShowSummary] = useState(showSummaryProp); + const [searchValue, setSearchValue] = useState(''); + const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); + const [expandedItems, setExpandedItems] = useState(initialExpandedItems); + const [, setSavedExpandedItems] = useState(null); + + // 扁平化树形数据 + const flatItems = useMemo(() => flattenTree(data), [data]); + + // 搜索匹配逻辑(可复用) + const isItemMatching = React.useCallback((itemData: FileTreeItem, search: string) => { + const lowerSearch = search.toLowerCase(); + return ( + itemData.name.toLowerCase().includes(lowerSearch) || + itemData.path.toLowerCase().includes(lowerSearch) || + itemData.summary?.toLowerCase().includes(lowerSearch) || false + ); + }, []); + + // 在所有项中搜索(包括未展开的) + const searchAllItems = React.useCallback((search: string) => { + if (!search) return []; + + const matchedPaths: string[] = []; + Object.values(flatItems).forEach(item => { + if (isItemMatching(item, search)) { + matchedPaths.push(item.path); + } + }); + + return matchedPaths; + }, [flatItems, isItemMatching]); + + // 获取所有匹配项及其父节点路径 + const getMatchedItemsAndParents = React.useCallback((search: string) => { + if (!search) return new Set(); + + const matchedPaths = new Set(); + + // 找到所有匹配的项 + Object.values(flatItems).forEach(item => { + if (isItemMatching(item, search)) { + matchedPaths.add(item.path); + + // 添加所有父节点路径 + const parts = item.path.split('/').filter(Boolean); + for (let i = 1; i < parts.length; i++) { + const parentPath = parts.slice(0, i).join('/'); + matchedPaths.add(parentPath); + } + // 添加根节点 + if (parts.length > 0) { + matchedPaths.add(''); + } + } + }); + + return matchedPaths; + }, [flatItems, isItemMatching]); + + // 防抖更新搜索值 + const debouncedSetSearchValue = useDebouncedCallback((value: string) => { + setDebouncedSearchValue(value); + }, 300); + + // 初始化树 + const tree = useTree({ + state: { + expandedItems, + }, + setExpandedItems, + indent, + rootItemId: data.path, + getItemName: (item) => item.getItemData().name, + isItemFolder: (item) => item.getItemData().isFolder, + // 自定义搜索匹配逻辑 + isSearchMatchingItem: (search: string, item) => { + return isItemMatching(item.getItemData(), search); + }, + dataLoader: { + getItem: (itemId) => flatItems[itemId], + getChildren: (itemId) => { + const item = flatItems[itemId]; + if (!item) return []; + return item.children?.map(child => child.path) ?? []; + }, + }, + features: [syncDataLoaderFeature, hotkeysCoreFeature, searchFeature, expandAllFeature], + }); + + // 当防抖后的搜索值变化时,处理展开状态 + React.useEffect(() => { + if (debouncedSearchValue) { + // 开始搜索时,保存当前展开状态 + setSavedExpandedItems(prev => { + if (prev === null) { + return expandedItems; + } + return prev; + }); + + // 在所有项中搜索(包括未展开的) + const matchedPaths = searchAllItems(debouncedSearchValue); + const itemsToExpand = new Set(); + + // 收集所有匹配项的父节点路径 + matchedPaths.forEach(path => { + const parts = path.split('/').filter(Boolean); + + // 构建所有父路径 + for (let i = 1; i < parts.length; i++) { + const parentPath = parts.slice(0, i).join('/'); + itemsToExpand.add(parentPath); + } + }); + + setExpandedItems(Array.from(itemsToExpand)); + } else { + // 搜索值为空时,恢复之前保存的展开状态 + setSavedExpandedItems(prev => { + if (prev !== null) { + setExpandedItems(prev); + } + return null; + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchValue, searchAllItems]); + + return ( +
+ {/* 搜索和选项区域 */} +
+
+
+ { + const value = e.target.value; + setSearchValue(value); + debouncedSetSearchValue(value); + tree.getSearchInputElementProps().onChange?.(e); + }} + placeholder="搜索文件、路径或摘要..." + className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" + /> + {tree.isSearchOpen() && debouncedSearchValue && ( + + {searchAllItems(debouncedSearchValue).length} 个匹配 + + )} +
+ +
+ setShowSummary(checked === true)} + /> + +
+
+
+ + {/* 树形列表 */} + +
+ + {tree.getItems().map((item) => { + const itemData = item.getItemData(); + const isMatched = item.isMatchingSearch(); + const extension = !itemData.isFolder ? getFileExtension(itemData.name) : ''; + + // 搜索时隐藏不匹配的节点(但保留匹配节点的父节点) + const matchedItemsAndParents = getMatchedItemsAndParents(debouncedSearchValue); + const shouldHide = debouncedSearchValue && !matchedItemsAndParents.has(item.getId()); + + if (shouldHide) { + return null; + } + + const isSelected = selectedItemPath === item.getId(); + + return ( + {/* data-search-match 默认样式比较丑,这里自己实现了就不要了这个data slot了 */} + { + // 点击时触发选中回调 + if (onItemSelect) { + onItemSelect(itemData); + } + }} + > +
+
+ {itemData.isFolder ? ( + item.isExpanded() ? ( + + ) : ( + + ) + ) : extension ? ( + + ) : ( + + )} + {itemData.name} +
+ + {itemData.summary && ( + + {itemData.summary} + + )} +
+
+
+ ); + })} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/directory-tree/page.dev.tsx b/src/app/(main)/dev/file/directory-tree/page.dev.tsx new file mode 100644 index 0000000..d8f5937 --- /dev/null +++ b/src/app/(main)/dev/file/directory-tree/page.dev.tsx @@ -0,0 +1,169 @@ +'use client'; + +import React, { useState } from 'react'; +import { FileCode, FileIcon, Loader2 } from 'lucide-react'; +import type { FileTreeItem } from '@/server/routers/dev/file'; +import { SearchDirectoryTree } from './components/SearchDirectoryTree'; +import { CarouselLayout, CarouselColumn } from '@/components/layout/carousel-layout'; +import { FolderAnalyzeTrigger, FolderAnalyzeDialog } from './components/FolderAnalyzeDialog'; +import { FolderDetailPanel } from './components/FolderDetailPanel'; +import { FileDetailPanel } from '../components/FileDetailPanel'; +import { trpc } from '@/lib/trpc'; +import { toast } from 'sonner'; + +export default function DirectoryTreePage() { + const [selectedItem, setSelectedItem] = useState(null); + const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false); + const [analyzeJobId, setAnalyzeJobId] = useState(null); + + // 获取目录树数据 + const { data: fileTree, isLoading, error, refetch } = trpc.devFile!.getDirectoryTree.useQuery(); + if (error) { + toast.error("获取目录树失败:" + error.toString().substring(0, 100)) + } + + // 启动文件夹分析任务 + const startAnalyzeMutation = trpc.devFile!.startAnalyzeFolders.useMutation({ + onSuccess: (data) => { + setAnalyzeJobId(data.jobId as string); + setIsAnalyzeDialogOpen(true); + }, + onError: (error) => { + toast.error(error.message || '启动文件夹分析失败'); + }, + }); + + // 处理列表项选中 + const handleItemSelect = (item: FileTreeItem) => { + setSelectedItem(item); + }; + + // 启动分析 + const handleStartAnalyze = () => { + startAnalyzeMutation.mutate(); + }; + + // 分析完成回调 + const handleAnalyzeCompleted = () => { + console.log('文件夹分析完成'); + // 重新获取目录树数据 + refetch(); + }; + + // 详情内容组件 + const DetailContent = () => { + if (!selectedItem) { + return ( +
+
+ +

+ 选择一个文件或文件夹查看详情 +

+ {/* 文件夹分析按钮 */} +
+ +

+ 使用 AI 分析项目文件夹结构和用途 +

+
+
+
+ ); + } + + // 根据选中项类型显示对应的详情组件 + if (selectedItem.isFolder) { + return ( +
+ +
+ ); + } else { + return (
+
+
+ +

{selectedItem.name}

+
+

+ {selectedItem.path} +

+
+ +
+ ); + } + }; + + // 配置列 + const columns: CarouselColumn[] = [ + { + id: 'tree', + title: '目录树', + content: isLoading ? ( +
+ +
+ ) : error ? ( +
+

加载失败: {error.message}

+ +
+ ) : fileTree ? ( + + ) : ( +
+

暂无数据

+
+ ), + desktopClassName: 'w-1/3 border-r', + mobileClassName: '', + }, + { + id: 'detail', + title: '详情', + content: , + desktopClassName: 'w-2/3 bg-muted/20', + mobileClassName: 'bg-muted/20', + }, + ]; + + return ( + <> + + + {/* 文件夹分析进度对话框 */} + + + ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/graph/page.dev.tsx b/src/app/(main)/dev/file/graph/page.dev.tsx new file mode 100644 index 0000000..928ba39 --- /dev/null +++ b/src/app/(main)/dev/file/graph/page.dev.tsx @@ -0,0 +1,61 @@ +'use client' + +import React, { useCallback, useState } from 'react' +import { trpc } from '@/lib/trpc' +import { FileDetailSheet } from '../components/FileDetailSheet' +import { FileDependencyGraph } from '../components/FileDependencyGraph' +import type { DevAnalyzedFile } from '@/server/routers/dev/file' +import { toast } from 'sonner' +import { Skeleton } from '@/components/ui/skeleton' + +export default function FileGraphPage() { + // 用于刷新数据的 utils + const utils = trpc.useUtils() + + // 详情Sheet状态 + const [selectedFile, setSelectedFile] = useState(null) + const [detailSheetOpen, setDetailSheetOpen] = useState(false) + + // 获取依赖图数据 + const { data: graphData, isLoading: isGraphLoading } = trpc.devFile!.getDependencyGraph.useQuery() + + // 处理依赖图节点点击 + const handleGraphNodeClick = useCallback(async (node: { id: string; path: string }) => { + try { + const fileId = parseInt(node.id) + // 通过 tRPC 查询文件详情 + const fileDetail = await utils.devFile!.getFileById.fetch({ id: fileId }) + setSelectedFile(fileDetail as DevAnalyzedFile) + setDetailSheetOpen(true) + } catch (error) { + toast.error(`获取文件详情失败: ${error instanceof Error ? error.message : '未知错误'}`) + } + }, [utils]) + + return ( + <> + {isGraphLoading ? ( +
+ +
+ ) : graphData ? ( + + ) : ( +
+

暂无依赖关系数据

+
+ )} + + {/* 文件详情Sheet */} + + + ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/layout.dev.tsx b/src/app/(main)/dev/file/layout.dev.tsx new file mode 100644 index 0000000..0c98206 --- /dev/null +++ b/src/app/(main)/dev/file/layout.dev.tsx @@ -0,0 +1,9 @@ +import { SubMenuLayout } from "@/components/layout/sub-menu-layout"; + +export default function FilePageLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/list/page.dev.tsx b/src/app/(main)/dev/file/list/page.dev.tsx new file mode 100644 index 0000000..a1d8660 --- /dev/null +++ b/src/app/(main)/dev/file/list/page.dev.tsx @@ -0,0 +1,289 @@ +'use client' + +import React, { useCallback, useMemo, useState, Suspense } from 'react' +import { trpc } from '@/lib/trpc' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { FileAnalyzeDialog } from '../components/FileAnalyzeDialog' +import { FileDetailSheet } from '../components/FileDetailSheet' +import { DataTable } from '@/components/data-table/data-table' +import { DataTableToolbar } from '@/components/data-table/toolbar' +import { createDevAnalyzedFileColumns, type DevAnalyzedFileColumnsOptions } from '../columns' +import type { DevAnalyzedFile } from '@/server/routers/dev/file' +import { useDataTable } from '@/hooks/use-data-table' +import { keepPreviousData } from '@tanstack/react-query' +import { DataTableSortList } from '@/components/data-table/sort-list' +import { toast } from 'sonner' +import { DataTableSkeleton } from '@/components/data-table/table-skeleton' +import { Skeleton } from '@/components/ui/skeleton' +import { FileText, GitCommit, Package, Clock } from 'lucide-react' +import { format } from 'date-fns' +import { zhCN } from 'date-fns/locale' +import { StatsCardGroup, StatsCardWrapper, type StatsCardItem } from '@/components/common/stats-card-group' + +// 计算相对时间描述 +function getRelativeTimeLabel(date: Date | string | null | undefined): string { + if (!date) return '-' + + const now = new Date() + const targetDate = new Date(date) + const diffMs = now.getTime() - targetDate.getTime() + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + if (diffMs <= 1000 * 60 * 5) return '刚刚' + if (diffMs <= 1000 * 60 * 60) return '一小时内' + if (diffDays === 0) return '一天内' + if (diffDays === 1) return '两天内' + if (diffDays === 2) return '三天前' + if (diffDays > 7) return '超过一星期前' + if (diffDays > 30) return '超过一个月前' + + return '未来...?' +} + +// 统计概览组件 +function StatsOverview() { + const { data: latestAnalyzedTime, isLoading: isLoadingLatestTime } = trpc.devFile!.getLatestAnalyzedTime.useQuery() + const { data: fileTypeStats, isLoading: isLoadingFileTypes } = trpc.devFile!.getFileTypeStats.useQuery() + const { data: commitIdStats, isLoading: isLoadingCommits } = trpc.devFile!.getCommitIdStats.useQuery() + const { data: pkgDependencyStats, isLoading: isLoadingPkgs } = trpc.devFile!.getPkgDependencyStats.useQuery() + + const totalFiles = useMemo(() => { + if (!fileTypeStats) return 0 + return fileTypeStats.reduce((sum, item) => sum + item.count, 0) + }, [fileTypeStats]) + + const latestCommit = useMemo(() => { + if (!commitIdStats || commitIdStats.length === 0) return null + return commitIdStats[0] + }, [commitIdStats]) + + const totalDependencies = useMemo(() => { + if (!pkgDependencyStats) return 0 + return pkgDependencyStats.length + }, [pkgDependencyStats]) + + // 计算相对时间标题 + const relativeTimeTitle = useMemo(() => + getRelativeTimeLabel(latestAnalyzedTime), + [latestAnalyzedTime] + ) + + // 构建统计卡片数据 + const statsCards: StatsCardItem[] = useMemo(() => [ + { + id: 'latest-analyzed-time', + title: '最近分析时间', + icon: Clock, + content: ( + + + 最近分析时间 + + + + {isLoadingLatestTime ? ( + + ) : ( + <> +
+ {latestAnalyzedTime ? relativeTimeTitle : '-'} +
+

+ {latestAnalyzedTime + ? format(new Date(latestAnalyzedTime), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN }) + : '暂无数据'} +

+ + )} +
+
+ ), + }, + { + id: 'latest-commit', + title: '最新提交', + icon: GitCommit, + content: ( + + + 最新提交 + + + + {isLoadingCommits ? ( + + ) : ( + <> +
{latestCommit?.name.substring(0, 7) || '-'}
+

+ {latestCommit?.minAnalyzedAt + ? format(new Date(latestCommit.minAnalyzedAt), 'yyyy-MM-dd HH:mm', { locale: zhCN }) + : '暂无数据'} +

+ + )} +
+
+ ), + }, + { + id: 'total-files', + title: '文件总数', + icon: FileText, + content: ( + + + 文件总数 + + + + {isLoadingFileTypes ? ( + + ) : ( + <> +
{totalFiles}
+

已分析的源代码文件

+ + )} +
+
+ ), + }, + { + id: 'total-dependencies', + title: '依赖包数', + icon: Package, + content: ( + + + 依赖包数 + + + + {isLoadingPkgs ? ( + + ) : ( + <> +
{totalDependencies}
+

项目使用的包

+ + )} +
+
+ ), + }, + ], [relativeTimeTitle, latestAnalyzedTime, latestCommit, totalFiles, totalDependencies, isLoadingLatestTime, isLoadingFileTypes, isLoadingCommits, isLoadingPkgs]) + + return +} + +interface DevFilePageDataTableProps { + children?: React.ReactNode +} + +function DevFilePageDataTable({ children }: DevFilePageDataTableProps) { + // 详情Sheet状态 + const [selectedFile, setSelectedFile] = useState(null) + const [detailSheetOpen, setDetailSheetOpen] = useState(false) + + const { data: fileTypeStats } = trpc.devFile!.getFileTypeStats.useQuery() + const { data: commitIdStats } = trpc.devFile!.getCommitIdStats.useQuery() + const { data: tagsStats } = trpc.devFile!.getTagsStats.useQuery() + const { data: pkgDependencyStats } = trpc.devFile!.getPkgDependencyStats.useQuery() + + // 处理查看详情 + const handleViewDetail = useCallback((file: DevAnalyzedFile) => { + setSelectedFile(file) + setDetailSheetOpen(true) + }, []) + + // 创建表格列定义选项 + const columnsOptions: DevAnalyzedFileColumnsOptions = useMemo(() => ({ + fileTypes: fileTypeStats || [], + commitIds: commitIdStats || [], + tagsStats: tagsStats || [], + pkgDependencyStats: pkgDependencyStats || [], + onViewDetail: handleViewDetail, + }), [fileTypeStats, commitIdStats, tagsStats, pkgDependencyStats, handleViewDetail]) + + // 创建表格列定义 + const columns = useMemo(() => createDevAnalyzedFileColumns(columnsOptions), [columnsOptions]) + + // 使用 useDataTable hook + const { table, queryResult } = useDataTable({ + columns, + initialState: { + pagination: { pageIndex: 1, pageSize: 10 }, + columnPinning: { left: ['select'], right: ['actions'] }, + sorting: [ { id: 'lastAnalyzedAt', desc: true } ] , + columnVisibility: { + path: false, + description: false, + exportedMembers: false, + dependencies: false, + pkgDependencies: true, + } + }, + getRowId: (row) => String(row.id), + queryFn: useCallback((params) => { + const result = trpc.devFile!.listAnalyzedFiles.useQuery(params, { + placeholderData: keepPreviousData, + }) + if (result.error) { + toast.error('获取文件数据失败:' + result.error.toString().substring(0, 100)) + } + return result + }, []), + }) + + return ( + <> + + + {children} + + + + + {/* 文件详情Sheet */} + + + ) +} + +export default function FileListPage() { + // 用于刷新数据的 utils + const utils = trpc.useUtils() + + // 刷新文件列表 + const handleRefreshFiles = useCallback(() => { + utils.devFile!.listAnalyzedFiles.invalidate() + utils.devFile!.getLatestAnalyzedTime.invalidate() + utils.devFile!.getFileTypeStats.invalidate() + utils.devFile!.getCommitIdStats.invalidate() + utils.devFile!.getTagsStats.invalidate() + utils.devFile!.getPkgDependencyStats.invalidate() + }, [utils]) + + return ( +
+ {/* 统计概览区域 */} + + + {/* 文件列表表格 */} + + + }> + + + + + + +
+ ) +} \ No newline at end of file diff --git a/src/app/(main)/dev/file/page.dev.tsx b/src/app/(main)/dev/file/page.dev.tsx new file mode 100644 index 0000000..38705a5 --- /dev/null +++ b/src/app/(main)/dev/file/page.dev.tsx @@ -0,0 +1,5 @@ +import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect"; + +export default function FilePage() { + return ; +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/layout.dev.tsx b/src/app/(main)/dev/frontend-design/layout.dev.tsx new file mode 100644 index 0000000..059c6f2 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/layout.dev.tsx @@ -0,0 +1,9 @@ +import { SubMenuLayout } from "@/components/layout/sub-menu-layout"; + +export default function FrontendDesignLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/page.dev.tsx b/src/app/(main)/dev/frontend-design/page.dev.tsx new file mode 100644 index 0000000..bf50396 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/page.dev.tsx @@ -0,0 +1,5 @@ +import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect"; + +export default function FrontendDesignPage() { + return ; +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/page/page.dev.tsx b/src/app/(main)/dev/frontend-design/page/page.dev.tsx new file mode 100644 index 0000000..65014a6 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/page/page.dev.tsx @@ -0,0 +1,19 @@ +"use client"; + +export default function PageTestPage() { + return ( +
+
+

页面测试

+

+ 在这里测试和展示完整的页面布局和功能 +

+
+ + {/* 这里可以添加完整页面的测试和展示 */} +
+ {/* 示例区域 */} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/ui/components/AddComponentSheet.tsx b/src/app/(main)/dev/frontend-design/ui/components/AddComponentSheet.tsx new file mode 100644 index 0000000..dfd9a81 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/ui/components/AddComponentSheet.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Search, Loader2, Package, Sparkles, Eye, ExternalLink } from "lucide-react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; +import { CardSelect } from "@/components/common/card-select"; +import { ComponentDetailDialog } from "./ComponentDetailDialog"; + +interface AddComponentSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * 添加组件的Sheet组件 + * 包含registry列表、搜索栏和搜索结果展示 + */ +export function AddComponentSheet({ open, onOpenChange }: AddComponentSheetProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedRegistries, setSelectedRegistries] = useState([]); + const [searchResults, setSearchResults] = useState; + error?: string; + }>>([]); + + // 组件详情对话框状态 + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [selectedComponentName, setSelectedComponentName] = useState(""); + + // 搜索框ref,用于自动聚焦 + const searchInputRef = useRef(null); + + // 获取registry列表 + const { data: registriesData, isLoading: isLoadingRegistries } = trpc.devFrontendDesign!.getRegistries.useQuery(); + + // 在registry列表中添加shadcn官方仓库,并放在第一项 + const registries = registriesData ? [ + { + name: '@shadcn', + url: 'https://registry.shadcn.com/{name}.json', + websiteUrl: 'https://ui.shadcn.com', + }, + ...registriesData.filter(r => r.name !== '@shadcn'), // 避免重复 + ] : undefined; + + // 当registry列表加载完成后,默认选中@shadcn + const [hasInitialized, setHasInitialized] = useState(false); + if (registries && !hasInitialized && selectedRegistries.length === 0) { + setSelectedRegistries(['@shadcn']); + setHasInitialized(true); + } + + // 当Sheet打开时,自动聚焦到搜索框 + useEffect(() => { + if (open) { + // 使用setTimeout确保Sheet动画完成后再聚焦 + const timer = setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + } + }, [open]); + + // 搜索组件 + const searchMutation = trpc.devFrontendDesign!.searchComponents.useMutation({ + onSuccess: (data) => { + setSearchResults(data); + const totalItems = data.reduce((sum, r) => sum + r.items.length, 0); + const errorCount = data.filter(r => r.error).length; + + if (totalItems === 0 && errorCount === 0) { + toast.info("未找到匹配的组件"); + } else if (errorCount > 0) { + toast.warning(`搜索完成,但有 ${errorCount} 个registry查询失败`); + } else { + toast.success(`找到 ${totalItems} 个组件`); + } + }, + onError: (error) => { + toast.error(`搜索失败: ${error.message}`); + }, + }); + + const handleSearch = () => { + if (!searchQuery.trim()) { + toast.error("请输入搜索关键词"); + return; + } + + if (selectedRegistries.length === 0) { + toast.error("请至少选择一个registry"); + return; + } + + searchMutation.mutate({ + registries: selectedRegistries, + query: searchQuery.trim(), + }); + }; + + const handleRegistryChange = (value: (string | number)[]) => { + setSelectedRegistries(value as string[]); + }; + + const selectAllRegistries = () => { + if (registries) { + setSelectedRegistries(registries.map(r => r.name)); + } + }; + + const clearSelection = () => { + setSelectedRegistries([]); + }; + + return ( + + + +
+
+ +
+
+ 添加组件 + + 从第三方registry搜索并添加新的UI组件到项目中 + +
+
+
+ + + +
+ {/* Registry列表 */} +
+
+
+ +

+ 已选择 {selectedRegistries.length} / {registries?.length || 0} 个源 +

+
+
+ + +
+
+ + {isLoadingRegistries ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : registries && registries.length > 0 ? ( + ({ + id: r.name, + name: r.name, + description: r.url, + websiteUrl: r.websiteUrl + }))} + showCheckbox={true} + showExternalLink={true} + disabled={isLoadingRegistries} + enablePagination={true} + pageSize={3} + showPaginationInfo={true} + className="min-h-61" + /> + ) : ( +
+ +

+ 未找到可用的registry +

+
+ )} +
+ + + + {/* 搜索框 */} +
+ +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !searchMutation.isPending) { + handleSearch(); + } + }} + className="pl-9" + disabled={searchMutation.isPending} + /> +
+ +
+
+ + {/* 搜索结果 */} + {searchResults.length > 0 && ( +
+ +
+ + + {searchResults.reduce((sum, r) => sum + r.items.length, 0)} 个组件 + +
+
+ {searchResults.map((result) => { + // 查找对应registry的websiteUrl + const registryInfo = registries?.find(r => r.name === result.registry); + const websiteUrl = registryInfo?.websiteUrl; + + return ( +
+
+ + {result.registry} + + {result.error ? ( + + + {result.error} + + ) : ( + + {result.items.length} 个结果 + + )} + {websiteUrl && ( + + + + )} +
+ + {result.items.length > 0 && ( + {}} + options={result.items.map((item, index) => ({ + id: `${result.registry}-${item.name}-${index}`, + name: item.name, + description: item.description, + type: item.type, + addCommandArgument: item.addCommandArgument + }))} + showCheckbox={false} + showBadge={true} + containerClassName="space-y-2 pl-4 border-l-2 border-primary/20" + className="group p-4 rounded-lg border bg-card hover:shadow-md hover:border-primary/50 transition-all" + renderExtra={(option) => ( + option.addCommandArgument ? ( +
+ + {option.addCommandArgument} + +
+ ) : null + )} + renderActions={(option) => ( + + )} + /> + )} +
+ ); + })} +
+
+ )} +
+
+ + {/* 组件详情对话框 */} + +
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/frontend-design/ui/components/ComponentDetailDialog.tsx b/src/app/(main)/dev/frontend-design/ui/components/ComponentDetailDialog.tsx new file mode 100644 index 0000000..4d6d80b --- /dev/null +++ b/src/app/(main)/dev/frontend-design/ui/components/ComponentDetailDialog.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, Package, FileCode, Terminal } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; +import { DetailCodeBlock } from "@/components/data-details/detail-code-block"; +import { DetailCopyable } from "@/components/data-details/detail-copyable"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; + +interface ComponentDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + componentName: string; // 如 @shadcn/tabs +} + +/** + * 组件详情对话框 + * 展示组件的详细信息,包括依赖、文件内容等 + */ +export function ComponentDetailDialog({ + open, + onOpenChange, + componentName, +}: ComponentDetailDialogProps) { + // 获取组件详情 + const viewComponentMutation = trpc.devFrontendDesign!.viewComponent.useMutation({ + onError: (error) => { + toast.error(`获取组件详情失败: ${error.message}`); + }, + }); + + // 当对话框打开时,触发查询 + const [hasQueried, setHasQueried] = useState(false); + if (open && !hasQueried && !viewComponentMutation.isPending) { + viewComponentMutation.mutate({ componentName }); + setHasQueried(true); + } + + // 当对话框关闭时,重置状态 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + setHasQueried(false); + } + onOpenChange(newOpen); + }; + + // 解析组件详情数据 + const componentInfo = viewComponentMutation.data?.[0]; + + return ( + + + +
+
+ +
+
+ + {componentName} + {componentInfo?.type && ( + + {componentInfo.type} + + )} + + + 组件详细信息 + +
+
+
+ + {viewComponentMutation.isPending ? ( +
+ + + +
+ ) : viewComponentMutation.error ? ( +
+

{viewComponentMutation.error.message}

+
+ ) : componentInfo ? ( +
+ {/* 添加组件命令 */} +
+

+ + 添加组件命令 +

+ +
+ + + + {/* 依赖信息 */} + {componentInfo.dependencies && componentInfo.dependencies.length > 0 && ( +
+

+ + 依赖项 +

+
+ {componentInfo.dependencies.map((dep: string) => ( + + {dep} + + ))} +
+
+ )} + + {/* 文件内容 */} + {componentInfo.files && componentInfo.files.length > 0 && ( +
+

+ + 文件内容 +

+ {componentInfo.files.length === 1 ? ( + + ) : ( + + + {componentInfo.files.map((file: any, index: number) => ( + + {file.path.split('/').pop()} + + ))} + + {componentInfo.files.map((file: any, index: number) => ( + + + + ))} + + )} +
+ )} +
+ ) : ( +
+

未找到组件信息

+
+ )} +
+
+ ); +} diff --git a/src/app/(main)/dev/frontend-design/ui/page.dev.tsx b/src/app/(main)/dev/frontend-design/ui/page.dev.tsx new file mode 100644 index 0000000..9b86454 --- /dev/null +++ b/src/app/(main)/dev/frontend-design/ui/page.dev.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Loader2, Plus, Package, Sparkles, Code2, Eye, Search, X } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import React from 'react'; +import { useForm } from "react-hook-form" +import { useDataTable } from "@/hooks/use-data-table"; +import { cn } from "@/lib/utils"; +import { blobUrlToBase64 } from "@/lib/format"; +import { + PromptInput, + PromptInputBody, + PromptInputTextarea, + PromptInputToolbar, + PromptInputTools, + PromptInputSubmit, + PromptInputAttachments, + PromptInputAttachment, + PromptInputActionMenu, + PromptInputActionMenuTrigger, + PromptInputActionMenuContent, + PromptInputActionAddAttachments, + type PromptInputMessage, +} from "@/components/ai-elements/prompt-input"; +import { AddComponentSheet } from "./components/AddComponentSheet"; +import { CardSelect } from "@/components/common/card-select"; +import { CodeEditorPreview } from "@/components/features/code-editor-preview"; + +/** + * 动态导入组件模块 + */ +async function importComponentModule(path: string) { + // 清理路径:移除 src/components/ 前缀和 .tsx/.ts 后缀 + const cleanPath = path + .replace(/^src\/components\//, '') + .replace(/\.(tsx|ts)$/, ''); + + try { + const importedModule = await import(`@/components/${cleanPath}`); + return importedModule; + } catch (error) { + console.error(`Failed to import component from ${path}:`, error); + return null; + } +} + +export default function UIComponentsPage() { + const [selectedComponents, setSelectedComponents] = useState([]); + const [generatedCode, setGeneratedCode] = useState(null); + const [componentScope, setComponentScope] = useState>({}); + const [searchQuery, setSearchQuery] = useState(""); + const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready"); + const [addSheetOpen, setAddSheetOpen] = useState(false); + + // 获取UI组件列表 + const { data: components, isLoading: isLoadingComponents } = trpc.devFrontendDesign!.getUIComponents.useQuery(); + + // 筛选和排序组件:选中的组件排在前面 + const filteredComponents = useMemo(() => { + if (!components) return []; + + let filtered = components; + + // 如果有搜索关键词,先筛选并计算匹配优先级 + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + + // 为每个组件计算匹配优先级:1=fileName, 2=path, 3=summary, 0=不匹配 + const componentsWithPriority = components.map(c => { + const fileNameMatch = c.fileName.toLowerCase().includes(query); + const pathMatch = c.path.toLowerCase().includes(query); + const summaryMatch = c.summary.toLowerCase().includes(query); + + let priority = 0; + if (fileNameMatch) priority = 1; + else if (pathMatch) priority = 2; + else if (summaryMatch) priority = 3; + + return { component: c, priority }; + }); + + // 过滤掉不匹配的,并按优先级排序 + filtered = componentsWithPriority + .filter(item => item.priority > 0) + .sort((a, b) => a.priority - b.priority) + .map(item => item.component); + } + + // 将选中的组件排到前面 + return filtered.sort((a, b) => { + const aSelected = selectedComponents.includes(a.path); + const bSelected = selectedComponents.includes(b.path); + + if (aSelected && !bSelected) return -1; + if (!aSelected && bSelected) return 1; + return 0; + }); + }, [components, searchQuery, selectedComponents]); + + // 生成演示代码 + const generateMutation = trpc.devFrontendDesign!.generateComponentDemo.useMutation({ + onSuccess: async (data) => { + // 动态构建scope + const scope: Record = { + React, + useState: React.useState, + useEffect: React.useEffect, + useMemo: React.useMemo, + useCallback: React.useCallback, + useRef: React.useRef, + useForm: useForm, + useDataTable: useDataTable, + cn, + }; + + // 导入所有选中的组件 + for (const component of data.components) { + const importedModule = await importComponentModule(component.path); + if (importedModule) { + // 将所有导出的成员添加到scope + for (const member of component.exportedMembers) { + if (importedModule[member.name]) { + scope[member.name] = importedModule[member.name]; + } + } + } + } + + setComponentScope(scope); + setGeneratedCode(data.code); + setStatus("ready"); + toast.success("代码生成成功!"); + }, + onError: (error) => { + setStatus("error"); + toast.error(`生成失败: ${error.message}`); + }, + }); + + const handleSubmit = async (message: PromptInputMessage) => { + if (status === "streaming") { // 还在生成结果,这时候用户再点提交就reset + generateMutation.reset(); + setStatus("ready"); + return; + } + if (!message.text) { + toast.error("请输入提示词"); + return; + } + + setStatus("submitted"); + // 将图片转换为base64 + const images: Array<{ url: string; mediaType: string }> = []; + if (message.files?.length) { + for (const file of message.files) { + if (file.url && file.mediaType?.startsWith('image/')) { + try { + const base64 = await blobUrlToBase64(file.url); + images.push({ url: base64, mediaType: file.mediaType }); + } catch (error) { + console.error('转换图片失败:', error); + toast.error('图片处理失败'); + } + } + } + } + + setTimeout(() => { + setStatus("streaming"); + generateMutation.mutate({ + componentPaths: selectedComponents, + prompt: message.text!.trim(), + images: images.length > 0 ? images : undefined, + }); + }, 100); + }; + + const handleComponentChange = (value: (string | number)[]) => { + setSelectedComponents(value as string[]); + }; + + return ( +
+
+ {/* 左侧:组件选择 */} + + +
+
+ +
+
+ 选择组件 + + 从项目中选择需要使用的UI组件 + +
+ +
+ +
+ + 已选择 {selectedComponents.length} 个 + + {filteredComponents.length < (components?.length || 0) && ( + + 显示 {filteredComponents.length}/{components?.length || 0} + + )} +
+
+ + {/* 搜索框 */} +
+ +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> + {searchQuery && ( + + )} +
+
+ + {/* 组件列表区域 - 可滚动 */} +
+ {isLoadingComponents ? ( +
+
+ +

加载组件列表...

+
+
+ ) : filteredComponents.length > 0 ? ( + <> + +
+ ({ + id: c.path, + name: c.fileName, + description: c.summary, + url: c.path + }))} + showCheckbox={true} + containerClassName="space-y-1.5" + className="group p-3 border-l-2 border-l-transparent hover:border-l-primary hover:bg-accent/50 transition-all" + renderExtra={(option) => ( +

+ {option.url} +

+ )} + /> +
+ + ) : searchQuery ? ( +
+ +

未找到匹配的组件

+

+ 尝试使用其他关键词搜索 +

+
+ ) : ( +
+ +

暂无可用的UI组件

+

+ 点击右上角“添加”按钮从registry导入组件 +

+
+ )} +
+ + + + {/* AI 提示词输入框 */} +
+ + + + + {(attachment) => } + + + + + + + + + + + + + + + +
+
+
+ + {/* 右侧:代码预览 */} +
+ + +
+
+ +
+
+ 代码预览 + + 实时编辑和预览生成的组件效果 + +
+ {generatedCode && ( + + + 实时预览 + + )} +
+ +
+ + {status === "streaming" || generatedCode ? ( + + ) : ( +
+
+
+ +
+
+

+ 准备开始创建 +

+

+ 选择需要使用的组件,然后在左侧输入提示词,AI将为你生成可预览的代码 +

+
+
+
+ )} +
+
+
+
+ + {/* 添加组件Sheet */} + +
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/dev/layout.dev.tsx b/src/app/(main)/dev/layout.dev.tsx new file mode 100644 index 0000000..46e28e2 --- /dev/null +++ b/src/app/(main)/dev/layout.dev.tsx @@ -0,0 +1,15 @@ +"use client"; + +import "./dev-theme.css"; + +export default function DevLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + {children} + + ); +} diff --git a/src/app/(main)/dev/panel/agents-config.tsx b/src/app/(main)/dev/panel/agents-config.tsx new file mode 100644 index 0000000..bcada9d --- /dev/null +++ b/src/app/(main)/dev/panel/agents-config.tsx @@ -0,0 +1,88 @@ +/** + * 智能体配置 + */ + +export interface AgentTool { + id: string + name: string + description: string +} + +export interface AgentModel { + id: string + name: string + logo: string +} + +export interface AgentType { + id: string + name: string + description: string + defaultModel: string + defaultTools: string[] + availableTools: AgentTool[] +} + +// 可用的模型列表 +export const AVAILABLE_MODELS: AgentModel[] = [ + { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', logo: 'anthropic' }, + { id: 'claude-sonnet-4-5-20250929:thinking', name: 'Claude Sonnet 4.5 深度思考', logo: 'anthropic' }, + { id: 'gpt-4.1', name: 'GPT-4.1', logo: 'openai' }, +] + +// 智能体类型配置 +export const AGENT_TYPES: AgentType[] = [ + { + id: 'project-assistant', + name: '项目管家', + description: '(功能开发中,暂不可用)帮助您了解和规划项目', + defaultModel: 'claude-sonnet-4-5-20250929:thinking', + defaultTools: ['read-project-files'], + availableTools: [ + // { id: 'read-project-files', name: '读取项目文件', description: '读取项目文件及其文件分析数据' }, + ], + }, + { + id: 'casual-chat', + name: '随便聊聊', + description: '适用于和项目关联不大的一般性问题和对话', + defaultModel: 'gpt-4.1', + defaultTools: [], + availableTools: [ + ], + }, + // { + // id: 'tech-selection', + // name: '技术选型', + // description: '帮助进行技术栈选择和架构设计', + // defaultModel: 'gpt-4.1', + // defaultTools: ['web-search', 'tech-analyzer'], + // availableTools: [ + // { id: 'web-search', name: '网络搜索', description: '搜索技术文档和最佳实践' }, + // { id: 'tech-analyzer', name: '技术分析器', description: '分析技术栈的优劣' }, + // { id: 'benchmark-tool', name: '性能基准', description: '对比不同技术的性能' }, + // ], + // }, + // { + // id: 'requirement', + // name: '需求沟通', + // description: '协助需求分析和功能设计', + // defaultModel: 'gpt-4.1', + // defaultTools: ['diagram-generator', 'requirement-analyzer'], + // availableTools: [ + // { id: 'diagram-generator', name: '图表生成器', description: '生成流程图和架构图' }, + // { id: 'requirement-analyzer', name: '需求分析器', description: '分析和拆解需求' }, + // { id: 'user-story-writer', name: '用户故事编写', description: '编写用户故事' }, + // ], + // }, +] + +// 根据智能体类型ID获取配置 +export function getAgentTypeById(id: string): AgentType | undefined { + return AGENT_TYPES.find((type) => type.id === id) +} + +// 根据模型ID获取模型信息 +export function getModelById(id: string): AgentModel | undefined { + return AVAILABLE_MODELS.find((model) => model.id === id) +} \ No newline at end of file diff --git a/src/app/(main)/dev/panel/components/version-control.tsx b/src/app/(main)/dev/panel/components/version-control.tsx new file mode 100644 index 0000000..2fb60bf --- /dev/null +++ b/src/app/(main)/dev/panel/components/version-control.tsx @@ -0,0 +1,584 @@ +import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { trpc } from '@/lib/trpc' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' +import { + Timeline, + TimelineEmpty, + TimelineItem, + TimelineConnector, + TimelineNode, + TimelineContent, + TimelineHeader, + TimelineTitleArea, + TimelineTitle, + TimelineBadge, + TimelineActions, + TimelineTimestamp, + TimelineDescription, + TimelineMetadata, +} from '@/components/data-details' +import { + AdvancedSelect, + SelectPopover, + SelectTrigger, + SelectContent, + SelectItemList, + SelectedName, +} from '@/components/common/advanced-select' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import React from 'react' +import { keepPreviousData } from '@tanstack/react-query' + + +/** + * 版本控制组件 + */ +export function VersionControl({ isOpen }: { isOpen: boolean }) { + const [commitMessage, setCommitMessage] = React.useState('') + const [selectedBranch, setSelectedBranch] = React.useState('') + const [showCommitDialog, setShowCommitDialog] = React.useState(false) + const [commitLimit, setCommitLimit] = React.useState(10) + const [isInitialLoad, setIsInitialLoad] = React.useState(true) + const [isLoadingMore, setIsLoadingMore] = React.useState(false) + const [commitType, setCommitType] = React.useState<'normal' | 'amend' | null>(null) + const [confirmAction, setConfirmAction] = React.useState<{ + type: 'checkout' | 'checkout-branch' | 'revert' | 'reset' + commitId?: string + message?: string + title?: string + description?: string + } | null>(null) + const scrollViewportRef = React.useRef(null) + + // 查询分支列表 + const { data: branches, refetch: refetchBranches, isLoading: branchesLoading } = trpc.devPanel!.getBranches.useQuery(undefined, { + enabled: isOpen, + }) + + // 查询当前分支 + const { data: currentBranchData, isLoading: currentBranchLoading } = trpc.devPanel!.getCurrentBranch.useQuery(undefined, { + enabled: isOpen, + }) + + // 初始化选中的分支:优先级为 isCurrent > master > main > 第一个分支 + React.useEffect(() => { + if (!branches || branches.length === 0 || selectedBranch) return + + const initialBranch = + branches.find(b => b.isCurrent)?.name || + branches.find(b => b.name === 'master')?.name || + branches.find(b => b.name === 'main')?.name || + branches[0].name + + setSelectedBranch(initialBranch) + }, [branches, selectedBranch]) + + // 查询提交历史(根据选中的分支) + const { data: commits, refetch: refetchCommits, isLoading: commitsLoading, isFetching } = trpc.devPanel!.getCommitHistory.useQuery( + { limit: commitLimit, branchName: selectedBranch }, + { + enabled: isOpen, + placeholderData: keepPreviousData + } + ) + + // 初始加载完成后设置标志 + React.useEffect(() => { + if (!commitsLoading && commits) { + setIsInitialLoad(false) + } + }, [commitsLoading, commits]) + + // 查询是否有未提交的更改 + const { data: hasChangesData, refetch: refetchHasChanges, isLoading: hasChangesLoading } = trpc.devPanel!.hasUncommittedChanges.useQuery(undefined, { + enabled: isOpen, + }) + + // 创建提交mutation + const createCommitMutation = trpc.devPanel!.createCommit.useMutation({ + onSuccess: (data) => { + toast.success(data.message) + setCommitMessage('') + setShowCommitDialog(false) + setCommitType(null) + refetchCommits() + refetchHasChanges() + }, + onError: (error) => { + toast.error(error.message) + setCommitType(null) + }, + }) + + // 切换到指定提交mutation + const checkoutCommitMutation = trpc.devPanel!.checkoutCommit.useMutation({ + onSuccess: (data) => { + toast.success(data.message) + refetchBranches() + refetchCommits() + setConfirmAction(null) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + // 切换到指定分支mutation + const checkoutBranchMutation = trpc.devPanel!.checkoutBranch.useMutation({ + onSuccess: (data) => { + toast.success(data.message) + refetchBranches() + refetchCommits() + setConfirmAction(null) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + // 反转提交mutation + const revertCommitMutation = trpc.devPanel!.revertCommit.useMutation({ + onSuccess: (data) => { + toast.success(data.message) + refetchCommits() + setConfirmAction(null) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + // 强制回滚mutation + const resetToCommitMutation = trpc.devPanel!.resetToCommit.useMutation({ + onSuccess: (data) => { + toast.success(data.message) + refetchCommits() + setConfirmAction(null) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + // 处理分支选择(仅用于查看历史,不切换实际分支) + const handleBranchChange = (branchName: string | null) => { + if (!branchName) return + setSelectedBranch(branchName) + } + + // 处理提交 + const handleCommit = (amend: boolean = false) => { + if (!commitMessage.trim()) { + toast.error('请输入提交信息') + return + } + setCommitType(amend ? 'amend' : 'normal') + createCommitMutation.mutate({ message: commitMessage, amend }) + } + + // 处理切换到指定提交 + const handleCheckoutCommit = (commitId: string, message: string, isFirstCommit: boolean = false) => { + // 如果是第一个节点,显示特殊提示,并且切换到这个分支 + if (isFirstCommit) { + setConfirmAction({ + type: 'checkout-branch', + commitId, + message, + title: '切换到此分支', + description: '这是该分支的最新版本。切换到此分支后,您可以继续进行开发和提交新的更改。', + }) + } else { + setConfirmAction({ + type: 'checkout', + commitId, + message, + title: '切换到指定提交', + description: '确定要切换到此提交吗?这将使代码回到该提交时的状态,但如果要继续编写代码和提交请切换回最新的提交节点。', + }) + } + } + + // 处理反转提交 + const handleRevert = (commitId: string, message: string) => { + setConfirmAction({ + type: 'revert', + commitId, + message, + title: '反转提交', + description: '确定要反转该提交吗?这将创建一个与该提交操作相反的提交。', + }) + } + + // 处理强制回滚 + const handleReset = (commitId: string, message: string) => { + setConfirmAction({ + type: 'reset', + commitId, + message, + title: '强制回滚到指定提交', + description: '⚠️ 警告:确定要强制回滚到此提交吗?这将永久删除该提交之后的所有提交,此操作不可恢复!', + }) + } + + // 处理滚动事件 + const handleScroll = React.useCallback(() => { + const viewport = scrollViewportRef.current + if (!viewport || isFetching || isLoadingMore) return + + const { scrollTop, scrollHeight, clientHeight } = viewport + // 当滚动到底部附近100px时加载更多 + if (scrollTop + clientHeight >= scrollHeight - 100) { + if (commits && commits.length >= commitLimit) { + setIsLoadingMore(true) + setCommitLimit(prev => prev + 10) + } + } + }, [isFetching, commits, commitLimit, isLoadingMore]) + + // 监听加载状态变化,加载完成后重置isLoadingMore + React.useEffect(() => { + if (!isFetching && isLoadingMore) { + setIsLoadingMore(false) + } + }, [isFetching, isLoadingMore]) + + // 监听滚动事件 + React.useEffect(() => { + const viewport = scrollViewportRef.current + if (!viewport) return + + viewport.addEventListener('scroll', handleScroll) + return () => viewport.removeEventListener('scroll', handleScroll) + }, [handleScroll]) + + // 执行确认的操作 + const executeConfirmedAction = () => { + if (!confirmAction) return + + switch (confirmAction.type) { + case 'checkout': + if (confirmAction.commitId) { + checkoutCommitMutation.mutate({ commitId: confirmAction.commitId }) + } + break + case 'checkout-branch': + // 切换到分支(使用选中的分支名称) + checkoutBranchMutation.mutate({ branchName: selectedBranch }) + break + case 'revert': + if (confirmAction.commitId) { + revertCommitMutation.mutate({ commitId: confirmAction.commitId }) + } + break + case 'reset': + if (confirmAction.commitId) { + resetToCommitMutation.mutate({ commitId: confirmAction.commitId }) + } + break + } + } + + // 手动刷新所有数据 + const handleRefresh = () => { + refetchBranches() + refetchCommits() + refetchHasChanges() + } + + const hasChanges = hasChangesData?.hasChanges + const isLoading = branchesLoading || currentBranchLoading || commitsLoading || hasChangesLoading + const branchOptions = React.useMemo( + () => + branches?.map((b) => ({ + id: b.name, + name: b.isCurrent ? `${b.name} (当前分支)` : b.name, + })) || [], + [branches] + ) + + return ( +
+ {/* 分支选择器和操作按钮 */} +
+
+ {/* 左半部分:分支选择器 */} +
+ +
+ {branchesLoading ? ( + + ) : ( + + + + + + + + + + + )} +
+
+ + {/* 右半部分:Commit按钮和刷新按钮 */} +
+ + +
+
+ {hasChanges && ( +
+ + 有未提交的更改 +
+ )} + {selectedBranch !== currentBranchData?.branch && ( +
+ 当前查看 {selectedBranch} 分支的提交历史 +
+ )} +
+ + + + {/* 提交历史 */} +
+ e.stopPropagation()} + > + {isInitialLoad && commitsLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ ) : (!commits || commits.length === 0) ? ( + 暂无提交记录 + ) : ( + + {commits.map((commit, index) => { + const isAfterHead = commit.isAfterHead + const isFirstCommit = index === 0 + + return ( + + + + + + + + + {commit.message} + + + + + + + + + handleCheckoutCommit(commit.shortId, commit.message, isFirstCommit)} + disabled={hasChanges} + > + + {isFirstCommit ? '切换到此分支' : '切换到此提交'} + + + handleRevert(commit.shortId, commit.message)} + > + + 反转提交 + + handleReset(commit.shortId, commit.message)} + > + + 强制回滚 + + + + + + + + + + Commit ID: {commit.shortId} + + + + + + ) + })} + + )} + {/* 加载更多指示器 */} + {isLoadingMore && !isInitialLoad && ( +
+ + 加载更多... +
+ )} +
+
+ + {/* Commit对话框 */} + + + + 提交更改 + + 请输入提交信息来描述本次更改 + + +
+
+ +