From e60dafe3b600d502ec4bd44ec12cc54641b0508d Mon Sep 17 00:00:00 2001 From: longdayi <13477510+longdayilongdayi@user.noreply.gitee.com> Date: Sat, 22 Feb 2025 22:06:44 +0800 Subject: [PATCH] linfeng --- .continue/prompts/coder.prompt | 19 + .continue/prompts/comment.prompt | 30 + .continue/prompts/explain.prompt | 6 + .continue/prompts/jstots.prompt | 13 + .continue/prompts/refactor.prompt | 52 + .continue/prompts/sci-post.prompt | 45 + .dockerignore | 9 + .gitignore | 71 + .npmrc | 1 + Dockerfile | 102 + apps/server/.env.example | 14 + apps/server/.eslintrc.js | 40 + apps/server/.prettierrc | 4 + apps/server/README.md | 73 + apps/server/entrypoint.sh | 41 + apps/server/nest-cli.json | 8 + apps/server/package.json | 113 + apps/server/src/app.module.ts | 52 + apps/server/src/auth/auth.controller.ts | 112 + apps/server/src/auth/auth.guard.ts | 37 + apps/server/src/auth/auth.module.ts | 19 + apps/server/src/auth/auth.service.ts | 243 + apps/server/src/auth/config.ts | 9 + apps/server/src/auth/session.service.ts | 61 + apps/server/src/auth/types.ts | 31 + apps/server/src/auth/utils.ts | 193 + apps/server/src/env.ts | 3 + apps/server/src/filters/exceptions.filter.ts | 33 + apps/server/src/main.ts | 34 + .../models/app-config/app-config.module.ts | 12 + .../models/app-config/app-config.router.ts | 47 + .../models/app-config/app-config.service.ts | 21 + apps/server/src/models/base/base.service.ts | 577 + .../src/models/base/base.tree.service.ts | 416 + apps/server/src/models/base/base.type.ts | 44 + .../server/src/models/base/errorMap.prisma.ts | 198 + .../src/models/base/row-cache.service.ts | 183 + .../src/models/base/row-model.service.ts | 307 + apps/server/src/models/base/sql-builder.ts | 138 + apps/server/src/models/base/test.sql | 30 + .../department/department.controller.ts | 87 + .../models/department/department.module.ts | 13 + .../models/department/department.router.ts | 71 + .../department/department.row.service.ts | 91 + .../models/department/department.service.ts | 338 + apps/server/src/models/department/utils.ts | 77 + .../src/models/goods/goods.controller.ts | 41 + apps/server/src/models/goods/goods.module.ts | 9 + apps/server/src/models/goods/goods.service.ts | 4 + .../src/models/message/message.controller.ts | 125 + .../src/models/message/message.module.ts | 14 + .../src/models/message/message.router.ts | 39 + .../src/models/message/message.service.ts | 57 + apps/server/src/models/message/utils.ts | 20 + .../server/src/models/post/post.controller.ts | 10 + apps/server/src/models/post/post.module.ts | 18 + apps/server/src/models/post/post.router.ts | 106 + apps/server/src/models/post/post.service.ts | 178 + apps/server/src/models/post/utils.ts | 54 + apps/server/src/models/rbac/rbac.module.ts | 14 + apps/server/src/models/rbac/role.router.ts | 88 + .../src/models/rbac/role.row.service.ts | 47 + apps/server/src/models/rbac/role.service.ts | 25 + apps/server/src/models/rbac/rolemap.router.ts | 71 + .../server/src/models/rbac/rolemap.service.ts | 316 + .../models/resource/pipe/resource.pipeline.ts | 85 + .../resource/processor/BaseProcessor.ts | 23 + .../resource/processor/ImageProcessor.ts | 62 + .../resource/processor/VideoProcessor.ts | 190 + .../src/models/resource/resource.module.ts | 10 + .../src/models/resource/resource.router.ts | 70 + .../src/models/resource/resource.service.ts | 36 + apps/server/src/models/resource/types.ts | 55 + .../src/models/staff/staff.controller.ts | 48 + apps/server/src/models/staff/staff.module.ts | 15 + apps/server/src/models/staff/staff.router.ts | 81 + .../src/models/staff/staff.row.service.ts | 135 + apps/server/src/models/staff/staff.service.ts | 180 + .../models/taxonomy/taxonomy.controller.ts | 28 + .../src/models/taxonomy/taxonomy.module.ts | 12 + .../src/models/taxonomy/taxonomy.router.ts | 55 + .../src/models/taxonomy/taxonomy.service.ts | 203 + .../server/src/models/term/term.controller.ts | 28 + apps/server/src/models/term/term.module.ts | 16 + apps/server/src/models/term/term.router.ts | 83 + .../src/models/term/term.row.service.ts | 91 + apps/server/src/models/term/term.service.ts | 425 + apps/server/src/models/term/utils.ts | 24 + .../src/models/transform/transform.module.ts | 21 + .../src/models/transform/transform.router.ts | 31 + .../src/models/transform/transform.service.ts | 541 + apps/server/src/models/visit/visit.module.ts | 10 + apps/server/src/models/visit/visit.router.ts | 37 + apps/server/src/models/visit/visit.service.ts | 83 + .../queue/postprocess/postprocess.service.ts | 10 + apps/server/src/queue/queue.module.ts | 34 + apps/server/src/queue/types.ts | 3 + .../server/src/queue/worker/file.processor.ts | 22 + apps/server/src/queue/worker/processor.ts | 20 + .../src/socket/base/base-websocket-server.ts | 205 + .../src/socket/collaboration/callback.ts | 150 + .../collaboration/collaboration.module.ts | 8 + .../src/socket/collaboration/persistence.ts | 34 + apps/server/src/socket/collaboration/types.ts | 5 + .../src/socket/collaboration/ws-shared-doc.ts | 158 + .../src/socket/collaboration/yjs.server.ts | 85 + .../src/socket/realtime/realtime.module.ts | 9 + .../src/socket/realtime/realtime.server.ts | 25 + apps/server/src/socket/types.ts | 29 + apps/server/src/socket/websocket.module.ts | 11 + apps/server/src/socket/websocket.service.ts | 61 + apps/server/src/tasks/init/gendev.service.ts | 225 + apps/server/src/tasks/init/init.module.ts | 16 + apps/server/src/tasks/init/init.service.ts | 166 + apps/server/src/tasks/init/utils.ts | 34 + .../src/tasks/reminder/reminder.module.ts | 10 + .../src/tasks/reminder/reminder.service.ts | 81 + apps/server/src/tasks/tasks.module.ts | 9 + apps/server/src/tasks/tasks.service.spec.ts | 18 + apps/server/src/tasks/tasks.service.ts | 46 + apps/server/src/trpc/trpc.module.ts | 42 + apps/server/src/trpc/trpc.router.ts | 73 + apps/server/src/trpc/trpc.service.ts | 62 + apps/server/src/trpc/types.ts | 3 + apps/server/src/trpc/utils.ts | 15 + apps/server/src/upload/tus.service.ts | 133 + apps/server/src/upload/types.ts | 29 + apps/server/src/upload/upload.controller.ts | 54 + apps/server/src/upload/upload.module.ts | 17 + apps/server/src/upload/utils.ts | 4 + apps/server/src/utils/event-bus.ts | 16 + apps/server/src/utils/file.ts | 66 + apps/server/src/utils/minio/minio.module.ts | 8 + apps/server/src/utils/minio/minio.service.ts | 26 + apps/server/src/utils/redis/redis.service.ts | 192 + apps/server/src/utils/redis/utils.ts | 13 + apps/server/src/utils/tool.ts | 148 + apps/server/test/app.e2e-spec.ts | 24 + apps/server/test/jest-e2e.json | 9 + apps/server/tsconfig.build.json | 4 + apps/server/tsconfig.json | 18 + apps/web/.env.example | 5 + apps/web/eslint.config.js | 43 + apps/web/index.html | 23 + apps/web/nginx.conf | 10 + apps/web/package.json | 85 + apps/web/postcss.config.js | 6 + apps/web/public/params.json | 2 + apps/web/public/vite.svg | 1 + apps/web/src/App.css | 42 + apps/web/src/App.tsx | 43 + apps/web/src/app/admin/base-setting/page.tsx | 190 + apps/web/src/app/admin/department/page.tsx | 7 + apps/web/src/app/admin/layout.tsx | 111 + apps/web/src/app/admin/role/page.tsx | 13 + apps/web/src/app/admin/staff/page.tsx | 8 + apps/web/src/app/admin/term/page.tsx | 11 + apps/web/src/app/denied.tsx | 7 + apps/web/src/app/error.tsx | 41 + apps/web/src/app/login.tsx | 273 + apps/web/src/app/main/home/page.tsx | 35 + apps/web/src/app/main/layout/index.tsx | 7 + apps/web/src/assets/react.svg | 1 + .../src/components/animation/sine-wave.tsx | 122 + .../src/components/common/container/Card.tsx | 41 + .../common/container/CollapsibleContent.tsx | 54 + .../common/editor/quill/QuillCharCounter.tsx | 42 + .../common/editor/quill/QuillEditor.tsx | 198 + .../common/editor/quill/constants.ts | 11 + .../common/element/AnimatedTabs.tsx | 44 + .../src/components/common/element/Avatar.tsx | 50 + .../src/components/common/element/Button.tsx | 221 + .../components/common/element/Pagination.tsx | 106 + .../web/src/components/common/element/Tag.tsx | 88 + .../components/common/form/FormArrayField.tsx | 136 + .../common/form/FormDynamicInputs.tsx | 156 + .../src/components/common/form/FormError.tsx | 32 + .../src/components/common/form/FormInput.tsx | 148 + .../components/common/form/FormQuillInput.tsx | 79 + .../src/components/common/form/FormSelect.tsx | 123 + .../src/components/common/input/InputList.tsx | 102 + .../web/src/components/common/space/Empty.tsx | 25 + .../common/uploader/AvatarUploader.tsx | 156 + .../common/uploader/TusUploader.tsx | 222 + .../components/layout/admin/AdminHeader.tsx | 198 + .../components/layout/admin/AdminLayout.tsx | 20 + .../components/layout/admin/AdminSidebar.tsx | 58 + apps/web/src/components/layout/breadcrumb.tsx | 38 + .../components/layout/element/breadcrumb.tsx | 37 + .../src/components/layout/element/types.ts | 5 + .../element/usermenu/user-edit-modal.tsx | 26 + .../layout/element/usermenu/user-form.tsx | 216 + .../layout/element/usermenu/usermenu.tsx | 259 + apps/web/src/components/layout/fix-header.tsx | 176 + .../components/layout/resizable-sidebar.tsx | 95 + .../src/components/layout/sidebar-content.tsx | 76 + .../web/src/components/layout/user-header.tsx | 46 + .../models/department/department-form.tsx | 136 + .../department/department-import-drawer.tsx | 63 + .../models/department/department-list.tsx | 110 + .../models/department/department-select.tsx | 178 + .../models/department/dept-editor.tsx | 90 + .../models/department/dept-import-form.tsx | 80 + .../models/department/dept-import-modal.tsx | 25 + .../models/department/dept-modal.tsx | 26 + .../models/role/role-editor/assign-list.tsx | 118 + .../models/role/role-editor/role-editor.tsx | 82 + .../models/role/role-editor/role-form.tsx | 61 + .../models/role/role-editor/role-list.tsx | 88 + .../models/role/role-editor/role-modal.tsx | 31 + .../role/role-editor/role-staff-modal.tsx | 64 + .../components/models/role/role-select.tsx | 46 + .../components/models/staff/staff-editor.tsx | 71 + .../components/models/staff/staff-form.tsx | 178 + .../components/models/staff/staff-list.tsx | 195 + .../components/models/staff/staff-modal.tsx | 27 + .../components/models/staff/staff-select.tsx | 84 + .../models/staff/staff-transfer.tsx | 79 + .../models/taxonomy/taxonomy-select.tsx | 67 + .../components/models/term/taxonomy-form.tsx | 60 + .../components/models/term/taxonomy-list.tsx | 45 + .../components/models/term/taxonomy-modal.tsx | 23 + .../components/models/term/term-editor.tsx | 94 + .../src/components/models/term/term-form.tsx | 85 + .../models/term/term-import-form.tsx | 87 + .../models/term/term-import-modal.tsx | 25 + .../src/components/models/term/term-list.tsx | 321 + .../src/components/models/term/term-modal.tsx | 28 + .../components/models/term/term-select.tsx | 193 + .../models/term/term-select_BACKUP.tsx | 92 + apps/web/src/components/models/term/util.ts | 15 + .../src/components/presentation/NavBar.tsx | 58 + .../src/components/presentation/Skeleton.tsx | 43 + .../presentation/ag-server-table.tsx | 494 + .../presentation/animate-progress.tsx | 28 + .../presentation/collapse-section.tsx | 133 + .../presentation/dashboard-card.tsx | 62 + .../src/components/presentation/dialog.tsx | 229 + .../components/presentation/dropdown-menu.tsx | 290 + .../presentation/excel-to-base64-uploader.tsx | 85 + .../presentation/general-dialog.tsx | 64 + .../src/components/presentation/id-card.tsx | 25 + .../src/components/presentation/nice-img.tsx | 80 + .../components/presentation/phone-book.tsx | 23 + .../src/components/presentation/round-tag.tsx | 20 + .../presentation/rounded-rectangle-tag.tsx | 19 + .../components/presentation/user/Avatar.tsx | 50 + .../ControlButtons/Brightness.tsx | 32 + .../ControlButtons/FullScreen.tsx | 29 + .../video-player/ControlButtons/Play.tsx | 25 + .../video-player/ControlButtons/Setting.tsx | 62 + .../video-player/ControlButtons/Speed.tsx | 59 + .../video-player/ControlButtons/TimeLine.tsx | 64 + .../video-player/ControlButtons/Volume.tsx | 43 + .../video-player/ControlButtons/index.ts | 4 + .../video-player/LoadingOverlay.tsx | 33 + .../video-player/VideoControls.tsx | 126 + .../video-player/VideoDisplay.tsx | 222 + .../presentation/video-player/VideoPlayer.tsx | 135 + .../video-player/VideoPlayerLayout.tsx | 35 + .../presentation/video-player/interface.ts | 10 + .../presentation/video-player/type.ts | 1 + .../presentation/video-player/utlis.ts | 5 + apps/web/src/components/svg/rounded-clip.tsx | 28 + .../src/components/utils/excel-importer.tsx | 136 + .../src/components/utils/image-uploader.tsx | 111 + apps/web/src/components/utils/with-auth.tsx | 62 + apps/web/src/env.ts | 23 + apps/web/src/hooks/useClickOutside.ts | 14 + apps/web/src/hooks/useLocalSetting.ts | 17 + apps/web/src/hooks/useTusUpload.ts | 113 + apps/web/src/index.css | 131 + apps/web/src/locale/ag-grid-locale.ts | 563 + apps/web/src/main.tsx | 18 + apps/web/src/polyfills/index.ts | 7 + apps/web/src/providers/auth-provider.tsx | 254 + apps/web/src/providers/params-provider.tsx | 45 + apps/web/src/providers/query-provider.tsx | 84 + apps/web/src/providers/theme-provider.tsx | 77 + apps/web/src/routes/admin-route.tsx | 124 + apps/web/src/routes/index.tsx | 31 + apps/web/src/routes/main-route.tsx | 13 + apps/web/src/routes/types.ts | 19 + apps/web/src/utils/axios.ts | 20 + apps/web/src/utils/idb.ts | 15 + apps/web/src/utils/index.ts | 3 + apps/web/src/utils/tusd.ts | 96 + apps/web/src/vite-env.d.ts | 1 + apps/web/tailwind.config.ts | 2 + apps/web/tsconfig.app.json | 32 + apps/web/tsconfig.json | 7 + apps/web/tsconfig.node.json | 17 + apps/web/vite.config.ts | 17 + auto.sh | 44 + config/backup.sh | 26 + config/nginx/conf.d/web.conf | 114 + config/nginx/conf.d/web.template | 114 + config/nginx/entrypoint.sh | 25 + config/nginx/nginx.conf | 31 + config/redis.conf | 94 + docker-compose.example.yml | 134 + package.json | 15 + packages/client/package.json | 42 + packages/client/src/api/hooks/index.ts | 12 + packages/client/src/api/hooks/useAppConfig.ts | 49 + packages/client/src/api/hooks/useCourse.ts | 76 + .../client/src/api/hooks/useDepartment.ts | 74 + packages/client/src/api/hooks/useEntity.ts | 117 + packages/client/src/api/hooks/useMessage.ts | 5 + packages/client/src/api/hooks/usePost.ts | 12 + packages/client/src/api/hooks/useQueryApi.ts | 27 + packages/client/src/api/hooks/useRole.ts | 33 + packages/client/src/api/hooks/useRoleMap.ts | 47 + packages/client/src/api/hooks/useStaff.ts | 43 + packages/client/src/api/hooks/useTaxonomy.ts | 47 + packages/client/src/api/hooks/useTerm.ts | 71 + packages/client/src/api/hooks/useTransform.ts | 34 + packages/client/src/api/hooks/useVisitor.ts | 178 + packages/client/src/api/index.ts | 3 + packages/client/src/api/trpc.ts | 11 + packages/client/src/api/utils.ts | 62 + packages/client/src/event/index.ts | 133 + packages/client/src/hooks/index.ts | 4 + packages/client/src/hooks/useAwaitState.ts | 31 + packages/client/src/hooks/useCheckBox.ts | 156 + packages/client/src/hooks/useStack.ts | 128 + packages/client/src/hooks/useTimeout.ts | 78 + packages/client/src/index.ts | 10 + packages/client/src/io/download.ts | 42 + packages/client/src/io/index.ts | 1 + packages/client/src/presentation/color.ts | 13 + packages/client/src/presentation/index.ts | 1 + packages/client/src/providers/index.ts | 1 + packages/client/src/singleton/DataHolder.ts | 85 + packages/client/src/tools/file.ts | 29 + packages/client/src/tools/index.ts | 5 + packages/client/src/tools/level.ts | 136 + packages/client/src/tools/number.ts | 76 + packages/client/src/tools/objects.ts | 33 + packages/client/src/types/index.ts | 5 + packages/client/src/upload/index.ts | 3 + packages/client/src/upload/types.ts | 13 + packages/client/src/upload/uploadManager.ts | 225 + packages/client/src/upload/useUpload.ts | 56 + packages/client/src/websocket/client.ts | 232 + packages/client/src/websocket/index.ts | 2 + packages/client/src/websocket/types.ts | 46 + packages/client/tsconfig.json | 22 + packages/client/tsup.config.ts | 10 + packages/common/.env.example | 1 + packages/common/package.json | 35 + packages/common/prisma/schema.prisma | 372 + packages/common/src/collaboration/index.ts | 4 + packages/common/src/collaboration/types.ts | 7 + packages/common/src/collaboration/utils.ts | 3 + packages/common/src/collaboration/y-auth.ts | 29 + .../common/src/collaboration/y-awareness.ts | 544 + .../common/src/collaboration/y-handler.ts | 87 + packages/common/src/collaboration/y-socket.ts | 388 + packages/common/src/collaboration/y-sync.ts | 189 + packages/common/src/constants.ts | 73 + packages/common/src/db.ts | 18 + packages/common/src/enum.ts | 121 + packages/common/src/index.ts | 9 + packages/common/src/models/department.ts | 15 + packages/common/src/models/index.ts | 7 + packages/common/src/models/message.ts | 7 + packages/common/src/models/post.ts | 40 + packages/common/src/models/rbac.ts | 22 + packages/common/src/models/section.ts | 5 + packages/common/src/models/select.ts | 67 + packages/common/src/models/staff.ts | 38 + packages/common/src/models/term.ts | 8 + packages/common/src/schema.ts | 435 + packages/common/src/types.ts | 40 + packages/common/src/utils/array-utils.ts | 0 packages/common/src/utils/browser-utils.ts | 0 packages/common/src/utils/crypto-utils.ts | 0 packages/common/src/utils/date-utils.ts | 0 packages/common/src/utils/dom-utils.ts | 0 packages/common/src/utils/file-utils.ts | 0 packages/common/src/utils/index.ts | 365 + packages/common/src/utils/math-utils.ts | 99 + packages/common/src/utils/object-utils.ts | 59 + packages/common/src/utils/random-utils.ts | 85 + packages/common/src/utils/string-utils.ts | 80 + packages/common/src/utils/type-utils.ts | 94 + packages/common/src/utils/uuid.ts | 59 + packages/common/src/utils/validation-utils.ts | 0 packages/common/tsconfig.json | 40 + packages/common/tsup.config.ts | 18 + packages/config/package.json | 34 + packages/config/src/index.ts | 1 + packages/config/src/tailwind.ts | 132 + packages/config/tsconfig.json | 43 + packages/config/tsup.config.ts | 13 + packages/iconer/.eslintrc.cjs | 18 + packages/iconer/README.md | 30 + packages/iconer/package.json | 33 + packages/iconer/public/vite.svg | 1 + packages/iconer/src/components/svg-icon.tsx | 40 + packages/iconer/src/generated/icon-names.ts | 2 + .../iconer/src/icons/account-location.svg | 1 + packages/iconer/src/icons/add.svg | 1 + packages/iconer/src/icons/admin-outlined.svg | 1 + packages/iconer/src/icons/airport.svg | 1 + packages/iconer/src/icons/align-center.svg | 4 + packages/iconer/src/icons/align-justify.svg | 1 + packages/iconer/src/icons/align-left.svg | 1 + packages/iconer/src/icons/align-right.svg | 1 + packages/iconer/src/icons/approve.svg | 1 + packages/iconer/src/icons/arrow-drop-down.svg | 1 + packages/iconer/src/icons/blocks-group.svg | 1 + packages/iconer/src/icons/bold.svg | 1 + packages/iconer/src/icons/caret-right.svg | 1 + .../iconer/src/icons/category-outline.svg | 1 + packages/iconer/src/icons/check-one.svg | 1 + packages/iconer/src/icons/check.svg | 1 + packages/iconer/src/icons/config.svg | 1 + packages/iconer/src/icons/content.svg | 1 + packages/iconer/src/icons/copy.svg | 1 + packages/iconer/src/icons/cube-duotone.svg | 1 + packages/iconer/src/icons/date-time.svg | 1 + packages/iconer/src/icons/delete.svg | 1 + packages/iconer/src/icons/edit.svg | 1 + packages/iconer/src/icons/error-duotone.svg | 1 + packages/iconer/src/icons/error-outline.svg | 1 + packages/iconer/src/icons/exit.svg | 1 + packages/iconer/src/icons/filter.svg | 1 + packages/iconer/src/icons/fluent-person.svg | 1 + packages/iconer/src/icons/get-text.svg | 1 + packages/iconer/src/icons/group-work.svg | 1 + packages/iconer/src/icons/health-circle.svg | 1 + packages/iconer/src/icons/history.svg | 1 + packages/iconer/src/icons/home.svg | 1 + packages/iconer/src/icons/horizontal-rule.svg | 1 + packages/iconer/src/icons/image.svg | 1 + packages/iconer/src/icons/inbox.svg | 1 + packages/iconer/src/icons/italic.svg | 1 + packages/iconer/src/icons/link-off.svg | 1 + packages/iconer/src/icons/link.svg | 1 + packages/iconer/src/icons/list.svg | 1 + packages/iconer/src/icons/logout.svg | 1 + packages/iconer/src/icons/loop.svg | 1 + packages/iconer/src/icons/more.svg | 1 + packages/iconer/src/icons/note.svg | 1 + packages/iconer/src/icons/number-symbol.svg | 1 + packages/iconer/src/icons/org.svg | 1 + packages/iconer/src/icons/people-32.svg | 1 + packages/iconer/src/icons/people-group.svg | 1 + packages/iconer/src/icons/people-plus.svg | 1 + packages/iconer/src/icons/people.svg | 1 + packages/iconer/src/icons/person-board.svg | 1 + packages/iconer/src/icons/person-hair.svg | 1 + packages/iconer/src/icons/person-home.svg | 1 + packages/iconer/src/icons/plane-takeoff.svg | 1 + packages/iconer/src/icons/plane.svg | 1 + packages/iconer/src/icons/progress.svg | 1 + packages/iconer/src/icons/radar-chart.svg | 1 + packages/iconer/src/icons/react.svg | 6 + packages/iconer/src/icons/redo.svg | 1 + packages/iconer/src/icons/right-line.svg | 1 + packages/iconer/src/icons/seal-check.svg | 1 + packages/iconer/src/icons/search.svg | 1 + packages/iconer/src/icons/setting.svg | 1 + packages/iconer/src/icons/share.svg | 1 + packages/iconer/src/icons/strike.svg | 1 + packages/iconer/src/icons/subject-rounded.svg | 1 + packages/iconer/src/icons/sum.svg | 1 + packages/iconer/src/icons/target.svg | 1 + packages/iconer/src/icons/text-indent.svg | 1 + packages/iconer/src/icons/text-outdent.svg | 1 + packages/iconer/src/icons/time.svg | 1 + packages/iconer/src/icons/underline.svg | 1 + packages/iconer/src/icons/undo.svg | 1 + packages/iconer/src/icons/user-id.svg | 1 + packages/iconer/src/icons/work.svg | 1 + packages/iconer/src/icons/zoomin.svg | 1 + packages/iconer/src/icons/zoomout.svg | 1 + packages/iconer/src/index.css | 0 packages/iconer/src/index.ts | 7 + packages/iconer/src/utils/useLazySvgImport.ts | 29 + packages/iconer/src/vite-env.d.ts | 1 + packages/iconer/tsconfig.app.json | 40 + packages/iconer/tsconfig.json | 11 + packages/iconer/tsconfig.node.json | 16 + .../iconer/types/src/components/svg-icon.d.ts | 9 + .../types/src/generated/icon-names.d.ts | 1 + packages/iconer/types/src/index.d.ts | 3 + .../types/src/utils/useLazySvgImport.d.ts | 6 + packages/iconer/vite.config.ts | 63 + packages/template/package.json | 24 + packages/template/src/index.ts | 1 + packages/template/tsconfig.json | 40 + packages/template/tsup.config.ts | 10 + packages/tus/package.json | 35 + packages/tus/src/handlers/BaseHandler.ts | 364 + packages/tus/src/handlers/DeleteHandler.ts | 64 + packages/tus/src/handlers/GetHandler.ts | 189 + packages/tus/src/handlers/HeadHandler.ts | 90 + packages/tus/src/handlers/OptionsHandler.ts | 61 + packages/tus/src/handlers/PatchHandler.ts | 256 + packages/tus/src/handlers/PostHandler.ts | 257 + packages/tus/src/index.ts | 5 + packages/tus/src/lockers/MemoryLocker.ts | 145 + packages/tus/src/lockers/index.ts | 1 + packages/tus/src/server.ts | 452 + packages/tus/src/store/file-store/index.ts | 230 + packages/tus/src/store/index.ts | 2 + packages/tus/src/store/s3-store/index.ts | 803 + packages/tus/src/types.ts | 211 + packages/tus/src/utils/constants.ts | 132 + packages/tus/src/utils/index.ts | 3 + .../tus/src/utils/kvstores/FileKvStore.ts | 94 + .../tus/src/utils/kvstores/IoRedisKvStore.ts | 54 + .../tus/src/utils/kvstores/MemoryKvStore.ts | 26 + .../tus/src/utils/kvstores/RedisKvStore.ts | 94 + packages/tus/src/utils/kvstores/Types.ts | 43 + packages/tus/src/utils/kvstores/index.ts | 5 + packages/tus/src/utils/models/Context.ts | 14 + packages/tus/src/utils/models/DataStore.ts | 72 + packages/tus/src/utils/models/Locker.ts | 12 + packages/tus/src/utils/models/Metadata.ts | 103 + .../tus/src/utils/models/StreamLimiter.ts | 54 + .../tus/src/utils/models/StreamSplitter.ts | 183 + packages/tus/src/utils/models/Uid.ts | 21 + packages/tus/src/utils/models/Upload.ts | 72 + packages/tus/src/utils/models/index.ts | 8 + .../tus/src/validators/HeaderValidator.ts | 138 + packages/tus/tsconfig.json | 40 + packages/tus/tsup.config.ts | 10 + pnpm-lock.yaml | 14241 ++++++++++++++++ pnpm-workspace.yaml | 7 + tsconfig.base.json | 26 + 534 files changed, 48356 insertions(+) create mode 100755 .continue/prompts/coder.prompt create mode 100755 .continue/prompts/comment.prompt create mode 100755 .continue/prompts/explain.prompt create mode 100755 .continue/prompts/jstots.prompt create mode 100755 .continue/prompts/refactor.prompt create mode 100755 .continue/prompts/sci-post.prompt create mode 100755 .dockerignore create mode 100755 .gitignore create mode 100755 .npmrc create mode 100755 Dockerfile create mode 100755 apps/server/.env.example create mode 100755 apps/server/.eslintrc.js create mode 100755 apps/server/.prettierrc create mode 100755 apps/server/README.md create mode 100755 apps/server/entrypoint.sh create mode 100755 apps/server/nest-cli.json create mode 100755 apps/server/package.json create mode 100755 apps/server/src/app.module.ts create mode 100755 apps/server/src/auth/auth.controller.ts create mode 100755 apps/server/src/auth/auth.guard.ts create mode 100755 apps/server/src/auth/auth.module.ts create mode 100755 apps/server/src/auth/auth.service.ts create mode 100755 apps/server/src/auth/config.ts create mode 100755 apps/server/src/auth/session.service.ts create mode 100755 apps/server/src/auth/types.ts create mode 100755 apps/server/src/auth/utils.ts create mode 100755 apps/server/src/env.ts create mode 100755 apps/server/src/filters/exceptions.filter.ts create mode 100755 apps/server/src/main.ts create mode 100755 apps/server/src/models/app-config/app-config.module.ts create mode 100755 apps/server/src/models/app-config/app-config.router.ts create mode 100755 apps/server/src/models/app-config/app-config.service.ts create mode 100755 apps/server/src/models/base/base.service.ts create mode 100755 apps/server/src/models/base/base.tree.service.ts create mode 100755 apps/server/src/models/base/base.type.ts create mode 100755 apps/server/src/models/base/errorMap.prisma.ts create mode 100755 apps/server/src/models/base/row-cache.service.ts create mode 100755 apps/server/src/models/base/row-model.service.ts create mode 100755 apps/server/src/models/base/sql-builder.ts create mode 100755 apps/server/src/models/base/test.sql create mode 100755 apps/server/src/models/department/department.controller.ts create mode 100755 apps/server/src/models/department/department.module.ts create mode 100755 apps/server/src/models/department/department.router.ts create mode 100755 apps/server/src/models/department/department.row.service.ts create mode 100755 apps/server/src/models/department/department.service.ts create mode 100755 apps/server/src/models/department/utils.ts create mode 100644 apps/server/src/models/goods/goods.controller.ts create mode 100644 apps/server/src/models/goods/goods.module.ts create mode 100644 apps/server/src/models/goods/goods.service.ts create mode 100755 apps/server/src/models/message/message.controller.ts create mode 100755 apps/server/src/models/message/message.module.ts create mode 100755 apps/server/src/models/message/message.router.ts create mode 100755 apps/server/src/models/message/message.service.ts create mode 100755 apps/server/src/models/message/utils.ts create mode 100755 apps/server/src/models/post/post.controller.ts create mode 100755 apps/server/src/models/post/post.module.ts create mode 100755 apps/server/src/models/post/post.router.ts create mode 100755 apps/server/src/models/post/post.service.ts create mode 100755 apps/server/src/models/post/utils.ts create mode 100755 apps/server/src/models/rbac/rbac.module.ts create mode 100755 apps/server/src/models/rbac/role.router.ts create mode 100755 apps/server/src/models/rbac/role.row.service.ts create mode 100755 apps/server/src/models/rbac/role.service.ts create mode 100755 apps/server/src/models/rbac/rolemap.router.ts create mode 100755 apps/server/src/models/rbac/rolemap.service.ts create mode 100755 apps/server/src/models/resource/pipe/resource.pipeline.ts create mode 100755 apps/server/src/models/resource/processor/BaseProcessor.ts create mode 100755 apps/server/src/models/resource/processor/ImageProcessor.ts create mode 100755 apps/server/src/models/resource/processor/VideoProcessor.ts create mode 100755 apps/server/src/models/resource/resource.module.ts create mode 100755 apps/server/src/models/resource/resource.router.ts create mode 100755 apps/server/src/models/resource/resource.service.ts create mode 100755 apps/server/src/models/resource/types.ts create mode 100755 apps/server/src/models/staff/staff.controller.ts create mode 100755 apps/server/src/models/staff/staff.module.ts create mode 100755 apps/server/src/models/staff/staff.router.ts create mode 100755 apps/server/src/models/staff/staff.row.service.ts create mode 100755 apps/server/src/models/staff/staff.service.ts create mode 100755 apps/server/src/models/taxonomy/taxonomy.controller.ts create mode 100755 apps/server/src/models/taxonomy/taxonomy.module.ts create mode 100755 apps/server/src/models/taxonomy/taxonomy.router.ts create mode 100755 apps/server/src/models/taxonomy/taxonomy.service.ts create mode 100755 apps/server/src/models/term/term.controller.ts create mode 100755 apps/server/src/models/term/term.module.ts create mode 100755 apps/server/src/models/term/term.router.ts create mode 100755 apps/server/src/models/term/term.row.service.ts create mode 100755 apps/server/src/models/term/term.service.ts create mode 100755 apps/server/src/models/term/utils.ts create mode 100755 apps/server/src/models/transform/transform.module.ts create mode 100755 apps/server/src/models/transform/transform.router.ts create mode 100755 apps/server/src/models/transform/transform.service.ts create mode 100755 apps/server/src/models/visit/visit.module.ts create mode 100755 apps/server/src/models/visit/visit.router.ts create mode 100755 apps/server/src/models/visit/visit.service.ts create mode 100755 apps/server/src/queue/postprocess/postprocess.service.ts create mode 100755 apps/server/src/queue/queue.module.ts create mode 100755 apps/server/src/queue/types.ts create mode 100755 apps/server/src/queue/worker/file.processor.ts create mode 100755 apps/server/src/queue/worker/processor.ts create mode 100755 apps/server/src/socket/base/base-websocket-server.ts create mode 100755 apps/server/src/socket/collaboration/callback.ts create mode 100755 apps/server/src/socket/collaboration/collaboration.module.ts create mode 100755 apps/server/src/socket/collaboration/persistence.ts create mode 100755 apps/server/src/socket/collaboration/types.ts create mode 100755 apps/server/src/socket/collaboration/ws-shared-doc.ts create mode 100755 apps/server/src/socket/collaboration/yjs.server.ts create mode 100755 apps/server/src/socket/realtime/realtime.module.ts create mode 100755 apps/server/src/socket/realtime/realtime.server.ts create mode 100755 apps/server/src/socket/types.ts create mode 100755 apps/server/src/socket/websocket.module.ts create mode 100755 apps/server/src/socket/websocket.service.ts create mode 100755 apps/server/src/tasks/init/gendev.service.ts create mode 100755 apps/server/src/tasks/init/init.module.ts create mode 100755 apps/server/src/tasks/init/init.service.ts create mode 100755 apps/server/src/tasks/init/utils.ts create mode 100755 apps/server/src/tasks/reminder/reminder.module.ts create mode 100755 apps/server/src/tasks/reminder/reminder.service.ts create mode 100755 apps/server/src/tasks/tasks.module.ts create mode 100755 apps/server/src/tasks/tasks.service.spec.ts create mode 100755 apps/server/src/tasks/tasks.service.ts create mode 100755 apps/server/src/trpc/trpc.module.ts create mode 100755 apps/server/src/trpc/trpc.router.ts create mode 100755 apps/server/src/trpc/trpc.service.ts create mode 100755 apps/server/src/trpc/types.ts create mode 100755 apps/server/src/trpc/utils.ts create mode 100755 apps/server/src/upload/tus.service.ts create mode 100755 apps/server/src/upload/types.ts create mode 100755 apps/server/src/upload/upload.controller.ts create mode 100755 apps/server/src/upload/upload.module.ts create mode 100755 apps/server/src/upload/utils.ts create mode 100755 apps/server/src/utils/event-bus.ts create mode 100755 apps/server/src/utils/file.ts create mode 100755 apps/server/src/utils/minio/minio.module.ts create mode 100755 apps/server/src/utils/minio/minio.service.ts create mode 100755 apps/server/src/utils/redis/redis.service.ts create mode 100755 apps/server/src/utils/redis/utils.ts create mode 100755 apps/server/src/utils/tool.ts create mode 100755 apps/server/test/app.e2e-spec.ts create mode 100755 apps/server/test/jest-e2e.json create mode 100755 apps/server/tsconfig.build.json create mode 100755 apps/server/tsconfig.json create mode 100755 apps/web/.env.example create mode 100755 apps/web/eslint.config.js create mode 100755 apps/web/index.html create mode 100644 apps/web/nginx.conf create mode 100755 apps/web/package.json create mode 100755 apps/web/postcss.config.js create mode 100755 apps/web/public/params.json create mode 100755 apps/web/public/vite.svg create mode 100644 apps/web/src/App.css create mode 100755 apps/web/src/App.tsx create mode 100755 apps/web/src/app/admin/base-setting/page.tsx create mode 100755 apps/web/src/app/admin/department/page.tsx create mode 100755 apps/web/src/app/admin/layout.tsx create mode 100755 apps/web/src/app/admin/role/page.tsx create mode 100755 apps/web/src/app/admin/staff/page.tsx create mode 100755 apps/web/src/app/admin/term/page.tsx create mode 100755 apps/web/src/app/denied.tsx create mode 100755 apps/web/src/app/error.tsx create mode 100755 apps/web/src/app/login.tsx create mode 100755 apps/web/src/app/main/home/page.tsx create mode 100644 apps/web/src/app/main/layout/index.tsx create mode 100755 apps/web/src/assets/react.svg create mode 100755 apps/web/src/components/animation/sine-wave.tsx create mode 100755 apps/web/src/components/common/container/Card.tsx create mode 100644 apps/web/src/components/common/container/CollapsibleContent.tsx create mode 100755 apps/web/src/components/common/editor/quill/QuillCharCounter.tsx create mode 100755 apps/web/src/components/common/editor/quill/QuillEditor.tsx create mode 100755 apps/web/src/components/common/editor/quill/constants.ts create mode 100755 apps/web/src/components/common/element/AnimatedTabs.tsx create mode 100755 apps/web/src/components/common/element/Avatar.tsx create mode 100755 apps/web/src/components/common/element/Button.tsx create mode 100755 apps/web/src/components/common/element/Pagination.tsx create mode 100755 apps/web/src/components/common/element/Tag.tsx create mode 100755 apps/web/src/components/common/form/FormArrayField.tsx create mode 100755 apps/web/src/components/common/form/FormDynamicInputs.tsx create mode 100755 apps/web/src/components/common/form/FormError.tsx create mode 100755 apps/web/src/components/common/form/FormInput.tsx create mode 100755 apps/web/src/components/common/form/FormQuillInput.tsx create mode 100755 apps/web/src/components/common/form/FormSelect.tsx create mode 100644 apps/web/src/components/common/input/InputList.tsx create mode 100755 apps/web/src/components/common/space/Empty.tsx create mode 100755 apps/web/src/components/common/uploader/AvatarUploader.tsx create mode 100755 apps/web/src/components/common/uploader/TusUploader.tsx create mode 100755 apps/web/src/components/layout/admin/AdminHeader.tsx create mode 100755 apps/web/src/components/layout/admin/AdminLayout.tsx create mode 100755 apps/web/src/components/layout/admin/AdminSidebar.tsx create mode 100755 apps/web/src/components/layout/breadcrumb.tsx create mode 100755 apps/web/src/components/layout/element/breadcrumb.tsx create mode 100755 apps/web/src/components/layout/element/types.ts create mode 100755 apps/web/src/components/layout/element/usermenu/user-edit-modal.tsx create mode 100755 apps/web/src/components/layout/element/usermenu/user-form.tsx create mode 100755 apps/web/src/components/layout/element/usermenu/usermenu.tsx create mode 100755 apps/web/src/components/layout/fix-header.tsx create mode 100755 apps/web/src/components/layout/resizable-sidebar.tsx create mode 100755 apps/web/src/components/layout/sidebar-content.tsx create mode 100755 apps/web/src/components/layout/user-header.tsx create mode 100755 apps/web/src/components/models/department/department-form.tsx create mode 100755 apps/web/src/components/models/department/department-import-drawer.tsx create mode 100755 apps/web/src/components/models/department/department-list.tsx create mode 100755 apps/web/src/components/models/department/department-select.tsx create mode 100755 apps/web/src/components/models/department/dept-editor.tsx create mode 100755 apps/web/src/components/models/department/dept-import-form.tsx create mode 100755 apps/web/src/components/models/department/dept-import-modal.tsx create mode 100755 apps/web/src/components/models/department/dept-modal.tsx create mode 100755 apps/web/src/components/models/role/role-editor/assign-list.tsx create mode 100755 apps/web/src/components/models/role/role-editor/role-editor.tsx create mode 100755 apps/web/src/components/models/role/role-editor/role-form.tsx create mode 100755 apps/web/src/components/models/role/role-editor/role-list.tsx create mode 100755 apps/web/src/components/models/role/role-editor/role-modal.tsx create mode 100755 apps/web/src/components/models/role/role-editor/role-staff-modal.tsx create mode 100755 apps/web/src/components/models/role/role-select.tsx create mode 100755 apps/web/src/components/models/staff/staff-editor.tsx create mode 100755 apps/web/src/components/models/staff/staff-form.tsx create mode 100755 apps/web/src/components/models/staff/staff-list.tsx create mode 100755 apps/web/src/components/models/staff/staff-modal.tsx create mode 100755 apps/web/src/components/models/staff/staff-select.tsx create mode 100755 apps/web/src/components/models/staff/staff-transfer.tsx create mode 100755 apps/web/src/components/models/taxonomy/taxonomy-select.tsx create mode 100755 apps/web/src/components/models/term/taxonomy-form.tsx create mode 100755 apps/web/src/components/models/term/taxonomy-list.tsx create mode 100755 apps/web/src/components/models/term/taxonomy-modal.tsx create mode 100755 apps/web/src/components/models/term/term-editor.tsx create mode 100755 apps/web/src/components/models/term/term-form.tsx create mode 100755 apps/web/src/components/models/term/term-import-form.tsx create mode 100755 apps/web/src/components/models/term/term-import-modal.tsx create mode 100755 apps/web/src/components/models/term/term-list.tsx create mode 100755 apps/web/src/components/models/term/term-modal.tsx create mode 100755 apps/web/src/components/models/term/term-select.tsx create mode 100755 apps/web/src/components/models/term/term-select_BACKUP.tsx create mode 100755 apps/web/src/components/models/term/util.ts create mode 100755 apps/web/src/components/presentation/NavBar.tsx create mode 100755 apps/web/src/components/presentation/Skeleton.tsx create mode 100755 apps/web/src/components/presentation/ag-server-table.tsx create mode 100755 apps/web/src/components/presentation/animate-progress.tsx create mode 100755 apps/web/src/components/presentation/collapse-section.tsx create mode 100755 apps/web/src/components/presentation/dashboard-card.tsx create mode 100755 apps/web/src/components/presentation/dialog.tsx create mode 100755 apps/web/src/components/presentation/dropdown-menu.tsx create mode 100755 apps/web/src/components/presentation/excel-to-base64-uploader.tsx create mode 100755 apps/web/src/components/presentation/general-dialog.tsx create mode 100755 apps/web/src/components/presentation/id-card.tsx create mode 100755 apps/web/src/components/presentation/nice-img.tsx create mode 100755 apps/web/src/components/presentation/phone-book.tsx create mode 100755 apps/web/src/components/presentation/round-tag.tsx create mode 100755 apps/web/src/components/presentation/rounded-rectangle-tag.tsx create mode 100755 apps/web/src/components/presentation/user/Avatar.tsx create mode 100755 apps/web/src/components/presentation/video-player/ControlButtons/Brightness.tsx create mode 100755 apps/web/src/components/presentation/video-player/ControlButtons/FullScreen.tsx create mode 100755 apps/web/src/components/presentation/video-player/ControlButtons/Play.tsx create mode 100755 apps/web/src/components/presentation/video-player/ControlButtons/Setting.tsx create mode 100755 apps/web/src/components/presentation/video-player/ControlButtons/Speed.tsx create mode 100755 apps/web/src/components/presentation/video-player/ControlButtons/TimeLine.tsx create mode 100755 apps/web/src/components/presentation/video-player/ControlButtons/Volume.tsx create mode 100755 apps/web/src/components/presentation/video-player/ControlButtons/index.ts create mode 100755 apps/web/src/components/presentation/video-player/LoadingOverlay.tsx create mode 100755 apps/web/src/components/presentation/video-player/VideoControls.tsx create mode 100755 apps/web/src/components/presentation/video-player/VideoDisplay.tsx create mode 100755 apps/web/src/components/presentation/video-player/VideoPlayer.tsx create mode 100755 apps/web/src/components/presentation/video-player/VideoPlayerLayout.tsx create mode 100755 apps/web/src/components/presentation/video-player/interface.ts create mode 100755 apps/web/src/components/presentation/video-player/type.ts create mode 100755 apps/web/src/components/presentation/video-player/utlis.ts create mode 100755 apps/web/src/components/svg/rounded-clip.tsx create mode 100755 apps/web/src/components/utils/excel-importer.tsx create mode 100755 apps/web/src/components/utils/image-uploader.tsx create mode 100755 apps/web/src/components/utils/with-auth.tsx create mode 100755 apps/web/src/env.ts create mode 100755 apps/web/src/hooks/useClickOutside.ts create mode 100755 apps/web/src/hooks/useLocalSetting.ts create mode 100755 apps/web/src/hooks/useTusUpload.ts create mode 100755 apps/web/src/index.css create mode 100755 apps/web/src/locale/ag-grid-locale.ts create mode 100755 apps/web/src/main.tsx create mode 100755 apps/web/src/polyfills/index.ts create mode 100755 apps/web/src/providers/auth-provider.tsx create mode 100755 apps/web/src/providers/params-provider.tsx create mode 100755 apps/web/src/providers/query-provider.tsx create mode 100755 apps/web/src/providers/theme-provider.tsx create mode 100755 apps/web/src/routes/admin-route.tsx create mode 100755 apps/web/src/routes/index.tsx create mode 100644 apps/web/src/routes/main-route.tsx create mode 100755 apps/web/src/routes/types.ts create mode 100755 apps/web/src/utils/axios.ts create mode 100755 apps/web/src/utils/idb.ts create mode 100644 apps/web/src/utils/index.ts create mode 100755 apps/web/src/utils/tusd.ts create mode 100755 apps/web/src/vite-env.d.ts create mode 100755 apps/web/tailwind.config.ts create mode 100755 apps/web/tsconfig.app.json create mode 100755 apps/web/tsconfig.json create mode 100755 apps/web/tsconfig.node.json create mode 100755 apps/web/vite.config.ts create mode 100755 auto.sh create mode 100755 config/backup.sh create mode 100755 config/nginx/conf.d/web.conf create mode 100755 config/nginx/conf.d/web.template create mode 100755 config/nginx/entrypoint.sh create mode 100755 config/nginx/nginx.conf create mode 100755 config/redis.conf create mode 100755 docker-compose.example.yml create mode 100755 package.json create mode 100755 packages/client/package.json create mode 100755 packages/client/src/api/hooks/index.ts create mode 100755 packages/client/src/api/hooks/useAppConfig.ts create mode 100755 packages/client/src/api/hooks/useCourse.ts create mode 100755 packages/client/src/api/hooks/useDepartment.ts create mode 100755 packages/client/src/api/hooks/useEntity.ts create mode 100755 packages/client/src/api/hooks/useMessage.ts create mode 100755 packages/client/src/api/hooks/usePost.ts create mode 100755 packages/client/src/api/hooks/useQueryApi.ts create mode 100755 packages/client/src/api/hooks/useRole.ts create mode 100755 packages/client/src/api/hooks/useRoleMap.ts create mode 100755 packages/client/src/api/hooks/useStaff.ts create mode 100755 packages/client/src/api/hooks/useTaxonomy.ts create mode 100755 packages/client/src/api/hooks/useTerm.ts create mode 100755 packages/client/src/api/hooks/useTransform.ts create mode 100755 packages/client/src/api/hooks/useVisitor.ts create mode 100755 packages/client/src/api/index.ts create mode 100755 packages/client/src/api/trpc.ts create mode 100755 packages/client/src/api/utils.ts create mode 100755 packages/client/src/event/index.ts create mode 100755 packages/client/src/hooks/index.ts create mode 100755 packages/client/src/hooks/useAwaitState.ts create mode 100755 packages/client/src/hooks/useCheckBox.ts create mode 100755 packages/client/src/hooks/useStack.ts create mode 100755 packages/client/src/hooks/useTimeout.ts create mode 100755 packages/client/src/index.ts create mode 100755 packages/client/src/io/download.ts create mode 100755 packages/client/src/io/index.ts create mode 100755 packages/client/src/presentation/color.ts create mode 100755 packages/client/src/presentation/index.ts create mode 100755 packages/client/src/providers/index.ts create mode 100755 packages/client/src/singleton/DataHolder.ts create mode 100755 packages/client/src/tools/file.ts create mode 100755 packages/client/src/tools/index.ts create mode 100755 packages/client/src/tools/level.ts create mode 100755 packages/client/src/tools/number.ts create mode 100755 packages/client/src/tools/objects.ts create mode 100755 packages/client/src/types/index.ts create mode 100755 packages/client/src/upload/index.ts create mode 100755 packages/client/src/upload/types.ts create mode 100755 packages/client/src/upload/uploadManager.ts create mode 100755 packages/client/src/upload/useUpload.ts create mode 100755 packages/client/src/websocket/client.ts create mode 100755 packages/client/src/websocket/index.ts create mode 100755 packages/client/src/websocket/types.ts create mode 100755 packages/client/tsconfig.json create mode 100755 packages/client/tsup.config.ts create mode 100755 packages/common/.env.example create mode 100755 packages/common/package.json create mode 100755 packages/common/prisma/schema.prisma create mode 100755 packages/common/src/collaboration/index.ts create mode 100755 packages/common/src/collaboration/types.ts create mode 100755 packages/common/src/collaboration/utils.ts create mode 100755 packages/common/src/collaboration/y-auth.ts create mode 100755 packages/common/src/collaboration/y-awareness.ts create mode 100755 packages/common/src/collaboration/y-handler.ts create mode 100755 packages/common/src/collaboration/y-socket.ts create mode 100755 packages/common/src/collaboration/y-sync.ts create mode 100755 packages/common/src/constants.ts create mode 100755 packages/common/src/db.ts create mode 100755 packages/common/src/enum.ts create mode 100755 packages/common/src/index.ts create mode 100755 packages/common/src/models/department.ts create mode 100755 packages/common/src/models/index.ts create mode 100755 packages/common/src/models/message.ts create mode 100755 packages/common/src/models/post.ts create mode 100755 packages/common/src/models/rbac.ts create mode 100755 packages/common/src/models/section.ts create mode 100755 packages/common/src/models/select.ts create mode 100755 packages/common/src/models/staff.ts create mode 100755 packages/common/src/models/term.ts create mode 100755 packages/common/src/schema.ts create mode 100755 packages/common/src/types.ts create mode 100755 packages/common/src/utils/array-utils.ts create mode 100755 packages/common/src/utils/browser-utils.ts create mode 100755 packages/common/src/utils/crypto-utils.ts create mode 100755 packages/common/src/utils/date-utils.ts create mode 100755 packages/common/src/utils/dom-utils.ts create mode 100755 packages/common/src/utils/file-utils.ts create mode 100755 packages/common/src/utils/index.ts create mode 100755 packages/common/src/utils/math-utils.ts create mode 100755 packages/common/src/utils/object-utils.ts create mode 100755 packages/common/src/utils/random-utils.ts create mode 100755 packages/common/src/utils/string-utils.ts create mode 100755 packages/common/src/utils/type-utils.ts create mode 100755 packages/common/src/utils/uuid.ts create mode 100755 packages/common/src/utils/validation-utils.ts create mode 100755 packages/common/tsconfig.json create mode 100755 packages/common/tsup.config.ts create mode 100755 packages/config/package.json create mode 100644 packages/config/src/index.ts create mode 100755 packages/config/src/tailwind.ts create mode 100755 packages/config/tsconfig.json create mode 100755 packages/config/tsup.config.ts create mode 100755 packages/iconer/.eslintrc.cjs create mode 100755 packages/iconer/README.md create mode 100755 packages/iconer/package.json create mode 100755 packages/iconer/public/vite.svg create mode 100755 packages/iconer/src/components/svg-icon.tsx create mode 100755 packages/iconer/src/generated/icon-names.ts create mode 100755 packages/iconer/src/icons/account-location.svg create mode 100755 packages/iconer/src/icons/add.svg create mode 100755 packages/iconer/src/icons/admin-outlined.svg create mode 100755 packages/iconer/src/icons/airport.svg create mode 100755 packages/iconer/src/icons/align-center.svg create mode 100755 packages/iconer/src/icons/align-justify.svg create mode 100755 packages/iconer/src/icons/align-left.svg create mode 100755 packages/iconer/src/icons/align-right.svg create mode 100755 packages/iconer/src/icons/approve.svg create mode 100755 packages/iconer/src/icons/arrow-drop-down.svg create mode 100755 packages/iconer/src/icons/blocks-group.svg create mode 100755 packages/iconer/src/icons/bold.svg create mode 100755 packages/iconer/src/icons/caret-right.svg create mode 100755 packages/iconer/src/icons/category-outline.svg create mode 100755 packages/iconer/src/icons/check-one.svg create mode 100755 packages/iconer/src/icons/check.svg create mode 100755 packages/iconer/src/icons/config.svg create mode 100755 packages/iconer/src/icons/content.svg create mode 100755 packages/iconer/src/icons/copy.svg create mode 100755 packages/iconer/src/icons/cube-duotone.svg create mode 100755 packages/iconer/src/icons/date-time.svg create mode 100755 packages/iconer/src/icons/delete.svg create mode 100755 packages/iconer/src/icons/edit.svg create mode 100755 packages/iconer/src/icons/error-duotone.svg create mode 100755 packages/iconer/src/icons/error-outline.svg create mode 100755 packages/iconer/src/icons/exit.svg create mode 100755 packages/iconer/src/icons/filter.svg create mode 100755 packages/iconer/src/icons/fluent-person.svg create mode 100755 packages/iconer/src/icons/get-text.svg create mode 100755 packages/iconer/src/icons/group-work.svg create mode 100755 packages/iconer/src/icons/health-circle.svg create mode 100755 packages/iconer/src/icons/history.svg create mode 100755 packages/iconer/src/icons/home.svg create mode 100755 packages/iconer/src/icons/horizontal-rule.svg create mode 100755 packages/iconer/src/icons/image.svg create mode 100755 packages/iconer/src/icons/inbox.svg create mode 100755 packages/iconer/src/icons/italic.svg create mode 100755 packages/iconer/src/icons/link-off.svg create mode 100755 packages/iconer/src/icons/link.svg create mode 100755 packages/iconer/src/icons/list.svg create mode 100755 packages/iconer/src/icons/logout.svg create mode 100755 packages/iconer/src/icons/loop.svg create mode 100755 packages/iconer/src/icons/more.svg create mode 100755 packages/iconer/src/icons/note.svg create mode 100755 packages/iconer/src/icons/number-symbol.svg create mode 100755 packages/iconer/src/icons/org.svg create mode 100755 packages/iconer/src/icons/people-32.svg create mode 100755 packages/iconer/src/icons/people-group.svg create mode 100755 packages/iconer/src/icons/people-plus.svg create mode 100755 packages/iconer/src/icons/people.svg create mode 100755 packages/iconer/src/icons/person-board.svg create mode 100755 packages/iconer/src/icons/person-hair.svg create mode 100755 packages/iconer/src/icons/person-home.svg create mode 100755 packages/iconer/src/icons/plane-takeoff.svg create mode 100755 packages/iconer/src/icons/plane.svg create mode 100755 packages/iconer/src/icons/progress.svg create mode 100755 packages/iconer/src/icons/radar-chart.svg create mode 100755 packages/iconer/src/icons/react.svg create mode 100755 packages/iconer/src/icons/redo.svg create mode 100755 packages/iconer/src/icons/right-line.svg create mode 100755 packages/iconer/src/icons/seal-check.svg create mode 100755 packages/iconer/src/icons/search.svg create mode 100755 packages/iconer/src/icons/setting.svg create mode 100755 packages/iconer/src/icons/share.svg create mode 100755 packages/iconer/src/icons/strike.svg create mode 100755 packages/iconer/src/icons/subject-rounded.svg create mode 100755 packages/iconer/src/icons/sum.svg create mode 100755 packages/iconer/src/icons/target.svg create mode 100755 packages/iconer/src/icons/text-indent.svg create mode 100755 packages/iconer/src/icons/text-outdent.svg create mode 100755 packages/iconer/src/icons/time.svg create mode 100755 packages/iconer/src/icons/underline.svg create mode 100755 packages/iconer/src/icons/undo.svg create mode 100755 packages/iconer/src/icons/user-id.svg create mode 100755 packages/iconer/src/icons/work.svg create mode 100755 packages/iconer/src/icons/zoomin.svg create mode 100755 packages/iconer/src/icons/zoomout.svg create mode 100755 packages/iconer/src/index.css create mode 100755 packages/iconer/src/index.ts create mode 100755 packages/iconer/src/utils/useLazySvgImport.ts create mode 100755 packages/iconer/src/vite-env.d.ts create mode 100755 packages/iconer/tsconfig.app.json create mode 100755 packages/iconer/tsconfig.json create mode 100755 packages/iconer/tsconfig.node.json create mode 100755 packages/iconer/types/src/components/svg-icon.d.ts create mode 100755 packages/iconer/types/src/generated/icon-names.d.ts create mode 100755 packages/iconer/types/src/index.d.ts create mode 100755 packages/iconer/types/src/utils/useLazySvgImport.d.ts create mode 100755 packages/iconer/vite.config.ts create mode 100755 packages/template/package.json create mode 100755 packages/template/src/index.ts create mode 100755 packages/template/tsconfig.json create mode 100755 packages/template/tsup.config.ts create mode 100755 packages/tus/package.json create mode 100755 packages/tus/src/handlers/BaseHandler.ts create mode 100755 packages/tus/src/handlers/DeleteHandler.ts create mode 100755 packages/tus/src/handlers/GetHandler.ts create mode 100755 packages/tus/src/handlers/HeadHandler.ts create mode 100755 packages/tus/src/handlers/OptionsHandler.ts create mode 100755 packages/tus/src/handlers/PatchHandler.ts create mode 100755 packages/tus/src/handlers/PostHandler.ts create mode 100755 packages/tus/src/index.ts create mode 100755 packages/tus/src/lockers/MemoryLocker.ts create mode 100755 packages/tus/src/lockers/index.ts create mode 100755 packages/tus/src/server.ts create mode 100755 packages/tus/src/store/file-store/index.ts create mode 100755 packages/tus/src/store/index.ts create mode 100755 packages/tus/src/store/s3-store/index.ts create mode 100755 packages/tus/src/types.ts create mode 100755 packages/tus/src/utils/constants.ts create mode 100755 packages/tus/src/utils/index.ts create mode 100755 packages/tus/src/utils/kvstores/FileKvStore.ts create mode 100755 packages/tus/src/utils/kvstores/IoRedisKvStore.ts create mode 100755 packages/tus/src/utils/kvstores/MemoryKvStore.ts create mode 100755 packages/tus/src/utils/kvstores/RedisKvStore.ts create mode 100755 packages/tus/src/utils/kvstores/Types.ts create mode 100755 packages/tus/src/utils/kvstores/index.ts create mode 100755 packages/tus/src/utils/models/Context.ts create mode 100755 packages/tus/src/utils/models/DataStore.ts create mode 100755 packages/tus/src/utils/models/Locker.ts create mode 100755 packages/tus/src/utils/models/Metadata.ts create mode 100755 packages/tus/src/utils/models/StreamLimiter.ts create mode 100755 packages/tus/src/utils/models/StreamSplitter.ts create mode 100755 packages/tus/src/utils/models/Uid.ts create mode 100755 packages/tus/src/utils/models/Upload.ts create mode 100755 packages/tus/src/utils/models/index.ts create mode 100755 packages/tus/src/validators/HeaderValidator.ts create mode 100755 packages/tus/tsconfig.json create mode 100755 packages/tus/tsup.config.ts create mode 100755 pnpm-lock.yaml create mode 100755 pnpm-workspace.yaml create mode 100755 tsconfig.base.json diff --git a/.continue/prompts/coder.prompt b/.continue/prompts/coder.prompt new file mode 100755 index 0000000..dce8ee7 --- /dev/null +++ b/.continue/prompts/coder.prompt @@ -0,0 +1,19 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +请扮演一名经验丰富的高级软件开发工程师,根据用户提供的指令创建、改进或扩展代码功能。 +输入要求: +1. 用户将提供目标文件名或需要实现的功能描述。 +2. 输入中可能包括文件路径、代码风格要求,以及与功能相关的具体业务逻辑或技术细节。 +任务描述: +1. 根据提供的文件名或功能需求,编写符合规范的代码文件或代码片段。 +2. 如果已有文件,检查并基于现有实现完善功能或修复问题。 +3. 遵循约定的开发框架、语言标准和最佳实践。 +4. 注重代码可维护性,添加适当的注释,确保逻辑清晰。 +输出要求: +1. 仅返回生成的代码或文件内容。 +2. 全程使用中文注释 +3. 尽量避免硬编码和不必要的复杂性,以提高代码的可重用性和效率。 +4. 如功能涉及外部接口或工具调用,请确保通过注释给出清晰的说明和依赖。 + \ No newline at end of file diff --git a/.continue/prompts/comment.prompt b/.continue/prompts/comment.prompt new file mode 100755 index 0000000..37fbee4 --- /dev/null +++ b/.continue/prompts/comment.prompt @@ -0,0 +1,30 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +角色定位: +- 高级软件开发工程师 +注释目标: +1. 顶部注释 + - 模块/文件整体功能描述 +2. 类注释 + - 核心功能概述 + - 设计模式解析 + - 使用示例 +3. 方法/函数注释 + - 功能详细描述 + - 输入参数解析 + - 返回值说明 + - 异常处理机制 +4. 代码块注释 + - 逐行解释代码意图 + - 关键语句原理阐述 + - 高级语言特性解读 +注释风格要求: +- 全程使用中文 +- 专业、清晰、通俗易懂 +输出约束: +- 仅返回添加注释后的代码 +- 注释与代码完美融合 +- 保持原代码结构不变 + \ No newline at end of file diff --git a/.continue/prompts/explain.prompt b/.continue/prompts/explain.prompt new file mode 100755 index 0000000..0cfb31e --- /dev/null +++ b/.continue/prompts/explain.prompt @@ -0,0 +1,6 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +你的任务是基于专业的计算机知识背景剖析代码原理,逐行进行详细分析,充分解释代码意图,并对代码的数据结构,算法或编码方式等进行深度剖析和举例说明,所有分析以中文标准文档型注释的形式插入原代码,除了返回带有分析的代码外,不要返回任何信息. + \ No newline at end of file diff --git a/.continue/prompts/jstots.prompt b/.continue/prompts/jstots.prompt new file mode 100755 index 0000000..5604665 --- /dev/null +++ b/.continue/prompts/jstots.prompt @@ -0,0 +1,13 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +角色定位: +- 高级软件开发工程师 +目标: +转换js代码为标准严格的最新typescript代码 +输出约束: +- 仅需返回转换后的代码 +- 如果不能一次返回,按顺序截断以便继续返回 +- 保持原代码结构不变 + \ No newline at end of file diff --git a/.continue/prompts/refactor.prompt b/.continue/prompts/refactor.prompt new file mode 100755 index 0000000..8d267e0 --- /dev/null +++ b/.continue/prompts/refactor.prompt @@ -0,0 +1,52 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +角色定位: +- 高级软件架构师 +- 代码质量与性能改进专家 + +重构核心目标: +1. 代码质量提升 + - 消除代码坏味道 + - 提高可读性 + - 增强可维护性 + - 优化代码结构 + +2. 架构设计优化 + - 应用合适的设计模式 + - 提升代码解耦程度 + - 增强系统扩展性 + - 改进模块间交互 + +3. 性能与资源优化 + - 算法复杂度改进 + - 内存使用效率 + - 计算资源利用率 + - 减少不必要的计算开销 + +4. 健壮性增强 + - 完善异常处理机制 + - 增加错误边界保护 + - 提高代码容错能力 + - 规范化错误处理流程 + +重构原则: +- 保持原始功能不变 +- 遵循SOLID设计原则 +- 代码简洁性 +- 高内聚低耦合 +- 尽量使用语言特性 +- 避免过度设计 + +注释与文档要求: +- 保留原有有效注释 +- 补充专业的中文文档型注释 +- 解释重构的关键决策 +- 说明性能与架构改进点 + +输出约束: +- 仅返回重构后的代码 +- 保持代码原有风格 +- 注释清晰专业 + \ No newline at end of file diff --git a/.continue/prompts/sci-post.prompt b/.continue/prompts/sci-post.prompt new file mode 100755 index 0000000..336287d --- /dev/null +++ b/.continue/prompts/sci-post.prompt @@ -0,0 +1,45 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +角色定位: +- 专业领域科普作家 +- 知识传播与教育专家 +- 多媒体内容策划师 +写作目标: +1. 开篇导读 +- 话题背景介绍 +- 阅读难度预期 +2. 核心概念解析 +- 专业术语通俗化 +- 基础原理清晰化 +- 生活案例类比 +- 历史发展脉络 +3. 深度知识传递 +- 科学原理剖析 +- 技术发展前沿 +- 争议观点评述 +- 实践应用场景 +4. 互动与延展 +- 趣味实验设计 +- 思考问题引导 +- 扩展阅读推荐 +- 知识图谱构建 +写作风格要求: +- 全程使用平实的中文 +- 深入浅出、生动有趣 +- 严谨专业、符合科学 +- 分层递进、逻辑清晰 +输出标准: +- 确保内容准确性 +- 保持叙事连贯性 +- 突出知识实用性 +- 强调趣味性与启发性 +质量控制: +- 引用权威来源 +- 多角度交叉验证 +输出约束: +- 避免过度技术化表达 +- 规避未经验证的观点 +- 考虑不同年龄层次需求 + \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..a8824f7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +dist +test +.md +volumes +*.tar \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..7d4585e --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +backup +# dependencies +**/node_modules/ +volumes +/.pnp +.pnp.js +*.tar +# testing +**/coverage/ +.env +docker-compose.yml +packages/common/prisma/migrations +packages/common/src/generated +# production +**/build/ +**/dist/ +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# 快速刷新错误记录 +.expo/web/cache/development/ + +# Expo +**/.expo/ +**/.expo-shared/ + +# Android +*.apk +*.aar +*.jks +!apps/mobile/android/app/release.jks +**/android/.gradle/ +**/android/app/build/ +**/android/app/release/ +**/android/react-native-jsc/build/ +**/android/react/build/ +**/android/*/google-services.json +**/android/app/src/debug/res/xml/react_native_debug.xml +**/android/app/src/dev19/res/xml/react_native_debug.xml +**/android/app/src/dev20/res/xml/react_native_debug.xml +**/android/app/src/main/assets/shell-app.bundle +**/android/app/src/main/res/raw/shell_app_bundle +**/android/app/src/release/res/xml/react_native_debug.xml + +# iOS +**/ios/Pods/ +/ios/*.xcworkspace +**/ios/DerivedData/ +**/ios/build/ +**/ios/Podfile.lock + +# Yarn Plug'n'Play +.pnp.* +.yarn/cache/ +.yarn/unplugged/ +.yarn/build-state.yml +.yarn/install-state.gz + +# Ignore .idea files in the Expo monorepo +**/.idea/ + +uploads \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100755 index 0000000..cc8df9d --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..e36b24a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,102 @@ +# 基础镜像 +FROM node:20-alpine as base +# 更改 apk 镜像源为阿里云 +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories +# 设置 npm 镜像源 +RUN yarn config set registry https://registry.npmmirror.com + +# 全局安装 pnpm 并设置其镜像源 +RUN yarn global add pnpm && pnpm config set registry https://registry.npmmirror.com + +# 设置工作目录 +WORKDIR /app + +# 复制 pnpm workspace 配置文件 +COPY pnpm-workspace.yaml ./ + +# 首先复制 package.json, package-lock.json 和 pnpm-lock.yaml 文件 +COPY package*.json pnpm-lock.yaml* ./ + +COPY tsconfig.json . +# 利用 Docker 缓存机制,如果依赖没有改变则不会重新执行 pnpm install +#100-500 5-40 + +FROM base As server-build +WORKDIR /app +COPY packages/common /app/packages/common +COPY apps/server /app/apps/server +RUN pnpm install --filter server +RUN pnpm install --filter common +RUN pnpm --filter common generate && pnpm --filter common build:cjs +RUN pnpm --filter server build + +FROM base As server-prod-dep +WORKDIR /app +COPY packages/common /app/packages/common +COPY apps/server /app/apps/server +RUN pnpm install --filter common --prod +RUN pnpm install --filter server --prod + + + +FROM server-prod-dep as server +WORKDIR /app +ENV NODE_ENV production +COPY --from=server-build /app/packages/common/dist ./packages/common/dist +COPY --from=server-build /app/apps/server/dist ./apps/server/dist +COPY apps/server/entrypoint.sh ./apps/server/entrypoint.sh + +RUN chmod +x ./apps/server/entrypoint.sh +RUN apk add --no-cache postgresql-client + + +EXPOSE 3000 + +ENTRYPOINT [ "/app/apps/server/entrypoint.sh" ] + + + +FROM base AS web-build +# 复制其余文件到工作目录 +COPY . . +RUN pnpm install +RUN pnpm --filter web build + +# 第二阶段,使用 nginx 提供服务 +FROM nginx:stable-alpine as web +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories +# 设置工作目录 +WORKDIR /usr/share/nginx/html +# 设置环境变量 +ENV NODE_ENV production +# 将构建的文件从上一阶段复制到当前镜像中 +COPY --from=web-build /app/apps/web/dist . +# 删除默认的nginx配置文件并添加自定义配置 +RUN rm /etc/nginx/conf.d/default.conf +COPY apps/web/nginx.conf /etc/nginx/conf.d +# 添加 entrypoint 脚本,并确保其可执行 +COPY apps/web/entrypoint.sh /usr/bin/ +RUN chmod +x /usr/bin/entrypoint.sh +# 安装 envsubst 以支持环境变量替换 +RUN apk add --no-cache gettext +# 暴露 80 端口 +EXPOSE 80 + +CMD ["/usr/bin/entrypoint.sh"] + + +FROM nginx:stable-alpine as nginx +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +# 设置工作目录 +WORKDIR /usr/share/nginx/html + +# 设置环境变量 +ENV NODE_ENV production + +# 安装 envsubst 以支持环境变量替换 +RUN apk add --no-cache gettext + +# 暴露 80 端口 +EXPOSE 80 + diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100755 index 0000000..88068be --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,14 @@ +DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/app?schema=public" +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=Letusdoit000 +TUS_URL=http://localhost:8080 +JWT_SECRET=/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA= +PUSH_URL=http://dns:9092 +PUSH_APPID=123 +PUSH_APPSECRET=123 +DEADLINE_CRON="0 0 8 * * *" +SERVER_PORT=3000 +ADMIN_PHONE_NUMBER=13258117304 +NODE_ENV=development +UPLOAD_DIR=/opt/projects/re-mooc/uploads \ No newline at end of file diff --git a/apps/server/.eslintrc.js b/apps/server/.eslintrc.js new file mode 100755 index 0000000..259975e --- /dev/null +++ b/apps/server/.eslintrc.js @@ -0,0 +1,40 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + // 允许使用 any 类型 + '@typescript-eslint/no-explicit-any': 'off', + + // 允许声明但未使用的变量 + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + vars: 'all', // 检查所有变量 + args: 'none', // 不检查函数参数 + ignoreRestSiblings: true, + }, + ], + + // 禁止使用未声明的变量 + 'no-undef': 'error', + }, +}; diff --git a/apps/server/.prettierrc b/apps/server/.prettierrc new file mode 100755 index 0000000..dcb7279 --- /dev/null +++ b/apps/server/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100755 index 0000000..8372941 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,73 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + + Support us + +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ yarn install +``` + +## Running the app + +```bash +# development +$ yarn run start + +# watch mode +$ yarn run start:dev + +# production mode +$ yarn run start:prod +``` + +## Test + +```bash +# unit tests +$ yarn run test + +# e2e tests +$ yarn run test:e2e + +# test coverage +$ yarn run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/apps/server/entrypoint.sh b/apps/server/entrypoint.sh new file mode 100755 index 0000000..9ea56fa --- /dev/null +++ b/apps/server/entrypoint.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +# # 从 DATABASE_URL 环境变量中提取主机名、端口和用户名 +# DB_HOST=$(echo $DATABASE_URL | cut -d '@' -f 2 | cut -d ':' -f 1) +# DB_PORT=$(echo $DATABASE_URL | cut -d ':' -f 4 | cut -d '/' -f 1) +# DB_USER=$(echo $DATABASE_URL | cut -d '/' -f 3 | cut -d ':' -f 1) + +# # 检查数据库是否就绪 +# until pg_isready -h $DB_HOST -p $DB_PORT -U $DB_USER; do +# echo "Database is unavailable - sleeping" +# sleep 1 +# done + +# echo "Database is up" + +# # 检查标记文件是否存在,如果不存在,则执行 prisma deploy 并创建标记文件 +# # if [ ! -f "/app/prisma-deployed" ]; then +# # pnpm prisma generate +# # pnpm prisma migrate deploy +# # touch /app/prisma-deployed +# # fi + +# # 启动主应用 +# exec node apps/server/dist/main + + +# 从 DATABASE_URL 环境变量中提取主机名、端口和用户名 +DB_HOST=$(echo $DATABASE_URL | cut -d '@' -f 2 | cut -d ':' -f 1) +DB_PORT=$(echo $DATABASE_URL | cut -d ':' -f 4 | cut -d '/' -f 1) +DB_USER=$(echo $DATABASE_URL | cut -d '/' -f 3 | cut -d ':' -f 1) + +# 检查数据库是否就绪 +until nc -z $DB_HOST $DB_PORT; do + echo "Database is unavailable - sleeping" + sleep 1 +done + +echo "Database is up" + +# 启动主应用 +exec node apps/server/dist/main \ No newline at end of file diff --git a/apps/server/nest-cli.json b/apps/server/nest-cli.json new file mode 100755 index 0000000..f9aa683 --- /dev/null +++ b/apps/server/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100755 index 0000000..566bfb2 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,113 @@ +{ + "name": "server", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@nestjs/bullmq": "^10.2.0", + "@nestjs/common": "^10.3.10", + "@nestjs/config": "^3.2.3", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-socket.io": "^10.3.10", + "@nestjs/schedule": "^4.1.0", + "@nestjs/websockets": "^10.3.10", + "@nice/common": "workspace:*", + "@nice/tus": "workspace:*", + "@trpc/server": "11.0.0-rc.456", + "argon2": "^0.41.1", + "axios": "^1.7.2", + "bullmq": "^5.12.0", + "cron": "^3.1.7", + "dayjs": "^1.11.13", + "dotenv": "^16.4.7", + "exceljs": "^4.4.0", + "fluent-ffmpeg": "^2.1.3", + "ioredis": "^5.4.1", + "lib0": "^0.2.97", + "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", + "mime-types": "^2.1.35", + "minio": "^8.0.1", + "mitt": "^3.0.1", + "nanoid": "^5.0.9", + "nanoid-cjs": "^0.0.7", + "pinyin-pro": "^3.26.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1", + "sharp": "^0.33.5", + "slugify": "^1.6.6", + "socket.io": "^4.7.5", + "superjson-cjs": "^2.2.3", + "transliteration": "^2.3.5", + "tus-js-client": "^4.1.0", + "uuid": "^10.0.0", + "ws": "^8.18.0", + "y-leveldb": "^0.1.2", + "yjs": "^13.6.20", + "zod": "^3.23.8" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/exceljs": "^1.3.0", + "@types/express": "^4.17.21", + "@types/fluent-ffmpeg": "^2.1.27", + "@types/jest": "^29.5.2", + "@types/mime-types": "^2.1.4", + "@types/multer": "^1.4.12", + "@types/node": "^20.3.1", + "@types/supertest": "^6.0.0", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.13", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.5.4" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} \ No newline at end of file diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts new file mode 100755 index 0000000..3bc892f --- /dev/null +++ b/apps/server/src/app.module.ts @@ -0,0 +1,52 @@ +import { Module } from '@nestjs/common'; +import { TrpcModule } from './trpc/trpc.module'; +import { QueueModule } from './queue/queue.module'; +import { AuthModule } from './auth/auth.module'; +import { TaxonomyModule } from './models/taxonomy/taxonomy.module'; +import { TasksModule } from './tasks/tasks.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { InitModule } from './tasks/init/init.module'; +import { ReminderModule } from './tasks/reminder/reminder.module'; +import { JwtModule } from '@nestjs/jwt'; +import { env } from './env'; +import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER } from '@nestjs/core'; +import { MinioModule } from './utils/minio/minio.module'; +import { WebSocketModule } from './socket/websocket.module'; +import { CollaborationModule } from './socket/collaboration/collaboration.module'; +import { ExceptionsFilter } from './filters/exceptions.filter'; +import { TransformModule } from './models/transform/transform.module'; +import { RealTimeModule } from './socket/realtime/realtime.module'; +import { UploadModule } from './upload/upload.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, // 全局可用 + envFilePath: '.env' + }), + ScheduleModule.forRoot(), + JwtModule.register({ + global: true, + secret: env.JWT_SECRET + }), + WebSocketModule, + TrpcModule, + QueueModule, + AuthModule, + TaxonomyModule, + TasksModule, + InitModule, + ReminderModule, + TransformModule, + MinioModule, + CollaborationModule, + RealTimeModule, + UploadModule + ], + providers: [{ + provide: APP_FILTER, + useClass: ExceptionsFilter, + }], +}) +export class AppModule { } diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts new file mode 100755 index 0000000..de67161 --- /dev/null +++ b/apps/server/src/auth/auth.controller.ts @@ -0,0 +1,112 @@ +import { + Controller, + Headers, + Post, + Body, + UseGuards, + Get, + Req, + HttpException, + HttpStatus, + BadRequestException, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, + Logger, +} from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthSchema, JwtPayload } from '@nice/common'; +import { AuthGuard } from './auth.guard'; +import { UserProfileService } from './utils'; +import { z } from 'zod'; +import { FileValidationErrorType } from './types'; +@Controller('auth') +export class AuthController { + private logger = new Logger(AuthController.name); + constructor(private readonly authService: AuthService) {} + @Get('file') + async authFileRequset( + @Headers('x-original-uri') originalUri: string, + @Headers('x-real-ip') realIp: string, + @Headers('x-original-method') method: string, + @Headers('x-query-params') queryParams: string, + @Headers('host') host: string, + @Headers('authorization') authorization: string, + ) { + try { + const fileRequest = { + originalUri, + realIp, + method, + queryParams, + host, + authorization, + }; + + const authResult = + await this.authService.validateFileRequest(fileRequest); + if (!authResult.isValid) { + // 使用枚举类型进行错误处理 + switch (authResult.error) { + case FileValidationErrorType.INVALID_URI: + throw new BadRequestException(authResult.error); + case FileValidationErrorType.RESOURCE_NOT_FOUND: + throw new NotFoundException(authResult.error); + case FileValidationErrorType.AUTHORIZATION_REQUIRED: + case FileValidationErrorType.INVALID_TOKEN: + throw new UnauthorizedException(authResult.error); + default: + throw new InternalServerErrorException( + authResult.error || FileValidationErrorType.UNKNOWN_ERROR, + ); + } + } + return { + headers: { + 'X-User-Id': authResult.userId, + 'X-Resource-Type': authResult.resourceType, + }, + }; + } catch (error: any) { + this.logger.verbose( + `File request auth failed from ${realIp} reason:${error.message}`, + ); + throw error; + } + } + @UseGuards(AuthGuard) + @Get('user-profile') + async getUserProfile(@Req() request: Request) { + const payload: JwtPayload = (request as any).user; + const { staff } = await UserProfileService.instance.getUserProfileById( + payload.sub, + ); + return staff; + } + @Post('login') + async login(@Body() body: z.infer) { + return this.authService.signIn(body); + } + @Post('signup') + async signup(@Body() body: z.infer) { + return this.authService.signUp(body); + } + @Post('refresh-token') + async refreshToken( + @Body() body: z.infer, + ) { + return this.authService.refreshToken(body); + } + // @UseGuards(AuthGuard) + @Post('logout') + async logout(@Body() body: z.infer) { + return this.authService.logout(body); + } + @UseGuards(AuthGuard) // Protecting the changePassword endpoint with AuthGuard + @Post('change-password') + async changePassword( + @Body() body: z.infer, + ) { + return this.authService.changePassword(body); + } +} diff --git a/apps/server/src/auth/auth.guard.ts b/apps/server/src/auth/auth.guard.ts new file mode 100755 index 0000000..e9fda52 --- /dev/null +++ b/apps/server/src/auth/auth.guard.ts @@ -0,0 +1,37 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { env } from '@server/env'; + +import { JwtPayload } from '@nice/common'; +import { extractTokenFromHeader } from './utils'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor(private jwtService: JwtService) { } + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException(); + } + try { + const payload: JwtPayload = await this.jwtService.verifyAsync( + token, + { + secret: env.JWT_SECRET + } + ); + request['user'] = payload; + } catch { + throw new UnauthorizedException(); + } + return true; + } + + +} \ No newline at end of file diff --git a/apps/server/src/auth/auth.module.ts b/apps/server/src/auth/auth.module.ts new file mode 100755 index 0000000..ade8298 --- /dev/null +++ b/apps/server/src/auth/auth.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { StaffModule } from '@server/models/staff/staff.module'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentService } from '@server/models/department/department.service'; +import { SessionService } from './session.service'; +import { RoleMapModule } from '@server/models/rbac/rbac.module'; +@Module({ + imports: [StaffModule, RoleMapModule], + providers: [ + AuthService, + TrpcService, + DepartmentService, + SessionService], + exports: [AuthService], + controllers: [AuthController], +}) +export class AuthModule { } diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts new file mode 100755 index 0000000..f7f679c --- /dev/null +++ b/apps/server/src/auth/auth.service.ts @@ -0,0 +1,243 @@ +import { + Injectable, + UnauthorizedException, + BadRequestException, + Logger, + InternalServerErrorException, +} from '@nestjs/common'; +import { StaffService } from '../models/staff/staff.service'; +import { db, AuthSchema, JwtPayload } from '@nice/common'; +import * as argon2 from 'argon2'; +import { JwtService } from '@nestjs/jwt'; +import { redis } from '@server/utils/redis/redis.service'; +import { extractTokenFromAuthorization, UserProfileService } from './utils'; +import { SessionInfo, SessionService } from './session.service'; +import { tokenConfig } from './config'; +import { z } from 'zod'; +import { FileAuthResult, FileRequest, FileValidationErrorType } from './types'; +import { TusService } from '@server/upload/tus.service'; +import { extractFileIdFromNginxUrl } from '@server/upload/utils'; +@Injectable() +export class AuthService { + private logger = new Logger(AuthService.name); + constructor( + private readonly staffService: StaffService, + private readonly jwtService: JwtService, + private readonly sessionService: SessionService, + ) {} + async validateFileRequest(params: FileRequest): Promise { + try { + // 基础参数验证 + if (!params?.originalUri) { + return { isValid: false, error: FileValidationErrorType.INVALID_URI }; + } + const fileId = extractFileIdFromNginxUrl(params.originalUri); + console.log(params.originalUri, fileId); + const resource = await db.resource.findFirst({ where: { fileId } }); + + // 资源验证 + if (!resource) { + return { + isValid: false, + error: FileValidationErrorType.RESOURCE_NOT_FOUND, + }; + } + // 处理公开资源 + if (resource.isPublic) { + return { + isValid: true, + resourceType: resource.type || 'unknown', + }; + } + // 处理私有资源 + const token = extractTokenFromAuthorization(params.authorization); + if (!token) { + return { + isValid: false, + error: FileValidationErrorType.AUTHORIZATION_REQUIRED, + }; + } + const payload: JwtPayload = await this.jwtService.verify(token); + if (!payload.sub) { + return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN }; + } + + return { + isValid: true, + userId: payload.sub, + resourceType: resource.type || 'unknown', + }; + } catch (error) { + this.logger.error('File validation error:', error); + return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR }; + } + } + + private async generateTokens(payload: JwtPayload): Promise<{ + accessToken: string; + refreshToken: string; + }> { + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync(payload, { + expiresIn: `${tokenConfig.accessToken.expirationMs / 1000}s`, + }), + this.jwtService.signAsync( + { sub: payload.sub }, + { expiresIn: `${tokenConfig.refreshToken.expirationMs / 1000}s` }, + ), + ]); + + return { accessToken, refreshToken }; + } + + async signIn( + data: z.infer, + ): Promise { + const { username, password, phoneNumber } = data; + + let staff = await db.staff.findFirst({ + where: { OR: [{ username }, { phoneNumber }], deletedAt: null }, + }); + + if (!staff && phoneNumber) { + staff = await this.signUp({ + showname: '新用户', + username: phoneNumber, + phoneNumber, + password: phoneNumber, + }); + } else if (!staff) { + throw new UnauthorizedException('帐号不存在'); + } + if (!staff.enabled) { + throw new UnauthorizedException('帐号已禁用'); + } + const isPasswordMatch = + phoneNumber || (await argon2.verify(staff.password, password)); + if (!isPasswordMatch) { + throw new UnauthorizedException('帐号或密码错误'); + } + + try { + const payload = { sub: staff.id, username: staff.username }; + const { accessToken, refreshToken } = await this.generateTokens(payload); + + return await this.sessionService.createSession( + staff.id, + accessToken, + refreshToken, + { + accessTokenExpirationMs: tokenConfig.accessToken.expirationMs, + refreshTokenExpirationMs: tokenConfig.refreshToken.expirationMs, + sessionTTL: tokenConfig.accessToken.expirationTTL, + }, + ); + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException('创建会话失败'); + } + } + async signUp(data: z.infer) { + const { username, phoneNumber, officerId } = data; + + const existingUser = await db.staff.findFirst({ + where: { + OR: [{ username }, { officerId }, { phoneNumber }], + deletedAt: null, + }, + }); + + if (existingUser) { + throw new BadRequestException('帐号或证件号已存在'); + } + + return this.staffService.create({ + data: { + ...data, + domainId: data.deptId, + }, + }); + } + async refreshToken(data: z.infer) { + const { refreshToken, sessionId } = data; + + let payload: JwtPayload; + try { + payload = this.jwtService.verify(refreshToken); + } catch { + throw new UnauthorizedException('用户会话已过期'); + } + + const session = await this.sessionService.getSession( + payload.sub, + sessionId, + ); + if (!session || session.refresh_token !== refreshToken) { + throw new UnauthorizedException('用户会话已过期'); + } + + const user = await db.staff.findUnique({ + where: { id: payload.sub, deletedAt: null }, + }); + if (!user) { + throw new UnauthorizedException('用户不存在'); + } + + const { accessToken } = await this.generateTokens({ + sub: user.id, + username: user.username, + }); + + const updatedSession = { + ...session, + access_token: accessToken, + access_token_expires_at: + Date.now() + tokenConfig.accessToken.expirationMs, + }; + await this.sessionService.saveSession( + payload.sub, + updatedSession, + tokenConfig.accessToken.expirationTTL, + ); + await redis.del( + UserProfileService.instance.getProfileCacheKey(payload.sub), + ); + return { + access_token: accessToken, + access_token_expires_at: updatedSession.access_token_expires_at, + }; + } + async changePassword(data: z.infer) { + const { newPassword, phoneNumber, username } = data; + const user = await db.staff.findFirst({ + where: { OR: [{ username }, { phoneNumber }], deletedAt: null }, + }); + + if (!user) { + throw new UnauthorizedException('用户不存在'); + } + await this.staffService.update({ + where: { id: user?.id }, + data: { + password: newPassword, + }, + }); + + return { message: '密码已修改' }; + } + async logout(data: z.infer) { + const { refreshToken, sessionId } = data; + + try { + const payload = this.jwtService.verify(refreshToken); + await Promise.all([ + this.sessionService.deleteSession(payload.sub, sessionId), + redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub)), + ]); + } catch { + throw new UnauthorizedException('无效的会话'); + } + + return { message: '注销成功' }; + } +} diff --git a/apps/server/src/auth/config.ts b/apps/server/src/auth/config.ts new file mode 100755 index 0000000..0edaf5d --- /dev/null +++ b/apps/server/src/auth/config.ts @@ -0,0 +1,9 @@ +export const tokenConfig = { + accessToken: { + expirationMs: 7 * 24 * 3600000, // 7 days + expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds + }, + refreshToken: { + expirationMs: 30 * 24 * 3600000, // 30 days + }, +}; \ No newline at end of file diff --git a/apps/server/src/auth/session.service.ts b/apps/server/src/auth/session.service.ts new file mode 100755 index 0000000..9ee9022 --- /dev/null +++ b/apps/server/src/auth/session.service.ts @@ -0,0 +1,61 @@ +// session.service.ts +import { Injectable } from '@nestjs/common'; +import { redis } from '@server/utils/redis/redis.service'; +import { v4 as uuidv4 } from 'uuid'; + +export interface SessionInfo { + session_id: string; + access_token: string; + access_token_expires_at: number; + refresh_token: string; + refresh_token_expires_at: number; +} + +@Injectable() +export class SessionService { + private getSessionKey(userId: string, sessionId: string): string { + return `session-${userId}-${sessionId}`; + } + async createSession( + userId: string, + accessToken: string, + refreshToken: string, + expirationConfig: { + accessTokenExpirationMs: number; + refreshTokenExpirationMs: number; + sessionTTL: number; + }, + ): Promise { + const sessionInfo: SessionInfo = { + session_id: uuidv4(), + access_token: accessToken, + access_token_expires_at: Date.now() + expirationConfig.accessTokenExpirationMs, + refresh_token: refreshToken, + refresh_token_expires_at: Date.now() + expirationConfig.refreshTokenExpirationMs, + }; + + await this.saveSession(userId, sessionInfo, expirationConfig.sessionTTL); + return sessionInfo; + } + + async getSession(userId: string, sessionId: string): Promise { + const sessionData = await redis.get(this.getSessionKey(userId, sessionId)); + return sessionData ? JSON.parse(sessionData) : null; + } + + async saveSession( + userId: string, + sessionInfo: SessionInfo, + ttl: number, + ): Promise { + await redis.setex( + this.getSessionKey(userId, sessionInfo.session_id), + ttl, + JSON.stringify(sessionInfo), + ); + } + + async deleteSession(userId: string, sessionId: string): Promise { + await redis.del(this.getSessionKey(userId, sessionId)); + } +} \ No newline at end of file diff --git a/apps/server/src/auth/types.ts b/apps/server/src/auth/types.ts new file mode 100755 index 0000000..4cb6c47 --- /dev/null +++ b/apps/server/src/auth/types.ts @@ -0,0 +1,31 @@ +export interface TokenConfig { + accessToken: { + expirationMs: number; + expirationTTL: number; + }; + refreshToken: { + expirationMs: number; + }; +} + +export interface FileAuthResult { + isValid: boolean + userId?: string + resourceType?: string + error?: string +} +export interface FileRequest { + originalUri: string; + realIp: string; + method: string; + queryParams: string; + host: string; + authorization: string +} +export enum FileValidationErrorType { + INVALID_URI = 'INVALID_URI', + RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', + AUTHORIZATION_REQUIRED = 'AUTHORIZATION_REQUIRED', + INVALID_TOKEN = 'INVALID_TOKEN', + UNKNOWN_ERROR = 'UNKNOWN_ERROR' +} \ No newline at end of file diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts new file mode 100755 index 0000000..5e8cd9a --- /dev/null +++ b/apps/server/src/auth/utils.ts @@ -0,0 +1,193 @@ +import { DepartmentService } from '@server/models/department/department.service'; +import { + UserProfile, + db, + JwtPayload, + RolePerms, + ObjectType, +} from '@nice/common'; +import { JwtService } from '@nestjs/jwt'; +import { env } from '@server/env'; +import { redis } from '@server/utils/redis/redis.service'; +import EventBus from '@server/utils/event-bus'; +import { RoleMapService } from '@server/models/rbac/rolemap.service'; +import { Request } from "express" +interface ProfileResult { + staff: UserProfile | undefined; + error?: string; +} + +interface TokenVerifyResult { + id?: string; + error?: string; +} +export function extractTokenFromHeader(request: Request): string | undefined { + return extractTokenFromAuthorization(request.headers.authorization) +} +export function extractTokenFromAuthorization(authorization: string): string | undefined { + const [type, token] = authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; +} +export class UserProfileService { + public static readonly instance = new UserProfileService(); + + private readonly CACHE_TTL = 3600; // 缓存时间1小时 + private readonly jwtService: JwtService; + private readonly departmentService: DepartmentService; + private readonly roleMapService: RoleMapService; + + private constructor() { + this.jwtService = new JwtService(); + this.departmentService = new DepartmentService(); + this.roleMapService = new RoleMapService(this.departmentService); + EventBus.on("dataChanged", ({ type, data }) => { + if (type === ObjectType.STAFF) { + // 确保 data 是数组,如果不是则转换为数组 + const dataArray = Array.isArray(data) ? data : [data]; + for (const item of dataArray) { + if (item.id) { + redis.del(this.getProfileCacheKey(item.id)); + } + } + } + }); + + } + public getProfileCacheKey(id: string) { + return `user-profile-${id}`; + } + /** + * 验证并解析token + */ + public async verifyToken(token?: string): Promise { + if (!token) { + return {}; + } + try { + const { sub: id } = await this.jwtService.verifyAsync(token, { + secret: env.JWT_SECRET, + }); + return { id }; + } catch (error) { + return { + error: + error instanceof Error ? error.message : 'Token verification failed', + }; + } + } + + /** + * 通过Token获取用户信息 + */ + public async getUserProfileByToken(token?: string): Promise { + const { id, error } = await this.verifyToken(token); + if (error) { + return { + staff: undefined, + error, + }; + } + return await this.getUserProfileById(id); + } + + /** + * 通过用户ID获取用户信息 + */ + public async getUserProfileById(id?: string): Promise { + if (!id) { + return { staff: undefined }; + } + try { + const cachedProfile = await this.getCachedProfile(id); + if (cachedProfile) { + return { staff: cachedProfile }; + } + const staff = await this.getBaseProfile(id); + if (!staff) { + throw new Error(`User with id ${id} does not exist`); + } + + await this.populateStaffExtras(staff); + await this.cacheProfile(id, staff); + + return { staff }; + } catch (error) { + return { + staff: undefined, + error: + error instanceof Error ? error.message : 'Failed to get user profile', + }; + } + } + + /** + * 从缓存获取用户信息 + */ + private async getCachedProfile(id: string): Promise { + const cachedData = await redis.get(this.getProfileCacheKey(id)); + if (!cachedData) return null; + + try { + const profile = JSON.parse(cachedData) as UserProfile; + return profile.id === id ? profile : null; + } catch { + return null; + } + } + + /** + * 缓存用户信息 + */ + private async cacheProfile(id: string, profile: UserProfile): Promise { + await redis.set( + this.getProfileCacheKey(id), + JSON.stringify(profile), + 'EX', + this.CACHE_TTL, + ); + } + + /** + * 获取基础用户信息 + */ + private async getBaseProfile(id: string): Promise { + return (await db.staff.findUnique({ + where: { id }, + select: { + id: true, + deptId: true, + department: true, + domainId: true, + domain: true, + showname: true, + username: true, + phoneNumber: true, + }, + })) as unknown as UserProfile; + } + + /** + * 填充用户权限信息 + */ + private async populateStaffExtras(staff: UserProfile): Promise { + const [deptIds, parentDeptIds, permissions] = await Promise.all([ + staff.deptId + ? this.departmentService.getDescendantIdsInDomain(staff.deptId) + : [], + staff.deptId + ? this.departmentService.getAncestorIds([staff.deptId]) + : [], + this.roleMapService.getPermsForObject({ + domainId: staff.domainId, + staffId: staff.id, + deptId: staff.deptId, + }) as Promise, + ]); + + Object.assign(staff, { + deptIds, + parentDeptIds, + permissions, + }); + } +} diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts new file mode 100755 index 0000000..74aecae --- /dev/null +++ b/apps/server/src/env.ts @@ -0,0 +1,3 @@ +export const env: { JWT_SECRET: string } = { + JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=' +} \ No newline at end of file diff --git a/apps/server/src/filters/exceptions.filter.ts b/apps/server/src/filters/exceptions.filter.ts new file mode 100755 index 0000000..b08dc1a --- /dev/null +++ b/apps/server/src/filters/exceptions.filter.ts @@ -0,0 +1,33 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; +@Catch() +export class ExceptionsFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.message + : 'Internal server error'; + + response.status(status).json({ + statusCode: status, + timestamp: new Date().toISOString(), + path: request.url, + message, + }); + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100755 index 0000000..bf49691 --- /dev/null +++ b/apps/server/src/main.ts @@ -0,0 +1,34 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { TrpcRouter } from './trpc/trpc.router'; +import { WebSocketService } from './socket/websocket.service'; + +/** + * 启动 NestJS 应用程序的引导函数 + * 该函数初始化 NestJS 应用程序,配置 CORS,初始化 WebSocket 服务,应用 TRPC 中间件,并启动服务器监听指定端口 + */ +async function bootstrap() { + // 创建 NestJS 应用实例,使用 AppModule 作为根模块 + const app = await NestFactory.create(AppModule); + + // 启用 CORS 并允许所有来源 + app.enableCors({ //允许跨域 + origin: '*', + }); + // 从 NestJS 应用实例中获取 WebSocketService 实例 + const wsService = app.get(WebSocketService); + // 初始化 WebSocket 服务,传入 HTTP 服务器实例 + await wsService.initialize(app.getHttpServer()); + // 从 NestJS 应用实例中获取 TrpcRouter 实例 + const trpc = app.get(TrpcRouter); + // 应用 TRPC 中间件到 NestJS 应用实例 + trpc.applyMiddleware(app); + + // 获取环境变量中的服务器端口,如果未设置则使用默认端口 3000 + const port = process.env.SERVER_PORT || 3000; + + // 启动服务器监听指定端口 + await app.listen(port); +} +// 调用引导函数启动应用程序 +bootstrap(); diff --git a/apps/server/src/models/app-config/app-config.module.ts b/apps/server/src/models/app-config/app-config.module.ts new file mode 100755 index 0000000..732313c --- /dev/null +++ b/apps/server/src/models/app-config/app-config.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AppConfigService } from './app-config.service'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { AppConfigRouter } from './app-config.router'; +import { RealTimeModule } from '@server/socket/realtime/realtime.module'; + +@Module({ + imports: [RealTimeModule], + providers: [AppConfigService, AppConfigRouter, TrpcService], + exports: [AppConfigService, AppConfigRouter] +}) +export class AppConfigModule { } diff --git a/apps/server/src/models/app-config/app-config.router.ts b/apps/server/src/models/app-config/app-config.router.ts new file mode 100755 index 0000000..ece1b35 --- /dev/null +++ b/apps/server/src/models/app-config/app-config.router.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { AppConfigService } from './app-config.service'; +import { z, ZodType } from 'zod'; +import { Prisma } from '@nice/common'; +import { RealtimeServer } from '@server/socket/realtime/realtime.server'; +const AppConfigUncheckedCreateInputSchema: ZodType = z.any() +const AppConfigUpdateArgsSchema: ZodType = z.any() +const AppConfigDeleteManyArgsSchema: ZodType = z.any() +const AppConfigFindFirstArgsSchema: ZodType = z.any() +@Injectable() +export class AppConfigRouter { + constructor( + private readonly trpc: TrpcService, + private readonly appConfigService: AppConfigService, + private readonly realtimeServer: RealtimeServer + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(AppConfigUncheckedCreateInputSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.appConfigService.create({ data: input }); + }), + update: this.trpc.protectProcedure + .input(AppConfigUpdateArgsSchema) + .mutation(async ({ ctx, input }) => { + + const { staff } = ctx; + return await this.appConfigService.update(input); + }), + deleteMany: this.trpc.protectProcedure.input(AppConfigDeleteManyArgsSchema).mutation(async ({ input }) => { + return await this.appConfigService.deleteMany(input) + }), + findFirst: this.trpc.protectProcedure.input(AppConfigFindFirstArgsSchema). + query(async ({ input }) => { + + return await this.appConfigService.findFirst(input) + }), + clearRowCache: this.trpc.protectProcedure.mutation(async () => { + return await this.appConfigService.clearRowCache() + }), + getClientCount: this.trpc.protectProcedure.query(() => { + return this.realtimeServer.getClientCount() + }) + }); +} diff --git a/apps/server/src/models/app-config/app-config.service.ts b/apps/server/src/models/app-config/app-config.service.ts new file mode 100755 index 0000000..733e620 --- /dev/null +++ b/apps/server/src/models/app-config/app-config.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { + db, + ObjectType, + Prisma, +} from '@nice/common'; + + +import { BaseService } from '../base/base.service'; +import { deleteByPattern } from '@server/utils/redis/utils'; + +@Injectable() +export class AppConfigService extends BaseService { + constructor() { + super(db, "appConfig"); + } + async clearRowCache() { + await deleteByPattern("row-*") + return true + } +} diff --git a/apps/server/src/models/base/base.service.ts b/apps/server/src/models/base/base.service.ts new file mode 100755 index 0000000..d1b8b16 --- /dev/null +++ b/apps/server/src/models/base/base.service.ts @@ -0,0 +1,577 @@ +import { db, Prisma, PrismaClient } from '@nice/common'; +import { + Operations, + DelegateArgs, + DelegateReturnTypes, + DataArgs, + WhereArgs, + DelegateFuncs, + UpdateOrderArgs, + TransactionType, + SelectArgs, +} from './base.type'; +import { + NotFoundException, + InternalServerErrorException, +} from '@nestjs/common'; +import { ERROR_MAP, operationT, PrismaErrorCode } from './errorMap.prisma'; + +/** + * BaseService provides a generic CRUD interface for a prisma model. + * It enables common data operations such as find, create, update, and delete. + * + * @template D - Type for the model delegate, defining available operations. + * @template A - Arguments for the model delegate's operations. + * @template R - Return types for the model delegate's operations. + */ +export class BaseService< + D extends DelegateFuncs, + A extends DelegateArgs = DelegateArgs, + R extends DelegateReturnTypes = DelegateReturnTypes, +> { + protected ORDER_INTERVAL = 100; + /** + * Initializes the BaseService with the specified model. + * @param model - The Prisma model delegate for database operations. + */ + constructor( + protected prisma: PrismaClient, + protected objectType: string, + protected enableOrder: boolean = false, + ) {} + + /** + * Retrieves the name of the model dynamically. + * @returns {string} - The name of the model. + */ + private getModelName(): string { + const modelName = this.getModel().constructor.name; + return modelName; + } + private getModel(tx?: TransactionType): D { + return tx?.[this.objectType] || (this.prisma[this.objectType] as D); + } + /** + * Error handling helper function + */ + private handleError(error: any, operation: operationT): never { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + const handler = ERROR_MAP[error.code as PrismaErrorCode]; + if (handler) { + throw handler( + operation, + error?.meta || { + target: 'record', + model: this.getModelName(), + }, + ); + } + throw new InternalServerErrorException( + `Database error: ${error.message}`, + ); + } + throw new InternalServerErrorException( + `Unexpected error: ${error.message || 'Unknown error occurred.'}`, + ); + } + /** + * Finds a unique record by given criteria. + * @param args - Arguments to find a unique record. + * @returns {Promise} - A promise resolving to the found record. + * @example + * const user = await service.findUnique({ where: { id: 'user_id' } }); + */ + async findUnique(args: A['findUnique']): Promise { + try { + return this.getModel().findUnique(args as any) as Promise< + R['findUnique'] + >; + } catch (error) { + this.handleError(error, 'read'); + } + } + /** + * Finds the first record matching the given criteria. + * @param args - Arguments to find the first matching record. + * @returns {Promise} - A promise resolving to the first matching record. + * @example + * const firstUser = await service.findFirst({ where: { name: 'John' } }); + */ + async findFirst(args: A['findFirst']): Promise { + try { + return this.getModel().findFirst(args as any) as Promise; + } catch (error) { + this.handleError(error, 'read'); + } + } + + /** + * Finds a record by its ID. + * @param id - The ID of the record to find. + * @param args - Optional additional arguments for the find operation. + * @returns {Promise} - A promise resolving to the found record. + * @throws {NotFoundException} - If no record is found with the given ID. + * @example + * const user = await service.findById('user_id'); + */ + async findById(id: string, args?: A['findFirst']): Promise { + try { + const record = (await this.getModel().findFirst({ + where: { id }, + ...(args || {}), + })) as R['findFirst']; + if (!record) { + throw new NotFoundException(`Record with ID ${id} not found.`); + } + return record; + } catch (error) { + this.handleError(error, 'read'); + } + } + + /** + * Finds multiple records matching the given criteria. + * @param args - Arguments to find multiple records. + * @returns {Promise} - A promise resolving to the list of found records. + * @example + * const users = await service.findMany({ where: { isActive: true } }); + */ + async findMany(args: A['findMany']): Promise { + try { + return this.getModel().findMany(args as any) as Promise; + } catch (error) { + this.handleError(error, 'read'); + } + } + + /** + * Creates a new record with the given data. + * @param args - Arguments to create a record. + * @returns {Promise} - A promise resolving to the created record. + * @example + * const newUser = await service.create({ data: { name: 'John Doe' } }); + */ + async create(args: A['create'], params?: any): Promise { + try { + if (this.enableOrder && !(args as any).data.order) { + // 查找当前最大的 order 值 + const maxOrderItem = (await this.getModel(params?.tx).findFirst({ + orderBy: { order: 'desc' }, + })) as any; + // 设置新记录的 order 值 + const newOrder = maxOrderItem + ? maxOrderItem.order + this.ORDER_INTERVAL + : 1; + // 将 order 添加到创建参数中 + (args as any).data.order = newOrder; + } + return this.getModel(params?.tx).create(args as any) as Promise< + R['create'] + >; + } catch (error) { + this.handleError(error, 'create'); + } + } + + /** + * Creates multiple new records with the given data. + * @param args - Arguments to create multiple records. + * @returns {Promise} - A promise resolving to the created records. + * @example + * const newUsers = await service.createMany({ data: [{ name: 'John' }, { name: 'Jane' }] }); + */ + async createMany( + args: A['createMany'], + params?: any, + ): Promise { + try { + return this.getModel(params?.tx).createMany(args as any) as Promise< + R['createMany'] + >; + } catch (error) { + this.handleError(error, 'create'); + } + } + + /** + * Updates a record with the given data. + * @param args - Arguments to update a record. + * @returns {Promise} - A promise resolving to the updated record. + * @example + * const updatedUser = await service.update({ where: { id: 'user_id' }, data: { name: 'John' } }); + */ + async update(args: A['update'], params?: any): Promise { + try { + return this.getModel(params?.tx).update(args as any) as Promise< + R['update'] + >; + } catch (error) { + this.handleError(error, 'update'); + } + } + + /** + * Updates a record by ID with the given data. + * @param id - The ID of the record to update. + * @param data - The data to update the record with. + * @returns {Promise} - A promise resolving to the updated record. + * @example + * const updatedUser = await service.updateById('user_id', { name: 'John Doe' }); + */ + async updateById( + id: string, + data: DataArgs, + ): Promise { + try { + return (await this.getModel().update({ + where: { id }, + data: data as any, + })) as R['update']; + } catch (error) { + this.handleError(error, 'update'); + } + } + + /** + * Deletes a record by ID. + * @param id - The ID of the record to delete. + * @returns {Promise} - A promise resolving to the deleted record. + * @example + * const deletedUser = await service.deleteById('user_id'); + */ + async deleteById(id: string): Promise { + try { + return (await this.getModel().delete({ + where: { id }, + })) as R['delete']; + } catch (error) { + this.handleError(error, 'delete'); + } + } + + /** + * Deletes a record based on the given criteria. + * @param args - Arguments to delete a record. + * @returns {Promise} - A promise resolving to the deleted record. + * @example + * const deletedUser = await service.delete({ where: { name: 'John' } }); + */ + async delete(args: A['delete'], params?: any): Promise { + try { + return this.getModel(params?.tx).delete(args as any) as Promise< + R['delete'] + >; + } catch (error) { + this.handleError(error, 'delete'); + } + } + + /** + * Creates or updates a record based on the given criteria. + * @param args - Arguments to upsert a record. + * @returns {Promise} - A promise resolving to the created or updated record. + * @example + * const user = await service.upsert({ where: { id: 'user_id' }, create: { name: 'John' }, update: { name: 'Johnny' } }); + */ + async upsert(args: A['upsert']): Promise { + try { + return this.getModel().upsert(args as any) as Promise; + } catch (error) { + this.handleError(error, 'create'); + } + } + + /** + * Counts the number of records matching the given criteria. + * @param args - Arguments to count records. + * @returns {Promise} - A promise resolving to the count. + * @example + * const userCount = await service.count({ where: { isActive: true } }); + */ + async count(args: A['count']): Promise { + try { + return this.getModel().count(args as any) as Promise; + } catch (error) { + this.handleError(error, 'read'); + } + } + + /** + * Aggregates records based on the given criteria. + * @param args - Arguments to aggregate records. + * @returns {Promise} - A promise resolving to the aggregation result. + * @example + * const userAggregates = await service.aggregate({ _count: true }); + */ + async aggregate(args: A['aggregate']): Promise { + try { + return this.getModel().aggregate(args as any) as Promise; + } catch (error) { + this.handleError(error, 'read'); + } + } + + /** + * Deletes multiple records based on the given criteria. + * @param args - Arguments to delete multiple records. + * @returns {Promise} - A promise resolving to the result of the deletion. + * @example + * const deleteResult = await service.deleteMany({ where: { isActive: false } }); + */ + async deleteMany( + args: A['deleteMany'], + params?: any, + ): Promise { + try { + return this.getModel(params?.tx).deleteMany(args as any) as Promise< + R['deleteMany'] + >; + } catch (error) { + this.handleError(error, 'delete'); + } + } + + /** + * Updates multiple records based on the given criteria. + * @param args - Arguments to update multiple records. + * @returns {Promise} - A promise resolving to the result of the update. + * @example + * const updateResult = await service.updateMany({ where: { isActive: true }, data: { isActive: false } }); + */ + async updateMany(args: A['updateMany']): Promise { + try { + return this.getModel().updateMany(args as any) as Promise< + R['updateMany'] + >; + } catch (error) { + this.handleError(error, 'update'); + } + } + + /** + * Finds a record by unique criteria or creates it if not found. + * @param args - Arguments to find or create a record. + * @returns {Promise} - A promise resolving to the found or created record. + * @example + * const user = await service.findOrCreate({ where: { email: 'john@example.com' }, create: { email: 'john@example.com', name: 'John' } }); + */ + async findOrCreate(args: { + where: WhereArgs; + create: DataArgs; + }): Promise { + try { + const existing = (await this.getModel().findUnique({ + where: args.where, + } as any)) as R['findUnique']; + if (existing) { + return existing; + } + return this.getModel().create({ data: args.create } as any) as Promise< + R['create'] + >; + } catch (error) { + this.handleError(error, 'create'); + } + } + + /** + * Checks if a record exists based on the given criteria. + * @param where - The criteria to check for existence. + * @returns {Promise} - A promise resolving to true if the record exists, false otherwise. + * @example + * const exists = await service.exists({ email: 'john@example.com' }); + */ + async exists(where: WhereArgs): Promise { + try { + const count = (await this.getModel().count({ where } as any)) as number; + return count > 0; + } catch (error) { + this.handleError(error, 'read'); + } + } + + /** + * Soft deletes records by setting `isDeleted` to true for the given IDs. + * @param ids - An array of IDs of the records to soft delete. + * @param data - Additional data to update on soft delete. (Optional) + * @returns {Promise} - A promise resolving to an array of updated records. + * @example + * const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' }); + */ + async softDeleteByIds( + ids: string[], + data: Partial> = {}, // Default to empty object + ): Promise { + try { + if (!ids || ids.length === 0) { + return []; // Return empty array if no IDs are provided + } + return this.getModel().updateMany({ + where: { id: { in: ids } }, + data: { ...data, deletedAt: new Date() } as any, + }) as Promise; + } catch (error) { + this.handleError(error, 'delete'); + } + } + /** + * Restores soft-deleted records by setting `isDeleted` to false for the given IDs. + * @param ids - An array of IDs of the records to restore. + * @param data - Additional data to update on restore. (Optional) + * @returns {Promise} - A promise resolving to an array of updated records. + * @example + * const restoredUsers = await service.restoreByIds(['user_id1', 'user_id2'], { restoredBy: 'admin' }); + */ + async restoreByIds( + ids: string[], + data: Partial> = {}, // Default to empty object + ): Promise { + try { + if (!ids || ids.length === 0) { + return []; // Return empty array if no IDs are provided + } + return this.getModel().updateMany({ + where: { id: { in: ids }, isDeleted: true }, // Only restore soft-deleted records + data: { ...data, deletedAt: null } as any, + }) as Promise; + } catch (error) { + this.handleError(error, 'update'); + } + } + + /** + * Finds multiple records with pagination. + * @param args - Arguments including page, pageSize, and optional filters. + * @returns {Promise} - A promise resolving to the paginated list of records. + * @example + * const users = await service.findManyWithPagination({ page: 1, pageSize: 10, where: { isActive: true } }); + */ + async findManyWithPagination(args: { + page?: number; + pageSize?: number; + where?: WhereArgs; + select?: SelectArgs; + }): Promise<{ items: R['findMany']; totalPages: number }> { + const { page = 1, pageSize = 10, where, select } = args; + + try { + // 获取总记录数 + const total = (await this.getModel().count({ where })) as number; + // 获取分页数据 + const items = (await this.getModel().findMany({ + where, + select, + skip: (page - 1) * pageSize, + take: pageSize, + } as any)) as R['findMany']; + // 计算总页数 + const totalPages = Math.ceil(total / pageSize); + return { + items, + totalPages, + }; + } catch (error) { + this.handleError(error, 'read'); + } + } + /** + * 基于游标的分页查询方法 + * @description 该方法实现了基于游标的分页查询,相比传统的offset/limit分页有更好的性能 + * @param args 查询参数对象,包含cursor、take、where、orderBy、select等字段 + * @returns 返回查询结果对象,包含items数组、总数和下一页游标 + */ + async findManyWithCursor( + args: A['findMany'], + ): Promise<{ items: R['findMany']; nextCursor: string | null }> { + // 解构查询参数,设置默认每页取10条记录 + const { cursor, take = 6, where, orderBy, select } = args as any; + + try { + const items = (await this.getModel().findMany({ + where: where, + orderBy: [{ ...orderBy }, { updatedAt: 'desc' }, { id: 'desc' }], + select, + take: take + 1, + cursor: cursor + ? { updatedAt: cursor.split('_')[0], id: cursor.split('_')[1] } + : undefined, + } as any)) as any[]; + + /** + * 处理下一页游标 + * @description + * 1. 如果查到的记录数超过take,说明还有下一页 + * 2. 将最后一条记录弹出,用其updatedAt和id构造下一页游标 + * 3. 游标格式为: updatedAt_id + */ + let nextCursor: string | null = ''; + if (items.length > take) { + const nextItem = items.pop(); + nextCursor = `${nextItem!.updatedAt?.toISOString()}_${nextItem!.id}`; + } + if (nextCursor === '') { + nextCursor = null; + } + + /** + * 返回查询结果 + * @returns {Object} + * - items: 当前页记录 + * - totalCount: 总记录数 + * - nextCursor: 下一页游标 + */ + return { + items: items as R['findMany'], + nextCursor: nextCursor, + }; + } catch (error) { + this.handleError(error, 'read'); + } + } + + async updateOrder(args: UpdateOrderArgs) { + const { id, overId } = args; + const [currentObject, targetObject] = (await Promise.all([ + this.findFirst({ where: { id } } as any), + this.findFirst({ where: { id: overId } } as any), + ])) as any; + if (!currentObject || !targetObject) { + throw new Error('Invalid object or target object'); + } + const nextObject = (await this.findFirst({ + where: { + order: { gt: targetObject.order }, + deletedAt: null, + }, + orderBy: { order: 'asc' }, + } as any)) as any; + + const newOrder = nextObject + ? (targetObject.order + nextObject.order) / 2 + : targetObject.order + this.ORDER_INTERVAL; + return this.update({ where: { id }, data: { order: newOrder } } as any); + } + /** + * Wraps the result of a database operation with a transformation function. + * @template T - The type of the result to be transformed. + * @param operationPromise - The promise representing the database operation. + * @param transformFn - A function that transforms the result. + * @returns {Promise} - A promise resolving to the transformed result. + * @example + * const user = await service.wrapResult( + * service.findUnique({ where: { id: 'user_id' } }), + * (result) => ({ ...result, fullName: `${result.firstName} ${result.lastName}` }) + * ); + */ + async wrapResult( + operationPromise: Promise, + transformFn: (result: T) => Promise, + ): Promise { + try { + const result = await operationPromise; + return await transformFn(result); + } catch (error) { + throw error; // Re-throw the error to maintain existing error handling + } + } +} diff --git a/apps/server/src/models/base/base.tree.service.ts b/apps/server/src/models/base/base.tree.service.ts new file mode 100755 index 0000000..f62aecc --- /dev/null +++ b/apps/server/src/models/base/base.tree.service.ts @@ -0,0 +1,416 @@ +import { Prisma, PrismaClient } from '@nice/common'; +import { BaseService } from './base.service'; +import { + DataArgs, + DelegateArgs, + DelegateFuncs, + DelegateReturnTypes, + UpdateOrderArgs, +} from './base.type'; + +/** + * BaseTreeService provides a generic CRUD interface for a tree prisma model. + * It enables common data operations such as find, create, update, and delete. + * + * @template D - Type for the model delegate, defining available operations. + * @template A - Arguments for the model delegate's operations. + * @template R - Return types for the model delegate's operations. + */ +export class BaseTreeService< + D extends DelegateFuncs, + A extends DelegateArgs = DelegateArgs, + R extends DelegateReturnTypes = DelegateReturnTypes, +> extends BaseService { + constructor( + protected prisma: PrismaClient, + protected objectType: string, + protected ancestryType: string = objectType + 'Ancestry', + protected enableOrder: boolean = false, + ) { + super(prisma, objectType, enableOrder); + } + async getNextOrder( + transaction: any, + parentId: string | null, + parentOrder?: number, + ): Promise { + // 查找同层级最后一个节点的 order + const lastOrder = await transaction[this.objectType].findFirst({ + where: { + parentId: parentId ?? null, + }, + select: { order: true }, + orderBy: { order: 'desc' }, + } as any); + + // 如果有父节点 + if (parentId) { + // 获取父节点的 order(如果未提供) + const parentNodeOrder = + parentOrder ?? + ( + await transaction[this.objectType].findUnique({ + where: { id: parentId }, + select: { order: true }, + }) + )?.order ?? + 0; + + // 如果存在最后一个同层级节点,确保新节点 order 大于最后一个节点 + // 否则,新节点 order 设置为父节点 order + 1 + return lastOrder + ? Math.max( + lastOrder.order + this.ORDER_INTERVAL, + parentNodeOrder + this.ORDER_INTERVAL, + ) + : parentNodeOrder + this.ORDER_INTERVAL; + } + + // 对于根节点,直接使用最后一个节点的 order + 1 + return lastOrder?.order ? lastOrder?.order + this.ORDER_INTERVAL : 1; + } + + async create(args: A['create'], params?: any) { + const anyArgs = args as any; + // 如果传入了外部事务,直接使用该事务执行所有操作 + // 如果没有外部事务,则创建新事务 + const executor = async (transaction: any) => { + if (this.enableOrder) { + anyArgs.data.order = await this.getNextOrder( + transaction, + anyArgs?.data.parentId ?? null, + ); + } + + const result: any = await super.create(anyArgs, { tx: transaction }); + + if (anyArgs.data.parentId) { + await transaction[this.objectType].update({ + where: { id: anyArgs.data.parentId }, + data: { hasChildren: true }, + }); + } + + const newAncestries = anyArgs.data.parentId + ? [ + ...( + await transaction[this.ancestryType].findMany({ + where: { descendantId: anyArgs.data.parentId }, + select: { ancestorId: true, relDepth: true }, + }) + ).map(({ ancestorId, relDepth }) => ({ + ancestorId, + descendantId: result.id, + relDepth: relDepth + 1, + })), + { + ancestorId: result.parentId, + descendantId: result.id, + relDepth: 1, + }, + ] + : [{ ancestorId: null, descendantId: result.id, relDepth: 1 }]; + + await transaction[this.ancestryType].createMany({ data: newAncestries }); + + return result; + }; + // 根据是否有外部事务决定执行方式 + if (params?.tx) { + return executor(params.tx) as Promise; + } else { + return this.prisma.$transaction(executor) as Promise; + } + } + + /** + * 更新现有单位,并在parentId更改时管理DeptAncestry关系。 + * @param data - 用于更新现有单位的数据。 + * @returns 更新后的单位对象。 + */ + async update(args: A['update'], params?: any) { + const anyArgs = args as any; + return this.prisma.$transaction(async (transaction) => { + const current = await transaction[this.objectType].findUnique({ + where: { id: anyArgs.where.id }, + }); + + if (!current) throw new Error('object not found'); + + const result: any = await super.update(anyArgs, { tx: transaction }); + + if (anyArgs.data.parentId !== current.parentId) { + await transaction[this.ancestryType].deleteMany({ + where: { descendantId: result.id }, + }); + // 更新原父级的 hasChildren 状态 + if (current.parentId) { + const childrenCount = await transaction[this.objectType].count({ + where: { parentId: current.parentId, deletedAt: null }, + }); + + if (childrenCount === 0) { + await transaction[this.objectType].update({ + where: { id: current.parentId }, + data: { hasChildren: false }, + }); + } + } + if (anyArgs.data.parentId) { + await transaction[this.objectType].update({ + where: { id: anyArgs.data.parentId }, + data: { hasChildren: true }, + }); + const parentAncestries = await transaction[ + this.ancestryType + ].findMany({ + where: { descendantId: anyArgs.data.parentId }, + }); + + const newAncestries = parentAncestries.map( + ({ ancestorId, relDepth }) => ({ + ancestorId, + descendantId: result.id, + relDepth: relDepth + 1, + }), + ); + + newAncestries.push({ + ancestorId: anyArgs.data.parentId, + descendantId: result.id, + relDepth: 1, + }); + + await transaction[this.ancestryType].createMany({ + data: newAncestries, + }); + } else { + await transaction[this.ancestryType].create({ + data: { ancestorId: null, descendantId: result.id, relDepth: 0 }, + }); + } + } + + return result; + }) as Promise; + } + /** + * Soft deletes records by setting `isDeleted` to true for the given IDs. + * @param ids - An array of IDs of the records to soft delete. + * @param data - Additional data to update on soft delete. (Optional) + * @returns {Promise} - A promise resolving to an array of updated records. + * @example + * const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' }); + */ + async softDeleteByIds( + ids: string[], + data: Partial> = {}, // Default to empty object + ): Promise { + return this.prisma.$transaction(async (tx) => { + // 首先找出所有需要软删除的记录的父级ID + const parentIds = await tx[this.objectType].findMany({ + where: { + id: { in: ids }, + parentId: { not: null }, + }, + select: { parentId: true }, + }); + + const uniqueParentIds = [...new Set(parentIds.map((p) => p.parentId))]; + + // 执行软删除 + const result = await super.softDeleteByIds(ids, data); + + // 删除相关的祖先关系 + await tx[this.ancestryType].deleteMany({ + where: { + OR: [{ ancestorId: { in: ids } }, { descendantId: { in: ids } }], + }, + }); + // 更新父级的 hasChildren 状态 + if (uniqueParentIds.length > 0) { + for (const parentId of uniqueParentIds) { + const remainingChildrenCount = await tx[this.objectType].count({ + where: { + parentId: parentId, + deletedAt: null, + }, + }); + if (remainingChildrenCount === 0) { + await tx[this.objectType].update({ + where: { id: parentId }, + data: { hasChildren: false }, + }); + } + } + } + + return result; + }) as Promise; + } + + getAncestors(ids: string[]) { + if (!ids || ids.length === 0) return []; + const validIds = ids.filter((id) => id != null); + const hasNull = ids.includes(null); + return this.prisma[this.ancestryType].findMany({ + where: { + OR: [ + { ancestorId: { in: validIds } }, + { ancestorId: hasNull ? null : undefined }, + ], + }, + }); + } + + getDescendants(ids: string[]) { + if (!ids || ids.length === 0) return []; + const validIds = ids.filter((id) => id != null); + const hasNull = ids.includes(null); + return this.prisma[this.ancestryType].findMany({ + where: { + OR: [ + { ancestorId: { in: validIds } }, + { ancestorId: hasNull ? null : undefined }, + ], + }, + }); + } + + async getDescendantIds( + ids: string | string[], + includeOriginalIds: boolean = false, + ): Promise { + // 将单个 ID 转换为数组 + const idArray = Array.isArray(ids) ? ids : [ids]; + + const res = await this.getDescendants(idArray); + const descendantSet = new Set(res?.map((item) => item.descendantId) || []); + + if (includeOriginalIds) { + idArray.forEach((id) => descendantSet.add(id)); + } + + return Array.from(descendantSet).filter(Boolean) as string[]; + } + + async getAncestorIds( + ids: string | string[], + includeOriginalIds: boolean = false, + ): Promise { + // 将单个 ID 转换为数组 + const idArray = Array.isArray(ids) ? ids : [ids]; + + const res = await this.getDescendants(idArray); + const ancestorSet = new Set(); + + // 按深度排序并添加祖先ID + res + ?.sort((a, b) => b.relDepth - a.relDepth) + ?.forEach((item) => ancestorSet.add(item.ancestorId)); + + // 根据参数决定是否添加原始ID + if (includeOriginalIds) { + idArray.forEach((id) => ancestorSet.add(id)); + } + + return Array.from(ancestorSet).filter(Boolean) as string[]; + } + async updateOrder(args: UpdateOrderArgs) { + const { id, overId } = args; + + return this.prisma.$transaction(async (transaction) => { + // 查找当前节点和目标节点 + const currentObject = await transaction[this.objectType].findUnique({ + where: { id }, + select: { id: true, parentId: true, order: true }, + }); + + const targetObject = await transaction[this.objectType].findUnique({ + where: { id: overId }, + select: { id: true, parentId: true, order: true }, + }); + + // 验证节点 + if (!currentObject || !targetObject) { + throw new Error('Invalid object or target object'); + } + + // 查找父节点 + const parentObject = currentObject.parentId + ? await transaction[this.objectType].findUnique({ + where: { id: currentObject.parentId }, + select: { id: true, order: true }, + }) + : null; + + // 确保在同一父节点下移动 + if (currentObject.parentId !== targetObject.parentId) { + throw new Error('Cannot move between different parent nodes'); + } + + // 查找同层级的所有节点,按 order 排序 + const siblingNodes = await transaction[this.objectType].findMany({ + where: { + parentId: targetObject.parentId, + }, + select: { id: true, order: true }, + orderBy: { order: 'asc' }, + }); + + // 找到目标节点和当前节点在兄弟节点中的索引 + const targetIndex = siblingNodes.findIndex( + (node) => node.id === targetObject.id, + ); + const currentIndex = siblingNodes.findIndex( + (node) => node.id === currentObject.id, + ); + + // 移除当前节点 + siblingNodes.splice(currentIndex, 1); + + // 在目标位置插入当前节点 + const insertIndex = + currentIndex > targetIndex ? targetIndex + 1 : targetIndex; + siblingNodes.splice(insertIndex, 0, currentObject); + + // 重新分配 order + const newOrders = this.redistributeOrder( + siblingNodes, + parentObject?.order || 0, + ); + + // 批量更新节点的 order + const updatePromises = newOrders.map((nodeOrder, index) => + transaction[this.objectType].update({ + where: { id: siblingNodes[index].id }, + data: { order: nodeOrder }, + }), + ); + + await Promise.all(updatePromises); + + // 返回更新后的当前节点 + return transaction[this.objectType].findUnique({ + where: { id: currentObject.id }, + }); + }); + } + + // 重新分配 order 的方法 + private redistributeOrder( + nodes: Array<{ id: string; order: number }>, + parentOrder: number, + ): number[] { + const MIN_CHILD_ORDER = parentOrder + this.ORDER_INTERVAL; // 子节点 order 必须大于父节点 + const newOrders: number[] = []; + + nodes.forEach((_, index) => { + // 使用等差数列分配 order,确保大于父节点 + const nodeOrder = MIN_CHILD_ORDER + (index + 1) * this.ORDER_INTERVAL; + newOrders.push(nodeOrder); + }); + + return newOrders; + } +} diff --git a/apps/server/src/models/base/base.type.ts b/apps/server/src/models/base/base.type.ts new file mode 100755 index 0000000..878dffe --- /dev/null +++ b/apps/server/src/models/base/base.type.ts @@ -0,0 +1,44 @@ +import { db, Prisma, PrismaClient } from "@nice/common"; + +export type Operations = + | 'aggregate' + | 'count' + | 'create' + | 'createMany' + | 'delete' + | 'deleteMany' + | 'findFirst' + | 'findMany' + | 'findUnique' + | 'update' + | 'updateMany' + | 'upsert'; +export type DelegateFuncs = { [K in Operations]: (args: any) => Promise } +export type DelegateArgs = { + [K in keyof T]: T[K] extends (args: infer A) => Promise ? A : never; +}; + +export type DelegateReturnTypes = { + [K in keyof T]: T[K] extends (args: any) => Promise ? R : never; +}; + +export type WhereArgs = T extends { where?: infer W } ? W : never; +export type SelectArgs = T extends { select?: infer S } ? S : never; +export type DataArgs = T extends { data: infer D } ? D : never; +export type IncludeArgs = T extends { include: infer I } ? I : never; +export type OrderByArgs = T extends { orderBy: infer O } ? O : never; +export type UpdateOrderArgs = { + id: string + overId: string +} +export interface FindManyWithCursorType { + cursor?: string; + limit?: number; + where?: WhereArgs['findUnique']>; + select?: SelectArgs['findUnique']>; + orderBy?: OrderByArgs['findMany']> +} +export type TransactionType = Omit< + PrismaClient, + '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' +>; \ No newline at end of file diff --git a/apps/server/src/models/base/errorMap.prisma.ts b/apps/server/src/models/base/errorMap.prisma.ts new file mode 100755 index 0000000..ab234ce --- /dev/null +++ b/apps/server/src/models/base/errorMap.prisma.ts @@ -0,0 +1,198 @@ +import { + BadRequestException, + NotFoundException, + ConflictException, + InternalServerErrorException, + } from '@nestjs/common'; + + export const PrismaErrorCode = Object.freeze({ + P2000: 'P2000', + P2001: 'P2001', + P2002: 'P2002', + P2003: 'P2003', + P2006: 'P2006', + P2007: 'P2007', + P2008: 'P2008', + P2009: 'P2009', + P2010: 'P2010', + P2011: 'P2011', + P2012: 'P2012', + P2014: 'P2014', + P2015: 'P2015', + P2016: 'P2016', + P2017: 'P2017', + P2018: 'P2018', + P2019: 'P2019', + P2021: 'P2021', + P2023: 'P2023', + P2025: 'P2025', + P2031: 'P2031', + P2033: 'P2033', + P2034: 'P2034', + P2037: 'P2037', + P1000: 'P1000', + P1001: 'P1001', + P1002: 'P1002', + P1015: 'P1015', + P1017: 'P1017', + }); + + export type PrismaErrorCode = keyof typeof PrismaErrorCode; + + + interface PrismaErrorMeta { + target?: string; + model?: string; + relationName?: string; + details?: string; + } + + export type operationT = 'create' | 'read' | 'update' | 'delete'; + + export type PrismaErrorHandler = ( + operation: operationT, + meta?: PrismaErrorMeta, + ) => Error; + + export const ERROR_MAP: Record = { + P2000: (_operation, meta) => new BadRequestException( + `The provided value for ${meta?.target || 'a field'} is too long. Please use a shorter value.` + ), + + P2001: (operation, meta) => new NotFoundException( + `The ${meta?.model || 'record'} you are trying to ${operation} could not be found.` + ), + + P2002: (operation, meta) => { + const field = meta?.target || 'unique field'; + switch (operation) { + case 'create': + return new ConflictException( + `A record with the same ${field} already exists. Please use a different value.` + ); + case 'update': + return new ConflictException( + `The new value for ${field} conflicts with an existing record.` + ); + default: + return new ConflictException( + `Unique constraint violation on ${field}.` + ); + } + }, + + P2003: (operation) => new BadRequestException( + `Foreign key constraint failed. Unable to ${operation} the record because related data is invalid or missing.` + ), + + P2006: (_operation, meta) => new BadRequestException( + `The provided value for ${meta?.target || 'a field'} is invalid. Please correct it.` + ), + + P2007: (operation) => new InternalServerErrorException( + `Data validation error during ${operation}. Please ensure all inputs are valid and try again.` + ), + + P2008: (operation) => new InternalServerErrorException( + `Failed to query the database during ${operation}. Please try again later.` + ), + + P2009: (operation) => new InternalServerErrorException( + `Invalid data fetched during ${operation}. Check query structure.` + ), + + P2010: () => new InternalServerErrorException( + `Invalid raw query. Ensure your query is correct and try again.` + ), + + P2011: (_operation, meta) => new BadRequestException( + `The required field ${meta?.target || 'a field'} is missing. Please provide it to continue.` + ), + + P2012: (operation, meta) => new BadRequestException( + `Missing required relation ${meta?.relationName || ''}. Ensure all related data exists before ${operation}.` + ), + + P2014: (operation) => { + switch (operation) { + case 'create': + return new BadRequestException( + `Cannot create record because the referenced data does not exist. Ensure related data exists.` + ); + case 'delete': + return new BadRequestException( + `Unable to delete record because it is linked to other data. Update or delete dependent records first.` + ); + default: + return new BadRequestException(`Foreign key constraint error.`); + } + }, + + P2015: () => new InternalServerErrorException( + `A record with the required ID was expected but not found. Please retry.` + ), + + P2016: (operation) => new InternalServerErrorException( + `Query ${operation} failed because the record could not be fetched. Ensure the query is correct.` + ), + + P2017: (operation) => new InternalServerErrorException( + `Connected records were not found for ${operation}. Check related data.` + ), + + P2018: () => new InternalServerErrorException( + `The required connection could not be established. Please check relationships.` + ), + + P2019: (_operation, meta) => new InternalServerErrorException( + `Invalid input for ${meta?.details || 'a field'}. Please ensure data conforms to expectations.` + ), + + P2021: (_operation, meta) => new InternalServerErrorException( + `The ${meta?.model || 'model'} was not found in the database.` + ), + + P2025: (operation, meta) => new NotFoundException( + `The ${meta?.model || 'record'} you are trying to ${operation} does not exist. It may have been deleted.` + ), + + P2031: () => new InternalServerErrorException( + `Invalid Prisma Client initialization error. Please check configuration.` + ), + + P2033: (operation) => new InternalServerErrorException( + `Insufficient database write permissions for ${operation}.` + ), + + P2034: (operation) => new InternalServerErrorException( + `Database read-only transaction failed during ${operation}.` + ), + + P2037: (operation) => new InternalServerErrorException( + `Unsupported combinations of input types for ${operation}. Please correct the query or input.` + ), + + P1000: () => new InternalServerErrorException( + `Database authentication failed. Verify your credentials and try again.` + ), + + P1001: () => new InternalServerErrorException( + `The database server could not be reached. Please check its availability.` + ), + + P1002: () => new InternalServerErrorException( + `Connection to the database timed out. Verify network connectivity and server availability.` + ), + + P1015: (operation) => new InternalServerErrorException( + `Migration failed. Unable to complete ${operation}. Check migration history or database state.` + ), + + P1017: () => new InternalServerErrorException( + `Database connection failed. Ensure the database is online and credentials are correct.` + ), + P2023: function (operation: operationT, meta?: PrismaErrorMeta): Error { + throw new Error('Function not implemented.'); + } + }; + \ No newline at end of file diff --git a/apps/server/src/models/base/row-cache.service.ts b/apps/server/src/models/base/row-cache.service.ts new file mode 100755 index 0000000..6150407 --- /dev/null +++ b/apps/server/src/models/base/row-cache.service.ts @@ -0,0 +1,183 @@ +import { UserProfile, RowModelRequest, RowRequestSchema } from "@nice/common"; +import { RowModelService } from "./row-model.service"; +import { isFieldCondition, LogicalCondition, SQLBuilder } from "./sql-builder"; +import EventBus from "@server/utils/event-bus"; +import supejson from "superjson-cjs" +import { deleteByPattern } from "@server/utils/redis/utils"; +import { redis } from "@server/utils/redis/redis.service"; +import { z } from "zod"; +export class RowCacheService extends RowModelService { + constructor(tableName: string, private enableCache: boolean = true) { + super(tableName) + if (this.enableCache) { + EventBus.on("dataChanged", async ({ type, data }) => { + if (type === tableName) { + const dataArray = Array.isArray(data) ? data : [data]; + for (const item of dataArray) { + try { + if (item.id) { + this.invalidateRowCacheById(item.id) + } + if (item.parentId) { + this.invalidateRowCacheById(item.parentId) + } + } catch (err) { + console.error(`Error deleting cache for type ${tableName}:`, err); + } + } + } + }); + } + } + protected getRowCacheKey(id: string) { + return `row-data-${id}`; + } + private async invalidateRowCacheById(id: string) { + if (!this.enableCache) return; + const pattern = this.getRowCacheKey(id); + await deleteByPattern(pattern); + } + createJoinSql(request?: RowModelRequest): string[] { + return [] + } + protected async getRowRelation(args: { data: any, staff?: UserProfile }) { + return args.data; + } + protected async setResPermissions( + data: any, + staff?: UserProfile, + ) { + return data + } + protected async getRowDto( + data: any, + staff?: UserProfile, + ): Promise { + // 如果没有id,直接返回原数据 + if (!data?.id) return data; + // 如果未启用缓存,直接处理并返回数据 + if (!this.enableCache) { + return this.processDataWithPermissions(data, staff); + } + const key = this.getRowCacheKey(data.id); + try { + // 尝试从缓存获取数据 + const cachedData = await this.getCachedData(key, staff); + // 如果缓存命中,直接返回 + if (cachedData) return cachedData; + // 处理数据并缓存 + const processedData = await this.processDataWithPermissions(data, staff); + await redis.set(key, supejson.stringify(processedData)); + return processedData; + } catch (err) { + this.logger.error('Error in getRowDto:', err); + throw err; + } + } + + private async getCachedData( + key: string, + staff?: UserProfile + ): Promise { + const cachedDataStr = await redis.get(key); + if (!cachedDataStr) return null; + const cachedData = supejson.parse(cachedDataStr) as any; + if (!cachedData?.id) return null; + return staff + ? this.setResPermissions(cachedData, staff) + : cachedData; + } + + private async processDataWithPermissions( + data: any, + staff?: UserProfile + ): Promise { + // 处理权限 + const permData = staff + ? await this.setResPermissions(data, staff) + : data; + // 获取关联数据 + return this.getRowRelation({ data: permData, staff }); + } + + protected createGetRowsFilters( + request: z.infer, + staff?: UserProfile, + ) { + const condition = super.createGetRowsFilters(request); + if (isFieldCondition(condition)) return {}; + const baseCondition: LogicalCondition[] = [ + { + field: `${this.tableName}.deleted_at`, + op: 'blank', + type: 'date', + }, + ]; + condition.AND = [...baseCondition, ...condition.AND]; + return condition; + } + createUnGroupingRowSelect(request?: RowModelRequest): string[] { + return [ + `${this.tableName}.id AS id`, + SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`), + ]; + } + protected createGroupingRowSelect( + request: RowModelRequest, + wrapperSql: boolean, + ): string[] { + const colsToSelect = super.createGroupingRowSelect(request, wrapperSql); + return colsToSelect.concat([ + SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`), + ]); + } + protected async getRowsSqlWrapper( + sql: string, + request?: RowModelRequest, + staff?: UserProfile, + ): Promise { + const groupingSql = SQLBuilder.join([ + SQLBuilder.select([ + ...this.createGroupingRowSelect(request, true), + `${this.tableName}.id AS id`, + ]), + SQLBuilder.from(this.tableName), + SQLBuilder.join(this.createJoinSql(request)), + SQLBuilder.where(this.createGetRowsFilters(request, staff)), + ]); + const { rowGroupCols, valueCols, groupKeys } = request; + if (this.isDoingGroup(request)) { + const rowGroupCol = rowGroupCols[groupKeys.length]; + const groupByField = rowGroupCol?.field?.replace('.', '_'); + return SQLBuilder.join([ + SQLBuilder.select([ + groupByField, + ...super.createAggSqlForWrapper(request), + 'COUNT(id) AS child_count', + ]), + SQLBuilder.from(`(${groupingSql})`), + SQLBuilder.where({ + field: 'row_num', + value: '1', + op: 'equals', + }), + SQLBuilder.groupBy([groupByField]), + SQLBuilder.orderBy( + this.getOrderByColumns(request).map((item) => item.replace('.', '_')), + ), + this.getLimitSql(request), + ]); + } else + return SQLBuilder.join([ + SQLBuilder.select(['*']), + SQLBuilder.from(`(${sql})`), + SQLBuilder.where({ + field: 'row_num', + value: '1', + op: 'equals', + }), + this.getLimitSql(request), + ]); + // return super.getRowsSqlWrapper(sql, request) + } +} \ No newline at end of file diff --git a/apps/server/src/models/base/row-model.service.ts b/apps/server/src/models/base/row-model.service.ts new file mode 100755 index 0000000..d43769f --- /dev/null +++ b/apps/server/src/models/base/row-model.service.ts @@ -0,0 +1,307 @@ +import { Logger } from '@nestjs/common'; +import { UserProfile, db, RowModelRequest } from '@nice/common'; +import { LogicalCondition, OperatorType, SQLBuilder } from './sql-builder'; +export interface GetRowOptions { + id?: string; + ids?: string[]; + extraCondition?: LogicalCondition; + staff?: UserProfile; +} +export abstract class RowModelService { + private keywords: Set = new Set([ + 'SELECT', + 'FROM', + 'WHERE', + 'ORDER', + 'BY', + 'GROUP', + 'JOIN', + 'AND', + 'OR', + // 添加更多需要引号的关键词 + ]); + protected logger = new Logger(this.tableName); + protected constructor(protected tableName: string) { } + protected async getRowDto(row: any, staff?: UserProfile): Promise { + return row; + } + protected async getRowsSqlWrapper( + sql: string, + request?: RowModelRequest, + staff?: UserProfile, + ) { + if (request) return SQLBuilder.join([sql, this.getLimitSql(request)]); + return sql; + } + protected getLimitSql(request: RowModelRequest) { + return SQLBuilder.limit( + request.endRow - request.startRow, + request.startRow, + ); + } + abstract createJoinSql(request?: RowModelRequest): string[]; + async getRows(request: RowModelRequest, staff?: UserProfile) { + try { + let SQL = SQLBuilder.join([ + SQLBuilder.select(this.getRowSelectCols(request)), + SQLBuilder.from(this.tableName), + SQLBuilder.join(this.createJoinSql(request)), + SQLBuilder.where(this.createGetRowsFilters(request, staff)), + SQLBuilder.groupBy(this.getGroupByColumns(request)), + SQLBuilder.orderBy(this.getOrderByColumns(request)), + ]); + SQL = await this.getRowsSqlWrapper(SQL, request, staff); + + // this.logger.debug('getrows', SQL); + + const results: any[] = (await db?.$queryRawUnsafe(SQL)) || []; + + const rowDataDto = await Promise.all( + results.map((row) => this.getRowDto(row, staff)), + ); + return { + rowCount: this.getRowCount(request, rowDataDto) || 0, + rowData: rowDataDto, + }; + } catch (error: any) { + this.logger.error('Error executing getRows:', error); + } + } + getRowCount(request: RowModelRequest, results: any[]) { + if (results === null || results === undefined || results.length === 0) { + return null; + } + const currentLastRow = request.startRow + results.length; + return currentLastRow <= request.endRow ? currentLastRow : -1; + } + + async getRowById(options: GetRowOptions): Promise { + const { + id, + extraCondition = { + field: `${this.tableName}.deleted_at`, + op: 'blank', + type: 'date', + }, + staff, + } = options; + return this.getSingleRow( + { AND: [this.createGetByIdFilter(id!), extraCondition] }, + staff, + ); + } + + async getRowByIds(options: GetRowOptions): Promise { + const { + ids, + extraCondition = { + field: `${this.tableName}.deleted_at`, + op: 'blank', + type: 'date', + }, + staff, + } = options; + return this.getMultipleRows( + { AND: [this.createGetByIdsFilter(ids!), extraCondition] }, + staff, + ); + } + + protected createGetRowsFilters( + request: RowModelRequest, + staff?: UserProfile, + ): LogicalCondition { + let groupConditions: LogicalCondition[] = []; + if (this.isDoingTreeGroup(request)) { + groupConditions = [ + { + field: 'parent_id', + op: 'equals' as OperatorType, + value: request.groupKeys[request.groupKeys.length - 1], + }, + ]; + } else { + groupConditions = request?.groupKeys?.map((key, index) => ({ + field: request.rowGroupCols[index].field, + op: 'equals' as OperatorType, + value: key, + })); + } + + const condition: LogicalCondition = { + AND: [ + ...groupConditions, + ...this.buildFilterConditions(request.filterModel), + ], + }; + + return condition; + } + private buildFilterConditions(filterModel: any): LogicalCondition[] { + return filterModel + ? Object.entries(filterModel)?.map(([key, item]) => + SQLBuilder.createFilterSql( + key === 'ag-Grid-AutoColumn' ? 'name' : key, + item, + ), + ) + : []; + } + + getRowSelectCols(request: RowModelRequest): string[] { + return this.isDoingGroup(request) + ? this.createGroupingRowSelect(request) + : this.createUnGroupingRowSelect(request); + } + protected createUnGroupingRowSelect(request?: RowModelRequest): string[] { + return ['*']; + } + protected createAggSqlForWrapper(request: RowModelRequest) { + const { rowGroupCols, valueCols, groupKeys } = request; + return valueCols.map( + (valueCol) => + `${valueCol.aggFunc}(${valueCol.field.replace('.', '_')}) AS ${valueCol.field.split('.').join('_')}`, + ); + } + protected createGroupingRowSelect( + request: RowModelRequest, + wrapperSql: boolean = false, + ): string[] { + const { rowGroupCols, valueCols, groupKeys } = request; + const colsToSelect: string[] = []; + + const rowGroupCol = rowGroupCols[groupKeys!.length]; + if (rowGroupCol) { + colsToSelect.push( + `${rowGroupCol.field} AS ${rowGroupCol.field.replace('.', '_')}`, + ); + } + colsToSelect.push( + ...valueCols.map( + (valueCol) => + `${wrapperSql ? '' : valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`, + ), + ); + + return colsToSelect; + } + + getGroupByColumns(request: RowModelRequest): string[] { + return this.isDoingGroup(request) + ? [request.rowGroupCols[request.groupKeys!.length]?.field] + : []; + } + + getOrderByColumns(request: RowModelRequest): string[] { + const { sortModel, rowGroupCols, groupKeys } = request; + const grouping = this.isDoingGroup(request); + const sortParts: string[] = []; + + if (sortModel) { + const groupColIds = rowGroupCols + .map((groupCol) => groupCol.id) + .slice(0, groupKeys.length + 1); + sortModel.forEach((item) => { + if ( + !grouping || + (groupColIds.indexOf(item.colId) >= 0 && + rowGroupCols[groupKeys.length].field === item.colId) + ) { + const colId = this.keywords.has(item.colId.toUpperCase()) + ? `"${item.colId}"` + : item.colId; + sortParts.push(`${colId} ${item.sort}`); + } + }); + } + + return sortParts; + } + isDoingGroup(requset: RowModelRequest): boolean { + return requset.rowGroupCols.length > requset.groupKeys.length; + } + isDoingTreeGroup(requset: RowModelRequest): boolean { + return requset.rowGroupCols.length === 0 && requset.groupKeys.length > 0; + } + private async getSingleRow( + condition: LogicalCondition, + staff?: UserProfile, + ): Promise { + const results = await this.getRowsWithFilters(condition, staff); + return results[0]; + } + private async getMultipleRows( + condition: LogicalCondition, + staff?: UserProfile, + ): Promise { + return this.getRowsWithFilters(condition, staff); + } + + private async getRowsWithFilters( + condition: LogicalCondition, + staff?: UserProfile, + ): Promise { + try { + const SQL = SQLBuilder.join([ + SQLBuilder.select(this.createUnGroupingRowSelect()), + SQLBuilder.from(this.tableName), + SQLBuilder.join(this.createJoinSql()), + SQLBuilder.where(condition), + ]); + + // this.logger.debug(SQL) + const results: any[] = await db.$queryRawUnsafe(SQL); + + const rowDataDto = await Promise.all( + results.map((item) => this.getRowDto(item, staff)), + ); + + // rowDataDto = getUniqueItems(rowDataDto, "id") + return rowDataDto; + } catch (error) { + this.logger.error('Error executing query:', error); + throw error; + } + } + + async getAggValues(request: RowModelRequest) { + try { + const SQL = SQLBuilder.join([ + SQLBuilder.select(this.buildAggSelect(request.valueCols)), + SQLBuilder.from(this.tableName), + SQLBuilder.join(this.createJoinSql(request)), + SQLBuilder.where(this.createGetRowsFilters(request)), + SQLBuilder.groupBy(this.buildAggGroupBy()), + ]); + const result: any[] = await db.$queryRawUnsafe(SQL); + return result[0]; + } catch (error) { + this.logger.error('Error executing query:', error); + throw error; + } + } + protected buildAggGroupBy(): string[] { + return []; + } + protected buildAggSelect(valueCols: any[]): string[] { + return valueCols.map( + (valueCol) => + `${valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`, + ); + } + + private createGetByIdFilter(id: string): LogicalCondition { + return { + field: `${this.tableName}.id`, + value: id, + op: 'equals', + }; + } + private createGetByIdsFilter(ids: string[]): LogicalCondition { + return { + field: `${this.tableName}.id`, + value: ids, + op: 'in', + }; + } +} diff --git a/apps/server/src/models/base/sql-builder.ts b/apps/server/src/models/base/sql-builder.ts new file mode 100755 index 0000000..67cd331 --- /dev/null +++ b/apps/server/src/models/base/sql-builder.ts @@ -0,0 +1,138 @@ +export interface FieldCondition { + field: string; + op: OperatorType + type?: "text" | "number" | "date"; + value?: any; + valueTo?: any; +}; +export type OperatorType = 'equals' | 'notEqual' | 'contains' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank' | 'greaterThan' | 'lessThanOrEqual' | 'inRange' | 'lessThan' | 'greaterThan' | 'in'; +export type LogicalCondition = FieldCondition | { + AND?: LogicalCondition[]; + OR?: LogicalCondition[]; +}; + +export function isFieldCondition(condition: LogicalCondition): condition is FieldCondition { + return (condition as FieldCondition).field !== undefined; +} +function buildCondition(condition: FieldCondition): string { + const { field, op, value, type = "text", valueTo } = condition; + switch (op) { + case 'equals': + return `${field} = '${value}'`; + case 'notEqual': + return `${field} != '${value}'`; + case 'contains': + return `${field} LIKE '%${value}%'`; + case 'startsWith': + return `${field} LIKE '${value}%'`; + case 'endsWith': + return `${field} LIKE '%${value}'`; + case 'blank': + if (type !== "date") + return `(${field} IS NULL OR ${field} = '')`; + else + return `${field} IS NULL`; + case 'notBlank': + if (type !== 'date') + return `${field} IS NOT NULL AND ${field} != ''`; + else + return `${field} IS NOT NULL`; + case 'greaterThan': + return `${field} > '${value}'`; + case 'lessThanOrEqual': + return `${field} <= '${value}'`; + case 'lessThan': + return `${field} < '${value}'`; + case 'greaterThan': + return `${field} > '${value}'`; + case 'inRange': + return `${field} >= '${value}' AND ${field} <= '${valueTo}'`; + case 'in': + if (!value || (Array.isArray(value) && value.length === 0)) { + // Return a condition that is always false if value is empty or an empty array + return '1 = 0'; + } + return `${field} IN (${(value as any[]).map(val => `'${val}'`).join(', ')})`; + default: + return 'true'; // Default return for unmatched conditions + } +} +function buildLogicalCondition(logicalCondition: LogicalCondition): string { + if (isFieldCondition(logicalCondition)) { + return buildCondition(logicalCondition); + } + const parts: string[] = []; + if (logicalCondition.AND && logicalCondition.AND.length > 0) { + const andParts = logicalCondition.AND + .map(c => buildLogicalCondition(c)) + .filter(part => part !== ''); // Filter out empty conditions + if (andParts.length > 0) { + parts.push(`(${andParts.join(' AND ')})`); + } + } + // Process OR conditions + if (logicalCondition.OR && logicalCondition.OR.length > 0) { + const orParts = logicalCondition.OR + .map(c => buildLogicalCondition(c)) + .filter(part => part !== ''); // Filter out empty conditions + if (orParts.length > 0) { + parts.push(`(${orParts.join(' OR ')})`); + } + } + // Join AND and OR parts with an 'AND' if both are present + return parts.length > 1 ? parts.join(' AND ') : parts[0] || ''; +} + +export class SQLBuilder { + static select(fields: string[], distinctField?: string): string { + const distinctClause = distinctField ? `DISTINCT ON (${distinctField}) ` : ""; + return `SELECT ${distinctClause}${fields.join(", ")}`; + } + static rowNumber(orderBy: string, partitionBy: string | null = null, alias: string = 'row_num'): string { + if (!orderBy) { + throw new Error("orderBy 参数不能为空"); + } + + let partitionClause = ''; + if (partitionBy) { + partitionClause = `PARTITION BY ${partitionBy} `; + } + + return `ROW_NUMBER() OVER (${partitionClause}ORDER BY ${orderBy}) AS ${alias}`; + } + static from(tableName: string): string { + return `FROM ${tableName}`; + } + + static where(conditions: LogicalCondition): string { + const whereClause = buildLogicalCondition(conditions); + return whereClause ? `WHERE ${whereClause}` : ""; + } + + static groupBy(columns: string[]): string { + return columns.length ? `GROUP BY ${columns.join(", ")}` : ""; + } + + static orderBy(columns: string[]): string { + return columns.length ? `ORDER BY ${columns.join(", ")}` : ""; + } + + static limit(pageSize: number, offset: number = 0): string { + return `LIMIT ${pageSize + 1} OFFSET ${offset}`; + } + + static join(clauses: string[]): string { + return clauses.filter(Boolean).join(' '); + } + static createFilterSql(key: string, item: any): LogicalCondition { + const conditionFuncs: Record LogicalCondition> = { + text: (item) => ({ value: item.filter, op: item.type, field: key }), + number: (item) => ({ value: item.filter, op: item.type, field: key }), + date: (item) => ({ value: item.dateFrom, valueTo: item.dateTo, op: item.type, field: key }), + set: (item) => ({ value: item.values, op: "in", field: key }) + } + return conditionFuncs[item.filterType](item) + + } +} + diff --git a/apps/server/src/models/base/test.sql b/apps/server/src/models/base/test.sql new file mode 100755 index 0000000..df6923a --- /dev/null +++ b/apps/server/src/models/base/test.sql @@ -0,0 +1,30 @@ +SELECT * +FROM ( + SELECT staff.id AS id, + ROW_NUMBER() OVER ( + PARTITION BY staff.id + ORDER BY staff.id + ) AS row_num, + staff.id AS id, + staff.username AS username, + staff.showname AS showname, + staff.avatar AS avatar, + staff.officer_id AS officer_id, + staff.phone_number AS phone_number, + staff.order AS order, + staff.enabled AS enabled, + dept.name AS dept_name, + domain.name AS domain_name + FROM staff + LEFT JOIN department dept ON staff.dept_id = dept.id + LEFT JOIN department domain ON staff.domain_id = domain.id + WHERE ( + staff.deleted_at IS NULL + AND enabled = 'Thu Dec 26 2024 11:55:47 GMT+0800 (中国标准时间)' + AND staff.domain_id = '784c7583-c7f3-4179-873d-f8195ccf2acf' + AND staff.deleted_at IS NULL + ) + ORDER BY "order" asc + ) +WHERE row_num = '1' +LIMIT 31 OFFSET 0 \ No newline at end of file diff --git a/apps/server/src/models/department/department.controller.ts b/apps/server/src/models/department/department.controller.ts new file mode 100755 index 0000000..7c4f063 --- /dev/null +++ b/apps/server/src/models/department/department.controller.ts @@ -0,0 +1,87 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; + +import { DepartmentService } from './department.service'; +import { AuthGuard } from '@server/auth/auth.guard'; +import { db } from '@nice/common'; + +@Controller('dept') +export class DepartmentController { + constructor(private readonly deptService: DepartmentService) { } + @UseGuards(AuthGuard) + @Get('get-detail') + async getDepartmentDetails(@Query('dept-id') deptId: string) { + try { + const result = await this.deptService.findById(deptId); + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } + @UseGuards(AuthGuard) + @Get('get-all-child-dept-ids') + async getAllChildDeptIds(@Query('dept-id') deptId: string) { + try { + const result = await this.deptService.getDescendantIds([deptId]); + + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } + @UseGuards(AuthGuard) + @Get('get-all-parent-dept-ids') + async getAllParentDeptIds(@Query('dept-id') deptId: string) { + try { + const result = await this.deptService.getAncestorIds([deptId]); + + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } + @UseGuards(AuthGuard) + @Get('find-by-name-in-dom') + async findInDomain( + @Query('domain-id') domainId?: string, + @Query('name') name?: string, + ) { + try { + const result = await this.deptService.findInDomain(domainId, name); + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } +} diff --git a/apps/server/src/models/department/department.module.ts b/apps/server/src/models/department/department.module.ts new file mode 100755 index 0000000..59e0f8e --- /dev/null +++ b/apps/server/src/models/department/department.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DepartmentService } from './department.service'; +import { DepartmentRouter } from './department.router'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentController } from './department.controller'; +import { DepartmentRowService } from './department.row.service'; + +@Module({ + providers: [DepartmentService, DepartmentRouter, DepartmentRowService, TrpcService], + exports: [DepartmentService, DepartmentRouter], + controllers: [DepartmentController], +}) +export class DepartmentModule { } diff --git a/apps/server/src/models/department/department.router.ts b/apps/server/src/models/department/department.router.ts new file mode 100755 index 0000000..2cd168c --- /dev/null +++ b/apps/server/src/models/department/department.router.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentService } from './department.service'; // assuming it's in the same directory +import { DepartmentMethodSchema, Prisma, UpdateOrderSchema } from '@nice/common'; +import { z, ZodType } from 'zod'; +import { DepartmentRowService } from './department.row.service'; + +const DepartmentCreateArgsSchema: ZodType = z.any() +const DepartmentUpdateArgsSchema: ZodType = z.any() +const DepartmentFindFirstArgsSchema: ZodType = z.any() +const DepartmentFindManyArgsSchema: ZodType = z.any() +@Injectable() +export class DepartmentRouter { + constructor( + private readonly trpc: TrpcService, + private readonly departmentService: DepartmentService, // 注入 DepartmentService + private readonly departmentRowService: DepartmentRowService + ) { } + router = this.trpc.router({ + // 创建部门 + create: this.trpc.protectProcedure + .input(DepartmentCreateArgsSchema) // 根据 schema 期望输入 + .mutation(async ({ input }) => { + return this.departmentService.create(input); + }), + // 更新部门 + update: this.trpc.protectProcedure + .input(DepartmentUpdateArgsSchema) // 根据 schema 期望输入 + .mutation(async ({ input }) => { + return this.departmentService.update(input); + }), + // 根据 ID 列表软删除部门 + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // 根据 schema 期望输入 + .mutation(async ({ input }) => { + return this.departmentService.softDeleteByIds(input.ids); + }), + // 更新部门顺序 + updateOrder: this.trpc.protectProcedure.input(UpdateOrderSchema).mutation(async ({ input }) => { + return this.departmentService.updateOrder(input) + }), + // 查询多个部门 + findMany: this.trpc.procedure + .input(DepartmentFindManyArgsSchema) // 假设 StaffMethodSchema.findMany 是根据关键字查找员工的 Zod schema + .query(async ({ input }) => { + return await this.departmentService.findMany(input); + }), + // 查询第一个部门 + findFirst: this.trpc.procedure + .input(DepartmentFindFirstArgsSchema) // 假设 StaffMethodSchema.findMany 是根据关键字查找员工的 Zod schema + .query(async ({ input }) => { + return await this.departmentService.findFirst(input); + }), + // 获取子部门的简单树结构 + getChildSimpleTree: this.trpc.procedure + .input(DepartmentMethodSchema.getSimpleTree).query(async ({ input }) => { + return await this.departmentService.getChildSimpleTree(input) + }), + // 获取父部门的简单树结构 + getParentSimpleTree: this.trpc.procedure + .input(DepartmentMethodSchema.getSimpleTree).query(async ({ input }) => { + return await this.departmentService.getParentSimpleTree(input) + }), + // 获取部门行数据 + getRows: this.trpc.protectProcedure + .input(DepartmentMethodSchema.getRows) + .query(async ({ input, ctx }) => { + return await this.departmentRowService.getRows(input, ctx.staff); + }), + }); +} diff --git a/apps/server/src/models/department/department.row.service.ts b/apps/server/src/models/department/department.row.service.ts new file mode 100755 index 0000000..bae18d8 --- /dev/null +++ b/apps/server/src/models/department/department.row.service.ts @@ -0,0 +1,91 @@ +/** + * 部门行服务类,继承自 RowCacheService,用于处理部门相关的数据行操作。 + * 该类提供了生成 SQL 查询字段、构建查询过滤器等功能。 + */ +import { Injectable } from '@nestjs/common'; +import { + db, + DepartmentMethodSchema, + ObjectType, + UserProfile, +} from '@nice/common'; +import { date, z } from 'zod'; +import { RowCacheService } from '../base/row-cache.service'; +import { isFieldCondition } from '../base/sql-builder'; + +@Injectable() +export class DepartmentRowService extends RowCacheService { + /** + * 构造函数,初始化 DepartmentRowService。 + * 调用父类构造函数,传入部门对象类型并禁用缓存。 + */ + constructor() { + super(ObjectType.DEPARTMENT, false); + } + + /** + * 生成未分组行的 SQL 查询字段列表,包括部门特定的字段。 + * @param requset - 请求对象,符合 DepartmentMethodSchema.getRows 的 schema。 + * @returns 返回包含 SQL 查询字段的数组。 + */ + createUnGroupingRowSelect( + requset: z.infer, + ): string[] { + // 调用父类方法生成基础查询字段,并拼接部门特定的字段 + const result = super.createUnGroupingRowSelect(requset).concat([ + `${this.tableName}.name AS name`, // 部门名称 + `${this.tableName}.is_domain AS is_domain`, // 是否为域 + `${this.tableName}.order AS order`, // 排序 + `${this.tableName}.has_children AS has_children`, // 是否有子部门 + `${this.tableName}.parent_id AS parent_id`, // 父部门 ID + ]); + return result; + } + + /** + * 根据请求和用户信息构建 getRows 查询的过滤器。 + * @param request - 请求对象,符合 DepartmentMethodSchema.getRows 的 schema。 + * @param staff - 发起请求的用户信息。 + * @returns 返回用于过滤行的条件对象。 + */ + protected createGetRowsFilters( + request: z.infer, + staff: UserProfile, + ) { + // 调用父类方法生成基础过滤条件 + const condition = super.createGetRowsFilters(request); + const { parentId, includeDeleted = false } = request; + + // 如果条件已经是字段条件,则跳过后续处理 + if (isFieldCondition(condition)) { + return; + } + + // 如果请求中没有分组键,则添加父部门 ID 过滤条件 + if (request.groupKeys.length === 0) { + if (parentId) { + condition.AND.push({ + field: `${this.tableName}.parent_id`, + value: parentId, + op: 'equals', // 等于操作符 + }); + } else if (parentId === null) { + condition.AND.push({ + field: `${this.tableName}.parent_id`, + op: 'blank', // 空白操作符 + }); + } + } + + // 如果 includeDeleted 为 false,则排除已删除的行 + if (!includeDeleted) { + condition.AND.push({ + field: `${this.tableName}.deleted_at`, + type: 'date', + op: 'blank', // 空白操作符 + }); + } + + return condition; + } +} diff --git a/apps/server/src/models/department/department.service.ts b/apps/server/src/models/department/department.service.ts new file mode 100755 index 0000000..3ec0e6e --- /dev/null +++ b/apps/server/src/models/department/department.service.ts @@ -0,0 +1,338 @@ +import { Injectable } from '@nestjs/common'; +import { + db, + DepartmentMethodSchema, + DeptAncestry, + getUniqueItems, + ObjectType, + Prisma, +} from '@nice/common'; +import { BaseTreeService } from '../base/base.tree.service'; +import { z } from 'zod'; +import { mapToDeptSimpleTree, getStaffsByDeptIds } from './utils'; +import EventBus, { CrudOperation } from '@server/utils/event-bus'; +@Injectable() +export class DepartmentService extends BaseTreeService { + constructor() { + super(db, ObjectType.DEPARTMENT, 'deptAncestry', true); + } + async getDescendantIdsInDomain( + ancestorId: string, + includeAncestor = true, + ): Promise { + // 如果没有提供部门ID,返回空数组 + if (!ancestorId) return []; + // 获取祖先部门信息 + const ancestorDepartment = await db.department.findUnique({ + where: { id: ancestorId }, + }); + + // 如果未找到部门,返回空数组 + if (!ancestorDepartment) return []; + + // 查询同域下以指定部门为祖先的部门血缘关系 + const departmentAncestries = await db.deptAncestry.findMany({ + where: { + ancestorId: ancestorId, + descendant: { + domainId: ancestorDepartment.domainId, + }, + }, + }); + // 提取子部门ID列表 + const descendantDepartmentIds = departmentAncestries.map( + (ancestry) => ancestry.descendantId, + ); + // 根据参数决定是否包含祖先部门ID + if (includeAncestor && ancestorId) { + descendantDepartmentIds.push(ancestorId); + } + return descendantDepartmentIds; + } + async getDescendantDomainIds( + ancestorDomainId: string, + includeAncestorDomain = true, + ): Promise { + if (!ancestorDomainId) return []; + // 查询所有以指定域ID为祖先的域的血缘关系 + const domainAncestries = await db.deptAncestry.findMany({ + where: { + ancestorId: ancestorDomainId, + descendant: { + isDomain: true, + }, + }, + }); + + // 提取子域的ID列表 + const descendantDomainIds = domainAncestries.map( + (ancestry) => ancestry.descendantId, + ); + // 根据参数决定是否包含祖先域ID + if (includeAncestorDomain && ancestorDomainId) { + descendantDomainIds.push(ancestorDomainId); + } + + return descendantDomainIds; + } + /** + * 获取指定DOM下的对应name的单位 + * @param domainId + * @param name + * @returns + */ + async findInDomain(domainId: string, name: string) { + const subDepts = await db.deptAncestry.findMany({ + where: { + ancestorId: domainId, + }, + include: { + descendant: true, + }, + }); + const dept = subDepts.find((item) => item.descendant.name === name); + + return dept.descendant; + } + + private async setDomainId(parentId: string) { + const parent = await this.findUnique({ where: { id: parentId } }); + return parent.isDomain ? parentId : parent.domainId; + } + + async create(args: Prisma.DepartmentCreateArgs) { + if (args.data.parentId) { + args.data.domainId = await this.setDomainId(args.data.parentId); + } + const result = await super.create(args); + EventBus.emit('dataChanged', { + type: this.objectType, + operation: CrudOperation.CREATED, + data: result, + }); + return result; + } + + async update(args: Prisma.DepartmentUpdateArgs) { + if (args.data.parentId) { + args.data.domainId = await this.setDomainId(args.data.parentId as string); + } + const result = await super.update(args); + EventBus.emit('dataChanged', { + type: this.objectType, + operation: CrudOperation.UPDATED, + data: result, + }); + return result; + } + + /** + * 删除现有单位并清理DeptAncestry关系。 + * @param data - 用于删除现有单位的数据。 + * @returns 删除的单位对象。 + */ + async softDeleteByIds(ids: string[]) { + const descendantIds = await this.getDescendantIds(ids, true); + const result = await super.softDeleteByIds(descendantIds); + EventBus.emit('dataChanged', { + type: this.objectType, + operation: CrudOperation.DELETED, + data: result, + }); + return result; + } + + /** + * 获取指定部门及其所有子部门的员工。 + * @param deptIds - 要获取员工ID的部门ID数组。 + * @returns 包含所有员工ID的数组。 + */ + async getStaffsInDepts(deptIds: string[]) { + const allDeptIds = await this.getDescendantIds(deptIds, true); + return await getStaffsByDeptIds(Array.from(allDeptIds)); + } + async getStaffIdsInDepts(deptIds: string[]) { + const result = await this.getStaffsInDepts(deptIds); + return result.map((s) => s.id); + } + /** + * 根据部门名称列表和域ID获取多个部门的ID。 + * + * @param {string[]} names - 部门名称列表 + * @param {string} domainId - 域ID + * @returns {Promise>} - 返回一个对象,键为部门名称,值为部门ID或null + */ + async getDeptIdsByNames( + names: string[], + domainId: string, + ): Promise> { + // 使用 Prisma 的 findMany 方法批量查询部门信息,优化性能 + const depts = await db.department.findMany({ + where: { + // 查询条件:部门名称在给定的名称列表中 + name: { in: names }, + // 查询条件:部门在指定的域下(通过ancestors关系查询) + ancestors: { + some: { + ancestorId: domainId, + }, + }, + }, + // 选择查询的字段:只查询部门的id和name字段 + select: { + id: true, + name: true, + }, + }); + + // 创建一个Map对象,将部门名称映射到部门ID + const deptMap = new Map(depts.map((dept) => [dept.name, dept.id])); + + // 初始化结果对象,用于存储最终的结果 + const result: Record = {}; + + // 遍历传入的部门名称列表 + for (const name of names) { + // 从Map中获取部门ID,如果不存在则返回null + result[name] = deptMap.get(name) || null; + } + + // 返回最终的结果对象 + return result; + } + + async getChildSimpleTree( + data: z.infer, + ) { + const { domain, deptIds, rootId } = data; + // 提取非空 deptIds + const validDeptIds = deptIds?.filter((id) => id !== null) ?? []; + const hasNullDeptId = deptIds?.includes(null) ?? false; + const [childrenData, selfData] = await Promise.all([ + db.deptAncestry.findMany({ + where: { + ...(deptIds && { + OR: [ + ...(validDeptIds.length + ? [{ ancestorId: { in: validDeptIds } }] + : []), + ...(hasNullDeptId ? [{ ancestorId: null }] : []), + ], + }), + ancestorId: rootId, + relDepth: 1, + descendant: { isDomain: domain }, + }, + include: { + descendant: { include: { children: true, deptStaffs: true } }, + }, + orderBy: { descendant: { order: 'asc' } }, + }), + deptIds + ? db.department.findMany({ + where: { + ...(deptIds && { + OR: [ + ...(validDeptIds.length + ? [{ id: { in: validDeptIds } }] + : []), + ], + }), + isDomain: domain, + }, + include: { children: true }, + orderBy: { order: 'asc' }, + }) + : [], + ]); + const children = childrenData + .map(({ descendant }) => descendant) + .filter(Boolean) + .map(mapToDeptSimpleTree); + const selfItems = selfData.map(mapToDeptSimpleTree); + return getUniqueItems([...children, ...selfItems], 'id'); + } + + /** + * 获取父级部门简单树结构的异步方法 + * + * @param data - 包含部门ID、域和根ID的输入参数 + * @returns 返回唯一的部门简单树结构数组 + * + * 方法整体设计思路: + * 1. 并行查询父级部门ancestry和自身部门数据 + * 2. 查询根节点的直接子节点 + * 3. 通过自定义过滤函数筛选超出根节点层级的祖先节点 + * 4. 将查询结果映射为简单树结构 + * 5. 去重并返回最终结果 + */ + async getParentSimpleTree( + data: z.infer, + ) { + // 解构输入参数 + const { deptIds, domain, rootId } = data; + // 并行查询父级部门ancestry和自身部门数据 + // 使用Promise.all提高查询效率,减少等待时间 + const [parentData, selfData] = await Promise.all([ + // 查询指定部门的所有祖先节点,包含子节点和父节点信息 + db.deptAncestry.findMany({ + where: { + descendantId: { in: deptIds }, // 查询条件:descendant在给定的部门ID列表中 + ancestor: { isDomain: domain }, // 限定域 + }, + include: { + ancestor: { + include: { + children: true, // 包含子节点信息 + parent: true, // 包含父节点信息 + }, + }, + }, + orderBy: { ancestor: { order: 'asc' } }, // 按祖先节点顺序升序排序 + }), + + // 查询自身部门数据 + db.department.findMany({ + where: { id: { in: deptIds }, isDomain: domain }, + include: { children: true }, // 包含子节点信息 + orderBy: { order: 'asc' }, // 按顺序升序排序 + }), + ]); + + // 查询根节点的直接子节点 + const rootChildren = await db.deptAncestry.findMany({ + where: { + ancestorId: rootId, // 祖先ID为根ID + descendant: { isDomain: domain }, // 限定域 + }, + }); + + /** + * 判断祖先节点是否超出根节点层级的函数 + * + * @param ancestor - 祖先节点 + * @returns 是否超出根节点层级 + */ + const isDirectDescendantOfRoot = (ancestor: DeptAncestry): boolean => { + return ( + rootChildren.findIndex( + (child) => child.descendantId === ancestor.ancestorId, + ) !== -1 + ); + }; + + // 处理父级节点:过滤并映射为简单树结构 + const parents = parentData + .map(({ ancestor }) => ancestor) // 提取祖先节点 + .filter( + (ancestor) => ancestor && isDirectDescendantOfRoot(ancestor as any), + ) // 过滤有效且超出根节点层级的节点 + .map(mapToDeptSimpleTree); // 映射为简单树结构 + + // 处理自身节点:映射为简单树结构 + const selfItems = selfData.map(mapToDeptSimpleTree); + + // 合并并去重父级和自身节点,返回唯一项 + return getUniqueItems([...parents, ...selfItems], 'id'); + } +} diff --git a/apps/server/src/models/department/utils.ts b/apps/server/src/models/department/utils.ts new file mode 100755 index 0000000..6694aa5 --- /dev/null +++ b/apps/server/src/models/department/utils.ts @@ -0,0 +1,77 @@ +import { + UserProfile, + db, + DeptSimpleTreeNode, + TreeDataNode, +} from '@nice/common'; + +/** + * 将部门数据映射为DeptSimpleTreeNode结构 + * @param department 部门数据对象 + * @returns 返回格式化后的DeptSimpleTreeNode对象 + * 数据结构说明: + * - id: 部门唯一标识 + * - key: 部门唯一标识,通常用于React中的key属性 + * - value: 部门唯一标识,通常用于表单值 + * - title: 部门名称 + * - order: 部门排序值 + * - pId: 父部门ID,用于构建树形结构 + * - isLeaf: 是否为叶子节点,即该部门是否没有子部门 + * - hasStaff: 该部门是否包含员工 + */ +export function mapToDeptSimpleTree(department: any): DeptSimpleTreeNode { + return { + id: department.id, + key: department.id, + value: department.id, + title: department.name, + order: department.order, + pId: department.parentId, + isLeaf: !Boolean(department.children?.length), + hasStaff: department?.deptStaffs?.length > 0, + }; +} + +/** + * 根据部门ID列表获取相关员工信息 + * @param ids 部门ID列表 + * @returns 返回与指定部门相关的员工ID列表 + * 算法说明: + * - 使用数据库查询方法findMany,根据部门ID列表查询相关部门的员工信息 + * - 使用flatMap将查询结果扁平化,提取所有员工ID + */ +export async function getStaffsByDeptIds(ids: string[]) { + const depts = await db.department.findMany({ + where: { id: { in: ids } }, + select: { + deptStaffs: { + select: { id: true }, + }, + }, + }); + return depts.flatMap((dept) => dept.deptStaffs); +} + +/** + * 提取唯一的员工ID列表 + * @param params 参数对象,包含部门ID列表、员工ID列表和员工信息 + * @returns 返回唯一的员工ID列表 + * 算法说明: + * - 根据部门ID列表获取相关员工ID + * - 将部门员工ID与传入的员工ID列表合并,并使用Set去重 + * - 如果传入了员工信息,则从结果中移除该员工的ID + * - 最终返回去重后的员工ID列表 + */ +export async function extractUniqueStaffIds(params: { + deptIds?: string[]; + staffIds?: string[]; + staff?: UserProfile; +}): Promise { + const { deptIds, staff, staffIds } = params; + const deptStaffs = await getStaffsByDeptIds(deptIds); + const result = new Set(deptStaffs.map((item) => item.id).concat(staffIds)); + if (staff) { + result.delete(staff.id); + } + return Array.from(result); +} diff --git a/apps/server/src/models/goods/goods.controller.ts b/apps/server/src/models/goods/goods.controller.ts new file mode 100644 index 0000000..806acd6 --- /dev/null +++ b/apps/server/src/models/goods/goods.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Query, Param } from '@nestjs/common'; + +@Controller('goods') +export class GoodsController { + constructor() { + console.log('goods controller') + } + + // 示例1:基本查询参数 + @Get('hello') + getHello(@Query('name') name?: string) { + return { + message: 'Hello World!', + name: name || 'Guest' + }; + } + + // 示例2:路径参数 + @Get('detail/:id') + getDetail(@Param('id') id: string) { + return { + id: id, + detail: `Detail for product ${id}` + }; + } + + // 示例3:多个查询参数 + @Get('search') + searchProducts( + @Query('keyword') keyword: string, + @Query('page') page: number = 1, + @Query('limit') limit: number = 10 + ) { + return { + keyword, + page, + limit, + results: [] + }; + } +} diff --git a/apps/server/src/models/goods/goods.module.ts b/apps/server/src/models/goods/goods.module.ts new file mode 100644 index 0000000..6989b7b --- /dev/null +++ b/apps/server/src/models/goods/goods.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GoodsService } from './goods.service'; +import { GoodsController } from './goods.controller'; + +@Module({ + providers: [GoodsService], + controllers: [GoodsController] +}) +export class GoodsModule {} diff --git a/apps/server/src/models/goods/goods.service.ts b/apps/server/src/models/goods/goods.service.ts new file mode 100644 index 0000000..09c472f --- /dev/null +++ b/apps/server/src/models/goods/goods.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class GoodsService {} diff --git a/apps/server/src/models/message/message.controller.ts b/apps/server/src/models/message/message.controller.ts new file mode 100755 index 0000000..9e2270a --- /dev/null +++ b/apps/server/src/models/message/message.controller.ts @@ -0,0 +1,125 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; + +import { MessageService } from './message.service'; +import { AuthGuard } from '@server/auth/auth.guard'; +import { db, VisitType } from '@nice/common'; +// /message/find-last-one?staff-id=1 +@Controller('message') +export class MessageController { + constructor(private readonly messageService: MessageService) { } + @UseGuards(AuthGuard) + @Get('find-last-one') + async findLastOne(@Query('staff-id') staffId: string) { + try { + const result = await db.message.findFirst({ + where: { + OR: [ + { + receivers: { + some: { + id: staffId, + }, + }, + }, + ], + }, + orderBy: { createdAt: 'desc' }, + select: { + title: true, + content: true, + url: true + }, + }); + + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } + @UseGuards(AuthGuard) + @Get('find-unreaded') + async findUnreaded(@Query('staff-id') staffId: string) { + try { + const result = await db.message.findMany({ + where: { + visits: { + none: { + id: staffId, + type: VisitType.READED + }, + }, + receivers: { + some: { + id: staffId, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + select: { + title: true, + content: true, + url: true, + }, + }); + + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } + @UseGuards(AuthGuard) + @Get('count-unreaded') + async countUnreaded(@Query('staff-id') staffId: string) { + try { + const result = await db.message.findMany({ + where: { + visits: { + none: { + id: staffId, + type: VisitType.READED + }, + }, + receivers: { + some: { + id: staffId, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + select: { + title: true, + content: true, + url: true, + }, + }); + + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } +} diff --git a/apps/server/src/models/message/message.module.ts b/apps/server/src/models/message/message.module.ts new file mode 100755 index 0000000..3c88776 --- /dev/null +++ b/apps/server/src/models/message/message.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { MessageService } from './message.service'; +import { MessageRouter } from './message.router'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentModule } from '../department/department.module'; +import { MessageController } from './message.controller'; + +@Module({ + imports: [DepartmentModule], //可能要用的 + providers: [MessageService, MessageRouter, TrpcService], //可以被自己使用这三个 + exports: [MessageService, MessageRouter], //可以被其他模块使用这两个 + controllers: [MessageController], //路由层 +}) +export class MessageModule { } diff --git a/apps/server/src/models/message/message.router.ts b/apps/server/src/models/message/message.router.ts new file mode 100755 index 0000000..44d56b9 --- /dev/null +++ b/apps/server/src/models/message/message.router.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { MessageService } from './message.service'; +import { Prisma } from '@nice/common'; +import { z, ZodType } from 'zod'; +const MessageUncheckedCreateInputSchema: ZodType = z.any() +const MessageWhereInputSchema: ZodType = z.any() +const MessageSelectSchema: ZodType = z.any() +@Injectable() +export class MessageRouter { + constructor( + private readonly trpc: TrpcService, + private readonly messageService: MessageService, + ) { } + router = this.trpc.router({ + create: this.trpc.procedure + .input(MessageUncheckedCreateInputSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.messageService.create({ data: input }, { staff }); + }), + findManyWithCursor: this.trpc.protectProcedure + .input(z.object({ + cursor: z.any().nullish(), + take: z.number().nullish(), + where: MessageWhereInputSchema.nullish(), + select: MessageSelectSchema.nullish() + })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.messageService.findManyWithCursor(input, staff); + }), + getUnreadCount: this.trpc.protectProcedure + .query(async ({ ctx }) => { + const { staff } = ctx; + return await this.messageService.getUnreadCount(staff); + }) + }) +} diff --git a/apps/server/src/models/message/message.service.ts b/apps/server/src/models/message/message.service.ts new file mode 100755 index 0000000..8b85635 --- /dev/null +++ b/apps/server/src/models/message/message.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { UserProfile, db, Prisma, VisitType, ObjectType } from '@nice/common'; +import { BaseService } from '../base/base.service'; +import EventBus, { CrudOperation } from '@server/utils/event-bus'; +import { setMessageRelation } from './utils'; +@Injectable() +export class MessageService extends BaseService { + constructor() { + super(db, ObjectType.MESSAGE); + } + async create(args: Prisma.MessageCreateArgs, params?: { tx?: Prisma.MessageDelegate, staff?: UserProfile }) { + args.data!.senderId = params?.staff?.id; + args.include = { + receivers: { + select: { id: true, registerToken: true, username: true } + } + } + const result = await super.create(args); + EventBus.emit("dataChanged", { + type: ObjectType.MESSAGE, + operation: CrudOperation.CREATED, + data: result + }) + return result + } + async findManyWithCursor( + args: Prisma.MessageFindManyArgs, + staff?: UserProfile, + ) { + + return this.wrapResult(super.findManyWithCursor(args), async (result) => { + let { items } = result; + await Promise.all( + items.map(async (item) => { + await setMessageRelation(item, staff); + }), + ); + + return { ...result, items }; + }); + } + async getUnreadCount(staff?: UserProfile) { + const count = await db.message.count({ + where: { + receivers: { some: { id: staff?.id } }, + visits: { + none: { + visitorId: staff?.id, + type: VisitType.READED + } + } + } + }) + + return count + } +} diff --git a/apps/server/src/models/message/utils.ts b/apps/server/src/models/message/utils.ts new file mode 100755 index 0000000..7c2bf35 --- /dev/null +++ b/apps/server/src/models/message/utils.ts @@ -0,0 +1,20 @@ +import { Message, UserProfile, VisitType, db } from "@nice/common" +export async function setMessageRelation( + data: Message, + staff?: UserProfile, +): Promise { + + const readed = + (await db.visit.count({ + where: { + messageId: data.id, + type: VisitType.READED, + visitorId: staff?.id, + }, + })) > 0; + + + Object.assign(data, { + readed + }) +} \ No newline at end of file diff --git a/apps/server/src/models/post/post.controller.ts b/apps/server/src/models/post/post.controller.ts new file mode 100755 index 0000000..1e9eb77 --- /dev/null +++ b/apps/server/src/models/post/post.controller.ts @@ -0,0 +1,10 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; + +import { PostService } from './post.service'; +import { AuthGuard } from '@server/auth/auth.guard'; +import { db } from '@nice/common'; + +@Controller('post') +export class PostController { + constructor(private readonly postService: PostService) {} +} diff --git a/apps/server/src/models/post/post.module.ts b/apps/server/src/models/post/post.module.ts new file mode 100755 index 0000000..836d47b --- /dev/null +++ b/apps/server/src/models/post/post.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentService } from '@server/models/department/department.service'; + +import { QueueModule } from '@server/queue/queue.module'; +import { MessageModule } from '../message/message.module'; +import { PostRouter } from './post.router'; +import { PostController } from './post.controller'; +import { PostService } from './post.service'; +import { RoleMapModule } from '../rbac/rbac.module'; + +@Module({ + imports: [QueueModule, RoleMapModule, MessageModule], + providers: [PostService, PostRouter, TrpcService, DepartmentService], + exports: [PostRouter, PostService], + controllers: [PostController], +}) +export class PostModule {} diff --git a/apps/server/src/models/post/post.router.ts b/apps/server/src/models/post/post.router.ts new file mode 100755 index 0000000..55b1217 --- /dev/null +++ b/apps/server/src/models/post/post.router.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { CourseMethodSchema, Prisma } from '@nice/common'; +import { PostService } from './post.service'; +import { z, ZodType } from 'zod'; +const PostCreateArgsSchema: ZodType = z.any(); +const PostUpdateArgsSchema: ZodType = z.any(); +const PostFindFirstArgsSchema: ZodType = z.any(); +const PostFindManyArgsSchema: ZodType = z.any(); +const PostDeleteManyArgsSchema: ZodType = z.any(); +const PostWhereInputSchema: ZodType = z.any(); +const PostSelectSchema: ZodType = z.any(); +const PostUpdateInputSchema: ZodType = z.any(); +@Injectable() +export class PostRouter { + constructor( + private readonly trpc: TrpcService, + private readonly postService: PostService, + ) {} + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(PostCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.create(input, { staff }); + }), + + softDeleteByIds: this.trpc.protectProcedure + .input( + z.object({ + ids: z.array(z.string()), + data: PostUpdateInputSchema.nullish(), + }), + ) + .mutation(async ({ input }) => { + return await this.postService.softDeleteByIds(input.ids, input.data); + }), + restoreByIds: this.trpc.protectProcedure + .input( + z.object({ + ids: z.array(z.string()), + args: PostUpdateInputSchema.nullish(), + }), + ) + .mutation(async ({ input }) => { + return await this.postService.restoreByIds(input.ids, input.args); + }), + update: this.trpc.protectProcedure + .input(PostUpdateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.update(input, staff); + }), + findById: this.trpc.protectProcedure + .input(z.object({ id: z.string(), args: PostFindFirstArgsSchema })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.findById(input.id, input.args); + }), + findMany: this.trpc.protectProcedure + .input(PostFindManyArgsSchema) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.findMany(input); + }), + + findFirst: this.trpc.procedure + .input(PostFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input, ctx }) => { + const { staff, ip } = ctx; + // 从请求中获取 IP + + return await this.postService.findFirst(input, staff, ip); + }), + deleteMany: this.trpc.protectProcedure + .input(PostDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.postService.deleteMany(input); + }), + findManyWithCursor: this.trpc.protectProcedure + .input( + z.object({ + cursor: z.any().nullish(), + take: z.number().nullish(), + where: PostWhereInputSchema.nullish(), + select: PostSelectSchema.nullish(), + }), + ) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.postService.findManyWithCursor(input, staff); + }), + findManyWithPagination: this.trpc.procedure + .input( + z.object({ + page: z.number().optional(), + pageSize: z.number().optional(), + where: PostWhereInputSchema.optional(), + select: PostSelectSchema.optional(), + }), + ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.postService.findManyWithPagination(input); + }), + }); +} diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts new file mode 100755 index 0000000..b4a18c0 --- /dev/null +++ b/apps/server/src/models/post/post.service.ts @@ -0,0 +1,178 @@ +import { Injectable } from '@nestjs/common'; +import { + db, + Prisma, + UserProfile, + VisitType, + Post, + PostType, + RolePerms, + ResPerm, + ObjectType, + + CourseMethodSchema, +} from '@nice/common'; +import { MessageService } from '../message/message.service'; +import { BaseService } from '../base/base.service'; +import { DepartmentService } from '../department/department.service'; +import { setPostRelation } from './utils'; +import EventBus, { CrudOperation } from '@server/utils/event-bus'; +import { BaseTreeService } from '../base/base.tree.service'; +import { z } from 'zod'; + +@Injectable() +export class PostService extends BaseTreeService { + constructor( + private readonly messageService: MessageService, + private readonly departmentService: DepartmentService, + ) { + super(db, ObjectType.POST, 'postAncestry', true); + } + + + + async create( + args: Prisma.PostCreateArgs, + params?: { staff?: UserProfile; tx?: Prisma.TransactionClient }, + ) { + args.data.authorId = params?.staff?.id; + const result = await super.create(args); + EventBus.emit('dataChanged', { + type: ObjectType.POST, + operation: CrudOperation.CREATED, + data: result, + }); + return result; + } + async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) { + args.data.authorId = staff?.id; + const result = await super.update(args); + EventBus.emit('dataChanged', { + type: ObjectType.POST, + operation: CrudOperation.UPDATED, + data: result, + }); + return result; + } + async findFirst( + args?: Prisma.PostFindFirstArgs, + staff?: UserProfile, + clientIp?: string, + ) { + const transDto = await this.wrapResult( + super.findFirst(args), + async (result) => { + if (result) { + await setPostRelation({ data: result, staff }); + await this.setPerms(result, staff); + } + + return result; + }, + ); + return transDto; + } + + async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { + if (!args.where) args.where = {}; + args.where.OR = await this.preFilter(args.where.OR, staff); + return this.wrapResult(super.findManyWithCursor(args), async (result) => { + const { items } = result; + await Promise.all( + items.map(async (item) => { + await setPostRelation({ data: item, staff }); + await this.setPerms(item, staff); + }), + ); + return { ...result, items }; + }); + } + + protected async setPerms(data: Post, staff?: UserProfile) { + if (!staff) return; + const perms: ResPerm = { + delete: false, + }; + const isMySelf = data?.authorId === staff?.id; + const isDomain = staff.domainId === data.domainId; + const setManagePermissions = (perms: ResPerm) => { + Object.assign(perms, { + delete: true, + // edit: true, + }); + }; + if (isMySelf) { + perms.delete = true; + // perms.edit = true; + } + staff.permissions.forEach((permission) => { + switch (permission) { + case RolePerms.MANAGE_ANY_POST: + setManagePermissions(perms); + break; + case RolePerms.MANAGE_DOM_POST: + if (isDomain) { + setManagePermissions(perms); + } + break; + } + }); + Object.assign(data, { perms }); + } + async preFilter(OR?: Prisma.PostWhereInput[], staff?: UserProfile) { + const preFilter = (await this.getPostPreFilter(staff)) || []; + const outOR = OR ? [...OR, ...preFilter].filter(Boolean) : preFilter; + return outOR?.length > 0 ? outOR : undefined; + } + async getPostPreFilter(staff?: UserProfile) { + if (!staff) return; + const { deptId, domainId } = staff; + if ( + staff.permissions.includes(RolePerms.READ_ANY_POST) || + staff.permissions.includes(RolePerms.MANAGE_ANY_POST) + ) { + return undefined; + } + const parentDeptIds = + (await this.departmentService.getAncestorIds(staff.deptId)) || []; + const orCondition: Prisma.PostWhereInput[] = [ + staff?.id && { + authorId: staff.id, + }, + staff?.id && { + watchableStaffs: { + some: { + id: staff.id, + }, + }, + }, + deptId && { + watchableDepts: { + some: { + id: { + in: parentDeptIds, + }, + }, + }, + }, + + { + AND: [ + { + watchableStaffs: { + none: {}, // 匹配 watchableStaffs 为空 + }, + }, + { + watchableDepts: { + none: {}, // 匹配 watchableDepts 为空 + }, + }, + ], + }, + ].filter(Boolean); + + if (orCondition?.length > 0) return orCondition; + return undefined; + } +} diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts new file mode 100755 index 0000000..f7f3c68 --- /dev/null +++ b/apps/server/src/models/post/utils.ts @@ -0,0 +1,54 @@ +import { + db, + Post, + PostType, + UserProfile, + VisitType, +} from '@nice/common'; + +export async function setPostRelation(params: { + data: Post; + staff?: UserProfile; +}) { + const { data, staff } = params; + const limitedComments = await db.post.findMany({ + where: { + parentId: data.id, + type: PostType.POST_COMMENT, + }, + include: { + author: true, + }, + take: 5, + }); + const commentsCount = await db.post.count({ + where: { + parentId: data.id, + type: PostType.POST_COMMENT, + }, + }); + const readed = + (await db.visit.count({ + where: { + postId: data.id, + type: VisitType.READED, + visitorId: staff?.id, + }, + })) > 0; + const readedCount = await db.visit.count({ + where: { + postId: data.id, + type: VisitType.READED, + }, + }); + + Object.assign(data, { + readed, + readedCount, + limitedComments, + commentsCount, + // trouble + }); +} + + diff --git a/apps/server/src/models/rbac/rbac.module.ts b/apps/server/src/models/rbac/rbac.module.ts new file mode 100755 index 0000000..9f3e0f8 --- /dev/null +++ b/apps/server/src/models/rbac/rbac.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { RoleMapService } from './rolemap.service'; +import { RoleRouter } from './role.router'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { RoleService } from './role.service'; +import { RoleMapRouter } from './rolemap.router'; +import { DepartmentModule } from '../department/department.module'; + +@Module({ + imports: [DepartmentModule], + providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter], + exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter] +}) +export class RoleMapModule { } diff --git a/apps/server/src/models/rbac/role.router.ts b/apps/server/src/models/rbac/role.router.ts new file mode 100755 index 0000000..5bb9dd3 --- /dev/null +++ b/apps/server/src/models/rbac/role.router.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma, UpdateOrderSchema } from '@nice/common'; +import { RoleService } from './role.service'; +import { z, ZodType } from 'zod'; +const RoleCreateArgsSchema: ZodType = z.any() +const RoleUpdateArgsSchema: ZodType = z.any() +const RoleCreateManyInputSchema: ZodType = z.any() +const RoleDeleteManyArgsSchema: ZodType = z.any() +const RoleFindManyArgsSchema: ZodType = z.any() +const RoleFindFirstArgsSchema: ZodType = z.any() +const RoleWhereInputSchema: ZodType = z.any() +const RoleSelectSchema: ZodType = z.any() +const RoleUpdateInputSchema: ZodType = z.any(); +@Injectable() +export class RoleRouter { + constructor( + private readonly trpc: TrpcService, + private readonly roleService: RoleService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(RoleCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.create(input, staff); + }), + update: this.trpc.protectProcedure + .input(RoleUpdateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.update(input, staff); + }), + createMany: this.trpc.protectProcedure.input(z.array(RoleCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.roleService.createMany({ data: input }, staff); + }), + softDeleteByIds: this.trpc.protectProcedure + .input( + z.object({ + ids: z.array(z.string()), + data: RoleUpdateInputSchema.optional() + }), + ) + .mutation(async ({ input }) => { + return await this.roleService.softDeleteByIds(input.ids, input.data); + }), + findFirst: this.trpc.procedure + .input(RoleFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.roleService.findFirst(input); + }), + + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.roleService.updateOrder(input); + }), + findMany: this.trpc.procedure + .input(RoleFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.roleService.findMany(input); + }), + findManyWithCursor: this.trpc.protectProcedure + .input(z.object({ + cursor: z.any().nullish(), + take: z.number().optional(), + where: RoleWhereInputSchema.optional(), + select: RoleSelectSchema.optional() + })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.findManyWithCursor(input); + }), + findManyWithPagination: this.trpc.procedure + .input(z.object({ + page: z.number(), + pageSize: z.number().optional(), + where: RoleWhereInputSchema.optional(), + select: RoleSelectSchema.optional() + })) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.roleService.findManyWithPagination(input); + }), + }); +} diff --git a/apps/server/src/models/rbac/role.row.service.ts b/apps/server/src/models/rbac/role.row.service.ts new file mode 100755 index 0000000..27e4574 --- /dev/null +++ b/apps/server/src/models/rbac/role.row.service.ts @@ -0,0 +1,47 @@ +import { db, ObjectType, RowModelRequest, RowRequestSchema, UserProfile } from "@nice/common"; +import { RowCacheService } from "../base/row-cache.service"; +import { isFieldCondition, LogicalCondition } from "../base/sql-builder"; +import { z } from "zod"; +export class RoleRowService extends RowCacheService { + protected createGetRowsFilters( + request: z.infer, + staff?: UserProfile + ) { + const condition = super.createGetRowsFilters(request) + if (isFieldCondition(condition)) + return {} + const baseModelCondition: LogicalCondition[] = [{ + field: `${this.tableName}.deleted_at`, + op: "blank", + type: "date" + }] + condition.AND = [...baseModelCondition, ...condition.AND!] + return condition + } + createUnGroupingRowSelect(): string[] { + return [ + `${this.tableName}.id AS id`, + `${this.tableName}.name AS name`, + `${this.tableName}.system AS system`, + `${this.tableName}.permissions AS permissions` + ]; + } + protected async getRowDto(data: any, staff?: UserProfile): Promise { + if (!data.id) + return data + const roleMaps = await db.roleMap.findMany({ + where: { + roleId: data.id + } + }) + const deptIds = roleMaps.filter(item => item.objectType === ObjectType.DEPARTMENT).map(roleMap => roleMap.objectId) + const staffIds = roleMaps.filter(item => item.objectType === ObjectType.STAFF).map(roleMap => roleMap.objectId) + const depts = await db.department.findMany({ where: { id: { in: deptIds } } }) + const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } }) + const result = { ...data, depts, staffs } + return result + } + createJoinSql(request?: RowModelRequest): string[] { + return []; + } +} \ No newline at end of file diff --git a/apps/server/src/models/rbac/role.service.ts b/apps/server/src/models/rbac/role.service.ts new file mode 100755 index 0000000..513482a --- /dev/null +++ b/apps/server/src/models/rbac/role.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { db, RoleMethodSchema, ObjectType, Prisma } from '@nice/common'; +import { BaseService } from '../base/base.service'; +@Injectable() +export class RoleService extends BaseService { + constructor() { + super(db, ObjectType.ROLE); + } + /** + * 批量删除角色 + * @param data 包含要删除的角色ID列表的数据 + * @returns 删除结果 + * @throws 如果未提供ID,将抛出错误 + */ + async softDeleteByIds(ids: string[], data?: Prisma.RoleUpdateInput) { + await db.roleMap.deleteMany({ + where: { + roleId: { + in: ids, + }, + }, + }); + return await super.softDeleteByIds(ids, data); + } +} diff --git a/apps/server/src/models/rbac/rolemap.router.ts b/apps/server/src/models/rbac/rolemap.router.ts new file mode 100755 index 0000000..72ae5a4 --- /dev/null +++ b/apps/server/src/models/rbac/rolemap.router.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { + ObjectType, + RoleMapMethodSchema, +} from '@nice/common'; +import { RoleMapService } from './rolemap.service'; + +@Injectable() +export class RoleMapRouter { + constructor( + private readonly trpc: TrpcService, + private readonly roleMapService: RoleMapService, + ) { } + router = this.trpc.router({ + deleteAllRolesForObject: this.trpc.protectProcedure + .input(RoleMapMethodSchema.deleteWithObject) + .mutation(({ input }) => + this.roleMapService.deleteAllRolesForObject(input), + ), + setRoleForObject: this.trpc.protectProcedure + .input(RoleMapMethodSchema.create) + .mutation(({ input }) => this.roleMapService.setRoleForObject(input)), + setRoleForObjects: this.trpc.protectProcedure + .input(RoleMapMethodSchema.setRoleForObjects) + .mutation(({ input }) => this.roleMapService.setRoleForObjects(input)), + addRoleForObjects: this.trpc.protectProcedure + .input(RoleMapMethodSchema.setRoleForObjects) + .mutation(({ input }) => this.roleMapService.addRoleForObjects(input)), + setRolesForObject: this.trpc.protectProcedure + .input(RoleMapMethodSchema.setRolesForObject) + .mutation(({ input }) => this.roleMapService.setRolesForObject(input)), + + getPermsForObject: this.trpc.procedure + .input(RoleMapMethodSchema.getPermsForObject) + .query(({ input }) => this.roleMapService.getPermsForObject(input)), + deleteMany: this.trpc.protectProcedure + .input(RoleMapMethodSchema.deleteMany) // Assuming RoleMapMethodSchema.deleteMany is the Zod schema for batch deleting staff + .mutation(async ({ input }) => { + return await this.roleMapService.deleteMany(input); + }), + + paginate: this.trpc.procedure + .input(RoleMapMethodSchema.paginate) // Define the input schema for pagination + .query(async ({ input }) => { + return await this.roleMapService.paginate(input); + }), + update: this.trpc.protectProcedure + .input(RoleMapMethodSchema.update) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleMapService.update(input); + }), + getRoleMapDetail: this.trpc.procedure + .input(RoleMapMethodSchema.getRoleMapDetail) + .query(async ({ input }) => { + return await this.roleMapService.getRoleMapDetail(input); + }), + getRows: this.trpc.procedure + .input(RoleMapMethodSchema.getRows) + .query(async ({ input, ctx }) => { + const { staff } = ctx; + return await this.roleMapService.getRows(input, staff); + }), + getStaffsNotMap: this.trpc.procedure + .input(RoleMapMethodSchema.getStaffsNotMap) + .query(async ({ input }) => { + return this.roleMapService.getStaffsNotMap(input); + }), + }); +} diff --git a/apps/server/src/models/rbac/rolemap.service.ts b/apps/server/src/models/rbac/rolemap.service.ts new file mode 100755 index 0000000..d3c971a --- /dev/null +++ b/apps/server/src/models/rbac/rolemap.service.ts @@ -0,0 +1,316 @@ +import { Injectable } from '@nestjs/common'; +import { + db, + RoleMapMethodSchema, + ObjectType, + Prisma, + RowModelRequest, + UserProfile, +} from '@nice/common'; +import { DepartmentService } from '@server/models/department/department.service'; +import { TRPCError } from '@trpc/server'; +import { RowModelService } from '../base/row-model.service'; +import { isFieldCondition } from '../base/sql-builder'; +import { z } from 'zod'; + +@Injectable() +export class RoleMapService extends RowModelService { + createJoinSql(request?: RowModelRequest): string[] { + return [ + `LEFT JOIN staff ON staff.id = ${this.tableName}.object_id`, + `LEFT JOIN department ON department.id = staff.dept_id`, + ]; + } + createUnGroupingRowSelect(): string[] { + return [ + `${this.tableName}.id AS id`, + `${this.tableName}.object_id AS object_id`, + `${this.tableName}.role_id AS role_id`, + `${this.tableName}.domain_id AS domain_id`, + `${this.tableName}.object_type AS object_type`, + `staff.officer_id AS staff_officer_id`, + `staff.username AS staff_username`, + `department.name AS department_name`, + `staff.showname AS staff_`, + ]; + } + + constructor(private readonly departmentService: DepartmentService) { + super('rolemap'); + } + protected createGetRowsFilters( + request: z.infer, + staff: UserProfile, + ) { + const { roleId, domainId } = request; + // Base conditions + let condition = super.createGetRowsFilters(request, staff); + if (isFieldCondition(condition)) return; + // Adding conditions based on parameters existence + if (roleId) { + condition.AND.push({ + field: `${this.tableName}.role_id`, + value: roleId, + op: 'equals', + }); + } + if (domainId) { + condition.AND.push({ + field: `${this.tableName}.domain_id`, + value: domainId, + op: 'equals', + }); + } + return condition; + } + + protected async getRowDto( + row: any, + staff?: UserProfile, + ): Promise { + if (!row.id) return row; + return row; + } + /** + * 删除某对象的所有角色 + * @param data 包含对象ID的数据 + * @returns 删除结果 + */ + async deleteAllRolesForObject( + data: z.infer, + ) { + const { objectId } = data; + return await db.roleMap.deleteMany({ + where: { + objectId, + }, + }); + } + /** + * 为某对象设置一个角色 + * @param data 角色映射数据 + * @returns 创建的角色映射 + */ + async setRoleForObject(data: z.infer) { + return await db.roleMap.create({ + data, + }); + } + /** + * 批量为多个对象创建角色映射 + * @param data 角色映射数据 + * @returns 创建的角色映射列表 + */ + async setRoleForObjects( + data: z.infer, + ) { + const { domainId, roleId, objectIds, objectType } = data; + const roleMaps = objectIds.map((id) => ({ + domainId, + objectId: id, + roleId, + objectType, + })); + // 开启事务 + const result = await db.$transaction(async (prisma) => { + // 首先,删除现有的角色映射 + await prisma.roleMap.deleteMany({ + where: { + domainId, + roleId, + objectType, + }, + }); + // 然后,创建新的角色映射 + return await prisma.roleMap.createManyAndReturn({ + data: roleMaps, + }); + }); + const wrapResult = Promise.all(result.map(async item => { + const staff = await db.staff.findMany({ + include: { department: true }, + where: { + id: item.objectId + } + }) + return { ...item, staff } + })) + return wrapResult; + } + async addRoleForObjects( + data: z.infer, + ) { + const { domainId, roleId, objectIds, objectType } = data; + const objects = await db.roleMap.findMany({ + where: { domainId, roleId, objectType }, + }); + data.objectIds = Array.from( + new Set([...objectIds, ...objects.map((obj) => obj.objectId)]), + ); + const result = this.setRoleForObjects(data); + return result; + } + /** + * 为某对象设置多个角色 + * @param data 角色映射数据 + * @returns 创建的角色映射列表 + */ + async setRolesForObject( + data: z.infer, + ) { + const { domainId, objectId, roleIds, objectType } = data; + const roleMaps = roleIds.map((id) => ({ + domainId, + objectId, + roleId: id, + objectType, + })); + + return await db.roleMap.createMany({ data: roleMaps }); + } + + /** + * 获取某对象的权限 + * @param data 包含域ID、部门ID和对象ID的数据 + * @returns 用户角色的权限列表 + */ + async getPermsForObject( + data: z.infer, + ) { + const { domainId, deptId, staffId } = data; + // Get all ancestor department IDs if deptId is provided. + const ancestorDeptIds = deptId + ? await this.departmentService.getAncestorIds(deptId) + : []; + // Define a common filter for querying roles. + const objectFilters: Prisma.RoleMapWhereInput[] = [ + { objectId: staffId, objectType: ObjectType.STAFF }, + ...(deptId || ancestorDeptIds.length > 0 + ? [ + { + objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) }, + objectType: ObjectType.DEPARTMENT, + }, + ] + : []), + ]; + // Helper function to fetch roles based on domain ID. + const fetchRoles = async (domainId: string) => { + return db.roleMap.findMany({ + where: { + AND: { + domainId, + OR: objectFilters, + }, + }, + include: { role: true }, + }); + }; + // Fetch roles with and without specific domain IDs. + const [nullDomainRoles, userRoles] = await Promise.all([ + fetchRoles(null), + fetchRoles(domainId), + ]); + // Extract permissions from roles and return them. + return [...userRoles, ...nullDomainRoles].flatMap( + ({ role }) => role.permissions, + ); + } + + /** + * 批量删除角色映射 + * @param data 包含要删除的角色映射ID列表的数据 + * @returns 删除结果 + * @throws 如果未提供ID,将抛出错误 + */ + async deleteMany(data: z.infer) { + const { ids } = data; + if (!ids || ids.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No IDs provided for deletion.', + }); + } + const rowData = await this.getRowByIds({ ids, extraCondition: {} }); + await db.roleMap.deleteMany({ + where: { id: { in: ids } }, + }); + return rowData; + } + + /** + * 分页获取角色映射 + * @param data 包含分页信息的数据 + * @returns 分页结果,包含角色映射列表和总数 + */ + async paginate(data: z.infer) { + const { page, pageSize, domainId, roleId } = data; + + const [items, totalCount] = await Promise.all([ + db.roleMap.findMany({ + skip: (page - 1) * pageSize, + take: pageSize, + where: { domainId, roleId }, + }), + db.roleMap.count({ + where: { domainId, roleId }, + }), + ]); + + // const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item))); + return { items, totalCount }; + } + async getStaffsNotMap(data: z.infer) { + const { domainId, roleId } = data; + let staffs = await db.staff.findMany({ + where: { + domainId, + }, + }); + const roleMaps = await db.roleMap.findMany({ + where: { + domainId, + roleId, + objectType: ObjectType.STAFF, + }, + }); + staffs = staffs.filter( + (staff) => + roleMaps.findIndex((roleMap) => roleMap.objectId === staff.id) === -1, + ); + return staffs; + } + /** + * 更新角色映射 + * @param data 包含更新信息的数据 + * @returns 更新后的角色映射 + */ + async update(data: z.infer) { + const { id, ...others } = data; + + const updatedRoleMap = await db.roleMap.update({ + where: { id }, + data: { ...others }, + }); + return updatedRoleMap; + } + + /** + * 获取角色映射详情 + * @param data 包含角色ID和域ID的数据 + * @returns 角色映射详情,包含部门ID和员工ID列表 + */ + async getRoleMapDetail(data: z.infer) { + const { roleId, domainId } = data; + const res = await db.roleMap.findMany({ where: { roleId, domainId } }); + + const deptIds = res + .filter((item) => item.objectType === ObjectType.DEPARTMENT) + .map((item) => item.objectId); + const staffIds = res + .filter((item) => item.objectType === ObjectType.STAFF) + .map((item) => item.objectId); + + return { deptIds, staffIds }; + } +} diff --git a/apps/server/src/models/resource/pipe/resource.pipeline.ts b/apps/server/src/models/resource/pipe/resource.pipeline.ts new file mode 100755 index 0000000..bd3c7fd --- /dev/null +++ b/apps/server/src/models/resource/pipe/resource.pipeline.ts @@ -0,0 +1,85 @@ +import { PrismaClient, Resource } from '@prisma/client'; +import { ProcessResult, ResourceProcessor } from '../types'; +import { db, ResourceStatus } from '@nice/common'; +import { Logger } from '@nestjs/common'; + +// Pipeline 类 +export class ResourceProcessingPipeline { + private processors: ResourceProcessor[] = []; + private logger = new Logger(ResourceProcessingPipeline.name); + + constructor() {} + + // 添加处理器 + addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline { + this.processors.push(processor); + return this; + } + + // 执行处理管道 + async execute(resource: Resource): Promise { + let currentResource = resource; + try { + this.logger.log(`开始处理资源: ${resource.id}`); + + currentResource = await this.updateProcessStatus( + resource.id, + ResourceStatus.PROCESSING, + ); + this.logger.log(`资源状态已更新为处理中`); + + for (const processor of this.processors) { + const processorName = processor.constructor.name; + this.logger.log(`开始执行处理器: ${processorName}`); + + currentResource = await this.updateProcessStatus( + currentResource.id, + processor.constructor.name as ResourceStatus, + ); + + currentResource = await processor.process(currentResource); + this.logger.log(`处理器 ${processorName} 执行完成`); + + currentResource = await db.resource.update({ + where: { id: currentResource.id }, + data: currentResource, + }); + } + + currentResource = await this.updateProcessStatus( + currentResource.id, + ResourceStatus.PROCESSED, + ); + this.logger.log( + `资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.meta)}`, + ); + + return { + success: true, + resource: currentResource, + }; + } catch (error) { + this.logger.error(`资源 ${resource.id} 处理失败:`, error); + + currentResource = await this.updateProcessStatus( + currentResource.id, + ResourceStatus.PROCESS_FAILED, + ); + + return { + success: false, + resource: currentResource, + error: error as Error, + }; + } + } + private async updateProcessStatus( + resourceId: string, + status: ResourceStatus, + ): Promise { + return db.resource.update({ + where: { id: resourceId }, + data: { status }, + }); + } +} diff --git a/apps/server/src/models/resource/processor/BaseProcessor.ts b/apps/server/src/models/resource/processor/BaseProcessor.ts new file mode 100755 index 0000000..21907f5 --- /dev/null +++ b/apps/server/src/models/resource/processor/BaseProcessor.ts @@ -0,0 +1,23 @@ +import path, { dirname } from "path"; +import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types"; +import { Resource, ResourceStatus, db } from "@nice/common"; +import { Logger } from "@nestjs/common"; +import fs from 'fs/promises'; + +export abstract class BaseProcessor implements ResourceProcessor { + constructor() { } + protected logger = new Logger(BaseProcessor.name) + + abstract process(resource: Resource): Promise + protected createOutputDir(filepath: string, subdirectory: string = 'assets'): string { + const outputDir = path.join( + path.dirname(filepath), + subdirectory, + ); + fs.mkdir(outputDir, { recursive: true }).catch(err => this.logger.error(`Failed to create directory: ${err.message}`)); + + return outputDir; + + } +} +// \ No newline at end of file diff --git a/apps/server/src/models/resource/processor/ImageProcessor.ts b/apps/server/src/models/resource/processor/ImageProcessor.ts new file mode 100755 index 0000000..1851c0d --- /dev/null +++ b/apps/server/src/models/resource/processor/ImageProcessor.ts @@ -0,0 +1,62 @@ +import path from 'path'; +import sharp from 'sharp'; +import { FileMetadata, ImageMetadata, ResourceProcessor } from '../types'; +import { Resource, ResourceStatus, db } from '@nice/common'; +import { getUploadFilePath } from '@server/utils/file'; +import { BaseProcessor } from './BaseProcessor'; + +export class ImageProcessor extends BaseProcessor { + constructor() { + super(); + } + + async process(resource: Resource): Promise { + const { url } = resource; + const filepath = getUploadFilePath(url); + const originMeta = resource.meta as unknown as FileMetadata; + if (!originMeta.filetype?.startsWith('image/')) { + this.logger.log(`Skipping non-image resource: ${resource.id}`); + return resource; + } + try { + const image = sharp(filepath); + const metadata = await image.metadata(); + if (!metadata) { + throw new Error(`Failed to get metadata for image: ${url}`); + } + // Create WebP compressed version + const compressedDir = this.createOutputDir(filepath, 'compressed'); + const compressedPath = path.join( + compressedDir, + `${path.basename(filepath, path.extname(filepath))}.webp`, + ); + await image + .webp({ + quality: 80, + lossless: false, + effort: 5, // Range 0-6, higher means slower but better compression + }) + .toFile(compressedPath); + const imageMeta: ImageMetadata = { + width: metadata.width || 0, + height: metadata.height || 0, + orientation: metadata.orientation, + space: metadata.space, + hasAlpha: metadata.hasAlpha, + }; + const updatedResource = await db.resource.update({ + where: { id: resource.id }, + data: { + meta: { + ...originMeta, + ...imageMeta, + }, + }, + }); + + return updatedResource; + } catch (error: any) { + throw new Error(`Failed to process image: ${error.message}`); + } + } +} diff --git a/apps/server/src/models/resource/processor/VideoProcessor.ts b/apps/server/src/models/resource/processor/VideoProcessor.ts new file mode 100755 index 0000000..083bb5f --- /dev/null +++ b/apps/server/src/models/resource/processor/VideoProcessor.ts @@ -0,0 +1,190 @@ +import path, { dirname } from 'path'; +import ffmpeg from 'fluent-ffmpeg'; +import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types'; +import { Resource, ResourceStatus, db } from '@nice/common'; +import { getUploadFilePath } from '@server/utils/file'; +import fs from 'fs/promises'; +import sharp from 'sharp'; +import { BaseProcessor } from './BaseProcessor'; + +export class VideoProcessor extends BaseProcessor { + constructor() { + super(); + } + async process(resource: Resource): Promise { + const { url } = resource; + const filepath = getUploadFilePath(url); + this.logger.log( + `Processing video for resource ID: ${resource.id}, File ID: ${url}`, + ); + + const originMeta = resource.meta as unknown as FileMetadata; + if (!originMeta.filetype?.startsWith('video/')) { + this.logger.log(`Skipping non-video resource: ${resource.id}`); + return resource; + } + + try { + const streamDir = this.createOutputDir(filepath, 'stream'); + + const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([ + this.generateM3U8Stream(filepath, streamDir), + this.getVideoMetadata(filepath), + this.generateVideoCover(filepath, dirname(filepath)), + ]); + const videoMeta: VideoMetadata = { + ...videoMetadata, + coverUrl: coverUrl, + }; + const updatedResource = await db.resource.update({ + where: { id: resource.id }, + data: { + meta: { + ...originMeta, + ...videoMeta, + }, + }, + }); + this.logger.log( + `Successfully processed video for resource ID: ${resource.id}`, + ); + return updatedResource; + } catch (error: any) { + this.logger.error( + `Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`, + ); + throw new Error(`Failed to process video: ${error.message}`); + } + } + private async generateVideoCover( + filepath: string, + outputDir: string, + ): Promise { + this.logger.log(`Generating video cover for: ${filepath}`); + const jpgCoverPath = path.join(outputDir, 'cover.jpg'); + const webpCoverPath = path.join(outputDir, 'cover.webp'); + return new Promise((resolve, reject) => { + ffmpeg(filepath) + .on('end', async () => { + try { + // 使用 Sharp 将 JPG 转换为 WebP + await sharp(jpgCoverPath) + .webp({ quality: 80 }) // 设置 WebP 压缩质量 + .toFile(webpCoverPath); + + // 删除临时 JPG 文件 + await fs.unlink(jpgCoverPath); + this.logger.log(`Video cover generated at: ${webpCoverPath}`); + resolve(path.basename(webpCoverPath)); + } catch (error: any) { + this.logger.error( + `Error converting cover to WebP: ${error.message}`, + ); + reject(error); + } + }) + .on('error', (err) => { + this.logger.error(`Error generating video cover: ${err.message}`); + reject(err); + }) + .screenshots({ + count: 1, + folder: outputDir, + filename: 'cover.jpg', + size: '640x360', + }); + }); + } + private async getVideoDuration(filepath: string): Promise { + this.logger.log(`Getting video duration for file: ${filepath}`); + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filepath, (err, metadata) => { + if (err) { + this.logger.error(`Error getting video duration: ${err.message}`); + reject(err); + return; + } + const duration = metadata.format.duration || 0; + this.logger.log(`Video duration: ${duration} seconds`); + resolve(duration); + }); + }); + } + private async generateM3U8Stream( + filepath: string, + outputDir: string, + ): Promise { + const m3u8Path = path.join(outputDir, 'index.m3u8'); + this.logger.log( + `Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`, + ); + return new Promise((resolve, reject) => { + ffmpeg(filepath) + .outputOptions([ + // Improved video encoding settings + '-c:v libx264', + '-preset medium', // Balance between encoding speed and compression + '-crf 23', // Constant Rate Factor for quality + '-profile:v high', // Higher profile for better compression + '-level:v 4.1', // Updated level for better compatibility + // Parallel processing and performance + '-threads 0', // Auto-detect optimal thread count + '-x264-params keyint=48:min-keyint=48', // More precise GOP control + // HLS specific optimizations + '-hls_time 4', // Shorter segment duration for better adaptive streaming + '-hls_list_size 0', // Keep all segments in playlist + '-hls_flags independent_segments+delete_segments', // Allow segment cleanup + // Additional encoding optimizations + '-sc_threshold 0', // Disable scene change detection for more consistent segments + '-max_muxing_queue_size 1024', // Increase muxing queue size + // Output format + '-f hls', + ]) + .output(m3u8Path) + .on('start', (commandLine) => { + this.logger.log(`Starting ffmpeg with command: ${commandLine}`); + }) + .on('end', () => { + this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`); + resolve(m3u8Path); + }) + .on('error', (err) => { + const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`; + this.logger.error(errorMessage); + reject(new Error(errorMessage)); + }) + .run(); + }); + } + private async getVideoMetadata( + filepath: string, + ): Promise> { + this.logger.log(`Getting video metadata for file: ${filepath}`); + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filepath, (err, metadata) => { + if (err) { + this.logger.error(`Error getting video metadata: ${err.message}`); + reject(err); + return; + } + const videoStream = metadata.streams.find( + (stream) => stream.codec_type === 'video', + ); + const audioStream = metadata.streams.find( + (stream) => stream.codec_type === 'audio', + ); + const videoMetadata: Partial = { + width: videoStream?.width || 0, + height: videoStream?.height || 0, + duration: metadata.format.duration || 0, + videoCodec: videoStream?.codec_name || '', + audioCodec: audioStream?.codec_name || '', + }; + this.logger.log( + `Extracted video metadata: ${JSON.stringify(videoMetadata)}`, + ); + resolve(videoMetadata); + }); + }); + } +} diff --git a/apps/server/src/models/resource/resource.module.ts b/apps/server/src/models/resource/resource.module.ts new file mode 100755 index 0000000..153bc6e --- /dev/null +++ b/apps/server/src/models/resource/resource.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ResourceRouter } from './resource.router'; +import { ResourceService } from './resource.service'; +import { TrpcService } from '@server/trpc/trpc.service'; + +@Module({ + exports: [ResourceRouter, ResourceService], + providers: [ResourceRouter, ResourceService, TrpcService], +}) +export class ResourceModule { } diff --git a/apps/server/src/models/resource/resource.router.ts b/apps/server/src/models/resource/resource.router.ts new file mode 100755 index 0000000..6d4290f --- /dev/null +++ b/apps/server/src/models/resource/resource.router.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma, UpdateOrderSchema } from '@nice/common'; +import { ResourceService } from './resource.service'; +import { z, ZodType } from 'zod'; +const ResourceCreateArgsSchema: ZodType = z.any() +const ResourceCreateManyInputSchema: ZodType = z.any() +const ResourceDeleteManyArgsSchema: ZodType = z.any() +const ResourceFindManyArgsSchema: ZodType = z.any() +const ResourceFindFirstArgsSchema: ZodType = z.any() +const ResourceWhereInputSchema: ZodType = z.any() +const ResourceSelectSchema: ZodType = z.any() + +@Injectable() +export class ResourceRouter { + constructor( + private readonly trpc: TrpcService, + private readonly resourceService: ResourceService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(ResourceCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.resourceService.create(input, { staff }); + }), + createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.resourceService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(ResourceDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.resourceService.deleteMany(input); + }), + findFirst: this.trpc.procedure + .input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.resourceService.findFirst(input); + }), + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema + .mutation(async ({ input }) => { + return this.resourceService.softDeleteByIds(input.ids); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.resourceService.updateOrder(input); + }), + findMany: this.trpc.procedure + .input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.resourceService.findMany(input); + }), + findManyWithCursor: this.trpc.protectProcedure + .input(z.object({ + cursor: z.any().nullish(), + take: z.number().nullish(), + where: ResourceWhereInputSchema.nullish(), + select: ResourceSelectSchema.nullish() + })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.resourceService.findManyWithCursor(input); + }), + }); +} diff --git a/apps/server/src/models/resource/resource.service.ts b/apps/server/src/models/resource/resource.service.ts new file mode 100755 index 0000000..3a35a7e --- /dev/null +++ b/apps/server/src/models/resource/resource.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { BaseService } from '../base/base.service'; +import { + UserProfile, + db, + ObjectType, + Prisma, + Resource, + ResourceStatus, +} from '@nice/common'; + +@Injectable() +export class ResourceService extends BaseService { + constructor() { + super(db, ObjectType.RESOURCE); + } + async create( + args: Prisma.ResourceCreateArgs, + params?: { staff?: UserProfile }, + ): Promise { + if (params?.staff) { + args.data.ownerId = params?.staff?.id; + } + return super.create(args); + } + async softDeleteByFileId(fileId: string) { + return this.update({ + where: { + fileId, + }, + data: { + deletedAt: new Date(), + }, + }); + } +} diff --git a/apps/server/src/models/resource/types.ts b/apps/server/src/models/resource/types.ts new file mode 100755 index 0000000..eb060ba --- /dev/null +++ b/apps/server/src/models/resource/types.ts @@ -0,0 +1,55 @@ +import { Resource } from "@nice/common"; + +export interface ResourceProcessor { + process(resource: Resource): Promise +} +export interface ProcessResult { + success: boolean + resource: Resource + error?: Error +} + +export interface BaseMetadata { + size: number + filetype: string + filename: string + extension: string + modifiedAt: Date +} +/** + * 图片特有元数据接口 + */ +export interface ImageMetadata { + width: number; // 图片宽度(px) + height: number; // 图片高度(px) + compressedUrl?: string; + orientation?: number; // EXIF方向信息 + space?: string; // 色彩空间 (如: RGB, CMYK) + hasAlpha?: boolean; // 是否包含透明通道 +} + +/** + * 视频特有元数据接口 + */ +export interface VideoMetadata { + width?: number; + height?: number; + duration?: number; + videoCodec?: string; + audioCodec?: string; + coverUrl?: string +} + +/** + * 音频特有元数据接口 + */ +export interface AudioMetadata { + duration: number; // 音频时长(秒) + bitrate?: number; // 比特率(bps) + sampleRate?: number; // 采样率(Hz) + channels?: number; // 声道数 + codec?: string; // 音频编码格式 +} + + +export type FileMetadata = ImageMetadata & VideoMetadata & AudioMetadata & BaseMetadata \ No newline at end of file diff --git a/apps/server/src/models/staff/staff.controller.ts b/apps/server/src/models/staff/staff.controller.ts new file mode 100755 index 0000000..7a590e9 --- /dev/null +++ b/apps/server/src/models/staff/staff.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; + +import { StaffService } from './staff.service'; +import { AuthGuard } from '@server/auth/auth.guard'; +import { db } from '@nice/common'; + +@Controller('staff') +export class StaffController { + constructor(private readonly staffService: StaffService) {} + @UseGuards(AuthGuard) + @Get('find-by-id') + async findById(@Query('id') id: string) { + try { + const result = await this.staffService.findById(id); + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } + @Get('find-by-dept') + async findByDept( + @Query('dept-id') deptId: string, + @Query('domain-id') domainId: string, + ) { + try { + const result = await this.staffService.findByDept({ deptId, domainId }); + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } +} diff --git a/apps/server/src/models/staff/staff.module.ts b/apps/server/src/models/staff/staff.module.ts new file mode 100755 index 0000000..fa681dc --- /dev/null +++ b/apps/server/src/models/staff/staff.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { StaffService } from './staff.service'; +import { StaffRouter } from './staff.router'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentModule } from '../department/department.module'; +import { StaffController } from './staff.controller'; +import { StaffRowService } from './staff.row.service'; + +@Module({ + imports: [DepartmentModule], + providers: [StaffService, StaffRouter, TrpcService, StaffRowService], + exports: [StaffService, StaffRouter, StaffRowService], + controllers: [StaffController], +}) +export class StaffModule { } diff --git a/apps/server/src/models/staff/staff.router.ts b/apps/server/src/models/staff/staff.router.ts new file mode 100755 index 0000000..4768b16 --- /dev/null +++ b/apps/server/src/models/staff/staff.router.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { StaffService } from './staff.service'; // Adjust the import path as necessary +import { StaffMethodSchema, Prisma, UpdateOrderSchema } from '@nice/common'; +import { z, ZodType } from 'zod'; +import { StaffRowService } from './staff.row.service'; +const StaffCreateArgsSchema: ZodType = z.any(); +const StaffUpdateArgsSchema: ZodType = z.any(); +const StaffFindFirstArgsSchema: ZodType = z.any(); +const StaffDeleteManyArgsSchema: ZodType = z.any(); +const StaffWhereInputSchema: ZodType = z.any(); +const StaffSelectSchema: ZodType = z.any(); +const StaffUpdateInputSchema: ZodType = z.any(); +const StaffFindManyArgsSchema: ZodType = z.any(); +@Injectable() +export class StaffRouter { + constructor( + private readonly trpc: TrpcService, + private readonly staffService: StaffService, + private readonly staffRowService: StaffRowService, + ) {} + + router = this.trpc.router({ + create: this.trpc.procedure + .input(StaffCreateArgsSchema) // Assuming StaffMethodSchema.create is the Zod schema for creating staff + .mutation(async ({ input }) => { + return await this.staffService.create(input); + }), + + update: this.trpc.procedure + .input(StaffUpdateArgsSchema) // Assuming StaffMethodSchema.update is the Zod schema for updating staff + .mutation(async ({ input }) => { + return await this.staffService.update(input); + }), + updateUserDomain: this.trpc.protectProcedure + .input( + z.object({ + domainId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + return await this.staffService.updateUserDomain(input, ctx.staff); + }), + softDeleteByIds: this.trpc.protectProcedure + .input( + z.object({ + ids: z.array(z.string()), + data: StaffUpdateInputSchema.nullish(), + }), + ) + .mutation(async ({ input }) => { + return await this.staffService.softDeleteByIds(input.ids, input.data); + }), + findByDept: this.trpc.procedure + .input(StaffMethodSchema.findByDept) + .query(async ({ input }) => { + return await this.staffService.findByDept(input); + }), + findMany: this.trpc.procedure + .input(StaffFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.staffService.findMany(input); + }), + getRows: this.trpc.protectProcedure + .input(StaffMethodSchema.getRows) + .query(async ({ input, ctx }) => { + return await this.staffRowService.getRows(input, ctx.staff); + }), + findFirst: this.trpc.protectProcedure + .input(StaffFindFirstArgsSchema) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.staffService.findFirst(input); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.staffService.updateOrder(input); + }), + }); +} diff --git a/apps/server/src/models/staff/staff.row.service.ts b/apps/server/src/models/staff/staff.row.service.ts new file mode 100755 index 0000000..c2e92c2 --- /dev/null +++ b/apps/server/src/models/staff/staff.row.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { + db, + ObjectType, + StaffMethodSchema, + UserProfile, + RolePerms, + ResPerm, + Staff, + RowModelRequest, +} from '@nice/common'; +import { DepartmentService } from '../department/department.service'; +import { RowCacheService } from '../base/row-cache.service'; +import { z } from 'zod'; +import { isFieldCondition } from '../base/sql-builder'; +@Injectable() +export class StaffRowService extends RowCacheService { + constructor( + private readonly departmentService: DepartmentService, + ) { + super(ObjectType.STAFF, false); + } + createUnGroupingRowSelect(request?: RowModelRequest): string[] { + const result = super.createUnGroupingRowSelect(request).concat([ + `${this.tableName}.id AS id`, + `${this.tableName}.username AS username`, + `${this.tableName}.showname AS showname`, + `${this.tableName}.avatar AS avatar`, + `${this.tableName}.officer_id AS officer_id`, + `${this.tableName}.phone_number AS phone_number`, + `${this.tableName}.order AS order`, + `${this.tableName}.enabled AS enabled`, + 'dept.name AS dept_name', + 'domain.name AS domain_name', + ]); + return result + } + createJoinSql(request?: RowModelRequest): string[] { + return [ + `LEFT JOIN department dept ON ${this.tableName}.dept_id = dept.id`, + `LEFT JOIN department domain ON ${this.tableName}.domain_id = domain.id`, + ]; + } + protected createGetRowsFilters( + request: z.infer, + staff: UserProfile, + ) { + const condition = super.createGetRowsFilters(request); + const { domainId, includeDeleted = false } = request; + if (isFieldCondition(condition)) { + return; + } + if (domainId) { + condition.AND.push({ + field: `${this.tableName}.domain_id`, + value: domainId, + op: 'equals', + }); + } else { + condition.AND.push({ + field: `${this.tableName}.domain_id`, + op: 'blank', + }); + } + if (!includeDeleted) { + condition.AND.push({ + field: `${this.tableName}.deleted_at`, + type: 'date', + op: 'blank', + }); + } + condition.OR = []; + if (!staff.permissions.includes(RolePerms.MANAGE_ANY_STAFF)) { + if (staff.permissions.includes(RolePerms.MANAGE_DOM_STAFF)) { + condition.OR.push({ + field: 'dept.id', + value: staff.domainId, + op: 'equals', + }); + } + } + + return condition; + } + + async getPermissionContext(id: string, staff: UserProfile) { + const data = await db.staff.findUnique({ + where: { id }, + select: { + deptId: true, + domainId: true, + }, + }); + const deptId = data?.deptId; + const isFromSameDept = staff.deptIds?.includes(deptId); + const domainChildDeptIds = await this.departmentService.getDescendantIds( + staff.domainId, true + ); + const belongsToDomain = domainChildDeptIds.includes( + deptId, + ); + return { isFromSameDept, belongsToDomain }; + } + protected async setResPermissions( + data: Staff, + staff: UserProfile, + ) { + const permissions: ResPerm = {}; + const { isFromSameDept, belongsToDomain } = await this.getPermissionContext( + data.id, + staff, + ); + const setManagePermissions = (permissions: ResPerm) => { + Object.assign(permissions, { + read: true, + delete: true, + edit: true, + }); + }; + staff.permissions.forEach((permission) => { + switch (permission) { + case RolePerms.MANAGE_ANY_STAFF: + setManagePermissions(permissions); + break; + case RolePerms.MANAGE_DOM_STAFF: + if (belongsToDomain) { + setManagePermissions(permissions); + } + break; + } + }); + return { ...data, perm: permissions }; + } + +} diff --git a/apps/server/src/models/staff/staff.service.ts b/apps/server/src/models/staff/staff.service.ts new file mode 100755 index 0000000..cf37549 --- /dev/null +++ b/apps/server/src/models/staff/staff.service.ts @@ -0,0 +1,180 @@ +import { Injectable } from '@nestjs/common'; +import { + db, + StaffMethodSchema, + ObjectType, + UserProfile, + Prisma, +} from '@nice/common'; +import { DepartmentService } from '../department/department.service'; +import { z } from 'zod'; +import { BaseService } from '../base/base.service'; +import * as argon2 from 'argon2'; +import EventBus, { CrudOperation } from '@server/utils/event-bus'; + +@Injectable() +export class StaffService extends BaseService { + + constructor(private readonly departmentService: DepartmentService) { + super(db, ObjectType.STAFF, true); + } + /** + * 获取某一单位下所有staff的记录 + * @param deptId 单位的id + * @returns 查到的staff记录 + */ + async findByDept(data: z.infer) { + const { deptId, domainId } = data; + const childDepts = await this.departmentService.getDescendantIds(deptId, true); + const result = await db.staff.findMany({ + where: { + deptId: { in: childDepts }, + domainId, + }, + }); + return result; + } + async create(args: Prisma.StaffCreateArgs) { + const { data } = args; + await this.validateUniqueFields(data); + const createData = { + ...data, + password: await argon2.hash((data.password || '123456') as string), + }; + const result = await super.create({ ...args, data: createData }); + this.emitDataChangedEvent(result, CrudOperation.CREATED); + return result; + } + async update(args: Prisma.StaffUpdateArgs) { + const { data, where } = args; + await this.validateUniqueFields(data, where.id); + const updateData = { + ...data, + ...(data.password && { password: await argon2.hash(data.password as string) }) + }; + const result = await super.update({ ...args, data: updateData }); + this.emitDataChangedEvent(result, CrudOperation.UPDATED); + return result; + } + private async validateUniqueFields(data: any, excludeId?: string) { + const uniqueFields = [ + { field: 'officerId', errorMsg: (val: string) => `证件号为${val}的用户已存在` }, + { field: 'phoneNumber', errorMsg: (val: string) => `手机号为${val}的用户已存在` }, + { field: 'username', errorMsg: (val: string) => `帐号为${val}的用户已存在` } + ]; + for (const { field, errorMsg } of uniqueFields) { + if (data[field]) { + const count = await db.staff.count({ + where: { + [field]: data[field], + ...(excludeId && { id: { not: excludeId } }) + } + }); + if (count > 0) { + throw new Error(errorMsg(data[field])); + } + } + } + } + + + private emitDataChangedEvent(data: any, operation: CrudOperation) { + EventBus.emit("dataChanged", { + type: this.objectType, + operation, + data, + }); + } + + /** + * 更新员工DomainId + * @param data 包含domainId对象 + * @returns 更新后的员工记录 + */ + async updateUserDomain(data: { domainId?: string }, staff?: UserProfile) { + let { domainId } = data; + if (staff.domainId !== domainId) { + const result = await this.update({ + where: { id: staff.id }, + data: { + domainId, + deptId: null, + }, + }); + return result; + } else { + return staff; + } + } + + + // /** + // * 根据关键词或ID集合查找员工 + // * @param data 包含关键词、域ID和ID集合的对象 + // * @returns 匹配的员工记录列表 + // */ + // async findMany(data: z.infer) { + // const { keyword, domainId, ids, deptId, limit = 30 } = data; + // const idResults = ids + // ? await db.staff.findMany({ + // where: { + // id: { in: ids }, + // deletedAt: null, + // domainId, + // deptId, + // }, + // select: { + // id: true, + // showname: true, + // username: true, + // deptId: true, + // domainId: true, + // department: true, + // domain: true, + // }, + // }) + // : []; + + // const mainResults = await db.staff.findMany({ + // where: { + // deletedAt: null, + // domainId, + // deptId, + // OR: (keyword || ids) && [ + // { showname: { contains: keyword } }, + // { + // username: { + // contains: keyword, + // }, + // }, + // { phoneNumber: { contains: keyword } }, + // // { + // // id: { in: ids }, + // // }, + // ], + // }, + // select: { + // id: true, + // showname: true, + // username: true, + // deptId: true, + // domainId: true, + // department: true, + // domain: true, + // }, + // orderBy: { order: 'asc' }, + // take: limit !== -1 ? limit : undefined, + // }); + // // Combine results, ensuring no duplicates + // const combinedResults = [ + // ...mainResults, + // ...idResults.filter( + // (idResult) => + // !mainResults.some((mainResult) => mainResult.id === idResult.id), + // ), + // ]; + + // return combinedResults; + // } + +} diff --git a/apps/server/src/models/taxonomy/taxonomy.controller.ts b/apps/server/src/models/taxonomy/taxonomy.controller.ts new file mode 100755 index 0000000..1d5f36c --- /dev/null +++ b/apps/server/src/models/taxonomy/taxonomy.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; + +import { TaxonomyService } from './taxonomy.service'; +import { AuthGuard } from '@server/auth/auth.guard'; +import { db } from '@nice/common'; + +@Controller('tax') +export class TaxonomyController { + constructor(private readonly taxService: TaxonomyService) {} + @UseGuards(AuthGuard) + @Get('find-by-id') + async findById(@Query('id') id: string) { + try { + const result = await this.taxService.findById({ id }); + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } +} diff --git a/apps/server/src/models/taxonomy/taxonomy.module.ts b/apps/server/src/models/taxonomy/taxonomy.module.ts new file mode 100755 index 0000000..64e6f55 --- /dev/null +++ b/apps/server/src/models/taxonomy/taxonomy.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TaxonomyRouter } from './taxonomy.router'; +import { TaxonomyService } from './taxonomy.service'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { TaxonomyController } from './taxonomy.controller'; + +@Module({ + providers: [TaxonomyRouter, TaxonomyService, TrpcService], + exports: [TaxonomyRouter, TaxonomyService], + controllers: [TaxonomyController], +}) +export class TaxonomyModule {} diff --git a/apps/server/src/models/taxonomy/taxonomy.router.ts b/apps/server/src/models/taxonomy/taxonomy.router.ts new file mode 100755 index 0000000..e827b36 --- /dev/null +++ b/apps/server/src/models/taxonomy/taxonomy.router.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { TaxonomyService } from './taxonomy.service'; +import { TaxonomyMethodSchema } from '@nice/common'; + +@Injectable() +export class TaxonomyRouter { + constructor( + private readonly trpc: TrpcService, + private readonly taxonomyService: TaxonomyService, + ) { } + + router = this.trpc.router({ + create: this.trpc.procedure + .input(TaxonomyMethodSchema.create) + .mutation(async ({ input }) => { + return this.taxonomyService.create(input); + }), + findById: this.trpc.procedure + .input(TaxonomyMethodSchema.findById) + .query(async ({ input }) => { + return this.taxonomyService.findById(input); + }), + findBySlug: this.trpc.procedure + .input(TaxonomyMethodSchema.findBySlug) + .query(async ({ input }) => { + return this.taxonomyService.findBySlug(input); + }), + update: this.trpc.procedure + .input(TaxonomyMethodSchema.update) + .mutation(async ({ input }) => { + return this.taxonomyService.update(input); + }), + delete: this.trpc.procedure + .input(TaxonomyMethodSchema.delete) + .mutation(async ({ input }) => { + return this.taxonomyService.delete(input); + }), + deleteMany: this.trpc.procedure + .input(TaxonomyMethodSchema.deleteMany) + .mutation(async ({ input }) => { + return this.taxonomyService.deleteMany(input); + }), + paginate: this.trpc.procedure + .input(TaxonomyMethodSchema.paginate!) + .query(async ({ input }) => { + return this.taxonomyService.paginate(input); + }), + getAll: this.trpc.procedure + .input(TaxonomyMethodSchema.getAll) + .query(async ({ input }) => { + return this.taxonomyService.getAll(input); + }), + }); +} diff --git a/apps/server/src/models/taxonomy/taxonomy.service.ts b/apps/server/src/models/taxonomy/taxonomy.service.ts new file mode 100755 index 0000000..a8efe3e --- /dev/null +++ b/apps/server/src/models/taxonomy/taxonomy.service.ts @@ -0,0 +1,203 @@ +import { Injectable } from '@nestjs/common'; +import { db, TaxonomyMethodSchema, Prisma } from '@nice/common'; +import { redis } from '@server/utils/redis/redis.service'; +import { deleteByPattern } from '@server/utils/redis/utils'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +@Injectable() +export class TaxonomyService { + constructor() {} + + /** + * 清除分页缓存,删除所有以'taxonomies:page:'开头的键 + */ + private async invalidatePaginationCache() { + deleteByPattern('taxonomies:page:*'); + } + + /** + * 创建新的分类记录 + * @param input 分类创建信息 + * @returns 新创建的分类记录 + */ + async create(input: z.infer) { + // 获取当前分类数量,设置新分类的order值为count + 1 + const count = await db.taxonomy.count(); + const taxonomy = await db.taxonomy.create({ + data: { ...input, order: count + 1 }, + }); + + // 删除该分类的缓存及分页缓存 + await redis.del(`taxonomy:${taxonomy.id}`); + await this.invalidatePaginationCache(); + return taxonomy; + } + + /** + * 根据name查找分类记录 + * @param input 包含分类name的对象 + * @returns 查找到的分类记录 + */ + async findByName(input: z.infer) { + const { name } = input; + const cacheKey = `taxonomy:${name}`; + const cachedTaxonomy = await redis.get(cacheKey); + if (cachedTaxonomy) { + return JSON.parse(cachedTaxonomy); + } + const taxonomy = await db.taxonomy.findUnique({ where: { name } }); + if (taxonomy) { + await redis.setex(cacheKey, 60, JSON.stringify(taxonomy)); + } + return taxonomy; + } + async findBySlug(input: z.infer) { + const { slug } = input; + const cacheKey = `taxonomy-slug:${slug}`; + const cachedTaxonomy = await redis.get(cacheKey); + if (cachedTaxonomy) { + return JSON.parse(cachedTaxonomy); + } + const taxonomy = await db.taxonomy.findUnique({ where: { slug } }); + if (taxonomy) { + await redis.setex(cacheKey, 60, JSON.stringify(taxonomy)); + } + return taxonomy; + } + /** + * 根据ID查找分类记录 + * @param input 包含分类ID的对象 + * @returns 查找到的分类记录 + */ + async findById(input: z.infer) { + const cacheKey = `taxonomy:${input.id}`; + const cachedTaxonomy = await redis.get(cacheKey); + if (cachedTaxonomy) { + return JSON.parse(cachedTaxonomy); + } + const taxonomy = await db.taxonomy.findUnique({ where: { id: input.id } }); + if (taxonomy) { + await redis.setex(cacheKey, 60, JSON.stringify(taxonomy)); + } + return taxonomy; + } + + /** + * 更新分类记录 + * @param input 包含ID和其他更新字段的对象 + * @returns 更新后的分类记录 + */ + async update(input: any) { + const { id, ...data } = input; + const updatedTaxonomy = await db.taxonomy.update({ where: { id }, data }); + + // 删除该分类的缓存及分页缓存 + await redis.del(`taxonomy:${updatedTaxonomy.id}`); + await this.invalidatePaginationCache(); + return updatedTaxonomy; + } + + /** + * 删除分类记录(软删除) + * @param input 包含分类ID的对象 + * @returns 删除后的分类记录 + */ + async delete(input: any) { + const deletedTaxonomy = await db.taxonomy.update({ + where: { id: input.id }, + data: { deletedAt: new Date() }, + }); + + // 删除该分类的缓存及分页缓存 + await redis.del(`taxonomy:${deletedTaxonomy.id}`); + await this.invalidatePaginationCache(); + return deletedTaxonomy; + } + + /** + * 批量删除分类记录(软删除) + * @param input 包含要删除的分类ID数组的对象 + * @returns 删除操作结果,包括删除的记录数 + */ + async deleteMany(input: any) { + const { ids } = input; + if (!ids || ids.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No IDs provided for deletion.', + }); + } + const deletedTaxonomies = await db.taxonomy.updateMany({ + where: { + id: { in: ids }, + }, + data: { deletedAt: new Date() }, + }); + // 删除每个分类的缓存及分页缓存 + await Promise.all( + ids.map(async (id: string) => redis.del(`taxonomy:${id}`)), + ); + await this.invalidatePaginationCache(); + return { success: true, count: deletedTaxonomies.count }; + } + + /** + * 分页查询分类记录 + * @param input 包含分页参数的对象 + * @returns 分类列表及总记录数 + */ + async paginate(input: any) { + const cacheKey = `taxonomies:page:${input.page}:size:${input.pageSize}`; + const cachedData = await redis.get(cacheKey); + if (cachedData) { + return JSON.parse(cachedData); + } + const { page, pageSize } = input; + const [items, totalCount] = await Promise.all([ + db.taxonomy.findMany({ + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { order: 'asc' }, + where: { deletedAt: null }, + }), + db.taxonomy.count({ where: { deletedAt: null } }), + ]); + const result = { items, totalCount }; + + // 缓存结果并设置过期时间 + await redis.setex(cacheKey, 60, JSON.stringify(result)); + return result; + } + + /** + * 获取所有未删除的分类记录 + * @returns 分类记录列表 + */ + async getAll(input: z.infer) { + const { type } = input; + let filter: Prisma.TaxonomyWhereInput = { + deletedAt: null, + }; + + if (type !== undefined) { + filter = { + ...filter, + OR: [ + { objectType: { has: type } }, // objectType 包含 type + ], + }; + } + return db.taxonomy.findMany({ + where: filter, + orderBy: { order: 'asc' }, + select: { + name: true, + id: true, + slug: true, + objectType: true, + order: true, + }, + }); + } +} diff --git a/apps/server/src/models/term/term.controller.ts b/apps/server/src/models/term/term.controller.ts new file mode 100755 index 0000000..787bed4 --- /dev/null +++ b/apps/server/src/models/term/term.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; + +import { TermService } from './term.service'; +import { AuthGuard } from '@server/auth/auth.guard'; +import { db } from '@nice/common'; + +@Controller('term') +export class TermController { + constructor(private readonly termService: TermService) {} + @UseGuards(AuthGuard) + @Get('get-tree-data') + async getTreeData(@Query('tax-id') taxId: string) { + try { + const result = await this.termService.getTreeData({ taxonomyId: taxId }); + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } +} diff --git a/apps/server/src/models/term/term.module.ts b/apps/server/src/models/term/term.module.ts new file mode 100755 index 0000000..850dbc1 --- /dev/null +++ b/apps/server/src/models/term/term.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TermService } from './term.service'; +import { TermRouter } from './term.router'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentModule } from '../department/department.module'; +import { TermController } from './term.controller'; +import { RoleMapModule } from '../rbac/rbac.module'; +import { TermRowService } from './term.row.service'; + +@Module({ + imports: [DepartmentModule, RoleMapModule], + providers: [TermService, TermRouter, TrpcService, TermRowService], + exports: [TermService, TermRouter], + controllers: [TermController], +}) +export class TermModule { } diff --git a/apps/server/src/models/term/term.router.ts b/apps/server/src/models/term/term.router.ts new file mode 100755 index 0000000..43afff5 --- /dev/null +++ b/apps/server/src/models/term/term.router.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { TermService } from './term.service'; // Adjust the import path as necessary +import { Prisma, TermMethodSchema, UpdateOrderSchema } from '@nice/common'; +import { z, ZodType } from 'zod'; +import { TermRowService } from './term.row.service'; +const TermCreateArgsSchema: ZodType = z.any(); +const TermUpdateArgsSchema: ZodType = z.any(); +const TermFindFirstArgsSchema: ZodType = z.any(); +const TermFindManyArgsSchema: ZodType = z.any(); +@Injectable() +export class TermRouter { + constructor( + private readonly trpc: TrpcService, + private readonly termService: TermService, + private readonly termRowService: TermRowService, + ) {} + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(TermCreateArgsSchema) + .mutation(async ({ input, ctx }) => { + const { staff } = ctx; + return this.termService.create(input, { staff }); + }), + update: this.trpc.protectProcedure + .input(TermUpdateArgsSchema) + .mutation(async ({ input }) => { + return this.termService.update(input); + }), + findMany: this.trpc.procedure + .input(TermFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.termService.findMany(input); + }), + findFirst: this.trpc.procedure + .input(TermFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.termService.findFirst(input); + }), + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema + .mutation(async ({ input }) => { + return this.termService.softDeleteByIds(input.ids); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.termService.updateOrder(input); + }), + upsertTags: this.trpc.protectProcedure + .input( + z.object({ + tags: z.array(z.string()), + }), + ) + .mutation(async ({ input, ctx }) => { + const { staff } = ctx; + return this.termService.upsertTags(staff, input.tags); + }), + getChildSimpleTree: this.trpc.procedure + .input(TermMethodSchema.getSimpleTree) + .query(async ({ input, ctx }) => { + const { staff } = ctx; + return await this.termService.getChildSimpleTree(staff, input); + }), + getParentSimpleTree: this.trpc.procedure + .input(TermMethodSchema.getSimpleTree) + .query(async ({ input, ctx }) => { + const { staff } = ctx; + return await this.termService.getParentSimpleTree(staff, input); + }), + getTreeData: this.trpc.protectProcedure + .input(TermMethodSchema.getTreeData) + .query(async ({ input }) => { + return await this.termService.getTreeData(input); + }), + getRows: this.trpc.protectProcedure + .input(TermMethodSchema.getRows) + .query(async ({ input, ctx }) => { + return await this.termRowService.getRows(input, ctx.staff); + }), + }); +} diff --git a/apps/server/src/models/term/term.row.service.ts b/apps/server/src/models/term/term.row.service.ts new file mode 100755 index 0000000..2b20e62 --- /dev/null +++ b/apps/server/src/models/term/term.row.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { + ObjectType, + RowModelRequest, + TermMethodSchema, + UserProfile, +} from '@nice/common'; +import { date, z } from 'zod'; +import { RowCacheService } from '../base/row-cache.service'; +import { isFieldCondition } from '../base/sql-builder'; + +@Injectable() +export class TermRowService extends RowCacheService { + constructor() { + super(ObjectType.TERM, false); + } + createUnGroupingRowSelect( + requset: z.infer, + ): string[] { + const result = super + .createUnGroupingRowSelect(requset) + .concat([ + `${this.tableName}.name AS name`, + `${this.tableName}.order AS order`, + `${this.tableName}.has_children AS has_children`, + `${this.tableName}.parent_id AS parent_id`, + `${this.tableName}.domain_id AS domain_id`, + `taxonomy.name AS taxonomy_name`, + `taxonomy.id AS taxonomy_id`, + ]); + return result; + } + createJoinSql(request?: RowModelRequest): string[] { + return [ + `LEFT JOIN taxonomy ON ${this.tableName}.taxonomy_id = taxonomy.id`, + ]; + } + protected createGetRowsFilters( + request: z.infer, + staff: UserProfile, + ) { + const condition = super.createGetRowsFilters(request); + const { parentId, domainId, includeDeleted = false, taxonomyId } = request; + if (isFieldCondition(condition)) { + return; + } + + if (request.groupKeys.length === 0) { + if (parentId) { + condition.AND.push({ + field: `${this.tableName}.parent_id`, + value: parentId, + op: 'equals', + }); + } else if (parentId === null) { + condition.AND.push({ + field: `${this.tableName}.parent_id`, + op: 'blank', + }); + } + } + if (domainId) { + condition.AND.push({ + field: `${this.tableName}.domain_id`, + value: domainId, + op: 'equals', + }); + } else if (domainId === null) { + condition.AND.push({ + field: `${this.tableName}.domain_id`, + op: 'blank', + }); + } + if (taxonomyId) { + condition.AND.push({ + field: `${this.tableName}.taxonomy_id`, + value: taxonomyId, + op: 'equals', + }); + } + if (!includeDeleted) { + condition.AND.push({ + field: `${this.tableName}.deleted_at`, + type: 'date', + op: 'blank', + }); + } + + return condition; + } +} diff --git a/apps/server/src/models/term/term.service.ts b/apps/server/src/models/term/term.service.ts new file mode 100755 index 0000000..cc119e6 --- /dev/null +++ b/apps/server/src/models/term/term.service.ts @@ -0,0 +1,425 @@ +import { Injectable } from '@nestjs/common'; +import { + TermMethodSchema, + db, + Staff, + Term, + Prisma, + TermDto, + TreeDataNode, + UserProfile, + getUniqueItems, + RolePerms, + TaxonomySlug, + ObjectType, + TermAncestry, +} from '@nice/common'; +import { z } from 'zod'; +import { BaseTreeService } from '../base/base.tree.service'; +import EventBus, { CrudOperation } from '@server/utils/event-bus'; +import { formatToTermTreeData, mapToTermSimpleTree } from './utils'; + +@Injectable() +export class TermService extends BaseTreeService { + constructor() { + super(db, ObjectType.TERM, 'termAncestry', true); + } + + async create(args: Prisma.TermCreateArgs, params?: { staff?: UserProfile }) { + args.data.createdBy = params?.staff?.id; + const result = await super.create(args); + EventBus.emit('dataChanged', { + type: this.objectType, + operation: CrudOperation.CREATED, + data: result, + }); + return result; + } + async update(args: Prisma.TermUpdateArgs) { + const result = await super.update(args); + EventBus.emit('dataChanged', { + type: this.objectType, + operation: CrudOperation.UPDATED, + data: result, + }); + return result; + } + + /** + * 删除现有单位并清理DeptAncestry关系。 + * @param data - 用于删除现有单位的数据。 + * @returns 删除的单位对象。 + */ + async softDeleteByIds(ids: string[]) { + const descendantIds = await this.getDescendantIds(ids, true); + const result = await super.softDeleteByIds(descendantIds); + EventBus.emit('dataChanged', { + type: this.objectType, + operation: CrudOperation.DELETED, + data: result, + }); + return result; + } + + // async query(data: z.infer) { + // const { limit = 10, initialIds, taxonomyId, taxonomySlug } = data; + // // Fetch additional objects excluding initialIds + // const ids = + // typeof initialIds === 'string' ? [initialIds] : initialIds || []; + // const initialTerms = await db.term.findMany({ + // where: { + // id: { + // in: ids, + // }, + // }, + // include: { + // domain: true, + // children: true, + // }, + // }); + // const terms = await db.term.findMany({ + // where: { + // taxonomyId, + // taxonomy: taxonomySlug && { slug: taxonomySlug }, + // deletedAt: null, + // }, + // take: limit !== -1 ? limit! : undefined, + // include: { + // domain: true, + // taxonomy: true, + // }, + // orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], + // }); + + // const results = getUniqueItems( + // [...initialTerms, ...terms].filter(Boolean), + // 'id', + // ); + // return results; + // } + // /** + + async upsertTags(staff: UserProfile, tags: string[]) { + const tagTax = await db.taxonomy.findFirst({ + where: { + slug: TaxonomySlug.TAG, + }, + }); + // 批量查找所有存在的标签 + const existingTerms = await db.term.findMany({ + where: { + name: { + in: tags, + }, + taxonomyId: tagTax.id, + }, + }); + + // 找出不存在的标签 + const existingTagNames = new Set(existingTerms.map((term) => term.name)); + const newTags = tags.filter((tag) => !existingTagNames.has(tag)); + // 批量创建不存在的标签 + const newTerms = await Promise.all( + newTags.map((tag) => + this.create({ + data: { + name: tag, + taxonomyId: tagTax.id, + domainId: staff.domainId, + }, + }), + ), + ); + + // 合并现有标签和新创建的标签 + return [...existingTerms, ...newTerms]; + } + + // /** + // * 查找多个术语并生成TermDto对象。 + // * + // * @param staff 当前操作的工作人员 + // * @param ids 术语ID + // * @returns 包含详细信息的术语对象 + // */ + // async findByIds(ids: string[], staff?: UserProfile) { + // const terms = await db.term.findMany({ + // where: { + // id: { + // in: ids, + // }, + // }, + // include: { + // domain: true, + // children: true, + // }, + // }); + // return await Promise.all( + // terms.map(async (term) => { + // return await this.transformDto(term, staff); + // }), + // ); + // } + // /** + // * 获取指定条件下的术语子节点。 + // * + // * @param staff 当前操作的工作人员 + // * @param data 查询条件 + // * @returns 子节点术语列表 + // */ + // async getChildren( + // staff: UserProfile, + // data: z.infer, + // ) { + // const { parentId, domainId, taxonomyId, cursor, limit = 10 } = data; + // let queryCondition: Prisma.TermWhereInput = { + // taxonomyId, + // parentId: parentId, + // OR: [{ domainId: null }], + // deletedAt: null, + // }; + // if ( + // staff?.permissions?.includes(RolePerms.MANAGE_ANY_TERM) || + // staff?.permissions?.includes(RolePerms.READ_ANY_TERM) + // ) { + // queryCondition.OR = undefined; + // } else { + // queryCondition.OR = queryCondition.OR.concat([ + // { domainId: staff?.domainId }, + // { domainId: null }, + // ]); + // } + // const terms = await db.term.findMany({ + // where: queryCondition, + // include: { + // children: { + // where: { + // deletedAt: null, + // }, + // }, + // }, + // take: limit + 1, + // cursor: cursor + // ? { createdAt: cursor.split('_')[0], id: cursor.split('_')[1] } + // : undefined, + // }); + // let nextCursor: typeof cursor | undefined = undefined; + // if (terms.length > limit) { + // const nextItem = terms.pop(); + // nextCursor = `${nextItem.createdAt.toISOString()}_${nextItem!.id}`; + // } + // const termDtos = await Promise.all( + // terms.map((item) => this.transformDto(item, staff)), + // ); + // return { + // items: termDtos, + // nextCursor, + // }; + // } + + async getTreeData(data: z.infer) { + const { taxonomyId, taxonomySlug, domainId } = data; + let terms = []; + if (taxonomyId) { + terms = await db.term.findMany({ + where: { taxonomyId, domainId, deletedAt: null }, + include: { children: true }, + orderBy: [{ order: 'asc' }], + }); + } else if (taxonomySlug) { + terms = await db.term.findMany({ + where: { + taxonomy: { + slug: taxonomySlug, + }, + deletedAt: null, + domainId, + }, + include: { children: true }, + orderBy: [{ order: 'asc' }], + }); + } + // Map to store terms by id for quick lookup + const termMap = new Map(); + terms.forEach((term) => + termMap.set(term.id, { + ...term, + children: [], + key: term.id, + value: term.id, + title: term.name, + isLeaf: true, // Initialize as true, will update later if it has children + }), + ); + // Root nodes collection + const roots = []; + // Build the tree structure iteratively + terms.forEach((term) => { + if (term.parentId) { + const parent = termMap.get(term.parentId); + if (parent) { + parent.children.push(termMap.get(term.id)); + parent.isLeaf = false; // Update parent's isLeaf field + } + } else { + roots.push(termMap.get(term.id)); + } + }); + return roots as TreeDataNode[]; + } + + async getChildSimpleTree( + staff: UserProfile, + data: z.infer, + ) { + const { domainId = null, permissions } = staff; + const hasAnyPerms = + staff?.permissions?.includes(RolePerms.MANAGE_ANY_TERM) || + staff?.permissions?.includes(RolePerms.READ_ANY_TERM); + const { termIds, parentId, taxonomyId } = data; + // 提取非空 deptIds + const validTermIds = termIds?.filter((id) => id !== null) ?? []; + const hasNullTermId = termIds?.includes(null) ?? false; + + const [childrenData, selfData] = await Promise.all([ + db.termAncestry.findMany({ + where: { + ...(termIds && { + OR: [ + ...(validTermIds.length + ? [{ ancestorId: { in: validTermIds } }] + : []), + ...(hasNullTermId ? [{ ancestorId: null }] : []), + ], + }), + descendant: { + taxonomyId: taxonomyId, + // 动态权限控制条件 + ...(hasAnyPerms + ? {} // 当有全局权限时,不添加任何额外条件 + : { + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), + }, + ancestorId: parentId, + relDepth: 1, + }, + include: { + descendant: { include: { children: true } }, + }, + orderBy: { descendant: { order: 'asc' } }, + }), + termIds + ? db.term.findMany({ + where: { + ...(termIds && { + OR: [ + ...(validTermIds.length + ? [{ id: { in: validTermIds } }] + : []), + ], + }), + taxonomyId: taxonomyId, + // 动态权限控制条件 + ...(hasAnyPerms + ? {} // 当有全局权限时,不添加任何额外条件 + : { + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), + }, + include: { children: true }, + orderBy: { order: 'asc' }, + }) + : [], + ]); + const children = childrenData + .map(({ descendant }) => descendant) + .filter(Boolean) + .map(formatToTermTreeData); + const selfItems = selfData.map(formatToTermTreeData); + return getUniqueItems([...children, ...selfItems], 'id'); + } + + async getParentSimpleTree( + staff: UserProfile, + data: z.infer, + ) { + const { domainId = null, permissions } = staff; + const hasAnyPerms = + permissions.includes(RolePerms.READ_ANY_TERM) || + permissions.includes(RolePerms.MANAGE_ANY_TERM); + // 解构输入参数 + const { termIds, taxonomyId } = data; + + // 并行查询父级部门ancestry和自身部门数据 + // 使用Promise.all提高查询效率,减少等待时间 + const [parentData, selfData] = await Promise.all([ + // 查询指定部门的所有祖先节点,包含子节点和父节点信息 + db.termAncestry.findMany({ + where: { + descendantId: { in: termIds }, // 查询条件:descendant在给定的部门ID列表中 + ancestor: { + taxonomyId: taxonomyId, + ...(hasAnyPerms + ? {} // 当有全局权限时,不添加任何额外条件 + : { + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), + }, + }, + include: { + ancestor: { + include: { + children: true, // 包含子节点信息 + parent: true, // 包含父节点信息 + }, + }, + }, + orderBy: { ancestor: { order: 'asc' } }, // 按祖先节点顺序升序排序 + }), + + // 查询自身部门数据 + db.term.findMany({ + where: { + id: { in: termIds }, + taxonomyId: taxonomyId, + ...(hasAnyPerms + ? {} // 当有全局权限时,不添加任何额外条件 + : { + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), + }, + include: { children: true }, // 包含子节点信息 + orderBy: { order: 'asc' }, // 按顺序升序排序 + }), + ]); + + // 处理父级节点:过滤并映射为简单树结构 + const parents = parentData + .map(({ ancestor }) => ancestor) // 提取祖先节点 + .filter((ancestor) => ancestor) // 过滤有效且超出根节点层级的节点 + .map(mapToTermSimpleTree); // 映射为简单树结构 + + // 处理自身节点:映射为简单树结构 + const selfItems = selfData.map(mapToTermSimpleTree); + + // 合并并去重父级和自身节点,返回唯一项 + return getUniqueItems([...parents, ...selfItems], 'id'); + } +} diff --git a/apps/server/src/models/term/utils.ts b/apps/server/src/models/term/utils.ts new file mode 100755 index 0000000..65bddcd --- /dev/null +++ b/apps/server/src/models/term/utils.ts @@ -0,0 +1,24 @@ +import { TreeDataNode } from '@nice/common'; + +export function formatToTermTreeData(term: any): TreeDataNode { + return { + id: term.id, + key: term.id, + value: term.id, + title: term.name, + order: term.order, + pId: term.parentId, + isLeaf: !Boolean(term.children?.length), + }; +} +export function mapToTermSimpleTree(term: any): TreeDataNode { + return { + id: term.id, + key: term.id, + value: term.id, + title: term.name, + order: term.order, + pId: term.parentId, + isLeaf: !Boolean(term.children?.length), + }; +} diff --git a/apps/server/src/models/transform/transform.module.ts b/apps/server/src/models/transform/transform.module.ts new file mode 100755 index 0000000..e7d2550 --- /dev/null +++ b/apps/server/src/models/transform/transform.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TransformRouter } from './transform.router'; +import { TransformService } from './transform.service'; +import { TermModule } from '@server/models/term/term.module'; +import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { DepartmentModule } from '../department/department.module'; +import { StaffModule } from '../staff/staff.module'; +// import { TransformController } from './transform.controller'; +@Module({ + imports: [ + DepartmentModule, + StaffModule, + TermModule, + TaxonomyModule, + ], + providers: [TransformService, TransformRouter, TrpcService], + exports: [TransformRouter, TransformService], + // controllers:[TransformController] +}) +export class TransformModule {} diff --git a/apps/server/src/models/transform/transform.router.ts b/apps/server/src/models/transform/transform.router.ts new file mode 100755 index 0000000..24cbd05 --- /dev/null +++ b/apps/server/src/models/transform/transform.router.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { TransformService } from './transform.service'; +import { TransformMethodSchema } from '@nice/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +@Injectable() +export class TransformRouter { + constructor( + private readonly trpc: TrpcService, + private readonly transformService: TransformService, + ) {} + router = this.trpc.router({ + importTerms: this.trpc.protectProcedure + .input(TransformMethodSchema.importTerms) // expect input according to the schema + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return this.transformService.importTerms(staff, input); + }), + importDepts: this.trpc.protectProcedure + .input(TransformMethodSchema.importDepts) // expect input according to the schema + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return this.transformService.importDepts(staff, input); + }), + importStaffs: this.trpc.protectProcedure + .input(TransformMethodSchema.importStaffs) // expect input according to the schema + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return this.transformService.importStaffs(input); + }), + }); +} diff --git a/apps/server/src/models/transform/transform.service.ts b/apps/server/src/models/transform/transform.service.ts new file mode 100755 index 0000000..a8dd88c --- /dev/null +++ b/apps/server/src/models/transform/transform.service.ts @@ -0,0 +1,541 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as ExcelJS from 'exceljs'; +import { TransformMethodSchema, db, Prisma, Staff } from '@nice/common'; +import dayjs from 'dayjs'; +import * as argon2 from 'argon2'; +import { TaxonomyService } from '@server/models/taxonomy/taxonomy.service'; +import { uploadFile } from '@server/utils/tool'; +import { DepartmentService } from '../department/department.service'; +import { StaffService } from '../staff/staff.service'; +import { z, ZodError } from 'zod'; +import { deleteByPattern } from '@server/utils/redis/utils'; + +class TreeNode { + value: string; + children: TreeNode[]; + + constructor(value: string) { + this.value = value; + this.children = []; + } + + addChild(childValue: string): TreeNode { + let newChild = undefined; + if (this.children.findIndex((child) => child.value === childValue) === -1) { + newChild = new TreeNode(childValue); + this.children.push(newChild); + } + return this.children.find((child) => child.value === childValue); + } +} + +@Injectable() +export class TransformService { + constructor( + private readonly departmentService: DepartmentService, + private readonly staffService: StaffService, + private readonly taxonomyService: TaxonomyService, + ) {} + private readonly logger = new Logger(TransformService.name); + + excelDateToISO(excelDate: number) { + // 设置 Excel 序列号的起点 + const startDate = dayjs('1899-12-31'); + // 加上 Excel 中的天数(注意必须减去2,因为 Excel 错误地把1900年当作闰年) + const date = startDate.add(excelDate, 'day'); + // 转换为 ISO 字符串 + return date.toDate(); + } + async getDepts(domainId: string, cellStr: string) { + const pattern = /[\s、,,;.。;\n]+/; + const depts: string[] = []; + if (pattern.test(cellStr)) { + const deptNames = cellStr.split(pattern); + for (const name of deptNames) { + const dept = await this.departmentService.findInDomain(domainId, name); + if (dept) depts.push(dept.id); + } + } else { + const dept = await this.departmentService.findInDomain(domainId, cellStr); + if (dept) depts.push(dept.id); + } + + if (depts.length === 0) { + this.logger.error(`未找到单位:${cellStr}`); + } + return depts; + } + async getStaffs(deptIds: string[], cellStr: string) { + const staffs: string[] = []; + const pattern = /[\s、,,;.。;\n]+/; + const allStaffsArrays = await Promise.all( + deptIds.map((deptId) => this.staffService.findByDept({ deptId })), + ); + const combinedStaffs = allStaffsArrays.reduce( + (acc, curr) => acc.concat(curr), + [], + ); + if (pattern.test(cellStr)) { + const staffNames = cellStr.split(pattern); + + for (const name of staffNames) { + if ( + combinedStaffs.map((staff, index) => staff?.showname).includes(name) + ) { + const staffWithName = combinedStaffs.find( + (staff) => staff?.showname === name, + ); + if (staffWithName) { + // 将该员工的 ID 添加到 staffIds 数组中 + staffs.push(staffWithName.id); + } + } + // if (staff) staffs.push(staff.staffId); + } + } else { + // const staff = await this.lanxin.getStaffsByDepartment(deptIds); + // if (staff) staffs.push(staff.staffId); + if ( + combinedStaffs.map((staff, index) => staff?.showname).includes(cellStr) + ) { + const staffWithName = combinedStaffs.find( + (staff) => staff?.showname === cellStr, + ); + if (staffWithName) { + // 将该员工的 ID 添加到 staffIds 数组中 + staffs.push(staffWithName.id); + } + } + } + if (staffs.length === 0) { + this.logger.error(`未找到人员:${cellStr}`); + } + return staffs; + } + + buildTree(data: string[][]): TreeNode { + const root = new TreeNode('root'); + try { + for (const path of data) { + let currentNode = root; + for (const value of path) { + currentNode = currentNode.addChild(value); + } + } + return root; + } catch (error) { + console.error(error); + } + } + async generateTreeFromFile(file: Buffer): Promise<{ tree: TreeNode }> { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file); + const worksheet = workbook.getWorksheet(1); + + const data: string[][] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { + // Skip header row if any + try { + const rowData: string[] = (row.values as string[]) + .slice(2) + .map((cell) => (cell || '').toString()); + data.push(rowData.map((value) => value.trim())); + } catch (error) { + throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`); + } + } + }); + // Fill forward values + for (let i = 1; i < data.length; i++) { + for (let j = 0; j < data[i].length; j++) { + if (!data[i][j]) data[i][j] = data[i - 1][j]; + } + } + return { tree: this.buildTree(data) }; + } + printTree(node: TreeNode, level: number = 0): void { + const indent = ' '.repeat(level); + + for (const child of node.children) { + this.printTree(child, level + 1); + } + } + swapKeyValue>( + input: T, + ): { [K in T[keyof T]]: Extract } { + const result: Partial<{ [K in T[keyof T]]: Extract }> = {}; + for (const key in input) { + if (Object.prototype.hasOwnProperty.call(input, key)) { + const value = input[key]; + result[value] = key; + } + } + return result as { [K in T[keyof T]]: Extract }; + } + isEmptyRow(row: any) { + return row.every((cell: any) => { + return !cell || cell.toString().trim() === ''; + }); + } + + async importStaffs(data: z.infer) { + const { base64, domainId } = data; + this.logger.log('开始'); + const buffer = Buffer.from(base64, 'base64'); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer); + const importsStaffMethodSchema = z.object({ + name: z.string(), + phoneNumber: z.string().regex(/^\d+$/), // Assuming phone numbers should be numeric + deptName: z.string(), + }); + const worksheet = workbook.getWorksheet(1); // Assuming the data is in the first sheet + const staffs: { name: string; phoneNumber: string; deptName: string }[] = + []; + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { + // Assuming the first row is headers + const name = row.getCell(1).value as string; + const phoneNumber = row.getCell(2).value.toString() as string; + const deptName = row.getCell(3).value as string; + try { + importsStaffMethodSchema.parse({ name, phoneNumber, deptName }); + staffs.push({ name, phoneNumber, deptName }); + } catch (error) { + throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`); + } + } + }); + // 获取所有唯一的部门名称 + const uniqueDeptNames = [...new Set(staffs.map((staff) => staff.deptName))]; + // 获取所有部门名称对应的部门ID + const deptIdsMap = await this.departmentService.getDeptIdsByNames( + uniqueDeptNames, + domainId, + ); + const count = await db.staff.count(); + const hashedPassword = await argon2.hash('123456'); + + // 为员工数据添加部门ID + const staffsToCreate = staffs.map((staff, index) => ({ + showname: staff.name, + username: staff.phoneNumber, + phoneNumber: staff.phoneNumber, + password: hashedPassword, + deptId: deptIdsMap[staff.deptName], + domainId, + order: index + count, + })); + // 批量创建员工数据 + const createdStaffs = await db.staff.createMany({ + data: staffsToCreate, + }); + await deleteByPattern('row-*'); + return createdStaffs; + } + async importTerms( + staff: Staff, + data: z.infer, + ) { + const { base64, domainId, taxonomyId, parentId } = data; + this.logger.log('开始'); + await db.$transaction(async (tx) => { + const buffer = Buffer.from(base64, 'base64'); + const { tree: root } = await this.generateTreeFromFile(buffer); + + this.printTree(root); + + const termsData: Prisma.TermCreateManyInput[] = []; + const termAncestriesData: Prisma.TermAncestryCreateManyInput[] = []; + if (!taxonomyId) { + throw new Error('未指定分类!'); + } + this.logger.log('存在taxonomyId'); + const taxonomy = await tx.taxonomy.findUnique({ + where: { id: taxonomyId }, + }); + if (!taxonomy) { + throw new Error('未找到对应分类'); + } + const count = await tx.term.count({ where: { taxonomyId: taxonomyId } }); + let termIndex = 0; + this.logger.log(count); + + const gatherTermsData = async (nodes: TreeNode[], depth = 0) => { + let currentIndex = 0; + + for (const node of nodes) { + const termData = { + name: node.value, + taxonomyId: taxonomyId, + domainId: domainId, + createdBy: staff.id, + order: count + termIndex + 1, + }; + termsData.push(termData); + termIndex++; + // Debug: Log term data preparation + + await gatherTermsData(node.children, depth + 1); + currentIndex++; + } + }; + await gatherTermsData(root.children); + let createdTerms: { id: string; name: string }[] = []; + try { + createdTerms = await tx.term.createManyAndReturn({ + data: termsData, + select: { id: true, name: true }, + }); + // Debug: Log created terms + } catch (error) { + console.error('创建Terms报错:', error); + throw new Error('创建失败'); + } + const termsUpdate = []; + + const gatherAncestryData = ( + nodes: TreeNode[], + ancestors: string[] = parentId ? [null, parentId] : [null], + depth = 0, + ) => { + let currentIndex = 0; + + for (const node of nodes) { + // if (depth !== 0) { + const dept = createdTerms.find((dept) => dept.name === node.value); + if (dept) { + termsUpdate.push({ + where: { id: dept.id }, + data: { parentId: ancestors[ancestors.length - 1] }, + }); + for (let i = 0; i < ancestors.length; i++) { + const ancestryData = { + ancestorId: ancestors[i], + descendantId: dept.id, + relDepth: depth - i + 1, + }; + termAncestriesData.push(ancestryData); + } + const newAncestors = [...ancestors, dept.id]; + gatherAncestryData(node.children, newAncestors, depth + 1); + } + currentIndex++; + } + + // console.log(`depth:${depth}`); + // for (const node of nodes) { + // if (depth !== 0) { + // const term = createdTerms.find((term) => term.name === node.value); + // if (term) { + // termsUpdate.push({ + // where: { id: term.id }, + // data: { parentId: ancestors[ancestors.length - 1] }, + // }); + // for (let i = 0; i < ancestors.length; i++) { + // const ancestryData = { + // ancestorId: ancestors[i], + // descendantId: term.id, + // relDepth: depth - i, + // }; + // termAncestriesData.push(ancestryData); + // console.log(`准备好的闭包表数据ATermAncestryData:`, ancestryData); + // } + // const newAncestors = [...ancestors, term.id]; + // gatherAncestryData(node.children, newAncestors, depth + 1); + // } + // } else { + // gatherAncestryData( + // node.children, + // [createdTerms.find((term) => term.name === node.value).id], + // depth + 1, + // ); + // } + // currentIndex++; + // } + }; + gatherAncestryData(root.children); + + this.logger.log('准备好闭包表数据 Ancestries Data:', termAncestriesData); + try { + const updatePromises = termsUpdate.map((update) => + tx.term.update(update), + ); + await Promise.all(updatePromises); + await tx.termAncestry.createMany({ data: termAncestriesData }); + const allTerm = await tx.term.findMany({ + where: { + id: { + in: createdTerms.map((termt) => termt.id), + }, + }, + select: { + id: true, + children: { + where: { deletedAt: null }, + select: { id: true, deletedAt: true }, + }, + }, + }); + for (const term of allTerm) { + await tx.term.update({ + where: { + id: term.id, + }, + data: { + hasChildren: term.children.length > 0, + }, + }); + } + await deleteByPattern('row-*'); + return { count: createdTerms.length }; + } catch (error) { + console.error('Error 更新Term或者创建Terms闭包表失败:', error); + throw new Error('更新术语信息或者创建术语闭包表失败'); + } + }); + //prisma的特性,create之后填入了对应id,需要做一次这个查询才会填入相应值 + const termAncestries = await db.termAncestry.findMany({ + include: { + ancestor: true, + descendant: true, + }, + }); + } + async importDepts( + staff: Staff, + data: z.infer, + ) { + const { base64, domainId, parentId } = data; + + // this.logger.log('开始', parentId); + const buffer = Buffer.from(base64, 'base64'); + + await db.$transaction(async (tx) => { + const { tree: root } = await this.generateTreeFromFile(buffer); + + this.printTree(root); + + const deptsData: Prisma.DepartmentCreateManyInput[] = []; + const deptAncestriesData: Prisma.DeptAncestryCreateManyInput[] = []; + const count = await tx.department.count({ where: {} }); + let deptIndex = 0; + // this.logger.log(count); + const gatherDeptsData = async ( + nodes: TreeNode[], + depth = 0, + dept?: string, + ) => { + let currentIndex = 0; + for (const node of nodes) { + const deptData = { + name: node.value, + // taxonomyId: taxonomyId, + domainId: domainId, + // createdBy: staff.id, + + order: count + deptIndex + 1, + }; + deptsData.push(deptData); + deptIndex++; + // Debug: Log term data preparation + + await gatherDeptsData(node.children, depth + 1); + currentIndex++; + } + }; + await gatherDeptsData(root.children); + let createdDepts: { id: string; name: string }[] = []; + try { + createdDepts = await tx.department.createManyAndReturn({ + data: deptsData, + select: { id: true, name: true }, + }); + // Debug: Log created terms + } catch (error) { + console.error('创建Depts报错:', error); + throw new Error('创建失败'); + } + const deptsUpdate = []; + const gatherAncestryData = ( + nodes: TreeNode[], + ancestors: string[] = parentId ? [null, parentId] : [null], + depth = 0, + ) => { + let currentIndex = 0; + + for (const node of nodes) { + // if (depth !== 0) { + const dept = createdDepts.find((dept) => dept.name === node.value); + if (dept) { + deptsUpdate.push({ + where: { id: dept.id }, + data: { parentId: ancestors[ancestors.length - 1] }, + }); + + for (let i = 0; i < ancestors.length; i++) { + const ancestryData = { + ancestorId: ancestors[i], + descendantId: dept.id, + relDepth: depth - i + 1, + }; + deptAncestriesData.push(ancestryData); + } + const newAncestors = [...ancestors, dept.id]; + gatherAncestryData(node.children, newAncestors, depth + 1); + } + + currentIndex++; + } + }; + gatherAncestryData(root?.children); + + this.logger.log('准备好闭包表数据 Ancestries Data:', deptAncestriesData); + try { + const updatePromises = deptsUpdate.map((update) => + tx.department.update(update), + ); + await Promise.all(updatePromises); + await tx.deptAncestry.createMany({ data: deptAncestriesData }); + const allDept = await tx.department.findMany({ + where: { + id: { + in: createdDepts.map((dept) => dept.id), + }, + }, + select: { + id: true, + children: { + where: { deletedAt: null }, + select: { id: true, deletedAt: true }, + }, + }, + }); + for (const dept of allDept) { + await tx.department.update({ + where: { + id: dept.id, + }, + data: { + hasChildren: dept.children.length > 0, + }, + }); + } + await deleteByPattern('row-*'); + return { count: createdDepts.length }; + } catch (error) { + console.error('Error 更新Dept或者创建Depts闭包表失败:', error); + throw new Error('更新单位信息或者创建单位闭包表失败'); + } + }); + //prisma的特性,create之后填入了对应id,需要做一次这个查询才会填入相应值 + // const deptAncestries = db.deptAncestry.findMany({ + // include: { + // ancestor: true, + // descendant: true, + // }, + // }); + } +} diff --git a/apps/server/src/models/visit/visit.module.ts b/apps/server/src/models/visit/visit.module.ts new file mode 100755 index 0000000..0d3e2ec --- /dev/null +++ b/apps/server/src/models/visit/visit.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { VisitService } from './visit.service'; +import { VisitRouter } from './visit.router'; +import { TrpcService } from '@server/trpc/trpc.service'; + +@Module({ + providers: [VisitService, VisitRouter, TrpcService], + exports: [VisitRouter] +}) +export class VisitModule { } diff --git a/apps/server/src/models/visit/visit.router.ts b/apps/server/src/models/visit/visit.router.ts new file mode 100755 index 0000000..2bb9064 --- /dev/null +++ b/apps/server/src/models/visit/visit.router.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { Prisma } from '@nice/common'; + +import { VisitService } from './visit.service'; +import { z, ZodType } from 'zod'; +const VisitCreateArgsSchema: ZodType = z.any() +const VisitCreateManyInputSchema: ZodType = z.any() +const VisitDeleteManyArgsSchema: ZodType = z.any() +@Injectable() +export class VisitRouter { + constructor( + private readonly trpc: TrpcService, + private readonly visitService: VisitService, + ) { } + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(VisitCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.visitService.create(input, staff); + }), + createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + + return await this.visitService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(VisitDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.visitService.deleteMany(input); + }), + + + }); +} diff --git a/apps/server/src/models/visit/visit.service.ts b/apps/server/src/models/visit/visit.service.ts new file mode 100755 index 0000000..a7f2ada --- /dev/null +++ b/apps/server/src/models/visit/visit.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { BaseService } from '../base/base.service'; +import { UserProfile, db, ObjectType, Prisma, VisitType } from '@nice/common'; +import EventBus from '@server/utils/event-bus'; +@Injectable() +export class VisitService extends BaseService { + constructor() { + super(db, ObjectType.VISIT); + } + async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) { + const { postId, lectureId, messageId } = args.data; + const visitorId = args.data.visitorId || staff?.id; + let result; + const existingVisit = await db.visit.findFirst({ + where: { + type: args.data.type, + visitorId, + OR: [{ postId }, { lectureId }, { messageId }], + }, + }); + if (!existingVisit) { + result = await super.create(args); + } else if (args.data.type === VisitType.READED) { + result = await super.update({ + where: { id: existingVisit.id }, + data: { + ...args.data, + views: existingVisit.views + 1, + }, + }); + } + + // if (troubleId && args.data.type === VisitType.READED) { + // EventBus.emit('updateViewCount', { + // objectType: ObjectType.TROUBLE, + // id: troubleId, + // }); + // } + return result; + } + async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) { + const data = Array.isArray(args.data) ? args.data : [args.data]; + const updatePromises: any[] = []; + const createData: Prisma.VisitCreateManyInput[] = []; + await Promise.all( + data.map(async (item) => { + if (staff && !item.visitorId) item.visitorId = staff.id; + const { postId, lectureId, messageId, visitorId } = item; + const existingVisit = await db.visit.findFirst({ + where: { + visitorId, + OR: [{ postId }, { lectureId }, { messageId }], + }, + }); + + if (existingVisit) { + updatePromises.push( + super.update({ + where: { id: existingVisit.id }, + data: { + ...item, + views: existingVisit.views + 1, + }, + }), + ); + } else { + createData.push(item); + } + }), + ); + // Execute all updates in parallel + await Promise.all(updatePromises); + // Create new visits for those not existing + if (createData.length > 0) { + return super.createMany({ + ...args, + data: createData, + }); + } + + return { count: updatePromises.length }; // Return the number of updates if no new creates + } +} diff --git a/apps/server/src/queue/postprocess/postprocess.service.ts b/apps/server/src/queue/postprocess/postprocess.service.ts new file mode 100755 index 0000000..9257e4f --- /dev/null +++ b/apps/server/src/queue/postprocess/postprocess.service.ts @@ -0,0 +1,10 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import EventBus from '@server/utils/event-bus'; +import { Queue } from 'bullmq'; +import { ObjectType } from '@nice/common'; +import { QueueJobType } from '../types'; +@Injectable() +export class PostProcessService { + constructor(@InjectQueue('general') private generalQueue: Queue) { } +} diff --git a/apps/server/src/queue/queue.module.ts b/apps/server/src/queue/queue.module.ts new file mode 100755 index 0000000..ca825eb --- /dev/null +++ b/apps/server/src/queue/queue.module.ts @@ -0,0 +1,34 @@ +import { BullModule } from '@nestjs/bullmq'; +import { Logger, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { join } from 'path'; + +@Module({ + imports: [ + ConfigModule.forRoot(), // 导入 ConfigModule + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + connection: { + password: configService.get('REDIS_PASSWORD'), + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT', 6379), + }, + }), + inject: [ConfigService], + }), + BullModule.registerQueue( + { + name: 'general', + processors: [join(__dirname, 'worker/processor.js')], + }, + { + name: 'file-queue', // 新增文件处理队列 + processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径 + }, + ), + ], + providers: [Logger], + exports: [], +}) +export class QueueModule {} diff --git a/apps/server/src/queue/types.ts b/apps/server/src/queue/types.ts new file mode 100755 index 0000000..9845f45 --- /dev/null +++ b/apps/server/src/queue/types.ts @@ -0,0 +1,3 @@ +export enum QueueJobType { + FILE_PROCESS = 'file_process', +} diff --git a/apps/server/src/queue/worker/file.processor.ts b/apps/server/src/queue/worker/file.processor.ts new file mode 100755 index 0000000..b416af4 --- /dev/null +++ b/apps/server/src/queue/worker/file.processor.ts @@ -0,0 +1,22 @@ +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { QueueJobType } from '../types'; +import { ResourceProcessingPipeline } from '@server/models/resource/pipe/resource.pipeline'; +import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor'; +import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor'; +const logger = new Logger('FileProcessorWorker'); +const pipeline = new ResourceProcessingPipeline() + .addProcessor(new ImageProcessor()) + .addProcessor(new VideoProcessor()); +export default async function processJob(job: Job) { + if (job.name === QueueJobType.FILE_PROCESS) { + console.log('job', job); + const { resource } = job.data; + if (!resource) { + throw new Error('No resource provided in job data'); + } + const result = await pipeline.execute(resource); + + return result; + } +} diff --git a/apps/server/src/queue/worker/processor.ts b/apps/server/src/queue/worker/processor.ts new file mode 100755 index 0000000..1d7023c --- /dev/null +++ b/apps/server/src/queue/worker/processor.ts @@ -0,0 +1,20 @@ +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { ObjectType } from '@nice/common'; +// import { +// updateCourseEnrollmentStats, +// updateCourseReviewStats, +// } from '@server/models/course/utils'; +import { QueueJobType } from '../types'; + +const logger = new Logger('QueueWorker'); +export default async function processJob(job: Job) { + try { + + } catch (error: any) { + logger.error( + `Error processing stats update job: ${error.message}`, + error.stack, + ); + } +} diff --git a/apps/server/src/socket/base/base-websocket-server.ts b/apps/server/src/socket/base/base-websocket-server.ts new file mode 100755 index 0000000..f5716c1 --- /dev/null +++ b/apps/server/src/socket/base/base-websocket-server.ts @@ -0,0 +1,205 @@ + +import { WebSocketServer, WebSocket } from "ws"; +import { Logger } from "@nestjs/common"; +import { WebSocketServerConfig, WSClient, WebSocketType } from "../types"; +import { SocketMessage } from '@nice/common'; + +const DEFAULT_CONFIG: WebSocketServerConfig = { + pingInterval: 30000, + pingTimeout: 5000, + debug: false, // 新增默认调试配置 +}; +interface IWebSocketServer { + start(): Promise; + stop(): Promise; + broadcast(data: any): void; + handleConnection(ws: WSClient): void; + handleDisconnection(ws: WSClient): void; +} + +export abstract class BaseWebSocketServer implements IWebSocketServer { + private _wss: WebSocketServer | null = null; + protected clients: Set = new Set(); + protected timeouts: Map = new Map(); + protected pingIntervalId?: NodeJS.Timeout; + protected readonly logger = new Logger(this.constructor.name); + protected readonly finalConfig: WebSocketServerConfig; + private userClientMap: Map = new Map(); + constructor( + protected readonly config: Partial = {} + ) { + this.finalConfig = { + ...DEFAULT_CONFIG, + ...config, + }; + } + protected debugLog(message: string, ...optionalParams: any[]): void { + if (this.finalConfig.debug) { + this.logger.debug(message, ...optionalParams); + } + } + public getClientCount() { + return this.clients.size + } + // 暴露 WebSocketServer 实例的只读访问 + public get wss(): WebSocketServer | null { + return this._wss; + } + + // 内部使用的 setter + protected set wss(value: WebSocketServer | null) { + this._wss = value; + } + + public abstract get serverType(): WebSocketType; + + public get serverPath(): string { + return this.finalConfig.path || `/${this.serverType}`; + } + + public async start(): Promise { + if (this._wss) await this.stop(); + + this._wss = new WebSocketServer({ + noServer: true, + path: this.serverPath + }); + + this.debugLog(`WebSocket server starting on path: ${this.serverPath}`); + this.setupServerEvents(); + this.startPingInterval(); + } + + public async stop(): Promise { + if (this.pingIntervalId) { + clearInterval(this.pingIntervalId); + this.pingIntervalId = undefined; + } + + this.clients.forEach(client => client.close()); + this.clients.clear(); + this.timeouts.clear(); + + if (this._wss) { + await new Promise(resolve => this._wss!.close(resolve)); + this._wss = null; + } + + this.debugLog(`WebSocket server stopped on path: ${this.serverPath}`); + } + + public broadcast(data: SocketMessage): void { + this.clients.forEach(client => + client.readyState === WebSocket.OPEN && client.send(JSON.stringify(data)) + ); + } + public sendToUser(id: string, data: SocketMessage) { + const message = JSON.stringify(data); + const client = this.userClientMap.get(id); + client?.send(message) + } + public sendToUsers(ids: string[], data: SocketMessage) { + const message = JSON.stringify(data); + ids.forEach(id => { + const client = this.userClientMap.get(id); + client?.send(message); + }); + } + public sendToRoom(roomId: string, data: SocketMessage) { + const message = JSON.stringify(data); + this.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN && client.roomId === roomId) { + client.send(message) + } + }) + } + protected getRoomClientsCount(roomId?: string): number { + if (!roomId) return 0; + return Array.from(this.clients).filter(client => client.roomId === roomId).length; + } + + public handleConnection(ws: WSClient): void { + if (ws.userId) { + this.userClientMap.set(ws.userId, ws); + } + ws.isAlive = true; + ws.type = this.serverType; + this.clients.add(ws); + this.setupClientEvents(ws); + + const roomClientsCount = this.getRoomClientsCount(ws.roomId); + this.debugLog(` + [${this.serverType}] connected + userId ${ws.userId} + roomId ${ws.roomId} + room clients ${roomClientsCount} + total clients ${this.clients.size}`); + } + + public handleDisconnection(ws: WSClient): void { + if (ws.userId) { + this.userClientMap.delete(ws.userId); + } + this.clients.delete(ws); + const timeout = this.timeouts.get(ws); + if (timeout) { + clearTimeout(timeout); + this.timeouts.delete(ws); + } + ws.terminate(); + + const roomClientsCount = this.getRoomClientsCount(ws.roomId); + + this.debugLog(` + [${this.serverType}] disconnected + userId ${ws.userId} + roomId ${ws.roomId} + room clients ${roomClientsCount} + total clients ${this.clients.size}`); + } + protected setupClientEvents(ws: WSClient): void { + ws.on('pong', () => this.handlePong(ws)) + .on('close', () => this.handleDisconnection(ws)) + .on('error', (error) => { + this.logger.error(`[${this.serverType}] client error on path ${this.serverPath}:`, error); + this.handleDisconnection(ws); + }); + } + + private handlePong(ws: WSClient): void { + ws.isAlive = true; + const timeout = this.timeouts.get(ws); + if (timeout) { + clearTimeout(timeout); + this.timeouts.delete(ws); + } + } + + private startPingInterval(): void { + this.pingIntervalId = setInterval( + () => this.pingClients(), + this.finalConfig.pingInterval + ); + } + + private pingClients(): void { + this.clients.forEach(ws => { + if (!ws.isAlive) return this.handleDisconnection(ws); + + ws.isAlive = false; + ws.ping(); + const timeout = setTimeout( + () => !ws.isAlive && this.handleDisconnection(ws), + this.finalConfig.pingTimeout + ); + this.timeouts.set(ws, timeout); + }); + } + + protected setupServerEvents(): void { + if (!this._wss) return; + this._wss + .on('connection', (ws: WSClient) => this.handleConnection(ws)) + .on('error', (error) => this.logger.error(`Server error on path ${this.serverPath}:`, error)); + } +} diff --git a/apps/server/src/socket/collaboration/callback.ts b/apps/server/src/socket/collaboration/callback.ts new file mode 100755 index 0000000..a8942f0 --- /dev/null +++ b/apps/server/src/socket/collaboration/callback.ts @@ -0,0 +1,150 @@ +/** + * 此模块实现了一个回调处理系统,用于在协同编辑文档发生更改时通知外部服务。 + * 它支持多种共享数据类型(Array、Map、Text、XML等)的同步,并可以将更新通过HTTP POST请求发送到指定的回调URL。 + * 主要用于与外部系统集成,实现文档变更的实时通知。 + */ + +import http from 'http'; +import { parseInt as libParseInt } from 'lib0/number'; +import { WSSharedDoc } from './ws-shared-doc'; + + +/** + * 回调URL配置,从环境变量中获取 + * 如果环境变量未设置则为null + */ +const CALLBACK_URL = process.env.CALLBACK_URL ? new URL(process.env.CALLBACK_URL) : null; + +/** + * 回调超时时间配置,从环境变量中获取 + * 默认为5000毫秒 + */ +const CALLBACK_TIMEOUT = libParseInt(process.env.CALLBACK_TIMEOUT || '5000'); + +/** + * 需要监听变更的共享对象配置 + * 从环境变量CALLBACK_OBJECTS中解析JSON格式的配置 + */ +const CALLBACK_OBJECTS: Record = process.env.CALLBACK_OBJECTS ? JSON.parse(process.env.CALLBACK_OBJECTS) : {}; + +/** + * 导出回调URL是否已配置的标志 + */ +export const isCallbackSet = !!CALLBACK_URL; + +/** + * 定义要发送的数据结构接口 + */ +interface DataToSend { + room: string; // 房间/文档标识 + data: Record; +} + +/** + * 定义更新数据的类型 + */ +type UpdateType = Uint8Array; + +/** + * 定义更新来源的类型 + */ +type OriginType = any; + +/** + * 处理文档更新的回调函数 + * @param update - 更新的数据 + * @param origin - 更新的来源 + * @param doc - 共享文档实例 + */ +export const callbackHandler = (update: UpdateType, origin: OriginType, doc: WSSharedDoc): void => { + // 获取文档名称作为房间标识 + const room = doc.name; + + // 初始化要发送的数据对象 + const dataToSend: DataToSend = { + room, + data: {} + }; + + // 获取所有需要监听的共享对象名称 + const sharedObjectList = Object.keys(CALLBACK_OBJECTS); + + // 遍历所有共享对象,获取它们的最新内容 + sharedObjectList.forEach(sharedObjectName => { + const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName]; + dataToSend.data[sharedObjectName] = { + type: sharedObjectType, + content: getContent(sharedObjectName, sharedObjectType, doc).toJSON() + }; + }); + + // 如果配置了回调URL,则发送HTTP请求 + if (CALLBACK_URL) { + callbackRequest(CALLBACK_URL, CALLBACK_TIMEOUT, dataToSend); + } +}; + +/** + * 发送HTTP回调请求 + * @param url - 回调的目标URL + * @param timeout - 超时时间 + * @param data - 要发送的数据 + */ +const callbackRequest = (url: URL, timeout: number, data: DataToSend): void => { + // 将数据转换为JSON字符串 + const dataString = JSON.stringify(data); + + // 配置HTTP请求选项 + const options = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + timeout, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(dataString) + } + }; + + // 创建HTTP请求 + const req = http.request(options); + + // 处理超时事件 + req.on('timeout', () => { + console.warn('Callback request timed out.'); + req.abort(); + }); + + // 处理错误事件 + req.on('error', (e) => { + console.error('Callback request error.', e); + req.abort(); + }); + + // 发送数据 + req.write(dataString); + req.end(); +}; + +/** + * 根据对象类型获取共享对象的内容 + * @param objName - 对象名称 + * @param objType - 对象类型 + * @param doc - 共享文档实例 + * @returns 共享对象的内容 + */ +const getContent = (objName: string, objType: string, doc: WSSharedDoc): any => { + // 根据对象类型返回相应的共享对象 + switch (objType) { + case 'Array': return doc.getArray(objName); + case 'Map': return doc.getMap(objName); + case 'Text': return doc.getText(objName); + case 'XmlFragment': return doc.getXmlFragment(objName); + case 'XmlElement': return doc.getXmlElement(objName); + default: return {}; + } +}; diff --git a/apps/server/src/socket/collaboration/collaboration.module.ts b/apps/server/src/socket/collaboration/collaboration.module.ts new file mode 100755 index 0000000..71f99ea --- /dev/null +++ b/apps/server/src/socket/collaboration/collaboration.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { YjsServer } from './yjs.server'; + +@Module({ + providers: [YjsServer], + exports: [YjsServer] +}) +export class CollaborationModule { } diff --git a/apps/server/src/socket/collaboration/persistence.ts b/apps/server/src/socket/collaboration/persistence.ts new file mode 100755 index 0000000..5cdc19b --- /dev/null +++ b/apps/server/src/socket/collaboration/persistence.ts @@ -0,0 +1,34 @@ +import { LeveldbPersistence } from 'y-leveldb'; +import * as Y from 'yjs'; +import { WSSharedDoc } from './ws-shared-doc'; +const persistenceDir = process.env.YPERSISTENCE; +interface Persistence { + bindState: (docName: string, ydoc: WSSharedDoc) => void; + writeState: (docName: string, ydoc: WSSharedDoc) => Promise; + provider: any; +} +let persistence: Persistence | null = null; + +if (typeof persistenceDir === 'string') { + console.info('Persisting documents to "' + persistenceDir + '"'); + const ldb = new LeveldbPersistence(persistenceDir); + persistence = { + provider: ldb, + bindState: async (docName, ydoc) => { + const persistedYdoc = await ldb.getYDoc(docName); + const newUpdates = Y.encodeStateAsUpdate(ydoc); + ldb.storeUpdate(docName, newUpdates); + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc)); + ydoc.on('update', (update: Uint8Array) => { + ldb.storeUpdate(docName, update); + }); + }, + writeState: async (_docName, _ydoc) => { }, + }; +} + +export const setPersistence = (persistence_: Persistence | null) => { + persistence = persistence_; +}; + +export const getPersistence = (): Persistence | null => persistence; diff --git a/apps/server/src/socket/collaboration/types.ts b/apps/server/src/socket/collaboration/types.ts new file mode 100755 index 0000000..502a42f --- /dev/null +++ b/apps/server/src/socket/collaboration/types.ts @@ -0,0 +1,5 @@ +export interface ConnectionOptions { + docName: string; + gc: boolean; + } + \ No newline at end of file diff --git a/apps/server/src/socket/collaboration/ws-shared-doc.ts b/apps/server/src/socket/collaboration/ws-shared-doc.ts new file mode 100755 index 0000000..ae1bd09 --- /dev/null +++ b/apps/server/src/socket/collaboration/ws-shared-doc.ts @@ -0,0 +1,158 @@ +import { readSyncMessage } from '@nice/common'; +import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates, writeSyncStep1, writeUpdate } from '@nice/common'; +import * as encoding from 'lib0/encoding'; +import * as decoding from 'lib0/decoding'; +import * as Y from "yjs" +import { debounce } from 'lodash'; +import { getPersistence, setPersistence } from './persistence'; +import { callbackHandler, isCallbackSet } from './callback'; +import { WebSocket } from "ws"; +import { YMessageType } from '@nice/common'; +import { WSClient } from '../types'; +export const docs = new Map(); +export const CALLBACK_DEBOUNCE_WAIT = parseInt(process.env.CALLBACK_DEBOUNCE_WAIT || '2000'); +export const CALLBACK_DEBOUNCE_MAXWAIT = parseInt(process.env.CALLBACK_DEBOUNCE_MAXWAIT || '10000'); +export const getYDoc = (docname: string, gc = true): WSSharedDoc => { + return docs.get(docname) || createYDoc(docname, gc); +}; +const createYDoc = (docname: string, gc: boolean): WSSharedDoc => { + const doc = new WSSharedDoc(docname, gc); + docs.set(docname, doc); + return doc; +}; + +export const send = (doc: WSSharedDoc, conn: WebSocket, m: Uint8Array) => { + if (conn.readyState !== WebSocket.OPEN) { + closeConn(doc, conn); + return; + } + try { + conn.send(m, {}, err => { err != null && closeConn(doc, conn) }); + } catch (e) { + closeConn(doc, conn); + } +}; +export const closeConn = (doc: WSSharedDoc, conn: WebSocket) => { + if (doc.conns.has(conn)) { + const controlledIds = doc.conns.get(conn) as Set; + doc.conns.delete(conn); + removeAwarenessStates( + doc.awareness, + Array.from(controlledIds), + null + ); + + if (doc.conns.size === 0 && getPersistence() !== null) { + getPersistence()?.writeState(doc.name, doc).then(() => { + doc.destroy(); + }); + docs.delete(doc.name); + } + } + conn.close(); +}; + +export const messageListener = (conn: WSClient, doc: WSSharedDoc, message: Uint8Array) => { + try { + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + switch (messageType) { + case YMessageType.Sync: + // console.log(`received sync message ${message.length}`) + encoding.writeVarUint(encoder, YMessageType.Sync); + readSyncMessage(decoder, encoder, doc, conn); + if (encoding.length(encoder) > 1) { + send(doc, conn, encoding.toUint8Array(encoder)); + } + break; + + case YMessageType.Awareness: { + applyAwarenessUpdate( + doc.awareness, + decoding.readVarUint8Array(decoder), + conn + ); + // console.log(`received awareness message from ${conn.origin} total ${doc.awareness.states.size}`) + break; + } + } + } catch (err) { + console.error(err); + doc.emit('error' as any, [err]); + } +}; + +const updateHandler = (update: Uint8Array, _origin: any, doc: WSSharedDoc, _tr: any) => { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, YMessageType.Sync); + writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + doc.conns.forEach((_, conn) => send(doc, conn, message)); +}; + +let contentInitializor: (ydoc: Y.Doc) => Promise = (_ydoc) => Promise.resolve(); +export const setContentInitializor = (f: (ydoc: Y.Doc) => Promise) => { + contentInitializor = f; +}; + +export class WSSharedDoc extends Y.Doc { + name: string; + conns: Map>; + awareness: Awareness; + whenInitialized: Promise; + + constructor(name: string, gc: boolean) { + super({ gc }); + + this.name = name; + this.conns = new Map(); + this.awareness = new Awareness(this); + this.awareness.setLocalState(null); + + const awarenessUpdateHandler = ({ + added, + updated, + removed + }: { + added: number[], + updated: number[], + removed: number[] + }, conn: WebSocket) => { + const changedClients = added.concat(updated, removed); + if (changedClients.length === 0) return + if (conn !== null) { + const connControlledIDs = this.conns.get(conn) as Set; + if (connControlledIDs !== undefined) { + added.forEach(clientID => { connControlledIDs.add(clientID); }); + removed.forEach(clientID => { connControlledIDs.delete(clientID); }); + } + } + + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, YMessageType.Awareness); + encoding.writeVarUint8Array( + encoder, + encodeAwarenessUpdate(this.awareness, changedClients) + ); + const buff = encoding.toUint8Array(encoder); + + this.conns.forEach((_, c) => { + send(this, c, buff); + }); + }; + + this.awareness.on('update', awarenessUpdateHandler); + this.on('update', updateHandler as any); + + if (isCallbackSet) { + this.on('update', debounce( + callbackHandler as any, + CALLBACK_DEBOUNCE_WAIT, + { maxWait: CALLBACK_DEBOUNCE_MAXWAIT } + ) as any); + } + + this.whenInitialized = contentInitializor(this); + } +} diff --git a/apps/server/src/socket/collaboration/yjs.server.ts b/apps/server/src/socket/collaboration/yjs.server.ts new file mode 100755 index 0000000..0b747bd --- /dev/null +++ b/apps/server/src/socket/collaboration/yjs.server.ts @@ -0,0 +1,85 @@ +import { Injectable } from "@nestjs/common"; +import { WebSocketType, WSClient } from "../types"; +import { BaseWebSocketServer } from "../base/base-websocket-server"; +import { encoding } from "lib0"; +import { YMessageType, writeSyncStep1, encodeAwarenessUpdate } from "@nice/common"; +import { getYDoc, closeConn, WSSharedDoc, messageListener, send } from "./ws-shared-doc"; +@Injectable() +export class YjsServer extends BaseWebSocketServer { + public get serverType(): WebSocketType { + return WebSocketType.YJS; + } + public override handleConnection( + connection: WSClient + ): void { + super.handleConnection(connection) + try { + connection.binaryType = 'arraybuffer'; + const doc = this.initializeDocument(connection, connection.roomId, true); + this.setupConnectionHandlers(connection, doc); + this.sendInitialSync(connection, doc); + } catch (error: any) { + this.logger.error(`Error in handleNewConnection: ${error.message}`, error.stack); + connection.close(); + } + } + + private initializeDocument(conn: WSClient, docName: string, gc: boolean) { + const doc = getYDoc(docName, gc); + + doc.conns.set(conn, new Set()); + return doc; + } + + private setupConnectionHandlers(connection: WSClient, doc: WSSharedDoc): void { + connection.on('message', (message: ArrayBuffer) => { + this.handleMessage(connection, doc, message); + }); + connection.on('close', () => { + this.handleClose(doc, connection); + }); + connection.on('error', (error) => { + this.logger.error(`WebSocket error for doc ${doc.name}: ${error.message}`, error.stack); + closeConn(doc, connection); + this.logger.warn(`Connection closed due to error for doc: ${doc.name}. Remaining connections: ${doc.conns.size}`); + }); + } + + private handleClose(doc: WSSharedDoc, connection: WSClient): void { + try { + closeConn(doc, connection); + } catch (error: any) { + this.logger.error(`Error closing connection: ${error.message}`, error.stack); + } + } + private handleMessage(connection: WSClient, doc: WSSharedDoc, message: ArrayBuffer): void { + try { + messageListener(connection, doc, new Uint8Array(message)); + } catch (error: any) { + this.logger.error(`Error handling message: ${error.message}`, error.stack); + } + } + private sendInitialSync(connection: WSClient, doc: any): void { + this.sendSyncStep1(connection, doc); + this.sendAwarenessStates(connection, doc); + } + private sendSyncStep1(connection: WSClient, doc: any): void { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, YMessageType.Sync); + writeSyncStep1(encoder, doc); + send(doc, connection, encoding.toUint8Array(encoder)); + } + private sendAwarenessStates(connection: WSClient, doc: WSSharedDoc): void { + const awarenessStates = doc.awareness.getStates(); + + if (awarenessStates.size > 0) { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, YMessageType.Awareness); + encoding.writeVarUint8Array( + encoder, + encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())) + ); + send(doc, connection, encoding.toUint8Array(encoder)); + } + } +} diff --git a/apps/server/src/socket/realtime/realtime.module.ts b/apps/server/src/socket/realtime/realtime.module.ts new file mode 100755 index 0000000..7a5a76e --- /dev/null +++ b/apps/server/src/socket/realtime/realtime.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RealtimeServer } from './realtime.server'; + + +@Module({ + providers: [ RealtimeServer], + exports: [ RealtimeServer] +}) +export class RealTimeModule { } diff --git a/apps/server/src/socket/realtime/realtime.server.ts b/apps/server/src/socket/realtime/realtime.server.ts new file mode 100755 index 0000000..18d7d99 --- /dev/null +++ b/apps/server/src/socket/realtime/realtime.server.ts @@ -0,0 +1,25 @@ +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { WebSocketType } from "../types"; +import { BaseWebSocketServer } from "../base/base-websocket-server"; +import EventBus, { CrudOperation } from "@server/utils/event-bus"; +import { ObjectType, SocketMsgType, MessageDto, PostDto, PostType } from "@nice/common"; +@Injectable() +export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit { + onModuleInit() { + EventBus.on("dataChanged", ({ data, type, operation }) => { + if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) { + const receiverIds = (data as Partial).receivers.map(receiver => receiver.id) + this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } }) + } + + if (type === ObjectType.POST) { + const post = data as Partial + + } + }) + + } + public get serverType(): WebSocketType { + return WebSocketType.REALTIME; + } +} diff --git a/apps/server/src/socket/types.ts b/apps/server/src/socket/types.ts new file mode 100755 index 0000000..01b689d --- /dev/null +++ b/apps/server/src/socket/types.ts @@ -0,0 +1,29 @@ +import { WebSocketServer, WebSocket } from "ws"; + +// 类型定义 +export enum WebSocketType { + YJS = "yjs", + REALTIME = "realtime" +} + +export interface WebSocketServerConfig { + path?: string; + pingInterval?: number; + pingTimeout?: number; + debug?: boolean +} + +export interface ServerInstance { + wss: WebSocketServer | null; + clients: Set; + pingIntervalId?: NodeJS.Timeout; + timeouts: Map; +} + +export interface WSClient extends WebSocket { + isAlive?: boolean; + type?: WebSocketType; + userId?: string + origin?: string + roomId?: string +} \ No newline at end of file diff --git a/apps/server/src/socket/websocket.module.ts b/apps/server/src/socket/websocket.module.ts new file mode 100755 index 0000000..050fc0d --- /dev/null +++ b/apps/server/src/socket/websocket.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { WebSocketService } from './websocket.service'; +import { RealTimeModule } from './realtime/realtime.module'; +import { CollaborationModule } from './collaboration/collaboration.module'; + +@Module({ + imports: [RealTimeModule, CollaborationModule], + providers: [WebSocketService], + exports: [WebSocketService], +}) +export class WebSocketModule { } diff --git a/apps/server/src/socket/websocket.service.ts b/apps/server/src/socket/websocket.service.ts new file mode 100755 index 0000000..1a110a3 --- /dev/null +++ b/apps/server/src/socket/websocket.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Server } from "http"; +import { WSClient } from "./types"; +import { RealtimeServer } from "./realtime/realtime.server"; +import { YjsServer } from "./collaboration/yjs.server"; +import { BaseWebSocketServer } from "./base/base-websocket-server"; + +@Injectable() +export class WebSocketService { + private readonly logger = new Logger(WebSocketService.name); + private readonly servers: BaseWebSocketServer[] = []; + constructor( + private realTimeServer: RealtimeServer, + private yjsServer: YjsServer + ) { + this.servers.push(this.realTimeServer) + this.servers.push(this.yjsServer) + } + public async initialize(httpServer: Server): Promise { + try { + await Promise.all(this.servers.map(server => server.start())); + this.setupUpgradeHandler(httpServer); + } catch (error) { + this.logger.error('Failed to initialize:', error); + throw error; + } + } + private setupUpgradeHandler(httpServer: Server): void { + if (httpServer.listeners('upgrade').length) return; + httpServer.on('upgrade', async (request, socket, head) => { + try { + const url = new URL(request.url!, `http://${request.headers.host}`); + const pathname = url.pathname; + + // 从URL查询参数中获取roomId和token + const urlParams = new URLSearchParams(url.search); + const roomId = urlParams.get('roomId'); + const userId = urlParams.get('userId'); + const server = this.servers.find(server => { + const serverPathClean = server.serverPath.replace(/\/$/, ''); + const pathnameClean = pathname.replace(/\/$/, ''); + return serverPathClean === pathnameClean; + }); + + if (!server || !server.wss) { + return socket.destroy(); + } + + server.wss!.handleUpgrade(request, socket, head, (ws: WSClient) => { + ws.userId = userId; + ws.origin = request.url + ws.roomId = roomId + server.wss!.emit('connection', ws, request); + }); + } catch (error) { + this.logger.error('Upgrade error:', error); + socket.destroy(); + } + }); + } +} diff --git a/apps/server/src/tasks/init/gendev.service.ts b/apps/server/src/tasks/init/gendev.service.ts new file mode 100755 index 0000000..539fa9b --- /dev/null +++ b/apps/server/src/tasks/init/gendev.service.ts @@ -0,0 +1,225 @@ +//开发数据生成服务 +import { Injectable, Logger } from '@nestjs/common'; +import { DepartmentService } from '@server/models/department/department.service'; +import { AppConfigService } from '@server/models/app-config/app-config.service'; +import { TermService } from '@server/models/term/term.service'; +import { + db, + Department, + getRandomElement, + getRandomElements, + Staff, + TaxonomySlug, + Term, +} from '@nice/common'; +import EventBus from '@server/utils/event-bus'; +import { capitalizeFirstLetter, DevDataCounts, getCounts } from './utils'; +import { StaffService } from '@server/models/staff/staff.service'; +@Injectable() +export class GenDevService { + private readonly logger = new Logger(GenDevService.name); + counts = {} as DevDataCounts; + deptStaffRecord: Record = {}; + terms: Record = { + [TaxonomySlug.CATEGORY]: [], + [TaxonomySlug.TAG]: [], + }; + depts: Department[] = []; + domains: Department[] = []; + domainDepts: Record = {}; + staffs: Staff[] = []; + deptGeneratedCount = 0; + constructor( + private readonly appConfigService: AppConfigService, + + private readonly departmentService: DepartmentService, + private readonly staffService: StaffService, + private readonly termService: TermService, + ) { } + async genDataEvent() { + EventBus.emit('genDataEvent', { type: 'start' }); + try { + await this.calculateCounts(); + await this.generateDepartments(3, 6); + await this.generateTerms(2, 6); + await this.generateStaffs(4); + } catch (err) { + this.logger.error(err); + } + EventBus.emit('genDataEvent', { type: 'end' }); + } + private async calculateCounts() { + this.counts = await getCounts(); + Object.entries(this.counts).forEach(([key, value]) => { + this.logger.log(`${capitalizeFirstLetter(key)} count: ${value}`); + }); + } + private async generateTerms(depth: number = 2, count: number = 10) { + if (this.counts.termCount === 0) { + this.logger.log('Generate terms'); + await this.createTerms(null, TaxonomySlug.CATEGORY, depth, count); + const domains = this.depts.filter((item) => item.isDomain); + for (const domain of domains) { + await this.createTerms(domain, TaxonomySlug.CATEGORY, depth, count); + // await this.createTerms(domain, TaxonomySlug.UNIT, depth, count); + } + } + const termCount = await db.term.count(); + this.logger.log(`terms ${termCount} generated`); + } + private async generateDepartments(depth: number = 3, count: number = 6) { + if (this.counts.deptCount !== 0) return; + const totalDepts = this.calculateTotalDepartments(depth, count); + this.logger.log('Starting department generation...'); + await this.generateSubDepartments(null, 1, depth, count, totalDepts); + this.depts = await db.department.findMany(); + this.domains.forEach((domain) => { + this.domainDepts[domain.id] = this.getAllChildDepartments(domain.id); + this.logger.log( + `Domain: ${domain.name} has ${this.domainDepts[domain.id].length} child departments`, + ); + }); + this.logger.log(`Completed: Generated ${this.depts.length} departments.`); + } + + private async generateSubDepartments( + parentId: string | null, + currentDepth: number, + maxDepth: number, + count: number, + total: number, + ) { + if (currentDepth > maxDepth) return; + + for (let i = 0; i < count; i++) { + const deptName = `${parentId?.slice(0, 6) || '根'}公司${currentDepth}-${i}`; + const newDept = await this.createDepartment( + deptName, + parentId, + currentDepth, + ); + if (newDept.isDomain) { + this.domains.push(newDept); + } + this.deptGeneratedCount++; + this.logger.log( + `Generated ${this.deptGeneratedCount}/${total} departments`, + ); + await this.generateSubDepartments( + newDept.id, + currentDepth + 1, + maxDepth, + count, + total, + ); + } + } + + // Helper function to calculate the total number of departments to be generated + private calculateTotalDepartments(depth: number, count: number): number { + // The total number of departments is the sum of departments at each level. + let total = 0; + for (let i = 1; i <= depth; i++) { + total += Math.pow(count, i); + } + return total; + } + + private getAllChildDepartments(domainId: string): Department[] { + const children: Department[] = []; + const collectChildren = (parentId: string) => { + const directChildren = this.depts.filter( + (dept) => dept.parentId === parentId, + ); + children.push(...directChildren); + directChildren.forEach((child) => { + collectChildren(child.id); + }); + }; + collectChildren(domainId); + return children; + } + private async generateStaffs(countPerDept: number = 3) { + if (this.counts.staffCount === 1) { + this.logger.log('Generating staffs...'); + // Calculate the total number of staffs to be generated + const totalStaffs = this.domains.reduce((sum, domain) => { + return sum + (this.domainDepts[domain.id]?.length || 0) * countPerDept; + }, 0); + let staffsGenerated = 0; + for (const domain of this.domains) { + for (const dept of this.domainDepts[domain.id]) { + if (!this.deptStaffRecord[dept.id]) { + this.deptStaffRecord[dept.id] = []; + } + for (let i = 0; i < countPerDept; i++) { + const username = `${dept.name}-S${staffsGenerated.toString().padStart(4, '0')}`; + const staff = await this.staffService.create({ + data: { + showname: username, + username: username, + deptId: dept.id, + domainId: domain.id, + }, + }); + // Update both deptStaffRecord and staffs array + this.deptStaffRecord[dept.id].push(staff); + staffsGenerated++; + // Log the progress after each staff is created + this.logger.log( + `Generated ${staffsGenerated}/${totalStaffs} staffs`, + ); + } + } + } + } + } + + private async createDepartment( + name: string, + parentId?: string | null, + currentDepth: number = 1, + ) { + const department = await this.departmentService.create({ + data: { + name, + isDomain: currentDepth === 1 ? true : false, + parentId, + }, + }); + return department; + } + private async createTerms( + domain: Department | null, + taxonomySlug: TaxonomySlug, + depth: number, + nodesPerLevel: number, + ) { + const taxonomy = await db.taxonomy.findFirst({ + where: { slug: taxonomySlug }, + }); + let counter = 1; + const createTermTree = async ( + parentId: string | null, + currentDepth: number, + ) => { + if (currentDepth > depth) return; + for (let i = 0; i < nodesPerLevel; i++) { + const name = `${taxonomySlug}-${domain?.name || 'public'}-${currentDepth}-${counter++} `; + const newTerm = await this.termService.create({ + data: { + name, + taxonomyId: taxonomy!.id, + domainId: domain?.id, + parentId, + }, + }); + this.terms[taxonomySlug].push(newTerm); + await createTermTree(newTerm.id, currentDepth + 1); + } + }; + // Start creating the tree from root level + + await createTermTree(null, 1); + } +} diff --git a/apps/server/src/tasks/init/init.module.ts b/apps/server/src/tasks/init/init.module.ts new file mode 100755 index 0000000..2b861fc --- /dev/null +++ b/apps/server/src/tasks/init/init.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { InitService } from './init.service'; +import { MinioModule } from '@server/utils/minio/minio.module'; +import { AuthModule } from '@server/auth/auth.module'; +import { AppConfigModule } from '@server/models/app-config/app-config.module'; +import { GenDevService } from './gendev.service'; +import { StaffModule } from '@server/models/staff/staff.module'; +import { DepartmentModule } from '@server/models/department/department.module'; +import { TermModule } from '@server/models/term/term.module'; + +@Module({ + imports: [MinioModule, AuthModule, AppConfigModule, StaffModule, DepartmentModule, TermModule], + providers: [InitService, GenDevService], + exports: [InitService] +}) +export class InitModule { } diff --git a/apps/server/src/tasks/init/init.service.ts b/apps/server/src/tasks/init/init.service.ts new file mode 100755 index 0000000..8d6a955 --- /dev/null +++ b/apps/server/src/tasks/init/init.service.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + db, + InitAppConfigs, + InitRoles, + InitTaxonomies, + ObjectType, +} from '@nice/common'; +import { AuthService } from '@server/auth/auth.service'; +import { MinioService } from '@server/utils/minio/minio.service'; +import { AppConfigService } from '@server/models/app-config/app-config.service'; +import { GenDevService } from './gendev.service'; + +@Injectable() +export class InitService { + private readonly logger = new Logger(InitService.name); + constructor( + private readonly appConfigService: AppConfigService, + private readonly minioService: MinioService, + private readonly authService: AuthService, + private readonly genDevService: GenDevService, + ) {} + private async createRoles() { + this.logger.log('Checking existing system roles'); + for (const role of InitRoles) { + const existingRole = await db.role.findUnique({ + where: { name: role.name }, + }); + if (!existingRole) { + this.logger.log(`Creating role: ${role.name}`); + await db.role.create({ + data: { ...role, system: true }, + }); + } else { + this.logger.log(`Role already exists: ${role.name}`); + } + } + } + private async createOrUpdateTaxonomy() { + this.logger.log('Checking existing taxonomies'); + + const existingTaxonomies = await db.taxonomy.findMany(); + const existingTaxonomyMap = new Map( + existingTaxonomies.map((taxonomy) => [taxonomy.name, taxonomy]), + ); + + for (const [index, taxonomy] of InitTaxonomies.entries()) { + const existingTaxonomy = existingTaxonomyMap.get(taxonomy.name); + + if (!existingTaxonomy) { + // Create new taxonomy + await db.taxonomy.create({ + data: { + ...taxonomy, + order: index, + }, + }); + this.logger.log(`Created new taxonomy: ${taxonomy.name}`); + } else { + // Check for differences and update if necessary + const differences = Object.keys(taxonomy).filter( + (key) => taxonomy[key] !== existingTaxonomy[key], + ); + + if (differences.length > 0) { + await db.taxonomy.update({ + where: { id: existingTaxonomy.id }, + data: { + ...taxonomy, + order: index, + }, + }); + this.logger.log(`Updated taxonomy: ${taxonomy.name}`); + } else { + this.logger.log(`No changes for taxonomy: ${taxonomy.name}`); + } + } + } + } + private async createRoot() { + this.logger.log('Checking for root account'); + + const rootAccountExists = await db.staff.findFirst({ + where: { + OR: [ + { + phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000', + }, + { + username: 'root', + }, + ], + }, + }); + + if (!rootAccountExists) { + this.logger.log('Creating root account'); + const rootStaff = await this.authService.signUp({ + username: 'root', + password: 'root', + }); + const rootRole = await db.role.findUnique({ + where: { name: '根管理员' }, + }); + + if (rootRole) { + this.logger.log('Assigning root role to root account'); + await db.roleMap.create({ + data: { + objectType: ObjectType.STAFF, + objectId: rootStaff.id, + roleId: rootRole.id, + }, + }); + } else { + this.logger.error('Root role does not exist'); + } + } else { + this.logger.log('Root account already exists'); + } + } + private async createBucket() { + await this.minioService.createBucket('app'); + } + private async initAppConfigs() { + const existingConfigs = await db.appConfig.findMany(); + const existingConfigSlugs = existingConfigs.map((config) => config.slug); + for (const [index, config] of InitAppConfigs.entries()) { + if (!existingConfigSlugs.includes(config.slug)) { + this.logger.log(`create Option Page ${config.title}`); + await this.appConfigService.create({ data: config }); + } else { + this.logger.log(`AppConfig already exists: ${config.title}`); + } + } + } + async init() { + try { + this.logger.log('Starting system initialization'); + + await this.createRoles(); + await this.createRoot(); + await this.createOrUpdateTaxonomy(); + await this.initAppConfigs(); + try { + this.logger.log('Initialize minio'); + await this.createBucket(); + } catch (error) { + this.logger.error('Minio initialization failed:', error); + } + + if (process.env.NODE_ENV === 'development') { + try { + await this.genDevService.genDataEvent(); + } catch (error) { + this.logger.error('Development data generation failed:', error); + // Not throwing here as this is development-only + } + } + + this.logger.log('System initialization completed successfully'); + } catch (error) { + this.logger.error('System initialization failed:', error); + } + } +} diff --git a/apps/server/src/tasks/init/utils.ts b/apps/server/src/tasks/init/utils.ts new file mode 100755 index 0000000..292eb9e --- /dev/null +++ b/apps/server/src/tasks/init/utils.ts @@ -0,0 +1,34 @@ +import { db, getRandomElement, getRandomIntInRange, getRandomTimeInterval, } from '@nice/common'; +import dayjs from 'dayjs'; +export interface DevDataCounts { + deptCount: number; + + staffCount: number + termCount: number +} +export async function getCounts(): Promise { + const counts = { + deptCount: await db.department.count(), + + staffCount: await db.staff.count(), + termCount: await db.term.count(), + }; + return counts; +} +export function capitalizeFirstLetter(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} +export function getRandomImageLinks(count: number = 5): string[] { + const baseUrl = 'https://picsum.photos/200/300?random='; + const imageLinks: string[] = []; + + for (let i = 0; i < count; i++) { + // 生成随机数以确保每个链接都是唯一的 + const randomId = Math.floor(Math.random() * 1000); + imageLinks.push(`${baseUrl}${randomId}`); + } + + return imageLinks; +} + + diff --git a/apps/server/src/tasks/reminder/reminder.module.ts b/apps/server/src/tasks/reminder/reminder.module.ts new file mode 100755 index 0000000..ed85068 --- /dev/null +++ b/apps/server/src/tasks/reminder/reminder.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ReminderService } from './reminder.service'; +import { MessageModule } from '@server/models/message/message.module'; + +@Module({ + imports: [ MessageModule], + providers: [ReminderService], + exports: [ReminderService] +}) +export class ReminderModule { } diff --git a/apps/server/src/tasks/reminder/reminder.service.ts b/apps/server/src/tasks/reminder/reminder.service.ts new file mode 100755 index 0000000..41a0f8e --- /dev/null +++ b/apps/server/src/tasks/reminder/reminder.service.ts @@ -0,0 +1,81 @@ +/** + * @file reminder.service.ts + * @description 提醒服务,用于处理问题截止日期提醒相关的业务逻辑 + * @author xxx + * @date 2023-xx-xx + */ + +import { Injectable, Logger } from '@nestjs/common'; +import dayjs from 'dayjs'; + +import { MessageService } from '@server/models/message/message.service'; + +/** + * 提醒服务类 + */ +@Injectable() +export class ReminderService { + /** + * 日志记录器实例 + * @private + */ + private readonly logger = new Logger(ReminderService.name); + + /** + * 构造函数 + * @param messageService 消息服务实例 + */ + constructor(private readonly messageService: MessageService) { } + + /** + * 生成提醒时间点 + * @param totalDays 总天数 + * @returns 提醒时间点数组 + */ + generateReminderTimes(totalDays: number): number[] { + // 如果总天数小于3天则不需要提醒 + if (totalDays < 3) return []; + // 使用Set存储提醒时间点,避免重复 + const reminders: Set = new Set(); + // 按照2的幂次方划分时间点 + for (let i = 1; i <= totalDays / 2; i++) { + reminders.add(Math.ceil(totalDays / Math.pow(2, i))); + } + // 将Set转为数组并升序排序 + return Array.from(reminders).sort((a, b) => a - b); + } + + /** + * 判断是否需要发送提醒 + * @param createdAt 创建时间 + * @param deadline 截止时间 + * @returns 是否需要提醒及剩余天数 + */ + shouldSendReminder(createdAt: Date, deadline: Date) { + // 获取当前时间 + const now = dayjs(); + const end = dayjs(deadline); + // 计算总时间和剩余时间(天) + const totalTimeDays = end.diff(createdAt, 'day'); + const timeLeftDays = end.diff(now, 'day'); + + if (totalTimeDays > 1) { + // 获取提醒时间点 + const reminderTimes = this.generateReminderTimes(totalTimeDays); + // 如果剩余时间在提醒时间点内,则需要提醒 + if (reminderTimes.includes(timeLeftDays)) { + return { shouldSend: true, timeLeft: timeLeftDays }; + } + } + return { shouldSend: false, timeLeft: timeLeftDays }; + } + + /** + * 发送截止日期提醒 + */ + async remindDeadline() { + this.logger.log('开始检查截止日期以发送提醒。'); + + + } +} diff --git a/apps/server/src/tasks/tasks.module.ts b/apps/server/src/tasks/tasks.module.ts new file mode 100755 index 0000000..64e430f --- /dev/null +++ b/apps/server/src/tasks/tasks.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TasksService } from './tasks.service'; +import { InitModule } from '@server/tasks/init/init.module'; +import { ReminderModule } from "@server/tasks/reminder/reminder.module" +@Module({ + imports: [InitModule, ReminderModule], + providers: [TasksService] +}) +export class TasksModule { } diff --git a/apps/server/src/tasks/tasks.service.spec.ts b/apps/server/src/tasks/tasks.service.spec.ts new file mode 100755 index 0000000..cb48230 --- /dev/null +++ b/apps/server/src/tasks/tasks.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TasksService } from './tasks.service'; + +describe('TasksService', () => { + let service: TasksService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TasksService], + }).compile(); + + service = module.get(TasksService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/server/src/tasks/tasks.service.ts b/apps/server/src/tasks/tasks.service.ts new file mode 100755 index 0000000..8160edf --- /dev/null +++ b/apps/server/src/tasks/tasks.service.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { InitService } from '@server/tasks/init/init.service'; +import { ReminderService } from '@server/tasks/reminder/reminder.service'; +import { CronJob } from 'cron'; + +@Injectable() +export class TasksService implements OnModuleInit { + private readonly logger = new Logger(TasksService.name); + + constructor( + private readonly schedulerRegistry: SchedulerRegistry, + private readonly initService: InitService, + private readonly reminderService: ReminderService + ) { } + + async onModuleInit() { + this.logger.log('Main node launch'); + await this.initService.init(); + this.logger.log('Initialization successful'); + + try { + const cronExpression = process.env.DEADLINE_CRON; + if (!cronExpression) { + throw new Error('DEADLINE_CRON environment variable is not set'); + } + + const handleRemindJob = new CronJob(cronExpression, async () => { + try { + await this.reminderService.remindDeadline(); + this.logger.log('Reminder successfully processed'); + } catch (reminderErr) { + this.logger.error('Error occurred while processing reminder', reminderErr); + } + }); + + this.schedulerRegistry.addCronJob('remindDeadline', handleRemindJob as any); + this.logger.log('Start remind cron job'); + handleRemindJob.start(); + } catch (cronJobErr) { + this.logger.error('Failed to initialize cron job', cronJobErr); + // Optionally rethrow the error if you want to halt further execution + // throw cronJobErr; + } + } +} diff --git a/apps/server/src/trpc/trpc.module.ts b/apps/server/src/trpc/trpc.module.ts new file mode 100755 index 0000000..f9b9e75 --- /dev/null +++ b/apps/server/src/trpc/trpc.module.ts @@ -0,0 +1,42 @@ +import { Logger, Module } from '@nestjs/common'; +import { TrpcService } from './trpc.service'; +import { TrpcRouter } from './trpc.router'; +import { QueueModule } from '@server/queue/queue.module'; +import { DepartmentModule } from '@server/models/department/department.module'; +import { StaffModule } from '@server/models/staff/staff.module'; +import { TermModule } from '@server/models/term/term.module'; +import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module'; +import { AuthModule } from '@server/auth/auth.module'; +import { AppConfigModule } from '@server/models/app-config/app-config.module'; +import { MessageModule } from '@server/models/message/message.module'; +import { PostModule } from '@server/models/post/post.module'; +import { VisitModule } from '@server/models/visit/visit.module'; +import { WebSocketModule } from '@server/socket/websocket.module'; +import { RoleMapModule } from '@server/models/rbac/rbac.module'; +import { TransformModule } from '@server/models/transform/transform.module'; + +import { ResourceModule } from '@server/models/resource/resource.module'; +import { GoodsModule } from '@server/models/goods/goods.module'; + +@Module({ + imports: [ + AuthModule, + QueueModule, + DepartmentModule, + StaffModule, + TermModule, + TaxonomyModule, + RoleMapModule, + TransformModule, + MessageModule, + AppConfigModule, + PostModule, + VisitModule, + WebSocketModule, + ResourceModule, + GoodsModule + ], + controllers: [], + providers: [TrpcService, TrpcRouter, Logger], +}) +export class TrpcModule {} diff --git a/apps/server/src/trpc/trpc.router.ts b/apps/server/src/trpc/trpc.router.ts new file mode 100755 index 0000000..7550867 --- /dev/null +++ b/apps/server/src/trpc/trpc.router.ts @@ -0,0 +1,73 @@ +import { INestApplication, Injectable, Logger } from '@nestjs/common'; +import { DepartmentRouter } from '@server/models/department/department.router'; +import { StaffRouter } from '@server/models/staff/staff.router'; +import { TaxonomyRouter } from '@server/models/taxonomy/taxonomy.router'; +import { TermRouter } from '@server/models/term/term.router'; +import { TrpcService } from '@server/trpc/trpc.service'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import ws, { WebSocketServer } from 'ws'; +import { AppConfigRouter } from '@server/models/app-config/app-config.router'; +import { MessageRouter } from '@server/models/message/message.router'; +import { PostRouter } from '@server/models/post/post.router'; +import { VisitRouter } from '@server/models/visit/visit.router'; +import { RoleMapRouter } from '@server/models/rbac/rolemap.router'; +import { TransformRouter } from '@server/models/transform/transform.router'; +import { RoleRouter } from '@server/models/rbac/role.router'; +import { ResourceRouter } from '../models/resource/resource.router'; + +@Injectable() +export class TrpcRouter { + logger = new Logger(TrpcRouter.name); + constructor( + private readonly trpc: TrpcService, + private readonly post: PostRouter, + private readonly department: DepartmentRouter, + private readonly staff: StaffRouter, + private readonly term: TermRouter, + private readonly taxonomy: TaxonomyRouter, + private readonly role: RoleRouter, + private readonly rolemap: RoleMapRouter, + private readonly transform: TransformRouter, + private readonly app_config: AppConfigRouter, + private readonly message: MessageRouter, + private readonly visitor: VisitRouter, + private readonly resource: ResourceRouter, + ) {} + getRouter() { + return; + } + appRouter = this.trpc.router({ + transform: this.transform.router, + post: this.post.router, + department: this.department.router, + staff: this.staff.router, + term: this.term.router, + taxonomy: this.taxonomy.router, + role: this.role.router, + rolemap: this.rolemap.router, + message: this.message.router, + app_config: this.app_config.router, + visitor: this.visitor.router, + resource: this.resource.router, + }); + wss: WebSocketServer = undefined; + + async applyMiddleware(app: INestApplication) { + app.use( + `/trpc`, + trpcExpress.createExpressMiddleware({ + router: this.appRouter, + createContext: this.trpc.createExpressContext, + onError(opts) { + const { error, type, path, input, ctx, req } = opts; + // console.error('TRPC Error:', error); + }, + }), + ); + // applyWSSHandler({ + // wss: this.websocketService.getWss("trpc"), + // router: this.appRouter, + // createContext: this.trpc.createWSSContext, + // }); + } +} diff --git a/apps/server/src/trpc/trpc.service.ts b/apps/server/src/trpc/trpc.service.ts new file mode 100755 index 0000000..38024c9 --- /dev/null +++ b/apps/server/src/trpc/trpc.service.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { initTRPC, TRPCError } from '@trpc/server'; +import superjson from 'superjson-cjs'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import { db, JwtPayload, UserProfile, RolePerms } from '@nice/common'; +import { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws'; +import { UserProfileService } from '@server/auth/utils'; +import { getClientIp } from './utils'; +type Context = Awaited>; +@Injectable() +export class TrpcService { + private readonly logger = new Logger(TrpcService.name); + + async createExpressContext( + opts: trpcExpress.CreateExpressContextOptions, + ): Promise<{ + staff: UserProfile | undefined; + ip: string; + }> { + const token = opts.req.headers.authorization?.split(' ')[1]; + const staff = + await UserProfileService.instance.getUserProfileByToken(token); + const ip = getClientIp(opts.req); + return { + staff: staff.staff, + ip: ip, + }; + } + async createWSSContext( + opts: CreateWSSContextFnOptions, + ): Promise<{ staff: UserProfile | undefined }> { + const token = opts.info.connectionParams?.token; + return await UserProfileService.instance.getUserProfileByToken(token); + } + trpc = initTRPC.context().create({ + transformer: superjson, + errorFormatter: ({ error, shape }) => { + if (error.code !== 'UNAUTHORIZED') { + this.logger.error(error.message, error.stack); + } + return shape; + }, + }); + + procedure = this.trpc.procedure; + router = this.trpc.router; + mergeRouters = this.trpc.mergeRouters; + + // Define a protected procedure that ensures the user is authenticated + protectProcedure = this.procedure.use(async ({ ctx, next }) => { + if (!ctx?.staff) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' }); + } + return next({ + ctx: { + // User value is confirmed to be non-null at this point + staff: ctx.staff, + ip: ctx.ip, + }, + }); + }); +} diff --git a/apps/server/src/trpc/types.ts b/apps/server/src/trpc/types.ts new file mode 100755 index 0000000..fc6ce2c --- /dev/null +++ b/apps/server/src/trpc/types.ts @@ -0,0 +1,3 @@ +import { TrpcRouter } from './trpc.router'; + +export type AppRouter = TrpcRouter[`appRouter`]; diff --git a/apps/server/src/trpc/utils.ts b/apps/server/src/trpc/utils.ts new file mode 100755 index 0000000..0c285a3 --- /dev/null +++ b/apps/server/src/trpc/utils.ts @@ -0,0 +1,15 @@ +export function getClientIp(req: any): string { + let ip = + req.ip || + (Array.isArray(req.headers['x-forwarded-for']) + ? req.headers['x-forwarded-for'][0] + : req.headers['x-forwarded-for']) || + req.socket.remoteAddress; + + // 如果是 IPv4-mapped IPv6 地址,转换为 IPv4 + if (typeof ip === 'string' && ip.startsWith('::ffff:')) { + ip = ip.substring(7); + } + + return ip || ''; +} diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts new file mode 100755 index 0000000..8844b97 --- /dev/null +++ b/apps/server/src/upload/tus.service.ts @@ -0,0 +1,133 @@ +import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { Server, Uid, Upload } from '@nice/tus'; +import { FileStore } from '@nice/tus'; +import { Request, Response } from 'express'; +import { db, ResourceStatus } from '@nice/common'; +import { getFilenameWithoutExt } from '@server/utils/file'; +import { ResourceService } from '@server/models/resource/resource.service'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QueueJobType } from '@server/queue/types'; +import { nanoid } from 'nanoid-cjs'; +import { slugify } from 'transliteration'; +const FILE_UPLOAD_CONFIG = { + directory: process.env.UPLOAD_DIR, + maxSizeBytes: 20_000_000_000, // 20GB + expirationPeriod: 24 * 60 * 60 * 1000, // 24 hours +}; +@Injectable() +export class TusService implements OnModuleInit { + private readonly logger = new Logger(TusService.name); + private tusServer: Server; + constructor( + private readonly resourceService: ResourceService, + @InjectQueue('file-queue') private fileQueue: Queue, + ) {} + onModuleInit() { + this.initializeTusServer(); + this.setupTusEventHandlers(); + } + private initializeTusServer() { + this.tusServer = new Server({ + namingFunction(req, metadata) { + const safeFilename = slugify(metadata.filename); + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const uniqueId = nanoid(10); + return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`; + }, + path: '/upload', + datastore: new FileStore({ + directory: FILE_UPLOAD_CONFIG.directory, + expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod, + }), + maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes, + postReceiveInterval: 1000, + getFileIdFromRequest: (req, lastPath) => { + const match = req.url.match(/\/upload\/(.+)/); + return match ? match[1] : lastPath; + }, + }); + } + + private setupTusEventHandlers() { + this.tusServer.on('POST_CREATE', this.handleUploadCreate.bind(this)); + this.tusServer.on('POST_FINISH', this.handleUploadFinish.bind(this)); + } + private getFileId(uploadId: string) { + return uploadId.replace(/\/[^/]+$/, ''); + } + private async handleUploadCreate( + req: Request, + res: Response, + upload: Upload, + url: string, + ) { + try { + const fileId = this.getFileId(upload.id); + // const filename = upload.metadata.filename; + await this.resourceService.create({ + data: { + title: getFilenameWithoutExt(upload.metadata.filename), + fileId, // 移除最后的文件名 + url: upload.id, + meta: upload.metadata, + status: ResourceStatus.UPLOADING, + }, + }); + } catch (error) { + this.logger.error('Failed to create resource during upload', error); + } + } + + private async handleUploadFinish( + req: Request, + res: Response, + upload: Upload, + ) { + try { + console.log('upload.id', upload.id); + console.log('fileId', this.getFileId(upload.id)); + const resource = await this.resourceService.update({ + where: { fileId: this.getFileId(upload.id) }, + data: { status: ResourceStatus.UPLOADED }, + }); + this.fileQueue.add( + QueueJobType.FILE_PROCESS, + { resource }, + { jobId: resource.id }, + ); + this.logger.log(`Upload finished ${resource.url}`); + } catch (error) { + this.logger.error('Failed to update resource after upload', error); + } + } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async cleanupExpiredUploads() { + try { + // Delete incomplete uploads older than 24 hours + const deletedResources = await db.resource.deleteMany({ + where: { + createdAt: { + lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod), + }, + status: ResourceStatus.UPLOADING, + }, + }); + const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads(); + this.logger.log( + `Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`, + ); + } catch (error) { + this.logger.error('Expired uploads cleanup failed', error); + } + } + + async handleTus(req: Request, res: Response) { + return this.tusServer.handle(req, res); + } +} diff --git a/apps/server/src/upload/types.ts b/apps/server/src/upload/types.ts new file mode 100755 index 0000000..2140ebc --- /dev/null +++ b/apps/server/src/upload/types.ts @@ -0,0 +1,29 @@ +export interface UploadCompleteEvent { + identifier: string; + filename: string; + size: number; + hash: string; + integrityVerified: boolean; +} + +export type UploadEvent = { + uploadStart: { + identifier: string; + filename: string; + totalSize: number; + resuming?: boolean; + }; + uploadComplete: UploadCompleteEvent; + uploadError: { identifier: string; error: string; filename: string }; +}; +export interface UploadLock { + clientId: string; + timestamp: number; +} +// 添加重试机制,处理临时网络问题 +// 实现定期清理过期的临时文件 +// 添加文件完整性校验 +// 实现上传进度持久化,支持服务重启后恢复 +// 添加并发限制,防止系统资源耗尽 +// 实现文件去重功能,避免重复上传 +// 添加日志记录和监控机制 diff --git a/apps/server/src/upload/upload.controller.ts b/apps/server/src/upload/upload.controller.ts new file mode 100755 index 0000000..f014c42 --- /dev/null +++ b/apps/server/src/upload/upload.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, + All, + Req, + Res, + Get, + Post, + Patch, + Param, + Delete, + Head, + Options, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { TusService } from './tus.service'; + +@Controller('upload') +export class UploadController { + constructor(private readonly tusService: TusService) {} + // @Post() + // async handlePost(@Req() req: Request, @Res() res: Response) { + // return this.tusService.handleTus(req, res); + // } + + @Options() + async handleOptions(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } + + @Head() + async handleHead(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } + + @Post() + async handlePost(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } + @Get('/*') + async handleGet(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } + + @Patch('/*') + async handlePatch(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } + + // Keeping the catch-all method as a fallback + @All() + async handleUpload(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } +} diff --git a/apps/server/src/upload/upload.module.ts b/apps/server/src/upload/upload.module.ts new file mode 100755 index 0000000..6c8e1b0 --- /dev/null +++ b/apps/server/src/upload/upload.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { UploadController } from './upload.controller'; +import { BullModule } from '@nestjs/bullmq'; +import { TusService } from './tus.service'; +import { ResourceModule } from '@server/models/resource/resource.module'; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致 + }), + ResourceModule, + ], + controllers: [UploadController], + providers: [TusService], +}) +export class UploadModule {} diff --git a/apps/server/src/upload/utils.ts b/apps/server/src/upload/utils.ts new file mode 100755 index 0000000..a7c189f --- /dev/null +++ b/apps/server/src/upload/utils.ts @@ -0,0 +1,4 @@ +export function extractFileIdFromNginxUrl(url: string) { + const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/); + return match ? match[1] : ''; +} diff --git a/apps/server/src/utils/event-bus.ts b/apps/server/src/utils/event-bus.ts new file mode 100755 index 0000000..8cc9c2e --- /dev/null +++ b/apps/server/src/utils/event-bus.ts @@ -0,0 +1,16 @@ +import mitt from 'mitt'; +import { ObjectType, UserProfile, MessageDto } from '@nice/common'; +export enum CrudOperation { + CREATED, + UPDATED, + DELETED +} +type Events = { + genDataEvent: { type: "start" | "end" }, + markDirty: { objectType: string, id: string, staff?: UserProfile, subscribers?: string[] } + updateViewCount: { id: string, objectType: ObjectType }, + onMessageCreated: { data: Partial }, + dataChanged: { type: string, operation: CrudOperation, data: any } +}; +const EventBus = mitt(); +export default EventBus; diff --git a/apps/server/src/utils/file.ts b/apps/server/src/utils/file.ts new file mode 100755 index 0000000..c24d5d9 --- /dev/null +++ b/apps/server/src/utils/file.ts @@ -0,0 +1,66 @@ +import { createHash } from 'crypto'; +import { createReadStream } from 'fs'; +import path from 'path'; +import * as dotenv from 'dotenv'; +dotenv.config(); +export function getFilenameWithoutExt(filename: string) { + return filename ? filename.replace(/\.[^/.]+$/, '') : filename; +} +/** + * 计算文件的 SHA-256 哈希值 + * @param filePath 文件路径 + * @returns Promise 返回文件的哈希值(十六进制字符串) + */ +export async function calculateFileHash(filePath: string): Promise { + return new Promise((resolve, reject) => { + // 创建一个 SHA-256 哈希对象 + const hash = createHash('sha256'); + // 创建文件读取流 + const readStream = createReadStream(filePath); + // 处理读取错误 + readStream.on('error', (error) => { + reject(new Error(`Failed to read file: ${error.message}`)); + }); + // 处理哈希计算错误 + hash.on('error', (error) => { + reject(new Error(`Failed to calculate hash: ${error.message}`)); + }); + // 流式处理文件内容 + readStream + .pipe(hash) + .on('finish', () => { + // 获取最终的哈希值(十六进制格式) + const fileHash = hash.digest('hex'); + resolve(fileHash); + }) + .on('error', (error) => { + reject(new Error(`Hash calculation failed: ${error.message}`)); + }); + }); +} + +/** + * 计算 Buffer 的 SHA-256 哈希值 + * @param buffer 要计算哈希的 Buffer + * @returns string 返回 Buffer 的哈希值(十六进制字符串) + */ +export function calculateBufferHash(buffer: Buffer): string { + const hash = createHash('sha256'); + hash.update(buffer); + return hash.digest('hex'); +} + +/** + * 计算字符串的 SHA-256 哈希值 + * @param content 要计算哈希的字符串 + * @returns string 返回字符串的哈希值(十六进制字符串) + */ +export function calculateStringHash(content: string): string { + const hash = createHash('sha256'); + hash.update(content); + return hash.digest('hex'); +} +export const getUploadFilePath = (fileId: string): string => { + const uploadDirectory = process.env.UPLOAD_DIR; + return path.join(uploadDirectory, fileId); +}; diff --git a/apps/server/src/utils/minio/minio.module.ts b/apps/server/src/utils/minio/minio.module.ts new file mode 100755 index 0000000..bce8484 --- /dev/null +++ b/apps/server/src/utils/minio/minio.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MinioService } from './minio.service'; + +@Module({ + providers: [MinioService], + exports: [MinioService] +}) +export class MinioModule {} diff --git a/apps/server/src/utils/minio/minio.service.ts b/apps/server/src/utils/minio/minio.service.ts new file mode 100755 index 0000000..e949bf6 --- /dev/null +++ b/apps/server/src/utils/minio/minio.service.ts @@ -0,0 +1,26 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as Minio from 'minio'; + +@Injectable() +export class MinioService { + private readonly logger = new Logger(MinioService.name); + private readonly minioClient: Minio.Client; + constructor() { + this.minioClient = new Minio.Client({ + endPoint: process.env.MINIO_HOST || 'localhost', + port: parseInt(process.env.MINIO_PORT || '9000'), + useSSL: false, + accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', + secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin', + }); + } + async createBucket(bucketName: string): Promise { + const exists = await this.minioClient.bucketExists(bucketName); + if (!exists) { + await this.minioClient.makeBucket(bucketName, ''); + this.logger.log(`Bucket ${bucketName} created successfully.`); + } else { + this.logger.log(`Bucket ${bucketName} already exists.`); + } + } +} diff --git a/apps/server/src/utils/redis/redis.service.ts b/apps/server/src/utils/redis/redis.service.ts new file mode 100755 index 0000000..43987aa --- /dev/null +++ b/apps/server/src/utils/redis/redis.service.ts @@ -0,0 +1,192 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { Logger } from '@nestjs/common'; +import Redis from 'ioredis'; +const logger = new Logger('RedisClient'); +const redisHost = process.env.REDIS_HOST || 'localhost'; +const redisPort = parseInt(process.env.REDIS_PORT || '6379'); +const redisPassword = process.env.REDIS_PASSWORD; +logger.log(`Initializing Redis with host ${redisHost} port ${redisPort}`); +let redisClientInstance: Redis | null = null; +export const getRedisClient = (): Redis => { + if (!redisClientInstance) { + redisClientInstance = new Redis({ + host: redisHost, + port: redisPort, + password: redisPassword, + + retryStrategy: (times) => { + logger.debug(`Reconnecting to Redis, attempt #${times}`); + return Math.min(times * 50, 2000); // Exponential backoff strategy + }, + }); + + redisClientInstance.on('connect', async () => { + logger.log('Redis client connected'); + try { + const info = await redisClientInstance.info('memory'); + const memoryUsedMatch = info.match(/used_memory:(\d+)/); + if (memoryUsedMatch) { + const memoryUsedMB = ( + Number(memoryUsedMatch[1]) / + (1024 * 1024) + ).toFixed(2); + logger.log(`Memory used by Redis: ${memoryUsedMB} MB`); + } else { + throw new Error('Could not retrieve memory usage information.'); + } + } catch (error) { + logger.error('Error fetching memory usage:', error); + } + }); + + redisClientInstance.on('error', (err) => { + logger.error('Redis client error', err); + }); + + redisClientInstance.on('reconnecting', () => { + logger.log( + `Reconnecting to Redis at host ${redisHost} port ${redisPort}`, + ); + }); + } + + return redisClientInstance; +}; + +// Usage +export const redis = getRedisClient(); + +// export class RedisService { +// private static instance: RedisService; +// private redisClient: Redis; +// private logger = new Logger(RedisService.name); + +// private constructor() { +// try { + +// const redisHost = process.env.REDIS_HOST || 'localhost'; +// const redisPort = parseInt(process.env.REDIS_PORT || '6379'); +// const redisPassword = process.env.REDIS_PASSWORD +// this.logger.log(`Initializing Redis with host ${redisHost} port ${redisPort}`); + +// this.redisClient = new Redis({ +// host: redisHost, +// port: redisPort, +// password: redisPassword, + +// retryStrategy: (times) => { +// this.logger.debug(`Reconnecting to Redis, attempt #${times}`); +// return Math.min(times * 50, 2000); // Exponential backoff strategy +// }, +// }); + +// this.redisClient.on('connect', async () => { +// this.logger.log('Redis client connected'); +// try { +// const info = await this.redisClient.info('memory'); +// const memoryUsedMatch = info.match(/used_memory:(\d+)/); +// if (memoryUsedMatch) { +// const memoryUsedMB = (Number(memoryUsedMatch[1]) / (1024 * 1024)).toFixed(2); +// this.logger.log(`Memory used by Redis: ${memoryUsedMB} MB`); +// } else { +// throw new Error('Could not retrieve memory usage information.'); +// } +// } catch (error) { +// this.logger.error('Error fetching memory usage:', error); +// } +// }); + +// this.redisClient.on('error', (err) => { +// this.logger.error('Redis client error', err); +// }); + +// this.redisClient.on('reconnecting', () => { +// this.logger.log(`Reconnecting to Redis at host ${redisHost} port ${redisPort}`); +// }); +// } catch (error) { +// this.logger.error('Error during Redis client initialization', error); +// } +// } + +// public static getInstance(): RedisService { +// if (!RedisService.instance) { + +// RedisService.instance = new RedisService(); +// } +// return RedisService.instance; +// } + +// setValue(key: string, value: string) { +// return this.redisClient.set(key, value); +// } + +// getValue(key: string) { +// return this.redisClient.get(key); +// } + +// keys(pattern: string) { +// return this.redisClient.keys(pattern); +// } + +// setWithExpiry(key: string, value: string, time: number) { +// return this.redisClient.setex(key, time, value); +// } + +// deleteKey(key: string) { +// // this.logger.log(`Deleted Key ${key}`); +// return this.redisClient.del(key); +// } + +// setHashField(key: string, field: string, value: string) { +// return this.redisClient.hset(key, field, value); +// } + +// getHashField(key: string, field: string) { +// return this.redisClient.hget(key, field); +// } + +// getAllHashFields(key: string) { +// return this.redisClient.hgetall(key); +// } + +// publishMessage(channel: string, message: string) { +// return this.redisClient.publish(channel, message); +// } + +// subscribeToMessages(channel: string, messageHandler: (channel: string, message: string) => void) { +// this.redisClient.subscribe(channel, (err, count) => { +// if (err) { +// this.logger.error('Subscription error', err); +// } else { +// this.logger.log(`Subscribed to ${count} channels`); +// } +// }); + +// this.redisClient.on('message', (channel, message) => { +// this.logger.log(`Received message ${message} from channel ${channel}`); +// messageHandler(channel, message); +// }); +// } + +// unsubscribeFromChannel(channel: string) { +// return this.redisClient.unsubscribe(channel); +// } + +// unsubscribeAll() { +// return this.redisClient.quit(); +// } + +// async deleteByPattern(pattern: string): Promise { +// try { +// const keys = await this.redisClient.keys(pattern); +// if (keys.length > 0) { +// await this.redisClient.del(keys); +// // this.logger.log(`Deleted ${keys.length} keys matching pattern ${pattern}`); +// } +// } catch (error) { +// this.logger.error(`Failed to delete keys by pattern ${pattern}:`, error); +// } +// } + +// } diff --git a/apps/server/src/utils/redis/utils.ts b/apps/server/src/utils/redis/utils.ts new file mode 100755 index 0000000..341f217 --- /dev/null +++ b/apps/server/src/utils/redis/utils.ts @@ -0,0 +1,13 @@ +import { redis } from "./redis.service"; + +export async function deleteByPattern(pattern: string) { + try { + const keys = await redis.keys(pattern); + if (keys.length > 0) { + await redis.del(keys); + // this.logger.log(`Deleted ${keys.length} keys matching pattern ${pattern}`); + } + } catch (error) { + console.error(`Failed to delete keys by pattern ${pattern}:`, error); + } +} \ No newline at end of file diff --git a/apps/server/src/utils/tool.ts b/apps/server/src/utils/tool.ts new file mode 100755 index 0000000..20b6554 --- /dev/null +++ b/apps/server/src/utils/tool.ts @@ -0,0 +1,148 @@ +import { createReadStream } from "fs"; +import { createInterface } from "readline"; + +import { db } from '@nice/common'; +import * as tus from "tus-js-client"; +import ExcelJS from 'exceljs'; + +export function truncateStringByByte(str, maxBytes) { + let byteCount = 0; + let index = 0; + while (index < str.length && byteCount + new TextEncoder().encode(str[index]).length <= maxBytes) { + byteCount += new TextEncoder().encode(str[index]).length; + index++; + } + return str.substring(0, index) + (index < str.length ? "..." : ""); +} +export async function loadPoliciesFromCSV(filePath: string) { + const policies = { + p: [], + g: [] + }; + const stream = createReadStream(filePath); + const rl = createInterface({ + input: stream, + crlfDelay: Infinity + }); + + // Updated regex to handle commas inside parentheses as part of a single field + const regex = /(?:\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)|"(?:\\"|[^"])*"|[^,"()\s]+)(?=\s*,|\s*$)/g; + + for await (const line of rl) { + // Ignore empty lines and comments + if (line.trim() && !line.startsWith("#")) { + const parts = []; + let match; + while ((match = regex.exec(line)) !== null) { + // Remove quotes if present and trim whitespace + parts.push(match[0].replace(/^"|"$/g, '').trim()); + } + + // Check policy type (p or g) + const ptype = parts[0]; + const rule = parts.slice(1); + + if (ptype === 'p' || ptype === 'g') { + policies[ptype].push(rule); + } else { + console.warn(`Unknown policy type '${ptype}' in policy: ${line}`); + } + } + } + + return policies; +} + +export function uploadFile(blob: any, fileName: string) { + return new Promise((resolve, reject) => { + const upload = new tus.Upload(blob, { + endpoint: `${process.env.TUS_URL}/files/`, + retryDelays: [0, 1000, 3000, 5000], + metadata: { + filename: fileName, + filetype: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + onError: (error) => { + console.error("Failed because: " + error); + reject(error); // 错误时,我们要拒绝 promise + }, + onProgress: (bytesUploaded, bytesTotal) => { + const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); + // console.log(bytesUploaded, bytesTotal, `${percentage}%`); + }, + onSuccess: () => { + // console.log('Upload finished:', upload.url); + resolve(upload.url); // 成功后,我们解析 promise,并返回上传的 URL + }, + }); + upload.start(); + }); +} + + +class TreeNode { + value: string; + children: TreeNode[]; + + constructor(value: string) { + this.value = value; + this.children = []; + } + + addChild(childValue: string): TreeNode { + let newChild = undefined + if (this.children.findIndex(child => child.value === childValue) === -1) { + newChild = new TreeNode(childValue); + this.children.push(newChild) + + } + return this.children.find(child => child.value === childValue) + + } +} +function buildTree(data: string[][]): TreeNode { + const root = new TreeNode('root'); + try { + for (const path of data) { + let currentNode = root; + for (const value of path) { + currentNode = currentNode.addChild(value); + } + } + return root; + } + catch (error) { + console.error(error) + } + + +} +export function printTree(node: TreeNode, level: number = 0): void { + const indent = ' '.repeat(level); + // console.log(`${indent}${node.value}`); + for (const child of node.children) { + printTree(child, level + 1); + } +} +export async function generateTreeFromFile(file: Buffer): Promise { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file); + const worksheet = workbook.getWorksheet(1); + + const data: string[][] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // Skip header row if any + const rowData: string[] = (row.values as string[]).slice(2).map(cell => (cell || '').toString()); + data.push(rowData.map(value => value.trim())); + } + }); + // Fill forward values + for (let i = 1; i < data.length; i++) { + for (let j = 0; j < data[i].length; j++) { + if (!data[i][j]) data[i][j] = data[i - 1][j]; + } + } + return buildTree(data); +} \ No newline at end of file diff --git a/apps/server/test/app.e2e-spec.ts b/apps/server/test/app.e2e-spec.ts new file mode 100755 index 0000000..50cda62 --- /dev/null +++ b/apps/server/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/apps/server/test/jest-e2e.json b/apps/server/test/jest-e2e.json new file mode 100755 index 0000000..e9d912f --- /dev/null +++ b/apps/server/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/server/tsconfig.build.json b/apps/server/tsconfig.build.json new file mode 100755 index 0000000..64f86c6 --- /dev/null +++ b/apps/server/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100755 index 0000000..858dbf5 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2020", + "sourceMap": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "incremental": true, + // "skipLibCheck": true, + }, +} \ No newline at end of file diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100755 index 0000000..826fc8f --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,5 @@ +VITE_APP_SERVER_IP=localhost +VITE_APP_SERVER_PORT=3000 +VITE_APP_FILE_PORT=80 +VITE_APP_VERSION=0.3.0 +VITE_APP_APP_NAME=MOOC diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100755 index 0000000..592790b --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,43 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + // 允许使用 any 类型 + "@typescript-eslint/no-explicit-any": "off", + + // 允许声明但未使用的变量 + "@typescript-eslint/no-unused-vars": [ + "warn", + { + vars: "all", // 检查所有变量 + args: "none", // 不检查函数参数 + ignoreRestSiblings: true, + }, + ], + + // 禁止使用未声明的变量 + "no-undef": "error", + }, + } +); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100755 index 0000000..cc1e699 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,23 @@ + + + + + + + + + nice-stack + + + +
+ + + + \ No newline at end of file diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..ac02f6a --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name localhost; + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } +} + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100755 index 0000000..fe70610 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,85 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@ag-grid-community/client-side-row-model": "~32.3.2", + "@ag-grid-community/core": "~32.3.2", + "@ag-grid-community/react": "~32.3.2", + "@ag-grid-enterprise/clipboard": "~32.3.2", + "@ag-grid-enterprise/column-tool-panel": "~32.3.2", + "@ag-grid-enterprise/core": "~32.3.2", + "@ag-grid-enterprise/filter-tool-panel": "~32.3.2", + "@ag-grid-enterprise/master-detail": "~32.3.2", + "@ag-grid-enterprise/menu": "~32.3.2", + "@ag-grid-enterprise/range-selection": "~32.3.2", + "@ag-grid-enterprise/server-side-row-model": "~32.3.2", + "@ag-grid-enterprise/set-filter": "~32.3.2", + "@ag-grid-enterprise/status-bar": "~32.3.2", + "@ant-design/icons": "^5.4.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@floating-ui/react": "^0.26.25", + "@heroicons/react": "^2.2.0", + "@hookform/resolvers": "^3.9.1", + "@nice/client": "workspace:^", + "@nice/common": "workspace:^", + "@nice/config": "workspace:^", + "@nice/iconer": "workspace:^", + "@tanstack/query-async-storage-persister": "^5.51.9", + "@tanstack/react-query": "^5.51.21", + "@tanstack/react-query-persist-client": "^5.51.9", + "@trpc/client": "11.0.0-rc.456", + "@trpc/react-query": "11.0.0-rc.456", + "@trpc/server": "11.0.0-rc.456", + "ag-grid-community": "~32.3.2", + "ag-grid-enterprise": "~32.3.2", + "ag-grid-react": "~32.3.2", + "antd": "^5.19.3", + "axios": "^1.7.2", + "browser-image-compression": "^2.0.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.12", + "framer-motion": "^11.15.0", + "hls.js": "^1.5.18", + "idb-keyval": "^6.2.1", + "mitt": "^3.0.1", + "quill": "2.0.3", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.4.1", + "react-resizable": "^3.0.5", + "react-router-dom": "^6.24.1", + "superjson": "^2.2.1", + "tailwind-merge": "^2.6.0", + "uuid": "^10.0.0", + "yjs": "^13.6.20", + "zod": "^3.23.8" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@types/react": "18.2.38", + "@types/react-dom": "18.2.15", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} \ No newline at end of file diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100755 index 0000000..2e7af2b --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/web/public/params.json b/apps/web/public/params.json new file mode 100755 index 0000000..7a73a41 --- /dev/null +++ b/apps/web/public/params.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/apps/web/public/vite.svg b/apps/web/public/vite.svg new file mode 100755 index 0000000..e7b8dfb --- /dev/null +++ b/apps/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/App.css b/apps/web/src/App.css new file mode 100644 index 0000000..f309ab4 --- /dev/null +++ b/apps/web/src/App.css @@ -0,0 +1,42 @@ +.ag-theme-alpine { + --ag-primary-color: var(--color-primary); + --ag-alpine-active-color: var(--color-primary); + --ag-background-color: var(--color-bg-container); + --ag-foreground-color: var(--colorText); + --ag-borders-critical: solid 1px; + --ag-critical-border-color: var(--color-border-secondary); + --ag-borders: 1px solid; + --ag-borders-input: solid 1px; + --ag-border-color: var(--color-border-secondary); + --ag-secondary-border-color: var(--color-border-secondary); + --ag-secondary-foreground-color: var(--color-text-tertiary); + /* --ag-border-radius: 2px; */ + --ag-header-column-separator-display: block; + --ag-header-column-separator-height: 30%; + --ag-header-column-separator-width: 2px; + --ag-header-column-separator-color: var(--color-fill-secondary); + --ag-font-size: var(--fontSize); + --ag-header-background-color: white; + --ag-selected-row-background-color: var(--color-border-primary); + --ag-range-selection-border-color: var(--color-border-primary); + --ag-header-font-size: var(--fontSize); + --ag-header-font-weight: 600; + --ag-header-foreground-color: var(--color-primary); + --ag-row-border-style: solid; + --ag-row-border-width: 1px; + --ag-row-border-color: var(--color-border-secondary); + --ag-row-hover-color: var(--color-bg-text-hover); + --ag-padding-horizontal: 0.7rem; + --ag-padding-vertical: 0.9rem; + --ag-side-panel-border-width: 1px; + --ag-side-panel-border-color: var(--color-border-secondary); + --ag-spacing: 6px; + --ag-odd-row-background-color: var(--color-fill-quaternary); + --ag-wrapper-border-width: 0px; + /* --ag-wrapper-border-color: var(--color-border-secondary); */ + /* --ag-wrapper-border-radius: 10px; */ +} + +.ag-root-wrapper { + border: 0px; +} \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100755 index 0000000..9e6304c --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,43 @@ +import 'ag-grid-community/styles/ag-grid.css'; // Core grid CSS, always needed +import 'ag-grid-community/styles/ag-theme-alpine.css'; // Optional theme CSS +import "./App.css"; +import { RouterProvider } from "react-router-dom"; +import QueryProvider from "./providers/query-provider"; +import { router } from "./routes"; +import ThemeProvider from "./providers/theme-provider"; +import { App as AntdApp, ConfigProvider, theme } from "antd"; +import locale from "antd/locale/zh_CN"; +import dayjs from "dayjs"; +import "dayjs/locale/zh-cn"; +import { AuthProvider } from './providers/auth-provider'; +import { Toaster } from 'react-hot-toast'; + +dayjs.locale("zh-cn"); +function App() { + return ( + <> + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/apps/web/src/app/admin/base-setting/page.tsx b/apps/web/src/app/admin/base-setting/page.tsx new file mode 100755 index 0000000..ae1060d --- /dev/null +++ b/apps/web/src/app/admin/base-setting/page.tsx @@ -0,0 +1,190 @@ +import { + AppConfigSlug, + BaseSetting, + RolePerms, +} from "@nice/common"; +import { useContext, useEffect, useState } from "react"; +import { + Button, + Form, + Input, + message, + theme, +} from "antd"; +import { useAppConfig } from "@nice/client"; +import { useAuth } from "@web/src/providers/auth-provider"; + +import FixedHeader from "@web/src/components/layout/fix-header"; +import { useForm } from "antd/es/form/Form"; +import { api } from "@nice/client" +import { MainLayoutContext } from "../layout"; + +export default function BaseSettingPage() { + const { update, baseSetting } = useAppConfig(); + const utils = api.useUtils() + const [form] = useForm() + const { token } = theme.useToken(); + const { data: clientCount } = api.app_config.getClientCount.useQuery(undefined, { + refetchInterval: 3000, + refetchIntervalInBackground: true + }) + const [isFormChanged, setIsFormChanged] = useState(false); + const [loading, setLoading] = useState(false); + const { user, hasSomePermissions } = useAuth(); + const { pageWidth } = useContext?.(MainLayoutContext); + function handleFieldsChange() { + setIsFormChanged(true); + } + function onResetClick() { + if (!form) + return + if (!baseSetting) { + form.resetFields(); + } else { + form.resetFields(); + form.setFieldsValue(baseSetting); + + } + setIsFormChanged(false); + } + function onSaveClick() { + if (form) + form.submit(); + } + async function onSubmit(values: BaseSetting) { + setLoading(true); + + try { + + await update.mutateAsync({ + where: { + slug: AppConfigSlug.BASE_SETTING, + }, + data: { meta: JSON.stringify(values) } + }); + setIsFormChanged(false); + message.success("已保存"); + } catch (err: any) { + console.error(err); + } finally { + setLoading(false); + } + } + useEffect(() => { + if (baseSetting && form) { + + form.setFieldsValue(baseSetting); + } + }, [baseSetting, form]); + return ( +
+ +
+ {isFormChanged && + hasSomePermissions(RolePerms.MANAGE_BASE_SETTING) && ( + <> + + + + )} +
+
+
+
+ {/*
+ 头像配置 +
*/} +
+ 全局参数配置 +
+
+ + + +
+ {/*
+ 登录页面配置 + +
+
+ + + +
*/} +
+
+ 服务端全局命令 +
+
+ +
+ {
+ app在线人数 +
+ {clientCount && clientCount > 0 ? `${clientCount}人在线` : '无人在线'} +
+
} +
+
+ ); +} diff --git a/apps/web/src/app/admin/department/page.tsx b/apps/web/src/app/admin/department/page.tsx new file mode 100755 index 0000000..a453c39 --- /dev/null +++ b/apps/web/src/app/admin/department/page.tsx @@ -0,0 +1,7 @@ +import DeptEditor from "@web/src/components/models/department/dept-editor"; + +export default function DepartmentAdminPage() { + return
+ +
+} \ No newline at end of file diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx new file mode 100755 index 0000000..da2aba3 --- /dev/null +++ b/apps/web/src/app/admin/layout.tsx @@ -0,0 +1,111 @@ +import React, { + createContext, + CSSProperties, + useEffect, + useState, +} from "react"; +import { Outlet, useLocation } from "react-router-dom"; +import "react-resizable/css/styles.css"; +import { theme } from "antd"; +import ResizableSidebar from "@web/src/components/layout/resizable-sidebar"; +import SidebarContent from "@web/src/components/layout/sidebar-content"; +import UserHeader from "@web/src/components/layout/user-header"; +import { env } from "@web/src/env"; +import RoundedClip from "@web/src/components/svg/rounded-clip"; +import { useTerm } from "@nice/client" + +export const MainLayoutContext = createContext<{ + pageWidth?: number; +}>({ + pageWidth: undefined, +}); +const ParallelogramTag = () => { + const { token } = theme.useToken(); + const parallelogramStyle: CSSProperties = { + display: "inline-flex", + alignItems: "center", // 垂直居中 + transform: "skew(-20deg)", + height: "25px", // 调整高度以适应文本 + padding: "0 20px", + backgroundColor: token.colorPrimaryBg, + // margin: '0 0 0 10px', + }; + + const contentStyle: CSSProperties = { + transform: "skew(20deg)", + fontSize: token.fontSize, + fontWeight: "bold", + color: token.colorPrimary, + }; + + return ( +
+ {env.VERSION} +
+ ); +}; +const MainLayoutPage: React.FC = () => { + const { token } = theme.useToken(); + const [sidebarWidth, setSidebarWidth] = useState(); + const [pageWidth, setPageWidth] = useState(); + useTerm(); + const updateWidth = () => { + const remainingWidth = + window.innerWidth - Math.max(sidebarWidth || 0, 200); + setPageWidth(remainingWidth); + }; + useEffect(() => { + window.addEventListener("resize", updateWidth); + return () => window.removeEventListener("resize", updateWidth); + }, []); + useEffect(() => { + updateWidth(); + }, [sidebarWidth]); + useEffect(() => { + document.title = `${env.APP_NAME}`; + }, []); + + return ( + +
+
+ +
+ {env.APP_NAME || "后台管理"} +
+ +
+
+ + + +
+ +
+ + +
+
+
+
+
+ ); +}; + +export default MainLayoutPage; diff --git a/apps/web/src/app/admin/role/page.tsx b/apps/web/src/app/admin/role/page.tsx new file mode 100755 index 0000000..4a7b4ff --- /dev/null +++ b/apps/web/src/app/admin/role/page.tsx @@ -0,0 +1,13 @@ +import FixedHeader from "@web/src/components/layout/fix-header"; +import RoleEditor from "@web/src/components/models/role/role-editor/role-editor"; + +export default function RoleAdminPage() { + return ( + <> + + + + + + ); +} diff --git a/apps/web/src/app/admin/staff/page.tsx b/apps/web/src/app/admin/staff/page.tsx new file mode 100755 index 0000000..35d3c2c --- /dev/null +++ b/apps/web/src/app/admin/staff/page.tsx @@ -0,0 +1,8 @@ +import StaffEditor from "@web/src/components/models/staff/staff-editor" +export default function StaffPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/admin/term/page.tsx b/apps/web/src/app/admin/term/page.tsx new file mode 100755 index 0000000..a756fbf --- /dev/null +++ b/apps/web/src/app/admin/term/page.tsx @@ -0,0 +1,11 @@ + +import FixedHeader from "@web/src/components/layout/fix-header"; +import TermEditor from "@web/src/components/models/term/term-editor"; + +export default function TermAdminPage() { + return (<> + + + + ); +} diff --git a/apps/web/src/app/denied.tsx b/apps/web/src/app/denied.tsx new file mode 100755 index 0000000..c994375 --- /dev/null +++ b/apps/web/src/app/denied.tsx @@ -0,0 +1,7 @@ +import { Empty } from "antd"; + +export default function DeniedPage() { + return
+ +
+} \ No newline at end of file diff --git a/apps/web/src/app/error.tsx b/apps/web/src/app/error.tsx new file mode 100755 index 0000000..c51af76 --- /dev/null +++ b/apps/web/src/app/error.tsx @@ -0,0 +1,41 @@ +/** + * 错误处理模块 - 全局路由级错误展示组件 + * 功能: 捕获React Router路由层级错误并展示可视化错误信息 + * 特性: + * - 自动解析路由错误对象 + * - 自适应错误信息展示 + * - 响应式布局设计 + */ +import { useRouteError } from "react-router-dom"; + +/** + * 错误展示页面组件 + * @核心功能 呈现标准化错误界面,用于处理应用程序的路由层级错误 + * @设计模式 采用展示型组件模式,完全解耦业务逻辑实现纯UI展示 + * @使用示例 在React Router的RouterProvider中配置errorElement={} + */ +export default function ErrorPage() { + // 使用React Router提供的Hook获取路由错误对象 + // 类型定义为any以兼容React Router不同版本的类型差异 + const error: any = useRouteError(); + + return ( + // 主容器: 基于Flex的垂直水平双居中布局 + // pt-64: 顶部留白实现视觉层次结构 +
+ {/* 内容区块: 采用纵向弹性布局控制内部元素间距 */} +
+ {/* 主标题: 强调性文字样式配置 */} +
+ 哦?页面似乎出错了... +
+ + {/* 错误详情: 动态渲染错误信息,实现优雅降级策略 */} + {/* 使用可选链操作符防止未定义错误,信息优先级: statusText > message */} +
+ {error?.statusText || error?.message} +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/app/login.tsx b/apps/web/src/app/login.tsx new file mode 100755 index 0000000..f2a9f1b --- /dev/null +++ b/apps/web/src/app/login.tsx @@ -0,0 +1,273 @@ +import React, { useState, useRef, useEffect } from "react"; +import { Form, Input, Button, message, Row, Col } from "antd"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "../providers/auth-provider"; +import DepartmentSelect from "../components/models/department/department-select"; +import SineWave from "../components/animation/sine-wave"; +const LoginPage: React.FC = () => { + const [showLogin, setShowLogin] = useState(true); + const [registerLoading, setRegisterLoading] = useState(false); + const { + login, + isAuthenticated, + signup + } = useAuth() + const loginFormRef = useRef(null); + const registerFormRef = useRef(null); + const location = useLocation(); + const navigate = useNavigate(); + const onFinishLogin = async (values: any) => { + try { + const { username, password } = values; + await login(username, password); + } catch (err: any) { + message.error(err?.response?.data?.message || "帐号或密码错误!"); + console.error(err); + } + }; + + const onFinishRegister = async (values: any) => { + setRegisterLoading(true); + const { username, password, deptId, officerId, showname } = values; + try { + await signup({ username, password, deptId, officerId, showname }); + message.success("注册成功!"); + setShowLogin(true); + // loginFormRef.current.submit(); + } catch (err: any) { + message.error(err?.response?.data?.message); + } finally { + setRegisterLoading(false); + } + }; + + useEffect(() => { + if (isAuthenticated) { + const params = new URLSearchParams(location.search); + const redirectUrl = params.get("redirect_url") || "/"; + navigate(redirectUrl, { replace: true }); + } + }, [isAuthenticated, location]); + + return ( +
+
+
+ {showLogin ? ( +
+ +
没有账号?
+
+ 点击注册一个属于你自己的账号吧! +
+
setShowLogin(false)} + className="hover:translate-y-1 my-8 p-2 text-center rounded-xl border-white border hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all"> + 注册 +
+
+ ) : ( +
+
注册小贴士
+
+ 请认真填写用户信息哦! +
+
setShowLogin(true)} + className="hover:translate-y-1 my-8 rounded-xl text-center border-white border p-2 hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all"> + 返回登录 +
+ +
+ )} +
+
+ {showLogin ? ( + <> +
+ 登录 +
+
+ + + + + + + +
+ +
+
+ + ) : ( +
+
+ 注册 +
+
+ + + + + + + + + + + + + + + + + + + + + + ({ + validator(_, value) { + if ( + !value || + getFieldValue( + "password" + ) === value + ) { + return Promise.resolve(); + } + return Promise.reject( + new Error( + "两次输入的密码不一致" + ) + ); + }, + }), + ]}> + + + +
+ +
+
+
+ )} +
+
+
+ ); +}; + +export default LoginPage; diff --git a/apps/web/src/app/main/home/page.tsx b/apps/web/src/app/main/home/page.tsx new file mode 100755 index 0000000..7f0bf77 --- /dev/null +++ b/apps/web/src/app/main/home/page.tsx @@ -0,0 +1,35 @@ +import { Button, Tag } from 'antd' +import { api } from '@nice/client' +import { useEffect, useMemo, useState } from 'react' +import { apiClient } from '@web/src/utils' +function HomePage() { + const { data } = api.staff.findMany.useQuery({ + take: 10 + }) + const [counter, setCounter] = useState(0) + const counterText = useMemo(() => { + return `当前计数为:${counter}` + },[counter]) + + // const getData = async () => { + // const res = await apiClient.get|{"/goods/hello"} + // console.log(res) + // } + + return
+ {counterText} +
+ + +
+ { + data?.map(i=>{ + return
+ {i.username} +
+ }) + } +
+} +//export {HomePage} +export default HomePage \ No newline at end of file diff --git a/apps/web/src/app/main/layout/index.tsx b/apps/web/src/app/main/layout/index.tsx new file mode 100644 index 0000000..41bd6f8 --- /dev/null +++ b/apps/web/src/app/main/layout/index.tsx @@ -0,0 +1,7 @@ +import { Outlet } from "react-router-dom"; + +export default function MainLayout() { + return <> + + +} \ No newline at end of file diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg new file mode 100755 index 0000000..6c87de9 --- /dev/null +++ b/apps/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/components/animation/sine-wave.tsx b/apps/web/src/components/animation/sine-wave.tsx new file mode 100755 index 0000000..11e0b6f --- /dev/null +++ b/apps/web/src/components/animation/sine-wave.tsx @@ -0,0 +1,122 @@ +import React, { useRef, useEffect } from "react"; + +interface CanvasProps { + width: number; + height: number; +} + +const SineWavesCanvas: React.FC = ({ width, height }) => { + const canvasRef = useRef(null); + + useEffect(() => { + if (canvasRef.current) { + const context = canvasRef.current.getContext("2d"); + if (context) { + drawSineWaves(context); + } + } + }, [width, height]); + + function drawSineWaves(ctx: CanvasRenderingContext2D) { + let startAngle = 0; + const waveParams = [ + { + baseAmplitude: height * 0.13, + amplitudeModifier: (x: number) => + Math.sin((Math.PI * x) / width), + phase: Math.PI / 2, + lineWidth: 3, + cycle: width * Math.random() * 0.0001, + opacityModifier: (x: number) => { + const distanceFromCenter = Math.abs(x - width / 2); + const maxDistance = width / 2; + return 1 - Math.pow(distanceFromCenter / maxDistance, 2); + }, + }, + { + baseAmplitude: height * 0.12, + amplitudeModifier: (x: number) => + Math.sin((Math.PI * x) / width), + phase: 0, + lineWidth: 1.5, + cycle: width * Math.random() * 0.001, + opacityModifier: (x: number) => { + const distanceFromCenter = Math.abs(x - width / 2); + const maxDistance = width / 2; + return 1 - Math.pow(distanceFromCenter / maxDistance, 2); + }, + }, + { + baseAmplitude: height * 0.1, + amplitudeModifier: (x: number) => + Math.sin((Math.PI * x) / width), + phase: Math.PI, + lineWidth: 0.5, + cycle: width * Math.random() * 0.01, + opacityModifier: (x: number) => { + const distanceFromCenter = Math.abs(x - width / 2); + const maxDistance = width / 2; + return 1 - Math.pow(distanceFromCenter / maxDistance, 2); + }, + }, + { + baseAmplitude: height * 0.11, + amplitudeModifier: (x: number) => + Math.sin((Math.PI * x) / width), + phase: Math.random() * Math.PI * 2, + lineWidth: 1.3, + cycle: width * Math.random() * 0.1, + opacityModifier: (x: number) => { + const distanceFromCenter = Math.abs(x - width / 2); + const maxDistance = width / 2; + return 1 - Math.pow(distanceFromCenter / maxDistance, 2); + }, + }, + ]; + + const gradient = ctx.createLinearGradient(0, 0, width, 0); + gradient.addColorStop(0, "rgba(255, 255, 255, 0)"); + gradient.addColorStop(0.5, "rgba(255, 255, 255, 1)"); + gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + + function draw() { + ctx.clearRect(0, 0, width, height); + + startAngle += 0.1; + + waveParams?.forEach((param) => { + ctx.beginPath(); + + for (let x = 0; x < width; x++) { + let y = + height / 2 + + param.baseAmplitude * + param.amplitudeModifier(x) * + Math.sin( + x * param.cycle + startAngle + param.phase + ); + + ctx.strokeStyle = gradient; + ctx.lineTo(x, y); + } + + ctx.lineWidth = param.lineWidth; + ctx.stroke(); + }); + + requestAnimationFrame(draw); + } + + draw(); + } + + return ( + + ); +}; + +export default SineWavesCanvas; diff --git a/apps/web/src/components/common/container/Card.tsx b/apps/web/src/components/common/container/Card.tsx new file mode 100755 index 0000000..2dbb089 --- /dev/null +++ b/apps/web/src/components/common/container/Card.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; + hover?: boolean; + onClick?: () => void; + variant?: 'default' | 'elevated' | 'outlined'; +} + +export const Card = ({ + children, + className = '', + hover = true, + onClick, + variant = 'default' +}: CardProps) => { + const baseStyles = 'rounded-xl transition-all duration-300'; + + const variantStyles = { + default: 'bg-white/90 border border-gray-100 shadow-sm', + elevated: 'bg-gradient-to-br from-white/95 to-white/90 shadow-lg border border-gray-50', + outlined: 'bg-transparent border-2 border-gray-200 hover:border-gray-300' + }; + + + + return ( +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/components/common/container/CollapsibleContent.tsx b/apps/web/src/components/common/container/CollapsibleContent.tsx new file mode 100644 index 0000000..6d1ea96 --- /dev/null +++ b/apps/web/src/components/common/container/CollapsibleContent.tsx @@ -0,0 +1,54 @@ +import React, { useRef, useState } from "react"; + +interface CollapsibleContentProps { + content: string; + maxHeight?: number; +} + +const CollapsibleContent: React.FC = ({ + content, + maxHeight = 150, +}) => { + const contentWrapperRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + + // Determine if content needs to be collapsed + const shouldCollapse = contentWrapperRef.current + ? contentWrapperRef.current.scrollHeight > maxHeight + : false; + + return ( +
+
+
+ + {/* Gradient overlay */} + {shouldCollapse && !isExpanded && ( +
+ )} +
+ + {/* Expand/Collapse button */} + {shouldCollapse && ( + + )} +
+ ); +}; + +export default CollapsibleContent; diff --git a/apps/web/src/components/common/editor/quill/QuillCharCounter.tsx b/apps/web/src/components/common/editor/quill/QuillCharCounter.tsx new file mode 100755 index 0000000..ed409b7 --- /dev/null +++ b/apps/web/src/components/common/editor/quill/QuillCharCounter.tsx @@ -0,0 +1,42 @@ +interface QuillCharCounterProps { + currentCount: number; + maxLength?: number; + minLength?: number; +} + +const QuillCharCounter: React.FC = ({ + currentCount, + maxLength, + minLength = 0 +}) => { + const getStatusColor = () => { + if (currentCount > (maxLength || Infinity)) return 'text-red-500'; + if (currentCount < minLength) return 'text-amber-500'; + return 'text-gray-500'; + }; + + return ( +
+ {currentCount} + {maxLength && ( + <> + / + {maxLength} + + )} + 字符 + {minLength > 0 && currentCount < minLength && ( + + 至少输入 {minLength} 字符 + + )} +
+ ); +}; + +export default QuillCharCounter \ No newline at end of file diff --git a/apps/web/src/components/common/editor/quill/QuillEditor.tsx b/apps/web/src/components/common/editor/quill/QuillEditor.tsx new file mode 100755 index 0000000..78ca9ff --- /dev/null +++ b/apps/web/src/components/common/editor/quill/QuillEditor.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useRef, useState } from "react"; +import Quill from "quill"; +import "quill/dist/quill.snow.css"; // 引入默认样式 +import QuillCharCounter from "./QuillCharCounter"; +import { defaultModules } from "./constants"; + +interface QuillEditorProps { + value?: string; + onChange?: (content: string) => void; + placeholder?: string; + readOnly?: boolean; + theme?: "snow" | "bubble"; + modules?: any; + className?: string; + style?: React.CSSProperties; + onFocus?: () => void; + onBlur?: () => void; + onKeyDown?: (event: KeyboardEvent) => void; + onKeyUp?: (event: KeyboardEvent) => void; + maxLength?: number; + minLength?: number; + minRows?: number; + maxRows?: number; +} +const QuillEditor: React.FC = ({ + value = "", + onChange, + placeholder = "请输入内容...", + readOnly = false, + theme = "snow", + modules = defaultModules, + className = "", + style = {}, + onFocus, + onBlur, + onKeyDown, + onKeyUp, + maxLength, + minLength = 0, + minRows = 1, + maxRows, +}) => { + const editorRef = useRef(null); + const quillRef = useRef(null); + const isMounted = useRef(false); + const [charCount, setCharCount] = useState(0); // 添加字符计数状态 + const handleTextChange = () => { + if (!quillRef.current) return; + const editor = quillRef.current; + // 获取文本并处理换行符 + const text = editor.getText().replace(/\n$/, ""); + const textLength = text.length; + + // 处理最大长度限制 + if (maxLength && textLength > maxLength) { + // 暂时移除事件监听器 + editor.off("text-change", handleTextChange); + + // 获取当前选区 + const selection = editor.getSelection(); + const delta = editor.getContents(); + let length = 0; + const newDelta = delta.ops?.reduce((acc: any, op: any) => { + if (typeof op.insert === "string") { + const remainingLength = maxLength - length; + if (length < maxLength) { + const truncatedText = op.insert.slice( + 0, + remainingLength + ); + length += truncatedText.length; + acc.push({ ...op, insert: truncatedText }); + } + } else { + acc.push(op); + } + return acc; + }, []); + // 更新内容 + editor.setContents({ ops: newDelta } as any); + // 恢复光标位置 + if (selection) { + editor.setSelection(Math.min(selection.index, maxLength)); + } + // 重新计算截断后的实际长度 + const finalText = editor.getText().replace(/\n$/, ""); + setCharCount(finalText.length); + + // 重新绑定事件监听器 + editor.on("text-change", handleTextChange); + } else { + // 如果没有超出最大长度,直接更新字符计数 + setCharCount(textLength); + } + + onChange?.(quillRef.current.root.innerHTML); + }; + + useEffect(() => { + if (!editorRef.current) return; + if (!isMounted.current) { + // 初始化 Quill 编辑器 + quillRef.current = new Quill(editorRef.current, { + theme, + modules, + placeholder, + readOnly, + }); + // 设置初始内容 + quillRef.current.root.innerHTML = value; + if (onFocus) { + quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => { + if (range) { + onFocus(); + } + }); + } + if (onBlur) { + quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => { + if (!range) { + onBlur(); + } + }); + } + quillRef.current.on(Quill.events.TEXT_CHANGE, handleTextChange); + if (onKeyDown) { + quillRef.current.root.addEventListener("keydown", onKeyDown); + } + if (onKeyUp) { + quillRef.current.root.addEventListener("keyup", onKeyUp); + } + isMounted.current = true; + } + }, [ + theme, + modules, + placeholder, + readOnly, + onFocus, + onBlur, + onKeyDown, + onKeyUp, + maxLength, + minLength, + ]); // 添加所有相关的依赖 + useEffect(() => { + if (quillRef.current) { + const editor = editorRef.current?.querySelector( + ".ql-editor" + ) as HTMLElement; + if (editor) { + const lineHeight = parseInt( + window.getComputedStyle(editor).lineHeight, + 10 + ); + const paddingTop = parseInt( + window.getComputedStyle(editor).paddingTop, + 10 + ); + const paddingBottom = parseInt( + window.getComputedStyle(editor).paddingBottom, + 10 + ); + const minHeight = + lineHeight * minRows + paddingTop + paddingBottom; + editor.style.minHeight = `${minHeight}px`; + if (maxRows) { + const maxHeight = + lineHeight * maxRows + paddingTop + paddingBottom; + editor.style.maxHeight = `${maxHeight}px`; + editor.style.overflowY = "auto"; + } + } + } + }, [minRows, maxRows, quillRef.current]); + + // 监听 value 属性变化 + useEffect(() => { + if (quillRef.current && value !== quillRef.current.root.innerHTML) { + quillRef.current.root.innerHTML = value; + } + }, [value]); + + return ( +
+
+ {(maxLength || minLength > 0) && ( + + )} +
+ ); +}; + +export default QuillEditor; diff --git a/apps/web/src/components/common/editor/quill/constants.ts b/apps/web/src/components/common/editor/quill/constants.ts new file mode 100755 index 0000000..d47f27b --- /dev/null +++ b/apps/web/src/components/common/editor/quill/constants.ts @@ -0,0 +1,11 @@ +export const defaultModules = { + toolbar: [ + [{ 'header': [1, 2, 3, 4, 5, 6, false] }], + ['bold', 'italic', 'underline', 'strike'], + [{ 'list': 'ordered' }, { 'list': 'bullet' }], + [{ 'color': [] }, { 'background': [] }], + [{ 'align': [] }], + ['link', 'image'], + ['clean'] + ] +}; \ No newline at end of file diff --git a/apps/web/src/components/common/element/AnimatedTabs.tsx b/apps/web/src/components/common/element/AnimatedTabs.tsx new file mode 100755 index 0000000..c6c2eff --- /dev/null +++ b/apps/web/src/components/common/element/AnimatedTabs.tsx @@ -0,0 +1,44 @@ +import { motion } from "framer-motion"; + +interface TabOption { + id: string; + label: string; +} + +interface AnimatedTabsProps { + options: TabOption[]; + activeTab: string; + onChange: (tabId: string) => void; + className?: string; +} + +export function AnimatedTabs({ options, activeTab, onChange, className = "" }: AnimatedTabsProps) { + return ( + + ); +} \ No newline at end of file diff --git a/apps/web/src/components/common/element/Avatar.tsx b/apps/web/src/components/common/element/Avatar.tsx new file mode 100755 index 0000000..0693169 --- /dev/null +++ b/apps/web/src/components/common/element/Avatar.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +interface AvatarProps { + src?: string; + name?: string; + size?: number; + className?: string; +} + +export function Avatar({ src, name = '', size = 40, className = '' }: AvatarProps) { + const initials = useMemo(() => { + return name + .split(/\s+|(?=[A-Z])/) + .map(word => word[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + }, [name]); + + const backgroundColor = useMemo(() => { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = hash % 360; + return `hsl(${hue}, 70%, 50%)`; + }, [name]); + + return ( +
+ {src ? ( + {name} + ) : ( +
+ {initials} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/common/element/Button.tsx b/apps/web/src/components/common/element/Button.tsx new file mode 100755 index 0000000..4a7ba79 --- /dev/null +++ b/apps/web/src/components/common/element/Button.tsx @@ -0,0 +1,221 @@ +import { HTMLMotionProps, motion } from 'framer-motion'; +import { forwardRef, ReactNode } from 'react'; +import { cn } from '@web/src/utils/classname'; +import { LoadingOutlined } from '@ant-design/icons'; + +export interface ButtonProps extends Omit, keyof React.ButtonHTMLAttributes> { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'info' | 'light' | 'dark' | + 'soft-primary' | 'soft-secondary' | 'soft-danger' | 'soft-success' | 'soft-warning' | 'soft-info' | + 'ghost-primary' | 'ghost-secondary' | 'ghost-danger' | 'ghost-success' | 'ghost-warning' | 'ghost-info'; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'icon'; + isLoading?: boolean; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + fullWidth?: boolean; + rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; + elevation?: 'none' | 'sm' | 'md' | 'lg'; + glassmorphism?: boolean; + animated?: boolean; + className?: string; + disabled?: boolean; + children?: ReactNode; + title?: string; + onClick?: (event: React.MouseEvent) => void; + onMouseEnter?: (event: React.MouseEvent) => void; + onMouseLeave?: (event: React.MouseEvent) => void; + onFocus?: (event: React.FocusEvent) => void; + onBlur?: (event: React.FocusEvent) => void; +} + +export const Button = forwardRef( + ( + { + className, + variant = 'primary', + size = 'md', + isLoading = false, + disabled, + leftIcon, + rightIcon, + children, + fullWidth, + rounded = 'md', + elevation = "none", + glassmorphism = false, + animated = true, + title, + ...props + }, + ref + ) => { + const variants = { + primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400', + secondary: 'bg-gray-600 text-white hover:bg-gray-700 disabled:bg-gray-400', + outline: 'border-2 bg-transparent hover:bg-gray-50 ', + ghost: 'bg-transparent hover:bg-gray-50 ', + danger: 'bg-red-600 text-white hover:bg-red-700', + success: 'bg-green-600 text-white hover:bg-green-700', + warning: 'bg-yellow-600 text-white hover:bg-yellow-700', + info: 'bg-cyan-600 text-white hover:bg-cyan-700', + light: 'bg-white text-gray-900 hover:bg-gray-50 border border-gray-200', + dark: 'bg-gray-900 text-white hover:bg-gray-800', + 'soft-primary': 'bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:bg-gray-50 disabled:text-gray-400', + 'soft-secondary': 'bg-gray-50 text-gray-600 hover:bg-gray-100 disabled:bg-gray-50 disabled:text-gray-400', + 'soft-danger': 'bg-red-50 text-red-600 hover:bg-red-100 disabled:bg-gray-50 disabled:text-gray-400', + 'soft-success': 'bg-green-50 text-green-600 hover:bg-green-100 disabled:bg-gray-50 disabled:text-gray-400', + 'soft-warning': 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100 disabled:bg-gray-50 disabled:text-gray-400', + 'soft-info': 'bg-cyan-50 text-cyan-600 hover:bg-cyan-100 disabled:bg-gray-50 disabled:text-gray-400', + 'ghost-primary': 'bg-transparent text-blue-600 hover:bg-blue-50', + 'ghost-secondary': 'bg-transparent text-gray-600 hover:bg-gray-50', + 'ghost-danger': 'bg-transparent text-red-600 hover:bg-red-50', + 'ghost-success': 'bg-transparent text-green-600 hover:bg-green-50', + 'ghost-warning': 'bg-transparent text-yellow-600 hover:bg-yellow-50', + 'ghost-info': 'bg-transparent text-cyan-600 hover:bg-cyan-50', + }; + const borderColors = { + primary: 'border-blue-600', + secondary: 'border-gray-600', + outline: 'border-gray-300 ', + ghost: 'border-transparent', + danger: 'border-red-600', + success: 'border-green-600', + warning: 'border-yellow-600', + info: 'border-cyan-600', + light: 'border-gray-200', + dark: 'border-gray-900', + 'soft-primary': 'border-transparent', + 'soft-secondary': 'border-transparent', + 'soft-danger': 'border-transparent', + 'soft-success': 'border-transparent', + 'soft-warning': 'border-transparent', + 'soft-info': 'border-transparent', + 'ghost-primary': 'border-transparent', + 'ghost-secondary': 'border-transparent', + 'ghost-danger': 'border-transparent', + 'ghost-success': 'border-transparent', + 'ghost-warning': 'border-transparent', + 'ghost-info': 'border-transparent', + }; + + const ringColors = { + primary: 'focus:ring-blue-500/50', + secondary: 'focus:ring-gray-500/50', + outline: 'focus:ring-gray-400/50', + ghost: 'focus:ring-gray-400/50', + danger: 'focus:ring-red-500/50', + success: 'focus:ring-green-500/50', + warning: 'focus:ring-yellow-500/50', + info: 'focus:ring-cyan-500/50', + light: 'focus:ring-gray-200/50', + dark: 'focus:ring-gray-900/50', + 'soft-primary': 'focus:ring-blue-200/50', + 'soft-secondary': 'focus:ring-gray-200/50', + 'soft-danger': 'focus:ring-red-200/50', + 'soft-success': 'focus:ring-green-200/50', + 'soft-warning': 'focus:ring-yellow-200/50', + 'soft-info': 'focus:ring-cyan-200/50', + 'ghost-primary': 'focus:ring-blue-200/50', + 'ghost-secondary': 'focus:ring-gray-200/50', + 'ghost-danger': 'focus:ring-red-200/50', + 'ghost-success': 'focus:ring-green-200/50', + 'ghost-warning': 'focus:ring-yellow-200/50', + 'ghost-info': 'focus:ring-cyan-200/50', + }; + const sizes = { + xs: 'px-2.5 py-1.5 text-xs', + sm: 'px-3 py-2 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', + xl: 'px-8 py-4 text-xl', + icon: 'p-2', // 修改为等边距的 padding + }; + + const iconSizes = { + xs: 'h-3 w-3', + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-6 w-6', + xl: 'h-7 w-7', + icon: 'h-4 w-4', + }; + + const roundedStyles = { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + xl: 'rounded-xl', + full: 'rounded-full', + }; + + const elevationStyles = { + none: '', + sm: 'shadow-sm', + md: 'shadow-md', + lg: 'shadow-lg', + }; + + const isIconOnly = size === 'icon' || (!children && (leftIcon || rightIcon)); + + return ( + + ); + } +); + +Button.displayName = 'Button'; \ No newline at end of file diff --git a/apps/web/src/components/common/element/Pagination.tsx b/apps/web/src/components/common/element/Pagination.tsx new file mode 100755 index 0000000..0c1cac5 --- /dev/null +++ b/apps/web/src/components/common/element/Pagination.tsx @@ -0,0 +1,106 @@ +import { motion } from 'framer-motion'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + maxVisiblePages?: number; + className?: string; +} + +export const Pagination = ({ + currentPage, + totalPages, + onPageChange, + maxVisiblePages = 7, + className = '', +}: PaginationProps) => { + const getVisiblePages = () => { + if (totalPages <= maxVisiblePages) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const leftSiblingIndex = Math.max(currentPage - 1, 1); + const rightSiblingIndex = Math.min(currentPage + 1, totalPages); + + const shouldShowLeftDots = leftSiblingIndex > 2; + const shouldShowRightDots = rightSiblingIndex < totalPages - 1; + + if (!shouldShowLeftDots && shouldShowRightDots) { + const leftRange = Array.from({ length: maxVisiblePages - 1 }, (_, i) => i + 1); + return [...leftRange, '...', totalPages]; + } + + if (shouldShowLeftDots && !shouldShowRightDots) { + const rightRange = Array.from( + { length: maxVisiblePages - 1 }, + (_, i) => totalPages - (maxVisiblePages - 2) + i + ); + return [1, '...', ...rightRange]; + } + + if (shouldShowLeftDots && shouldShowRightDots) { + const middleRange = Array.from( + { length: maxVisiblePages - 4 }, + (_, i) => leftSiblingIndex + i + ); + return [1, '...', ...middleRange, '...', totalPages]; + } + + return Array.from({ length: totalPages }, (_, i) => i + 1); + }; + + const visiblePages = getVisiblePages(); + + return ( + + onPageChange(currentPage - 1)} + disabled={currentPage <= 1} + className="px-4 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50 + disabled:opacity-50 disabled:cursor-not-allowed transition-colors + shadow-sm text-gray-700 font-medium" + > + 上一页 + + +
+ {visiblePages.map((page, index) => ( + typeof page === 'number' && onPageChange(page)} + className={`px-4 py-2 rounded-md font-medium transition-colors ${currentPage === page + ? 'bg-blue-500 text-white shadow-md' + : page === '...' + ? 'cursor-default' + : 'bg-white border border-gray-200 hover:bg-gray-50 text-gray-700 shadow-sm' + }`} + > + {page} + + ))} +
+ + onPageChange(currentPage + 1)} + disabled={currentPage >= totalPages} + className="px-4 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50 + disabled:opacity-50 disabled:cursor-not-allowed transition-colors + shadow-sm text-gray-700 font-medium" + > + 下一页 + +
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/components/common/element/Tag.tsx b/apps/web/src/components/common/element/Tag.tsx new file mode 100755 index 0000000..c8c91f4 --- /dev/null +++ b/apps/web/src/components/common/element/Tag.tsx @@ -0,0 +1,88 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import { motion } from 'framer-motion' +import { FC, ReactNode } from 'react' + +const tagVariants = cva( + 'inline-flex items-center justify-center rounded-full px-3 py-1 text-sm font-medium transition-all', + { + variants: { + variant: { + default: 'bg-gray-100 text-gray-800 hover:bg-gray-200', + primary: 'bg-blue-100 text-blue-800 hover:bg-blue-200', + success: 'bg-green-100 text-green-800 hover:bg-green-200', + warning: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200', + danger: 'bg-red-100 text-red-800 hover:bg-red-200', + }, + size: { + sm: 'text-xs px-2 py-0.5', + md: 'text-sm px-3 py-1', + lg: 'text-base px-4 py-1.5', + }, + interactive: { + true: 'cursor-pointer', + }, + removable: { + true: 'pr-2', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + interactive: false, + removable: false, + }, + } +) + +interface TagProps extends VariantProps { + children: ReactNode + onClick?: () => void + onRemove?: () => void + className?: string +} + +const Tag: FC = ({ + children, + variant, + size, + interactive, + removable, + onClick, + onRemove, + className, +}) => { + return ( + + {children} + {removable && ( + { + e.stopPropagation() + onRemove?.() + }} + className="ml-1.5 rounded-full p-0.5 hover:bg-black/5" + > + + + + + )} + + ) +} + +export default Tag \ No newline at end of file diff --git a/apps/web/src/components/common/form/FormArrayField.tsx b/apps/web/src/components/common/form/FormArrayField.tsx new file mode 100755 index 0000000..4979c49 --- /dev/null +++ b/apps/web/src/components/common/form/FormArrayField.tsx @@ -0,0 +1,136 @@ +import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; +import { Reorder } from "framer-motion"; +import React, { useEffect, useState } from "react"; +import { Input, Button, Form, Typography } from "antd"; +import type { InputProps } from "antd"; +import { UUIDGenerator } from "@nice/common"; + +interface ArrayFieldProps { + name: string; + label: string; + placeholder?: string; + addButtonText?: string; + inputProps?: Partial; +} + +type ItemType = { id: string; value: string }; + +export function FormArrayField({ + name, + label, + placeholder, + addButtonText = "添加项目", + inputProps = {}, +}: ArrayFieldProps) { + const [items, setItems] = useState([]); + + const updateItems = (newItems: ItemType[]) => { + setItems(newItems); + }; + useEffect(() => { + console.log(items) + }) + return ( + + + {(fields, { add, remove }, { errors }) => ( + +
+ + {fields.map((field, index) => ( + +
+ + { + // 更新 items 状态 + const newItems = [...items]; + if (!newItems[index]) { + newItems[index] = { + id: field.key.toString(), + value: e.target + .value, + }; + } else { + newItems[index].value = + e.target.value; + } + setItems(newItems); + }} + /> + + + +
+
+ ))} +
+ +
+ +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/common/form/FormDynamicInputs.tsx b/apps/web/src/components/common/form/FormDynamicInputs.tsx new file mode 100755 index 0000000..6f2a9d2 --- /dev/null +++ b/apps/web/src/components/common/form/FormDynamicInputs.tsx @@ -0,0 +1,156 @@ +// components/FormDynamicInputs.tsx +import { useFieldArray, useFormContext } from "react-hook-form"; +import { AnimatePresence, motion } from "framer-motion"; +import React, { useState } from "react"; +import { CheckIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline"; +import FormError from "./FormError"; +import { TrashIcon } from "@heroicons/react/24/solid"; + +export interface DynamicFormInputProps + extends Omit< + React.InputHTMLAttributes, + "type" + > { + name: string; + addTitle?: string; + subTitle?: string; + label: string; + type?: + | "text" + | "textarea" + | "password" + | "email" + | "number" + | "tel" + | "url" + | "search" + | "date" + | "time" + | "datetime-local"; + rows?: number; +} + +export function FormDynamicInputs({ + name, + addTitle, + label, + subTitle, + type = "text", + rows = 4, + className, + ...restProps +}: DynamicFormInputProps) { + const [focusedIndexes, setFocusedIndexes] = useState([]); + const { + register, + formState: { errors }, + watch, + setValue, + trigger, + control, + } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name, + }); + // 添加 onChange 处理函数 + const handleInputChange = (index: number, value: string) => { + setValue(`${name}.${index}`, value, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }; + const handleBlur = async (index: number) => { + setFocusedIndexes(focusedIndexes.filter((i) => i !== index)); + await trigger(`${name}.${index}`); + }; + + const values = watch(name) || []; + const fieldErrors = errors[name] as any; + + const inputClasses = ` + w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm + transition-all duration-300 ease-out placeholder:text-gray-400 + border-gray-300 focus:border-blue-500 focus:ring-blue-200 + `; + + const InputElement = type === "textarea" ? "textarea" : "input"; + return ( +
+ + {subTitle && ( + + )} + + + {fields.map((field, index) => ( + +
+ + handleInputChange( + index, + e.target.value + ), + })} + type={type !== "textarea" ? type : undefined} + rows={type === "textarea" ? rows : undefined} + {...restProps} + onFocus={() => + setFocusedIndexes([ + ...focusedIndexes, + index, + ]) + } + onBlur={() => handleBlur(index)} + className={inputClasses} + /> + + {/* 修改这部分,将删除按钮放在 input 内部右侧 */} +
+ {values[index] && !fieldErrors?.[index] && ( + + )} + {fields.length > 1 && ( + + )} +
+
+ {fieldErrors?.[index]?.message && ( + + )} +
+ ))} +
+ + append("")} + className="flex items-center gap-1 text-blue-500 hover:text-blue-600 + transition-colors px-4 py-2 rounded-lg hover:bg-blue-50"> + + 添加新{addTitle || label} + +
+ ); +} diff --git a/apps/web/src/components/common/form/FormError.tsx b/apps/web/src/components/common/form/FormError.tsx new file mode 100755 index 0000000..56d9448 --- /dev/null +++ b/apps/web/src/components/common/form/FormError.tsx @@ -0,0 +1,32 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; + +const ANIMATIONS = { + tooltip: { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 10 }, + transition: { duration: 0.2 } + } +}; + +export default function FormError({ error }: { error: string }) { + if (!error) return null; + + return ( +
+ + + + {error} + {/* 添加小三角形 */} +
+ + +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/common/form/FormInput.tsx b/apps/web/src/components/common/form/FormInput.tsx new file mode 100755 index 0000000..325eeba --- /dev/null +++ b/apps/web/src/components/common/form/FormInput.tsx @@ -0,0 +1,148 @@ +import { useFormContext } from 'react-hook-form'; +import { useRef, useState } from 'react'; +import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import FormError from './FormError'; +import { Button } from '../element/Button'; +export interface FormInputProps extends Omit, 'type'> { + name: string; + label?: string; + type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local'; + rows?: number; + viewMode?: boolean; + +} +export function FormInput({ + name, + label, + type = 'text', + rows = 4, + className, + viewMode = false, // 默认为编辑模式 + ...restProps +}: FormInputProps) { + const [isFocused, setIsFocused] = useState(false); + const [isEditing, setIsEditing] = useState(!viewMode); + const inputWrapper = useRef(null); + const { + register, + formState: { errors }, + watch, + setValue, + trigger, // Add trigger from useFormContext + } = useFormContext(); + const handleBlur = async () => { + setIsFocused(false); + await trigger(name); // Trigger validation for this field + if (viewMode) { + setIsEditing(false) + } + + }; + const value = watch(name); + const error = errors[name]?.message as string; + const isValid = value && !error; + const inputClasses = ` + w-full rounded-lg border bg-white px-4 py-2 outline-none + transition-all duration-200 ease-out placeholder:text-gray-400 + ${error + ? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200' + : 'border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100' + } + ${isFocused ? 'ring-2 ring-opacity-50' : ''} + ${className || ''} + `; + + const viewModeClasses = ` + w-full text-gray-700 hover:text-blue-600 min-h-[48px] + flex items-center gap-2 relative cursor-pointer group + transition-all duration-200 ease-out select-none +`; + + const InputElement = type === 'textarea' ? 'textarea' : 'input'; + + const renderViewMode = () => ( +
setIsEditing(true)} + > + + {value || 点击编辑} + + +
+ ); + + const renderEditMode = () => ( +
+ setIsFocused(true)} + onBlur={handleBlur} + className={inputClasses} + aria-label={label} + autoFocus + /> +
+ {value && isFocused && ( + + )} + {isValid && ( + + )} + {error && } +
+
+ ); + return ( +
+ {label &&
+ + {restProps.maxLength && ( + + {value?.length || 0}/{restProps.maxLength} + + )} +
} + {viewMode && !isEditing ? renderViewMode() : renderEditMode()} +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/common/form/FormQuillInput.tsx b/apps/web/src/components/common/form/FormQuillInput.tsx new file mode 100755 index 0000000..7820f55 --- /dev/null +++ b/apps/web/src/components/common/form/FormQuillInput.tsx @@ -0,0 +1,79 @@ +import { useFormContext, Controller } from 'react-hook-form'; +import FormError from './FormError'; +import { useState } from 'react'; +import QuillEditor from '../editor/quill/QuillEditor'; + +export interface FormQuillInputProps { + name: string; + label: string; + placeholder?: string; + maxLength?: number; + minLength?: number; + className?: string; + readOnly?: boolean; + maxRows?: number; + minRows?: number; +} + +export function FormQuillInput({ + name, + label, + placeholder, + maxLength, + minLength, + className, + readOnly = false, + maxRows = 10, + minRows = 4 +}: FormQuillInputProps) { + const [isFocused, setIsFocused] = useState(false); + const { + control, + formState: { errors }, + trigger, + } = useFormContext(); + + const error = errors[name]?.message as string; + + const handleBlur = async () => { + + setIsFocused(false); + await trigger(name); + }; + console.log(isFocused) + const containerClasses = ` + w-full rounded-md border bg-white shadow-sm + transition-all duration-300 ease-out + ${isFocused + ? `ring-2 ring-opacity-50 ${error ? 'ring-red-200 border-red-500' : 'ring-blue-200 border-blue-500'}` + : 'border-gray-300' + } + ${className} + `.trim() + return ( +
+ +
+ ( + setIsFocused(true)} + onBlur={handleBlur} + /> + )} + /> +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/common/form/FormSelect.tsx b/apps/web/src/components/common/form/FormSelect.tsx new file mode 100755 index 0000000..84eb27f --- /dev/null +++ b/apps/web/src/components/common/form/FormSelect.tsx @@ -0,0 +1,123 @@ +import { useFormContext } from 'react-hook-form'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useEffect, useState, useRef } from 'react'; +import { ChevronUpDownIcon, CheckIcon } from '@heroicons/react/24/outline'; +import FormError from './FormError'; + +interface Option { + value: string; + label: string; +} + +interface FormSelectProps { + name: string; + label: string; + options: Option[]; + placeholder?: string; + className?: string +} + +const ANIMATIONS = { + dropdown: { + initial: { opacity: 0, y: -4 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -4 }, + transition: { duration: 0.15 } + }, + error: { + initial: { opacity: 0, y: -8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -8 }, + transition: { duration: 0.2 } + } +}; + +export function FormSelect({ name, label, options, placeholder = '请选择', className }: FormSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const { + register, + formState: { errors }, + watch, + setValue, + } = useFormContext(); + + const value = watch(name); + const error = errors[name]?.message as string; + const selectedOption = options.find(opt => opt.value === value); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!dropdownRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const containerClasses = ` + w-full rounded-md border bg-white shadow-sm + transition-all duration-300 ease-out + p-2 pr-8 outline-none cursor-pointer + ${isOpen + ? `ring-2 ring-opacity-50 ${error ? 'ring-red-200 border-red-500' : 'ring-blue-200 border-blue-500'}` + : 'border-gray-300' + } + placeholder:text-gray-400 + ${className} + `; + return ( +
+ +
+ +
setIsOpen(!isOpen)} + > + {selectedOption?.label || {placeholder}} +
+ + + + + {isOpen && ( + + {options.map((option) => ( +
{ + setValue(name, option.value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true + }); + setIsOpen(false); + }} + > + {option.label} + {value === option.value && } +
+ ))} +
+ )} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/common/input/InputList.tsx b/apps/web/src/components/common/input/InputList.tsx new file mode 100644 index 0000000..f6247d5 --- /dev/null +++ b/apps/web/src/components/common/input/InputList.tsx @@ -0,0 +1,102 @@ +import React, { useState } from "react"; +import { Input, Button } from "antd"; +import { DeleteOutlined } from "@ant-design/icons"; + +interface InputListProps { + initialValue?: string[]; + onChange?: (value: string[]) => void; + placeholder?: string; +} + +const InputList: React.FC = ({ + initialValue, + onChange, + placeholder = "请输入内容", +}) => { + // Internal state management with fallback to initial value or empty array + const [inputValues, setInputValues] = useState( + initialValue && initialValue.length > 0 ? initialValue : [""] + ); + + // Handle individual input value change + const handleInputChange = (index: number, newValue: string) => { + const newValues = [...inputValues]; + newValues[index] = newValue; + + // Update internal state + setInputValues(newValues); + + // Call external onChange if provided + onChange?.(newValues); + }; + + // Handle delete operation + const handleDelete = (index: number) => { + const newValues = inputValues.filter((_, i) => i !== index); + + // Ensure at least one input remains + const finalValues = newValues.length === 0 ? [""] : newValues; + + // Update internal state + setInputValues(finalValues); + + // Call external onChange if provided + onChange?.(finalValues); + }; + + // Add new input field + const handleAdd = () => { + const newValues = [...inputValues, ""]; + + // Update internal state + setInputValues(newValues); + + // Call external onChange if provided + onChange?.(newValues); + }; + + return ( +
+ {inputValues.map((item, index) => ( +
+ + handleInputChange(index, e.target.value) + } + placeholder={placeholder} + className="flex-grow" + /> + {inputValues.length > 1 && ( +
+ ))} +
+ + {inputValues.length > 1 && ( +
+
+ ); +}; + +export default InputList; diff --git a/apps/web/src/components/common/space/Empty.tsx b/apps/web/src/components/common/space/Empty.tsx new file mode 100755 index 0000000..d5a8dec --- /dev/null +++ b/apps/web/src/components/common/space/Empty.tsx @@ -0,0 +1,25 @@ +import { EmptyStateIllustration } from "../../presentation/EmptyStateIllustration"; + +interface EmptyStateProps { + title?: string; + description?: string; + illustration?: React.ReactNode; +} + +export const EmptyState = ({ + title = "暂无数据", + description = "当前列表为空,请稍后再试", + illustration: Illustration = +}: EmptyStateProps) => { + return ( +
+ {Illustration} +

+ {title} +

+

+ {description} +

+
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/components/common/uploader/AvatarUploader.tsx b/apps/web/src/components/common/uploader/AvatarUploader.tsx new file mode 100755 index 0000000..d4077f3 --- /dev/null +++ b/apps/web/src/components/common/uploader/AvatarUploader.tsx @@ -0,0 +1,156 @@ +import { env } from "@web/src/env"; +import { message, Progress, Spin, theme } from "antd"; +import React, { useState, useEffect, useRef } from "react"; +import { useTusUpload } from "@web/src/hooks/useTusUpload"; +import { Avatar } from "antd/lib"; +import toast from "react-hot-toast"; + +export interface AvatarUploaderProps { + value?: string; + placeholder?: string; + className?: string; + onChange?: (value: string) => void; + compressed?: boolean; + style?: React.CSSProperties; // 添加style属性 +} + +interface UploadingFile { + name: string; + progress: number; + status: "uploading" | "done" | "error"; + fileId?: string; + url?: string; + compressedUrl?: string; + fileKey?: string; +} + +const AvatarUploader: React.FC = ({ + value, + onChange, + compressed = false, + className, + placeholder = "点击上传", + style, // 解构style属性 +}) => { + const { handleFileUpload, uploadProgress } = useTusUpload(); + const [file, setFile] = useState(null); + const avatarRef = useRef(null); + const [previewUrl, setPreviewUrl] = useState(value || ""); + + const [compressedUrl, setCompressedUrl] = useState(value || ""); + const [url, setUrl] = useState(value || ""); + const [uploading, setUploading] = useState(false); + const inputRef = useRef(null); + // 在组件中定义 key 状态 + const [avatarKey, setAvatarKey] = useState(0); + const { token } = theme.useToken(); + + const handleChange = async (event: React.ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) return; + // Create an object URL for the selected file + const objectUrl = URL.createObjectURL(selectedFile); + setPreviewUrl(objectUrl); + setFile({ + name: selectedFile.name, + progress: 0, + status: "uploading", + fileKey: `${selectedFile.name}-${Date.now()}`, + }); + setUploading(true); + + try { + const uploadedUrl = await new Promise((resolve, reject) => { + handleFileUpload( + selectedFile, + (result) => { + setFile((prev) => ({ + ...prev!, + progress: 100, + status: "done", + fileId: result.fileId, + url: result.url, + compressedUrl: result.compressedUrl, + })); + + setUrl(result.url); + setCompressedUrl(result.compressedUrl); + // 直接使用 result 中的最新值 + resolve(compressed ? result.compressedUrl : result.url); + }, + (error) => { + reject(error); + }, + file?.fileKey + ); + }); + // await new Promise((resolve) => setTimeout(resolve,4999)); // 方法1:使用 await 暂停执行 + // 使用 resolved 的最新值调用 onChange + // 强制刷新 Avatar 组件 + setAvatarKey((prev) => prev + 1); // 修改 key 强制重新挂载 + onChange?.(uploadedUrl); + console.log(uploadedUrl); + toast.success("头像上传成功"); + } catch (error) { + console.error("上传错误:", error); + toast.error("头像上传失败"); + setFile((prev) => ({ ...prev!, status: "error" })); + } finally { + setUploading(false); + } + }; + + const triggerUpload = () => { + inputRef.current?.click(); + }; + + return ( +
+ + {previewUrl ? ( + + ) : ( +
+ {placeholder} +
+ )} + {uploading && ( +
+ +
+ )} + {file && file.status === "uploading" && ( +
+ +
+ )} +
+ ); +}; + +export default AvatarUploader; diff --git a/apps/web/src/components/common/uploader/TusUploader.tsx b/apps/web/src/components/common/uploader/TusUploader.tsx new file mode 100755 index 0000000..e9fa45b --- /dev/null +++ b/apps/web/src/components/common/uploader/TusUploader.tsx @@ -0,0 +1,222 @@ +import { useCallback, useState } from "react"; +import { + UploadOutlined, + CheckCircleOutlined, + DeleteOutlined, +} from "@ant-design/icons"; +import { Upload, Progress, Button } from "antd"; +import { useTusUpload } from "@web/src/hooks/useTusUpload"; +import toast from "react-hot-toast"; +export interface TusUploaderProps { + value?: string[]; + onChange?: (value: string[]) => void; + multiple?: boolean; +} + +interface UploadingFile { + name: string; + progress: number; + status: "uploading" | "done" | "error"; + fileId?: string; + fileKey?: string; +} + +export const TusUploader = ({ + value = [], + onChange, + multiple = true, +}: TusUploaderProps) => { + + const { handleFileUpload, uploadProgress } = useTusUpload(); + const [uploadingFiles, setUploadingFiles] = useState([]); + const [completedFiles, setCompletedFiles] = useState( + () => + value?.map((fileId) => ({ + name: `文件 ${fileId}`, + progress: 100, + status: "done" as const, + fileId, + })) || [] + ); + const [uploadResults, setUploadResults] = useState(value || []); + + const handleRemoveFile = useCallback( + (fileId: string) => { + setCompletedFiles((prev) => + prev.filter((f) => f.fileId !== fileId) + ); + setUploadResults((prev) => { + const newValue = prev.filter((id) => id !== fileId); + onChange?.(newValue); + return newValue; + }); + }, + [onChange] + ); + + // 新增:处理删除上传中的失败文件 + const handleRemoveUploadingFile = useCallback((fileKey: string) => { + setUploadingFiles((prev) => prev.filter((f) => f.fileKey !== fileKey)); + }, []); + + const handleBeforeUpload = useCallback( + (file: File) => { + + const fileKey = `${file.name}-${Date.now()}`; + + setUploadingFiles((prev) => [ + ...prev, + { + name: file.name, + progress: 0, + status: "uploading", + fileKey, + }, + ]); + + handleFileUpload( + file, + (result) => { + setCompletedFiles((prev) => [ + ...prev, + { + name: file.name, + progress: 100, + status: "done", + fileId: result.fileId, + }, + ]); + + setUploadingFiles((prev) => + prev.filter((f) => f.fileKey !== fileKey) + ); + + setUploadResults((prev) => { + // 如果是单文件模式,则替换现有文件 + const newValue = multiple + ? [...prev, result.fileId] + : [result.fileId]; + onChange?.(newValue); + return newValue; + }); + + // 单文件模式下,清除之前的完成文件 + if (!multiple) { + setCompletedFiles([ + { + name: file.name, + progress: 100, + status: "done", + fileId: result.fileId, + }, + ]); + } + }, + (error) => { + console.error("上传错误:", error); + toast.error( + `上传失败: ${error instanceof Error ? error.message : "未知错误"}` + ); + setUploadingFiles((prev) => + prev.map((f) => + f.fileKey === fileKey + ? { ...f, status: "error" } + : f + ) + ); + }, + fileKey + ); + + return false; + }, + [handleFileUpload, onChange] + ); + + return ( +
+ +

+ +

+

+ 点击或拖拽文件到此区域进行上传 +

+

+ {multiple ? "支持单个或批量上传文件" : "仅支持上传单个文件"} +

+ +
+ {uploadingFiles.map((file) => ( +
+
+ {file.name} +
+
+ + {file.status === "error" && ( +
+
+ ))} + + {completedFiles.map((file) => ( +
+
+ + {file.name} +
+
+ ))} +
+
+
+ ); +}; diff --git a/apps/web/src/components/layout/admin/AdminHeader.tsx b/apps/web/src/components/layout/admin/AdminHeader.tsx new file mode 100755 index 0000000..7be4a0c --- /dev/null +++ b/apps/web/src/components/layout/admin/AdminHeader.tsx @@ -0,0 +1,198 @@ +import { useAuth } from "@web/src/providers/auth-provider"; +import { Avatar, Tag, theme, Tooltip } from "antd"; +import React, { + ReactNode, + useEffect, + useState, + useRef, + CSSProperties, +} from "react"; +import { SyncOutlined } from "@ant-design/icons"; +import * as Y from "yjs"; +import { stringToColor, YWsProvider } from "@nice/common"; +import { lightenColor } from "@nice/client"; +import { useLocalSettings } from "@web/src/hooks/useLocalSetting"; +import Breadcrumb from "../element/breadcrumb"; + +interface AdminHeaderProps { + children?: ReactNode; + roomId?: string; + awarePlaceholder?: string; + borderless?: boolean; + style?: CSSProperties; + className?: string; +} + +const AdminHeader: React.FC = ({ + className, + style, + borderless = false, + children, + roomId, + awarePlaceholder = "协作人员", +}) => { + const { user, sessionId, accessToken } = useAuth(); + const [userStates, setUserStates] = useState>(new Map()); + const { token } = theme.useToken(); + const providerRef = useRef(null); + const { websocketUrl } = useLocalSettings(); + + useEffect(() => { + let cleanup: (() => void) | undefined; + // 如果已经连接或缺少必要参数,则返回 + if (!user || !roomId || !websocketUrl) { + return; + } + // 设置延时,避免立即连接 + const connectTimeout = setTimeout(() => { + try { + const ydoc = new Y.Doc(); + const provider = new YWsProvider( + websocketUrl + "/yjs", + roomId, + ydoc, + { + params: { + userId: user?.id, + sessionId, + }, + } + ); + providerRef.current = provider; + const { awareness } = provider; + const updateAwarenessData = () => { + const uniqueStates = new Map(); + awareness.getStates().forEach((value, key) => { + const sessionId = value?.user?.sessionId; + if (sessionId) { + uniqueStates.set(sessionId, value); + } + }); + setUserStates(uniqueStates); + }; + + const localState = { + user: { + id: user.id, + showname: user?.showname || user.username, + deptName: user.department?.name, + sessionId, + }, + }; + + awareness.setLocalStateField("user", localState.user); + awareness.on("change", updateAwarenessData); + updateAwarenessData(); + + const handleBeforeUnload = () => { + awareness.setLocalState(null); + provider.disconnect(); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + + // 定义清理函数 + cleanup = () => { + if (providerRef.current) { + awareness.off("change", updateAwarenessData); + awareness.setLocalState(null); + provider.disconnect(); + providerRef.current = null; + } + + setUserStates(new Map()); + window.removeEventListener( + "beforeunload", + handleBeforeUnload + ); + }; + } catch (error) { + console.error("WebSocket connection error:", error); + } + }, 100); + + // 返回清理函数 + return () => { + clearTimeout(connectTimeout); + if (cleanup) { + cleanup(); + } + }; + }, [roomId, user, websocketUrl, sessionId]); + + // 其余渲染代码保持不变... + const renderAvatars = () => + Array.from(userStates.entries()).map(([key, value]) => ( + + {value?.user.deptName && ( + + {value?.user?.deptName} + + )} + + {value?.user?.showname || "匿名用户"} + + + } + key={key}> + + {!value?.user?.avatarUrl && + (value?.user?.showname?.toUpperCase() || "匿名用户")} + + + )); + + return ( +
+
+ +
+ {roomId && ( + } + color={token.colorPrimaryHover}> + {awarePlaceholder} + + )} + + {renderAvatars()} + +
+
+ {children} +
+ ); +}; + +export default AdminHeader; diff --git a/apps/web/src/components/layout/admin/AdminLayout.tsx b/apps/web/src/components/layout/admin/AdminLayout.tsx new file mode 100755 index 0000000..ed2cd5a --- /dev/null +++ b/apps/web/src/components/layout/admin/AdminLayout.tsx @@ -0,0 +1,20 @@ +import { Outlet } from "react-router-dom"; +import { Layout } from "antd"; + +import { adminRoute } from "@web/src/routes/admin-route"; +import AdminSidebar from "./AdminSidebar"; + +const { Content } = Layout; + +export default function AdminLayout() { + return ( + + + + + + + + + ); +} diff --git a/apps/web/src/components/layout/admin/AdminSidebar.tsx b/apps/web/src/components/layout/admin/AdminSidebar.tsx new file mode 100755 index 0000000..005bac4 --- /dev/null +++ b/apps/web/src/components/layout/admin/AdminSidebar.tsx @@ -0,0 +1,58 @@ +import { useState, useMemo } from "react"; +import { NavLink, matchPath, useLocation, useMatches } from "react-router-dom"; +import { Layout, Menu, theme } from "antd"; +import type { MenuProps } from "antd"; +import { CustomRouteObject } from "@web/src/routes/types"; + +const { Sider } = Layout; +const { useToken } = theme; + +type SidebarProps = { + routes: CustomRouteObject[]; +}; + +export default function AdminSidebar({ routes }: SidebarProps) { + const [collapsed, setCollapsed] = useState(false); + const { token } = useToken(); + const matches = useMatches(); + console.log(matches); + const menuItems: MenuProps["items"] = useMemo( + () => + routes.map((route) => ({ + key: route.path, + icon: route.icon, + label: {route.name}, + })), + [routes] + ); + + return ( + setCollapsed(value)} + width={150} + className="h-screen sticky top-0" + style={{ + backgroundColor: token.colorBgContainer, + borderRight: `1px solid ${token.colorBorderSecondary}`, + }}> + + matches.some((match) => + match.pathname.includes(route.path) + ) + ) + .map((route) => route.path)} + items={menuItems} + className="border-r-0" + style={{ + borderRight: 0, + }} + /> + + ); +} diff --git a/apps/web/src/components/layout/breadcrumb.tsx b/apps/web/src/components/layout/breadcrumb.tsx new file mode 100755 index 0000000..1804dab --- /dev/null +++ b/apps/web/src/components/layout/breadcrumb.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useLocation, Link, useMatches } from 'react-router-dom'; +import { theme } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; + +export default function Breadcrumb() { + let matches = useMatches(); + const { token } = theme.useToken() + + let crumbs = matches + // first get rid of any matches that don't have handle and crumb + .filter((match) => Boolean((match.handle as any)?.crumb)) + // now map them into an array of elements, passing the loader + // data to each one + .map((match) => (match.handle as any).crumb(match.data)); + + return ( +
    + {crumbs.map((crumb, index) => ( + +
  1. + {crumb} +
  2. + {index < crumbs.length - 1 && ( +
  3. + +
  4. + )} +
    + ))} +
+ ); +} diff --git a/apps/web/src/components/layout/element/breadcrumb.tsx b/apps/web/src/components/layout/element/breadcrumb.tsx new file mode 100755 index 0000000..c6908da --- /dev/null +++ b/apps/web/src/components/layout/element/breadcrumb.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useLocation, Link, useMatches } from 'react-router-dom'; +import { theme } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; + +export default function Breadcrumb() { + let matches = useMatches(); + const { token } = theme.useToken() + let crumbs = matches + // first get rid of any matches that don't have handle and crumb + .filter((match) => Boolean((match.handle as any)?.crumb)) + // now map them into an array of elements, passing the loader + // data to each one + .map((match) => (match.handle as any).crumb(match.data)); + + return ( +
    + {crumbs.map((crumb, index) => ( + +
  1. + {crumb} +
  2. + {index < crumbs.length - 1 && ( +
  3. + +
  4. + )} +
    + ))} +
+ ); +} diff --git a/apps/web/src/components/layout/element/types.ts b/apps/web/src/components/layout/element/types.ts new file mode 100755 index 0000000..03d24cb --- /dev/null +++ b/apps/web/src/components/layout/element/types.ts @@ -0,0 +1,5 @@ +export interface MenuItemType { + icon: JSX.Element; + label: string; + action: () => void; +} diff --git a/apps/web/src/components/layout/element/usermenu/user-edit-modal.tsx b/apps/web/src/components/layout/element/usermenu/user-edit-modal.tsx new file mode 100755 index 0000000..30214d8 --- /dev/null +++ b/apps/web/src/components/layout/element/usermenu/user-edit-modal.tsx @@ -0,0 +1,26 @@ +import { Button, Drawer, Modal } from "antd"; +import React, { useContext, useEffect, useState } from "react"; + +import { UserEditorContext } from "./usermenu"; +import UserForm from "./user-form"; + +export default function UserEditModal() { + const { formLoading, modalOpen, setModalOpen, form } = + useContext(UserEditorContext); + const handleOk = () => { + form.submit(); + }; + return ( + { + setModalOpen(false); + }} + title={"编辑个人信息"}> + + + ); +} diff --git a/apps/web/src/components/layout/element/usermenu/user-form.tsx b/apps/web/src/components/layout/element/usermenu/user-form.tsx new file mode 100755 index 0000000..6774028 --- /dev/null +++ b/apps/web/src/components/layout/element/usermenu/user-form.tsx @@ -0,0 +1,216 @@ +import { Button, Form, Input, Spin, Switch, message } from "antd"; +import { useContext, useEffect } from "react"; +import { useStaff } from "@nice/client"; +import DepartmentSelect from "@web/src/components/models/department/department-select"; +import { api } from "@nice/client"; + +import { useAuth } from "@web/src/providers/auth-provider"; +import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader"; +import { StaffDto } from "@nice/common"; +import { UserEditorContext } from "./usermenu"; +import toast from "react-hot-toast"; +export default function StaffForm() { + const { user } = useAuth(); + const { create, update } = useStaff(); // Ensure you have these methods in your hooks + const { + formLoading, + modalOpen, + setModalOpen, + domainId, + setDomainId, + form, + setFormLoading, + } = useContext(UserEditorContext); + const { + data, + isLoading, + }: { + data: StaffDto; + isLoading: boolean; + } = api.staff.findFirst.useQuery( + { where: { id: user?.id } }, + { enabled: !!user?.id } + ); + const { isRoot } = useAuth(); + async function handleFinish(values: any) { + const { + username, + showname, + deptId, + domainId, + password, + phoneNumber, + officerId, + avatar, + enabled, + } = values; + setFormLoading(true); + try { + if (data && user?.id) { + await update.mutateAsync({ + where: { id: data.id }, + data: { + username, + deptId, + avatar, + officerId, + showname, + domainId, + password, + phoneNumber, + enabled, + }, + }); + } + toast.success("提交成功"); + setModalOpen(false); + } catch (err: any) { + toast.error(err.message); + } finally { + setFormLoading(false); + } + } + useEffect(() => { + form.resetFields(); + console.log(data?.avatar); + if (data) { + form.setFieldValue("username", data.username); + form.setFieldValue("showname", data.showname); + form.setFieldValue("domainId", data.domainId); + form.setFieldValue("deptId", data.deptId); + form.setFieldValue("avatar", data.avatar); + form.setFieldValue("phoneNumber", data.phoneNumber); + form.setFieldValue("officerId", data.officerId); + form.setFieldValue("enabled", data.enabled); + } + }, [data]); + // useEffect(() => { + // if (!data && domainId) { + // form.setFieldValue("domainId", domainId); + // form.setFieldValue("deptId", domainId); + // } + // }, [domainId, data as any]); + return ( +
+ {isLoading && ( +
+ +
+ )} +
+
+
+ + + +
+ +
+ + + + + + + + { + setDomainId(value as string); + }} + domain={true} + /> + + + + +
+
+
+ + + + + + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/element/usermenu/usermenu.tsx b/apps/web/src/components/layout/element/usermenu/usermenu.tsx new file mode 100755 index 0000000..e00965b --- /dev/null +++ b/apps/web/src/components/layout/element/usermenu/usermenu.tsx @@ -0,0 +1,259 @@ +import { useClickOutside } from "@web/src/hooks/useClickOutside"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { motion, AnimatePresence } from "framer-motion"; +import React, { + useState, + useRef, + useCallback, + useMemo, + createContext, +} from "react"; +import { Avatar } from "../../../common/element/Avatar"; +import { + UserOutlined, + SettingOutlined, + LogoutOutlined, +} from "@ant-design/icons"; +import { FormInstance, Spin } from "antd"; +import { useNavigate } from "react-router-dom"; +import { MenuItemType } from "../types"; +import { RolePerms } from "@nice/common"; +import { useForm } from "antd/es/form/Form"; +import UserEditModal from "./user-edit-modal"; +const menuVariants = { + hidden: { opacity: 0, scale: 0.95, y: -10 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: "spring", + stiffness: 300, + damping: 30, + }, + }, + exit: { + opacity: 0, + scale: 0.95, + y: -10, + transition: { + duration: 0.2, + }, + }, +}; + +export const UserEditorContext = createContext<{ + domainId: string; + setDomainId: React.Dispatch>; + modalOpen: boolean; + setModalOpen: React.Dispatch>; + form: FormInstance; + formLoading: boolean; + setFormLoading: React.Dispatch>; +}>({ + modalOpen: false, + domainId: undefined, + setDomainId: undefined, + setModalOpen: undefined, + form: undefined, + formLoading: undefined, + setFormLoading: undefined, +}); + +export function UserMenu() { + const [form] = useForm(); + const [formLoading, setFormLoading] = useState(); + const [showMenu, setShowMenu] = useState(false); + const menuRef = useRef(null); + const { user, logout, isLoading, hasSomePermissions } = useAuth(); + const navigate = useNavigate(); + useClickOutside(menuRef, () => setShowMenu(false)); + const [modalOpen, setModalOpen] = useState(false); + const [domainId, setDomainId] = useState(); + const toggleMenu = useCallback(() => { + setShowMenu((prev) => !prev); + }, []); + const canManageAnyStaff = useMemo(() => { + return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF); + }, [user]); + const menuItems: MenuItemType[] = useMemo( + () => + [ + { + icon: , + label: "个人信息", + action: () => { + setModalOpen(true); + }, + }, + canManageAnyStaff && { + icon: , + label: "设置", + action: () => { + navigate("/admin/staff"); + }, + }, + // { + // icon: , + // label: '帮助', + // action: () => { }, + // }, + { + icon: , + label: "注销", + action: () => logout(), + }, + ].filter(Boolean), + [logout] + ); + + const handleMenuItemClick = useCallback((action: () => void) => { + action(); + setShowMenu(false); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + +
+ + {/* Avatar 容器,相对定位 */} +
+ + {/* 小绿点 */} +
+ + {/* 用户信息,显示在 Avatar 右侧 */} +
+ + {user?.showname || user?.username} + + + {user?.department?.name} + +
+
+ + + {showMenu && ( + + {/* User Profile Section */} +
+
+ +
+ + {user?.showname || user?.username} + + + + 在线 + +
+
+
+ + {/* Menu Items */} +
+ {menuItems.map((item, index) => ( + + ))} +
+
+ )} +
+
+ +
+ ); +} diff --git a/apps/web/src/components/layout/fix-header.tsx b/apps/web/src/components/layout/fix-header.tsx new file mode 100755 index 0000000..d5aa1b1 --- /dev/null +++ b/apps/web/src/components/layout/fix-header.tsx @@ -0,0 +1,176 @@ +import { useAuth } from "@web/src/providers/auth-provider"; +import { Avatar, Tag, theme, Tooltip } from "antd"; +import React, { ReactNode, useEffect, useState, useRef, CSSProperties } from "react"; +import { SyncOutlined } from "@ant-design/icons"; +import Breadcrumb from "../layout/breadcrumb"; +import * as Y from "yjs"; +import { stringToColor, YWsProvider } from "@nice/common"; +import { lightenColor } from "@nice/client" +import { useLocalSettings } from "@web/src/hooks/useLocalSetting"; +interface FixedHeaderProps { + children?: ReactNode; + roomId?: string; + awarePlaceholder?: string; + borderless?: boolean; + style?: CSSProperties; + className?: string; +} + +const FixedHeader: React.FC = ({ + className, + style, + borderless = false, + children, + roomId, + awarePlaceholder = '协作人员' +}) => { + const { user, sessionId, accessToken } = useAuth(); + const [userStates, setUserStates] = useState>(new Map()); + const { token } = theme.useToken(); + const providerRef = useRef(null); + const { websocketUrl } = useLocalSettings(); + + useEffect(() => { + let cleanup: (() => void) | undefined; + // 如果已经连接或缺少必要参数,则返回 + if (!user || !roomId || !websocketUrl) { + return; + } + // 设置延时,避免立即连接 + const connectTimeout = setTimeout(() => { + try { + const ydoc = new Y.Doc(); + const provider = new YWsProvider(websocketUrl + "/yjs", roomId, ydoc, { + params: { + userId: user?.id, + sessionId + } + }); + providerRef.current = provider; + const { awareness } = provider; + const updateAwarenessData = () => { + const uniqueStates = new Map(); + awareness.getStates().forEach((value, key) => { + const sessionId = value?.user?.sessionId; + if (sessionId) { + uniqueStates.set(sessionId, value); + } + }); + setUserStates(uniqueStates); + }; + + const localState = { + user: { + id: user.id, + showname: user.showname || user.username, + deptName: user.department?.name, + sessionId, + }, + }; + + awareness.setLocalStateField("user", localState.user); + awareness.on("change", updateAwarenessData); + updateAwarenessData(); + + const handleBeforeUnload = () => { + awareness.setLocalState(null); + provider.disconnect(); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + + // 定义清理函数 + cleanup = () => { + if (providerRef.current) { + awareness.off("change", updateAwarenessData); + awareness.setLocalState(null); + provider.disconnect(); + providerRef.current = null; + } + + setUserStates(new Map()); + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + + } catch (error) { + console.error('WebSocket connection error:', error); + } + }, 100); + + // 返回清理函数 + return () => { + clearTimeout(connectTimeout); + if (cleanup) { + cleanup(); + } + }; + }, [roomId, user, websocketUrl, sessionId]); + + + // 其余渲染代码保持不变... + const renderAvatars = () => + Array.from(userStates.entries()).map(([key, value]) => ( + + {value?.user.deptName && ( + {value?.user?.deptName} + )} + {value?.user?.showname || "匿名用户"} + + } + key={key} + > + + {!value?.user?.avatarUrl && + (value?.user?.showname?.toUpperCase() || "匿名用户")} + + + )); + + return ( +
+
+ +
+ {roomId && ( + } color={token.colorPrimaryHover}> + {awarePlaceholder} + + )} + + {renderAvatars()} + +
+
+ {children} +
+ ); +}; + +export default FixedHeader; diff --git a/apps/web/src/components/layout/resizable-sidebar.tsx b/apps/web/src/components/layout/resizable-sidebar.tsx new file mode 100755 index 0000000..da9e970 --- /dev/null +++ b/apps/web/src/components/layout/resizable-sidebar.tsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from 'react'; +import { ResizableBox } from 'react-resizable'; +import 'react-resizable/css/styles.css'; +import { theme } from 'antd'; +import { ReactNode } from 'react'; + +type SidebarProps = { + children: ReactNode; + handlePosition?: 'left' | 'right'; + className?: string; + minWidth?: number; + maxWidth?: number; + defaultWidth?: number; + onWidthChange?: (width: number) => void; // New prop for handling width change +}; + +export default function ResizableSidebar({ + children, + handlePosition = 'right', + className = '', + minWidth = 200, + maxWidth = 400, + defaultWidth = 200, + onWidthChange +}: SidebarProps) { + const [width, setWidth] = useState(defaultWidth); + const [isDragging, setIsDragging] = useState(false); + const [isHoveringHandle, setIsHoveringHandle] = useState(false); + const { token } = theme.useToken(); + + useEffect(() => { + if (isDragging) { + document.body.style.cursor = 'col-resize'; + } else { + document.body.style.cursor = ''; + } + + return () => { + document.body.style.cursor = ''; // Cleanup on unmount + }; + }, [isDragging]); + + const handleResizeStop = (e, data) => { + const newWidth = data.size.width; + setWidth(newWidth); + setIsDragging(false); + + if (onWidthChange) { + onWidthChange(newWidth); // Call the callback with new width + } + }; + + return ( + setIsDragging(true)} + onResizeStop={handleResizeStop} + handle={ + setIsHoveringHandle(true)} + onMouseLeave={() => setIsHoveringHandle(false)} + /> + } + className={className} + style={{ + overflow: 'hidden', + position: 'relative', + ...(handlePosition === 'right' && { + borderRight: (isDragging || isHoveringHandle) ? `2px solid ${token.colorPrimaryBorder}` : ``, + }), + ...(handlePosition === 'left' && { + borderLeft: (isDragging || isHoveringHandle) ? `2px solid ${token.colorPrimaryBorder}` : ``, + }), + transition: 'border-color 0.3s', + }} + > + {children} + + ); +} diff --git a/apps/web/src/components/layout/sidebar-content.tsx b/apps/web/src/components/layout/sidebar-content.tsx new file mode 100755 index 0000000..01eca3e --- /dev/null +++ b/apps/web/src/components/layout/sidebar-content.tsx @@ -0,0 +1,76 @@ +import CollapsibleSection from "../presentation/collapse-section"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { RolePerms } from "@nice/common"; +import { SettingOutlined, ToolOutlined, FolderOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons'; + +export default function SidebarContent() { + const { logout, user, isAuthenticated, hasSomePermissions } = useAuth(); + + return ( +
+
+ , + children: [ + hasSomePermissions( + RolePerms.MANAGE_BASE_SETTING + ) && { + key: "4-0", + icon: , + label: "参数配置", + link: "/admin/base-setting", + }, + + hasSomePermissions( + RolePerms.MANAGE_ANY_TERM, + // RolePerms.MANAGE_DOM_TERM + ) && { + key: "4-1", + icon: , + label: "分类配置", + link: "/admin/term", + }, + hasSomePermissions( + RolePerms.MANAGE_ANY_DEPT + ) && { + key: "4-5", + icon: , + label: "组织架构", + link: "/admin/department", + }, + hasSomePermissions( + RolePerms.MANAGE_ANY_STAFF, + RolePerms.MANAGE_DOM_STAFF + ) && { + key: "4-6", + icon: , + label: "用户管理", + link: "/admin/staff", + }, + hasSomePermissions( + RolePerms.MANAGE_ANY_ROLE, + RolePerms.MANAGE_DOM_ROLE + ) && { + key: "4-7", + icon: , + label: "角色管理", + link: "/admin/role", + }, + ].filter(Boolean), + }, + ].filter(Boolean)}> +
+
+ ); +} diff --git a/apps/web/src/components/layout/user-header.tsx b/apps/web/src/components/layout/user-header.tsx new file mode 100755 index 0000000..235ace3 --- /dev/null +++ b/apps/web/src/components/layout/user-header.tsx @@ -0,0 +1,46 @@ +import { Avatar, Button, Dropdown, theme } from "antd"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useNavigate } from "react-router-dom"; +import { LogoutOutlined, TeamOutlined } from "@ant-design/icons"; // 引入 Ant Design 图标 + +export default function UserHeader() { + const { logout, user, isAuthenticated } = useAuth(); + const { token } = theme.useToken(); + + const navigate = useNavigate(); + return ( +
+
+
+
+ + + {(user?.showname || user?.username) + ?.slice(0, 1) + .toUpperCase()} + + {user?.showname || user?.username} + {user?.department && <> + {/* 替换为 Ant Design 的团队图标 */} + {user?.department?.name}} +
+
+
{ + await logout() + }} + className="active:bg-gray-100/60 flex items-center gap-2 text-white hover:bg-gray-100/30 px-2 rounded py-1 cursor-pointer"> + {/* 替换为 Ant Design 的注销图标 */} + 注销 +
+
+
+ ); +} diff --git a/apps/web/src/components/models/department/department-form.tsx b/apps/web/src/components/models/department/department-form.tsx new file mode 100755 index 0000000..37d8bc3 --- /dev/null +++ b/apps/web/src/components/models/department/department-form.tsx @@ -0,0 +1,136 @@ +import { Spin, message, Input, InputNumber, Checkbox, Form } from "antd"; +import { useDepartment, api } from "@nice/client"; +import { Department, DepartmentDto, ObjectType, mapArrayToObjectArray } from "@nice/common"; +import { useContext, useState, useEffect } from "react"; +import TermSelect from "../term/term-select"; +import DepartmentSelect from "./department-select"; +import { DeptEditorContext } from "./dept-editor"; + +export default function DepartmentForm() { + const { editId, form, parentId, setModalOpen, setEditId, setParentId } = + useContext(DeptEditorContext); + const { create, update } = useDepartment(); + const [loading, setLoading] = useState(false); + const { + data, + isLoading: editDataIsLoading, + }: { data: DepartmentDto; isLoading: boolean } = + api.department.findFirst.useQuery( + { where: { id: editId }, include: { terms: true } }, + { enabled: Boolean(editId) } + ); + // useEffect(() => { + // if (parentId) form?.setFieldValue("parentId", parentId); + // taxonomies?.forEach((tax) => { + // form.setFieldValue( + // `taxonomies.${tax.name}`, + // (data?.terms || []) + // .filter((term) => term.taxonomyId === tax.id) + // .map((term) => term.id) + // ); + // }); + // }, [parentId]); + const { data: taxonomies } = api.taxonomy.getAll.useQuery({ + type: ObjectType.DEPARTMENT, + }); + useEffect(() => { + if (editId && data && taxonomies) { + form.setFieldsValue({ + ...data, + taxonomy: taxonomies.reduce((acc, tax) => { + acc[tax.name] = (data?.terms || []) + .filter((term) => term.taxonomyId === tax.id) + .map((term) => term.id); + return acc; + }, {}) + }); + } else { + form.resetFields() + } + if (parentId) form?.setFieldValue("parentId", parentId); + }, [data, taxonomies, editId, parentId]); + + if (editDataIsLoading) { + return ( +
+ +
+ ); + } + return ( +
{ + setLoading(true); + const { taxonomy, ...others } = values + try { + const termIds = taxonomies?.reduce((acc, taxonomy) => { + const taxonomyTerms = taxonomy?.[taxonomy.name]; + if (taxonomyTerms) { + if (Array.isArray(taxonomyTerms)) { + acc.push(...taxonomyTerms); + } else { + acc.push(taxonomyTerms); + } + } + return acc; + }, [] as string[]); + if (data) { + await update.mutateAsync({ + where: { id: editId }, + data: { + ...others, + terms: { set: mapArrayToObjectArray(termIds) } + } + }); + + } else { + await create.mutateAsync({ + data: { + ...others, + terms: { connect: mapArrayToObjectArray(termIds) } + } + }); + form?.resetFields(); + if (parentId) form?.setFieldValue("parentId", parentId); + } + setLoading(false); + message.success("提交成功"); + setModalOpen(false) + setParentId(undefined) + setEditId(undefined) + } catch (e) { + console.log(e); + message.error('提交失败') + } finally { + setLoading(false); + } + }}> + + + + + + + {taxonomies?.map((tax) => { + return ( + + + + ); + })} + + + + + 是否为域 + +
+ ); +} diff --git a/apps/web/src/components/models/department/department-import-drawer.tsx b/apps/web/src/components/models/department/department-import-drawer.tsx new file mode 100755 index 0000000..b77383c --- /dev/null +++ b/apps/web/src/components/models/department/department-import-drawer.tsx @@ -0,0 +1,63 @@ +import { Button, Drawer, Form } from "antd"; +import React, { useRef, useState } from "react"; +import type { ButtonProps, FormInstance } from "antd"; +import { Department } from "@nice/common"; +import { ExcelImporter } from "../../utils/excel-importer"; +import DepartmentSelect from "./department-select"; + + +interface DepartmentDrawerProps extends ButtonProps { + title: string; + data?: Partial; + parentId?: string; +} + +export default function DepartmentImportDrawer({ + data, + parentId, + title, + ...buttonProps +}: DepartmentDrawerProps) { + const [open, setOpen] = useState(false); + const [deptParentId, setDeptParentId] = useState( + parentId ? parentId : undefined + ); + const formRef = useRef(null); + const handleTrigger = () => { + setOpen(true); + }; + + return ( + <> + + { + setOpen(false); + }} + title={title} + width={400}> +
+ + + setDeptParentId(value as string) + }> + +
+ +
+
+
+ + ); +} diff --git a/apps/web/src/components/models/department/department-list.tsx b/apps/web/src/components/models/department/department-list.tsx new file mode 100755 index 0000000..ee4f24c --- /dev/null +++ b/apps/web/src/components/models/department/department-list.tsx @@ -0,0 +1,110 @@ +import React, { useMemo, useContext, useCallback, useState } from "react"; +import { ObjectType } from "@nice/common" +import { ICellRendererParams, SortDirection } from "ag-grid-community"; +import { ColDef } from "@ag-grid-community/core"; +import AgServerTable from "../../presentation/ag-server-table"; +import { DeleteOutlined, EditFilled, EllipsisOutlined, PlusOutlined } from "@ant-design/icons"; +import { Menu, MenuItem } from "../../presentation/dropdown-menu"; +import { DeptEditorContext } from "./dept-editor"; +import { CustomCellRendererProps } from "ag-grid-react"; +import { message, Tag } from "antd"; +import { CrudOperation, emitDataChange, useDepartment } from "@nice/client"; + +export default function DepartmentList() { + const { setEditId, setModalOpen, setParentId } = useContext(DeptEditorContext); + // 将 params 转换为 state + const [params, setParams] = useState({ parentId: null }); + const { softDeleteByIds } = useDepartment() + const OpreationRenderer = ({ props }: { props: ICellRendererParams }) => { + const handleEdit = () => { + setEditId(props?.data?.id); + setModalOpen(true); + }; + const handleCreate = () => { + setParentId(props.data?.id) + setModalOpen(true); + } + return ( +
+ + }> + } + onClick={handleCreate} + /> + } + onClick={handleEdit} + /> + { + softDeleteByIds.mutateAsync({ + ids: [props?.data?.id], + }, { + onSettled: () => { + message.success("删除成功"); + emitDataChange(ObjectType.DEPARTMENT, props.data as any, CrudOperation.DELETED) + }, + }); + }} + icon={}> + +
+ ); + }; + + const columnDefs = useMemo(() => { + return [ + { + headerName: "是否为域", + field: "is_domain", + cellRenderer: (props: CustomCellRendererProps) => { + return + {props.value ? '域节点' : '普通节点'} + + } + }, + { + field: "order", + hide: true, + sort: "asc" as SortDirection + }, + { + headerName: "操作", + sortable: true, + cellRenderer: (props: CustomCellRendererProps) => ( + + ), + maxWidth: 80, + }, + ].filter(Boolean); + }, []); + + const autoGroupColumnDef = useMemo(() => ({ + rowDrag: true, + headerName: "单位名", + field: "name", + filter: "agTextColumnFilter", + }), []); + + const getServerSideGroupKey = useCallback((item) => item.id, []); + const isServerSideGroup = useCallback((item) => item.has_children, []); + + return ( + + ); +} diff --git a/apps/web/src/components/models/department/department-select.tsx b/apps/web/src/components/models/department/department-select.tsx new file mode 100755 index 0000000..79cb7f4 --- /dev/null +++ b/apps/web/src/components/models/department/department-select.tsx @@ -0,0 +1,178 @@ +import { TreeSelect, TreeSelectProps } from "antd"; +import React, { useEffect, useState, useCallback } from "react"; +import { getUniqueItems } from "@nice/common"; +import { api } from "@nice/client" +import { DefaultOptionType } from "antd/es/select"; + +interface DepartmentSelectProps { + defaultValue?: string | string[]; + value?: string | string[]; + onChange?: (value: string | string[]) => void; + placeholder?: string; + multiple?: boolean; + rootId?: string; + domain?: boolean; + disabled?: boolean; + className?: string; +} + +export default function DepartmentSelect({ + defaultValue, + value, + onChange, + className, + placeholder = "选择单位", + multiple = false, + rootId = null, + disabled = false, + domain = undefined, +}: DepartmentSelectProps) { + const utils = api.useUtils(); + const [listTreeData, setListTreeData] = useState< + Omit[] + >([]); + + const fetchParentDepts = useCallback( + async (deptIds: string | string[], rootId?: string) => { + const idsArray = Array.isArray(deptIds) ? deptIds : [deptIds]; + try { + return await utils.department.getParentSimpleTree.fetch({ + deptIds: idsArray, + rootId, + domain, + }); + } catch (error) { + console.error( + "Error fetching parent departments for deptIds", + idsArray, + ":", + error + ); + throw error; + } + }, + [utils] + ); + + const fetchDepts = useCallback(async () => { + try { + const rootDepts = + await utils.department.getChildSimpleTree.fetch({ + deptIds: [rootId], + domain, + }); + let combinedDepts = [...rootDepts]; + if (defaultValue) { + const defaultDepts = await fetchParentDepts(defaultValue, rootId); + combinedDepts = getUniqueItems( + [...listTreeData, ...combinedDepts, ...defaultDepts], + "id" + ); + } + if (value) { + const valueDepts = await fetchParentDepts(value, rootId); + combinedDepts = getUniqueItems( + [...listTreeData, ...combinedDepts, ...valueDepts], + "id" + ); + } + + setListTreeData(combinedDepts); + } catch (error) { + console.error("Error fetching departments:", error); + } + }, [defaultValue, value, rootId, utils, fetchParentDepts]); + + useEffect(() => { + fetchDepts(); + }, [defaultValue, value, rootId, fetchDepts]); + + const handleChange = (newValue: any) => { + if (onChange) { + const processedValue = + multiple && Array.isArray(newValue) + ? newValue.map((item) => item.value) + : newValue; + onChange(processedValue); + } + }; + + const onLoadData: TreeSelectProps["loadData"] = async ({ id }) => { + try { + const result = await utils.department.getChildSimpleTree.fetch({ + deptIds: [id], + domain, + }); + const newItems = getUniqueItems([...listTreeData, ...result], "id"); + setListTreeData(newItems); + } catch (error) { + console.error( + "Error loading data for node with id", + id, + ":", + error + ); + } + }; + + const handleExpand = async (keys: React.Key[]) => { + // console.log(keys); + try { + const allKeyIds = + keys.map((key) => key.toString()).filter(Boolean) || []; + // const expandedNodes = await Promise.all( + // keys.map(async (key) => { + // return await utils.department.getChildSimpleTree.fetch({ + // deptId: key.toString(), + // domain, + // }); + // }) + // ); + // + //上面那样一个个拉会拉爆,必须直接拉deptIds + const expandedNodes = + await utils.department.getChildSimpleTree.fetch({ + deptIds: allKeyIds, + domain, + }); + const flattenedNodes = expandedNodes.flat(); + const newItems = getUniqueItems( + [...listTreeData, ...flattenedNodes], + "id" + ); + setListTreeData(newItems); + } catch (error) { + console.error("Error expanding nodes with keys", keys, ":", error); + } + }; + + const handleDropdownVisibleChange = async (open: boolean) => { + if (open) { + // This will attempt to expand all nodes and fetch their children when the dropdown opens + const allKeys = listTreeData.map((item) => item.id); + await handleExpand(allKeys); + } + }; + + return ( + handleChange(multiple ? [] : undefined)} + onTreeExpand={handleExpand} + onDropdownVisibleChange={handleDropdownVisibleChange} + /> + ); +} diff --git a/apps/web/src/components/models/department/dept-editor.tsx b/apps/web/src/components/models/department/dept-editor.tsx new file mode 100755 index 0000000..1d43a2d --- /dev/null +++ b/apps/web/src/components/models/department/dept-editor.tsx @@ -0,0 +1,90 @@ +import { createContext, useMemo, useState } from "react"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { RolePerms } from "@nice/common"; + +import { Button, FormInstance } from "antd"; +import { useForm } from "antd/es/form/Form"; +import DepartmentList from "./department-list"; +import DeptModal from "./dept-modal"; +import DeptImportModal from "./dept-import-modal"; +import FixedHeader from "../../layout/fix-header"; +export const DeptEditorContext = createContext<{ + parentId: string; + domainId: string; + modalOpen: boolean; + setParentId: React.Dispatch>; + setDomainId: React.Dispatch>; + setModalOpen: React.Dispatch>; + + editId: string; + setEditId: React.Dispatch>; + form: FormInstance; + canManageDept: boolean; + importModalOpen: boolean; + setImportModalOpen: React.Dispatch>; +}>(undefined); + +export default function DeptEditor() { + const [parentId, setParentId] = useState(); + const [domainId, setDomainId] = useState(); + const [modalOpen, setModalOpen] = useState(false); + const [importModalOpen, setImportModalOpen] = useState(false); + const { user, hasSomePermissions } = useAuth(); + const [editId, setEditId] = useState(); + const [form] = useForm(); + const canManageDept = useMemo(() => { + return hasSomePermissions( + RolePerms.MANAGE_ANY_DEPT, + RolePerms.MANAGE_DOM_DEPT + ); + }, [user]); + + return ( + + +
+ {canManageDept && ( + <> + + + + )} +
+ +
+ + + +
+ ); +} diff --git a/apps/web/src/components/models/department/dept-import-form.tsx b/apps/web/src/components/models/department/dept-import-form.tsx new file mode 100755 index 0000000..dc70a23 --- /dev/null +++ b/apps/web/src/components/models/department/dept-import-form.tsx @@ -0,0 +1,80 @@ +import { + DatePicker, + Form, + Input, + Radio, + Row, + Col, + Select, + message, + Button, +} from "antd"; +import { useContext, useState, useEffect, useCallback } from "react"; +import DepartmentSelect from "../department/department-select"; + + +import { useAuth } from "@web/src/providers/auth-provider"; +import { useTransform } from "@nice/client"; +import ExcelToBase64Uploader from "../../presentation/excel-to-base64-uploader"; +import { DeptEditorContext } from "./dept-editor"; + +export default function DeptImportForm() { + const { importDepts } = useTransform(); + const { user } = useAuth(); + const { parentId, setParentId, canManageDept, domainId, setDomainId } = + useContext(DeptEditorContext); + const [base64, setBase64] = useState(undefined); + + // Reset fields when type changes + const handleImport = async () => { + if (!base64) { + message.warning("请先上传一个文件"); + } else { + // 在这里处理导入逻辑 + console.log("导入的 Base64:", base64); + try { + message.info("正在导入..."); + await importDepts.mutateAsync({ + base64, + parentId, + domainId, + }); + message.success("导入完成"); + } catch (err:any) { + message.error(err.message); + } + } + }; + return ( + <> +
+ { + setBase64(base64); + }} + /> +
+ 所属域: + + setDomainId(value as string)} + disabled={!canManageDept} + domain + value={domainId} + className="w-32"> + 所属单位: + setParentId(value as string)} + disabled={!canManageDept} + value={parentId} + className="w-32"> + +
+
+ + ); +} diff --git a/apps/web/src/components/models/department/dept-import-modal.tsx b/apps/web/src/components/models/department/dept-import-modal.tsx new file mode 100755 index 0000000..6083532 --- /dev/null +++ b/apps/web/src/components/models/department/dept-import-modal.tsx @@ -0,0 +1,25 @@ +import { useContext } from "react"; +import { Modal } from "antd"; + +import { DeptEditorContext } from "./dept-editor"; +import DeptImportForm from "./dept-import-form"; +export default function DeptImportModal() { + const { importModalOpen, setImportModalOpen } = + useContext(DeptEditorContext); + const handleOk = () => { + // form.submit() + }; + return ( + handleOk()} + open={importModalOpen} + onCancel={() => { + setImportModalOpen(false); + }} + title={"导入单位"} + width={600} + footer={null}> + + + ); +} diff --git a/apps/web/src/components/models/department/dept-modal.tsx b/apps/web/src/components/models/department/dept-modal.tsx new file mode 100755 index 0000000..fbc69e1 --- /dev/null +++ b/apps/web/src/components/models/department/dept-modal.tsx @@ -0,0 +1,26 @@ +import { useContext } from "react"; +// import { TroubleEditorContext } from "./trouble-editor"; +import { Modal } from "antd"; + +import { DeptEditorContext } from "./dept-editor"; +import DepartmentForm from "./department-form"; +export default function DeptModal() { + const { editId, form, setEditId, modalOpen, setModalOpen } = + useContext(DeptEditorContext); + const handleOk = () => { + form.submit(); + }; + return ( + handleOk()} + open={modalOpen} + onCancel={() => { + setModalOpen(false); + setEditId(undefined); + }} + title={editId ? "编辑单位" : "创建单位"} + width={600}> + + + ); +} diff --git a/apps/web/src/components/models/role/role-editor/assign-list.tsx b/apps/web/src/components/models/role/role-editor/assign-list.tsx new file mode 100755 index 0000000..4aadd01 --- /dev/null +++ b/apps/web/src/components/models/role/role-editor/assign-list.tsx @@ -0,0 +1,118 @@ +import AgServerTable from "@web/src/components/presentation/ag-server-table"; +import { ObjectType } from "@nice/common" +import { ICellRendererParams } from "@ag-grid-community/core"; +import { Menu, MenuItem } from "@web/src/components/presentation/dropdown-menu"; +import { DeleteOutlined, EllipsisOutlined, PlusOutlined } from "@ant-design/icons"; +import { ColDef, ValueGetterParams } from "@ag-grid-community/core"; +import { Button } from "antd"; +import DepartmentSelect from "../../department/department-select"; +import { useContext, useEffect } from "react"; +import { RoleEditorContext } from "./role-editor"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useRoleMap } from "@nice/client" +const OpreationRenderer = ({ props }: { props: ICellRendererParams }) => { + const { deleteMany } = useRoleMap() + return ( +
+ + }> + { + deleteMany.mutateAsync({ + ids: [props?.data?.id], + }); + }} + icon={}> + +
+ ); + +}; +export default function AssignList() { + const { user, hasSomePermissions } = useAuth(); + const { domainId, setModalOpen, role, setDomainId, canManageRole } = + useContext(RoleEditorContext); + useEffect(() => { + if (user) { + setDomainId?.(user.domainId); + } + }, [user]); + const columnDefs: ColDef[] = [ + { + headerName: "帐号", + field: "staff.username", + sort: "desc", + valueGetter: (params: ValueGetterParams) => { + return params.data?.staff_username; + }, + filter: "agTextColumnFilter", + maxWidth: 300, + }, + { + headerName: "姓名", + field: "staff.showname", + sort: "desc", + valueGetter: (params: ValueGetterParams) => { + return params.data?.staff_showname; + }, + filter: "agTextColumnFilter", + maxWidth: 300, + }, + { + headerName: "证件号", + field: "staff.officer_id", + sort: "desc", + valueGetter: (params: ValueGetterParams) => { + return params.data?.staff_officer_id; + }, + filter: "agTextColumnFilter", + }, + { + headerName: "所在单位", + field: "department.name", + sort: "desc", + valueGetter: (params: ValueGetterParams) => { + return params.data?.department_name; + }, + filter: "agTextColumnFilter", + maxWidth: 300, + }, + + { + headerName: "操作", + sortable: true, + + cellRenderer: (props) => , // 指定 cellRenderer + maxWidth: 100, + }, + ]; + return
+
+
+ {role?.name} + 角色成员列表 + +
+
+ setDomainId(value as string)} rootId={user?.domainId} value={domainId} disabled={!canManageRole} domain={true} className=" w-48"> + {canManageRole && } +
+ +
+ +
+} diff --git a/apps/web/src/components/models/role/role-editor/role-editor.tsx b/apps/web/src/components/models/role/role-editor/role-editor.tsx new file mode 100755 index 0000000..1d83609 --- /dev/null +++ b/apps/web/src/components/models/role/role-editor/role-editor.tsx @@ -0,0 +1,82 @@ +import { createContext, useMemo, useState } from "react"; +import AssignList from "./assign-list"; +import RoleList from "./role-list"; + +import RoleStaffModal from "./role-staff-modal"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { Role, RolePerms } from "@nice/common" +import RoleModal from "./role-modal"; +import { FormInstance, useForm } from "antd/es/form/Form"; +// 扩展上下文类型以包括 mapStaffIds 和 setMapStaffIds +export const RoleEditorContext = createContext<{ + role: Role, + domainId: string, + modalOpen: boolean, + mapStaffIds: string[], + setRole: React.Dispatch>, + setDomainId: React.Dispatch>, + setModalOpen: React.Dispatch>, + setMapStaffIds: React.Dispatch>, + canManageRole: boolean, + editRoleId: string, + setEditRoleId: React.Dispatch>, + roleModalOpen: boolean, + setRoleModalOpen: React.Dispatch>, + roleForm: FormInstance, + +}>({ + role: undefined, + domainId: undefined, + modalOpen: false, + mapStaffIds: [], + setRole: undefined, + setDomainId: undefined, + setModalOpen: undefined, + setMapStaffIds: undefined, + canManageRole: false, + editRoleId: undefined, + setEditRoleId: undefined, + roleModalOpen: undefined, + roleForm: undefined, + setRoleModalOpen: undefined +}); + +export default function RoleEditor() { + const [role, setRole] = useState(); + const [editRoleId, setEditRoleId] = useState(); + const [domainId, setDomainId] = useState(); + const [roleForm] = useForm() + const [modalOpen, setModalOpen] = useState(false); + const [roleModalOpen, setRoleModalOpen] = useState(false); + const [mapStaffIds, setMapStaffIds] = useState([]); // 初始化为空数组 + const { user, hasSomePermissions } = useAuth() + const canManageRole = useMemo(() => { + return hasSomePermissions(RolePerms.MANAGE_ANY_ROLE, RolePerms.MANAGE_DOM_ROLE) + }, [user]) + return ( + +
+ + + +
+ + +
+ ); +} diff --git a/apps/web/src/components/models/role/role-editor/role-form.tsx b/apps/web/src/components/models/role/role-editor/role-form.tsx new file mode 100755 index 0000000..db58daf --- /dev/null +++ b/apps/web/src/components/models/role/role-editor/role-form.tsx @@ -0,0 +1,61 @@ +import { Form, Input, message, Select, Spin } from "antd"; +import { useContext, useEffect } from "react"; +import { Role, RolePerms } from "@nice/common"; +import { useRole } from "@nice/client"; +import { api } from "@nice/client"; +import { RoleEditorContext } from "./role-editor"; +const options: { value: string; label: string }[] = Object.values(RolePerms).map((permission) => ({ + value: permission, + label: permission, +})); +export default function RoleForm() { + const { editRoleId, roleForm, setRoleModalOpen } = useContext(RoleEditorContext) + const { data, isLoading }: { data: Role, isLoading: boolean } = api.role.findFirst.useQuery( + { where: { id: editRoleId } }, + { enabled: !!editRoleId } + ); + useEffect(() => { + roleForm.resetFields(); + if (data) { + roleForm.setFieldValue("name", data.name); + roleForm.setFieldValue("permissions", data.permissions); + } + }, [data]); + const { create, update } = useRole(); // Ensure you have these methods in your hooks + return ( +
+
{ + if (data) { + try { + await update.mutateAsync({ where: { id: data.id }, data: { ...values } }); + } catch (err: any) { + message.error("更新失败"); + } + } else { + try { + await create.mutateAsync(values); + roleForm?.resetFields(); + } catch (err: any) { + message.error("创建失败"); + } + } + message.success('提交成功') + setRoleModalOpen(false); + }} + > + + + + + : null} + filterOption={false} + onSearch={handleSearch} + options={options} + value={value} + onChange={onChange} + style={{ minWidth: 200, ...style }} + /> + ); +} diff --git a/apps/web/src/components/models/staff/staff-editor.tsx b/apps/web/src/components/models/staff/staff-editor.tsx new file mode 100755 index 0000000..c671c85 --- /dev/null +++ b/apps/web/src/components/models/staff/staff-editor.tsx @@ -0,0 +1,71 @@ +import StaffList from "./staff-list"; +import { ObjectType, RolePerms } from "@nice/common" +import StaffModal from "./staff-modal"; +import { createContext, useEffect, useMemo, useState } from "react"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { Button } from "antd"; +import DepartmentSelect from "../department/department-select"; +import { FormInstance, useForm } from "antd/es/form/Form"; +import FixedHeader from "../../layout/fix-header"; +import { PlusOutlined } from "@ant-design/icons"; +export const StaffEditorContext = createContext<{ + domainId: string, + modalOpen: boolean, + setDomainId: React.Dispatch>, + setModalOpen: React.Dispatch>, + editId: string, + setEditId: React.Dispatch>, + form: FormInstance, + formLoading: boolean, + setFormLoading: React.Dispatch>, + canManageAnyStaff: boolean +}>({ + domainId: undefined, + modalOpen: false, + setDomainId: undefined, + setModalOpen: undefined, + editId: undefined, + setEditId: undefined, + form: undefined, + formLoading: undefined, + setFormLoading: undefined, + canManageAnyStaff: false +}); +export default function StaffEditor() { + const [form] = useForm() + const [domainId, setDomainId] = useState(); + const [modalOpen, setModalOpen] = useState(false); + const [editId, setEditId] = useState() + const { user, hasSomePermissions } = useAuth() + const [formLoading, setFormLoading] = useState() + useEffect(() => { + if (user) { + setDomainId(user.domainId) + } + }, [user]) + + const canManageStaff = useMemo(() => { + return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF, RolePerms.MANAGE_DOM_STAFF) + }, [user]) + const canManageAnyStaff = useMemo(() => { + return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF) + }, [user]) + return + +
+ setDomainId(value as string)} disabled={!canManageAnyStaff} value={domainId} className="w-48" domain={true}> + {canManageStaff && } +
+
+ + +
+} \ No newline at end of file diff --git a/apps/web/src/components/models/staff/staff-form.tsx b/apps/web/src/components/models/staff/staff-form.tsx new file mode 100755 index 0000000..1dc4920 --- /dev/null +++ b/apps/web/src/components/models/staff/staff-form.tsx @@ -0,0 +1,178 @@ +import { Button, Form, Input, Spin, Switch, message } from "antd"; +import { useContext, useEffect} from "react"; +import { useStaff } from "@nice/client"; +import DepartmentSelect from "../department/department-select"; +import { api } from "@nice/client" +import { StaffEditorContext } from "./staff-editor"; +import { useAuth } from "@web/src/providers/auth-provider"; +export default function StaffForm() { + const { create, update } = useStaff(); // Ensure you have these methods in your hooks + const { + domainId, + form, + editId, + setModalOpen, + formLoading, + setFormLoading, + canManageAnyStaff, + setEditId, + } = useContext(StaffEditorContext); + const { data, isLoading } = api.staff.findFirst.useQuery( + { where: { id: editId } }, + { enabled: !!editId } + ); + const { isRoot } = useAuth(); + async function handleFinish(values: any) { + const { + username, + showname, + deptId, + domainId: fieldDomainId, + password, + phoneNumber, + officerId, + enabled + } = values + setFormLoading(true); + try { + if (data && editId) { + await update.mutateAsync({ + where: { id: data.id }, + data: { + username, + deptId, + showname, + domainId: fieldDomainId ? fieldDomainId : domainId, + password, + phoneNumber, + officerId, + enabled + } + }); + } else { + await create.mutateAsync({ + data: { + username, + deptId, + showname, + domainId: fieldDomainId ? fieldDomainId : domainId, + password, + officerId, + phoneNumber + } + }); + form.resetFields(); + if (deptId) form.setFieldValue("deptId", deptId); + if (domainId) form.setFieldValue("domainId", domainId); + } + message.success("提交成功"); + setModalOpen(false); + } catch (err: any) { + message.error(err.message); + } finally { + setFormLoading(false); + setEditId(undefined); + } + } + useEffect(() => { + form.resetFields(); + if (data && editId) { + form.setFieldValue("username", data.username); + form.setFieldValue("showname", data.showname); + form.setFieldValue("domainId", data.domainId); + form.setFieldValue("deptId", data.deptId); + form.setFieldValue("officerId", data.officerId); + form.setFieldValue("phoneNumber", data.phoneNumber); + form.setFieldValue("enabled", data.enabled) + } + }, [data]); + useEffect(() => { + if (!data && domainId) { + form.setFieldValue("domainId", domainId); + form.setFieldValue("deptId", domainId); + } + }, [domainId, data]); + return ( +
+ {isLoading && ( +
+ +
+ )} + + {canManageAnyStaff && ( + + + + )} + + + + + + + + + + + + + + + + + + + {editId && + + } + +
+ ); +} diff --git a/apps/web/src/components/models/staff/staff-list.tsx b/apps/web/src/components/models/staff/staff-list.tsx new file mode 100755 index 0000000..63fb71b --- /dev/null +++ b/apps/web/src/components/models/staff/staff-list.tsx @@ -0,0 +1,195 @@ +import { + DeleteOutlined, + EditFilled, + EllipsisOutlined, +} from "@ant-design/icons"; +import { ICellRendererParams } from "@ag-grid-community/core"; +import { + ColDef, + ValueGetterParams, +} from "@ag-grid-community/core"; +import { ObjectType, StaffRowModel } from "@nice/common"; +import { Menu, MenuItem } from "../../presentation/dropdown-menu"; +import AgServerTable from "../../presentation/ag-server-table"; +import IdCard from "../../presentation/id-card"; +import { useContext, useEffect, useState } from "react"; +import { StaffEditorContext } from "./staff-editor"; +import PhoneBook from "../../presentation/phone-book"; +import { SortDirection } from "ag-grid-community"; +import { CrudOperation, emitDataChange, useStaff } from "@nice/client"; +import { message, Tag } from "antd"; +import { CustomCellRendererProps } from "@ag-grid-community/react"; +const OpreationRenderer = ({ props }: { props: ICellRendererParams }) => { + const { setEditId, setModalOpen } = useContext(StaffEditorContext); + const { softDeleteByIds } = useStaff() + if (props?.data?.id) + return ( +
+ + }> + } + onClick={() => { + setEditId(props?.data?.id); + setModalOpen(true); + }}> + { + softDeleteByIds.mutateAsync({ + ids: [props?.data?.id], + }, { + onSettled: () => { + message.success("删除成功"); + emitDataChange(ObjectType.STAFF, props.data as any, CrudOperation.DELETED) + }, + }); + }} + icon={}> + +
+ ); +}; +const StaffList = ({ + domainId, + height = "calc(100vh - 48px - 49px)", +}: { + domainId?: string; + height?: string | number; +}) => { + const { canManageAnyStaff } = useContext(StaffEditorContext); + const [params, setParams] = useState({ domainId: null }); + useEffect(() => { + + if (domainId) { + setParams((prev) => ({ ...prev, domainId })) + } else { + setParams((prev) => ({ ...prev, domainId: null })) + } + }, [domainId]) + const columnDefs: ColDef[] = [ + canManageAnyStaff && { + headerName: "所属域", + field: "domain.name", + sortable: true, + valueGetter: (params) => { + return params.data?.domain_name; + }, + filter: "agTextColumnFilter", + }, + { + headerName: "所属单位", + field: "dept.name", + valueGetter: (params: ValueGetterParams) => { + return params.data?.dept_name; + }, + cellRenderer: (params) => { + + return ( + params.value || ( + 未录入所属单位 + ) + ); + }, + sortable: true, + + filter: "agTextColumnFilter", + enableRowGroup: true, + maxWidth: 200, + }, + { + field: "order", + hide: true, + sort: "asc" as SortDirection + }, + { + headerName: "帐号", + field: "username", + cellRenderer: (params) => { + if (params?.data?.id) + return ( + params.value || ( + 未录入帐号 + ) + ); + }, + sortable: true, + rowDrag: true, + filter: "agTextColumnFilter", + maxWidth: 300, + }, + { + headerName: "姓名", + field: "showname", + cellRenderer: (params) => { + if (params?.data?.id) + return ( + params.value || ( + 未录入姓名 + ) + ); + }, + sortable: true, + + filter: "agTextColumnFilter", + maxWidth: 300, + }, + { + headerName: "证件号", + field: "officer_id", + sortable: true, + filter: "agTextColumnFilter", + cellRenderer: (params) => { + const { data }: { data: StaffRowModel } = params; + if (params?.data?.id) + return ; + }, + }, + { + headerName: "手机号", + field: "phone_number", + sortable: true, + filter: "agTextColumnFilter", + cellRenderer: (params) => { + const { data }: { data: StaffRowModel } = params; + if (params?.data?.id) + return ; + }, + }, + { + headerName: "是否启用", + field: "enabled", + sortable: true, + enableRowGroup: true, + cellRenderer: (props: CustomCellRendererProps) => { + + return {props?.data?.enabled ? "已启用" : "已禁用"} + }, + }, + { + headerName: "操作", + sortable: true, + + cellRenderer: (props) => ( + + ), // 指定 cellRenderer + maxWidth: 80, + }, + ].filter(Boolean); + + return ( + + ); +}; + +export default StaffList; diff --git a/apps/web/src/components/models/staff/staff-modal.tsx b/apps/web/src/components/models/staff/staff-modal.tsx new file mode 100755 index 0000000..a99b058 --- /dev/null +++ b/apps/web/src/components/models/staff/staff-modal.tsx @@ -0,0 +1,27 @@ +import { Button, Drawer, Modal } from "antd"; +import React, { useContext, useEffect, useState } from "react"; +import StaffForm from "./staff-form"; +import { StaffEditorContext } from "./staff-editor"; + +export default function StaffModal() { + const { editId, formLoading, modalOpen, setModalOpen, form, setEditId } = useContext(StaffEditorContext); + const handleOk = () => { + form.submit(); + + }; + return ( + { + setModalOpen(false); + setEditId(undefined) + }} + title={editId ? "编辑用户" : "创建用户"} + > + + + ); +} diff --git a/apps/web/src/components/models/staff/staff-select.tsx b/apps/web/src/components/models/staff/staff-select.tsx new file mode 100755 index 0000000..5881f2a --- /dev/null +++ b/apps/web/src/components/models/staff/staff-select.tsx @@ -0,0 +1,84 @@ +import { useMemo, useState } from "react"; +import { Button, Select, Spin } from "antd"; +import type { SelectProps } from "antd"; +import { api } from "@nice/client"; +interface StaffSelectProps { + value?: string | string[]; + onChange?: (value: string | string[]) => void; + style?: React.CSSProperties; + multiple?: boolean; + domainId?: string; + placeholder?: string; +} + +export default function StaffSelect({ + value, + onChange, + placeholder, + style, + multiple, + domainId, +}: StaffSelectProps) { + const [keyword, setQuery] = useState(""); + + // Determine ids based on whether value is an array or not + const ids = useMemo(() => { + return Array.isArray(value) ? value : []; + }, [value]); + + // Adjust the query to include ids when they are present + const { data, isLoading } = api.staff.findMany.useQuery({ + where: { + OR: [ + { + username: { + contains: keyword, + }, + }, + { + showname: { + contains: keyword, + }, + }, + { + id: { + in: ids + } + } + ], + domainId, + + }, + select: { id: true, showname: true, username: true }, + take: 30, + orderBy: { order: "asc" } + }); + + const handleSearch = (value: string) => { + setQuery(value); + }; + + const options: SelectProps["options"] = + data?.map((staff: any) => ({ + value: staff.id, + label: staff?.showname || staff?.username, + })) || []; + + return ( + <> + ({ + value: tax.id, + label: tax.name, + })) || []), + ...extraOptions, // 添加额外选项 + ]} + loading={isTaxLoading} + placeholder={placeholder} + onChange={handleChange} + /> + + ); +} diff --git a/apps/web/src/components/models/term/taxonomy-form.tsx b/apps/web/src/components/models/term/taxonomy-form.tsx new file mode 100755 index 0000000..3bdeee2 --- /dev/null +++ b/apps/web/src/components/models/term/taxonomy-form.tsx @@ -0,0 +1,60 @@ +import { Form, Input, Select } from "antd"; +import { useContext, useState } from "react"; +import { ObjectType } from "@nice/common"; +import { useTaxonomy } from "@nice/client"; +import { TermEditorContext } from "./term-editor"; +import { api } from "@nice/client" +export default function TaxonomyForm() { + const { create, update } = useTaxonomy(); + const [loading, setLoading] = useState(false); + const { taxonomyForm, setTaxonomyModalOpen, editTaxonomyId } = useContext(TermEditorContext) + const { data, isLoading } = api.taxonomy.findById.useQuery( + { id: editTaxonomyId }, + { enabled: !!editTaxonomyId } + ); + return ( +
{ + console.log(values); + setLoading(true); + if (data) { + await update.mutateAsync({ id: data.id, ...values }); + } else { + await create.mutateAsync(values); + taxonomyForm.resetFields(); + } + setLoading(false); + setTaxonomyModalOpen(false) + }}> + + + + + + + + + + +
+ ); +} diff --git a/apps/web/src/components/models/term/taxonomy-list.tsx b/apps/web/src/components/models/term/taxonomy-list.tsx new file mode 100755 index 0000000..2689cce --- /dev/null +++ b/apps/web/src/components/models/term/taxonomy-list.tsx @@ -0,0 +1,45 @@ +import React, { useContext, useMemo, useEffect, useState } from 'react'; +import { api } from "@nice/client"; +import { TermEditorContext } from './term-editor'; +import { Button, theme } from 'antd'; + +const TaxonomyList: React.FC = () => { + const { token } = theme.useToken() + const { data: taxonomies, isLoading } = api.taxonomy.getAll.useQuery({}); + const { taxonomyId, taxonomyName, setTaxonomyName, setTaxonomyId, setTaxonomyModalOpen } = useContext(TermEditorContext) + useEffect(() => { + if (!taxonomyId && taxonomies && taxonomies.length > 0) { + setTaxonomyId(taxonomies[0]?.id) + setTaxonomyName(taxonomies[0]?.name) + } + }, [taxonomies]) + return ( +
+
+ 分类法列表 + +
+
+ {taxonomies?.map((item) => ( +
{ + setTaxonomyId(item.id) + setTaxonomyName(item?.name) + }} className={`flex items-center ${item.id === taxonomyId ? " text-primary border-l-4 border-primaryHover" : ""} gap-4 p-2 hover:bg-textHover transition-all ease-in-out`}> +
+ {item.name} +
+ +
+ ))} +
+
+ ); +}; + +export default TaxonomyList; diff --git a/apps/web/src/components/models/term/taxonomy-modal.tsx b/apps/web/src/components/models/term/taxonomy-modal.tsx new file mode 100755 index 0000000..78df47c --- /dev/null +++ b/apps/web/src/components/models/term/taxonomy-modal.tsx @@ -0,0 +1,23 @@ +import { Modal } from "antd"; +import { TermEditorContext } from "./term-editor"; +import { useContext } from "react"; +import TaxonomyForm from "./taxonomy-form"; + +export default function TaxonomyModel() { + const { editTaxonomyId, taxonomyForm, setTaxonomyModalOpen, taxonomyModalOpen } = useContext(TermEditorContext) + const handleOk = () => { + taxonomyForm.submit(); + }; + return handleOk()} + open={taxonomyModalOpen} + onCancel={() => { + setTaxonomyModalOpen(false); + }} + title={editTaxonomyId ? '编辑分类法' : '创建分类法'} + width={400} + > + + +} \ No newline at end of file diff --git a/apps/web/src/components/models/term/term-editor.tsx b/apps/web/src/components/models/term/term-editor.tsx new file mode 100755 index 0000000..f1a82b7 --- /dev/null +++ b/apps/web/src/components/models/term/term-editor.tsx @@ -0,0 +1,94 @@ +import React, { createContext, useMemo, useState } from "react"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { RolePerms } from "@nice/common"; +import TaxonomyModal from "./taxonomy-modal"; +import TaxonomyList from "./taxonomy-list"; +import TermList from "./term-list"; +import { FormInstance } from "antd"; +import { useForm } from "antd/es/form/Form"; +import TermModal from "./term-modal"; +import TermImportModal from "./term-import-modal"; +// 扩展上下文类型以包括 mapStaffIds 和 setMapStaffIds +export const TermEditorContext = createContext<{ + taxonomyId: string; + taxonomyName: string; + domainId: string; + parentId: string; + taxonomyModalOpen: boolean; + termModalOpen: boolean; + setTaxonomyId: React.Dispatch>; + setTaxonomyName: React.Dispatch>; + setDomainId: React.Dispatch>; + setParentId: React.Dispatch>; + setTaxonomyModalOpen: React.Dispatch>; + setTermModalOpen: React.Dispatch>; + canManageAnyTerm: boolean; + editId: string; + setEditId: React.Dispatch>; + editTaxonomyId: string; + setEditTaxonomyId: React.Dispatch>; + termForm: FormInstance; + taxonomyForm: FormInstance; + canManageTerm: boolean; + importModalOpen: boolean; + setImportModalOpen: React.Dispatch>; +}>(undefined); + +export default function TermEditor() { + const [taxonomyId, setTaxonomyId] = useState(); + const [taxonomyName, setTaxonomyName] = useState(); + const [domainId, setDomainId] = useState(); + const [taxonomyModalOpen, setTaxonomyModalOpen] = useState(false); + const [termModalOpen, setTermModalOpen] = useState(false); + const [importModalOpen, setImportModalOpen] = useState(false); + const { user, hasSomePermissions } = useAuth(); + const [editId, setEditId] = useState(); + const [editTaxonomyId, setEditTaxonomyId] = useState(); + const [parentId, setParentId] = useState(); + const [termForm] = useForm(); + const [taxonomyForm] = useForm(); + const canManageTerm = useMemo(() => { + return hasSomePermissions( + RolePerms.MANAGE_ANY_TERM, + RolePerms.MANAGE_DOM_TERM + ); + }, [user]); + const canManageAnyTerm = useMemo(() => { + return hasSomePermissions(RolePerms.MANAGE_ANY_TERM); + }, [user]); + return ( + +
+ + +
+ + + +
+ ); +} diff --git a/apps/web/src/components/models/term/term-form.tsx b/apps/web/src/components/models/term/term-form.tsx new file mode 100755 index 0000000..59b8594 --- /dev/null +++ b/apps/web/src/components/models/term/term-form.tsx @@ -0,0 +1,85 @@ +import { Button, Form, Input, message, Checkbox, Spin } from "antd"; +import { useContext, useEffect, useRef, useState } from "react"; +import { useTerm } from "@nice/client"; +import TermSelect from "./term-select"; +import { TermEditorContext } from "./term-editor"; +import { api } from "@nice/client" +export default function TermForm() { + const { termForm, setTermModalOpen, taxonomyId, domainId, editId, parentId, setEditId, setParentId } = useContext(TermEditorContext); + const { create, update } = useTerm(); // Ensure you have these methods in your hooks + const [loading, setLoading] = useState(false); + const { data, isLoading } = api.term.findFirst.useQuery( + { where: { id: editId } }, + { enabled: !!editId } + ); + useEffect(() => { + if (data) { + termForm.setFieldValue("parentId", data?.parentId); + termForm.setFieldValue("name", data?.name); + } else { + termForm.resetFields() + } + if (parentId) { + termForm.setFieldValue("parentId", parentId); + } + }, [data, parentId]); + return ( +
+ {isLoading && ( +
+ +
+ )} +
{ + setLoading(true); + try { + if (data) { + await update.mutateAsync({ + where: { id: data.id, }, + data: { + taxonomyId, + domainId, + ...values, + } + }); + } else { + await create.mutateAsync({ + data: { + domainId, + taxonomyId, + ...values, + } + }); + termForm?.resetFields(); + } + setTermModalOpen(false) + setEditId(undefined) + setParentId(undefined) + } catch (err: any) { + message.error("提交失败"); + } finally { + setLoading(false); + + } + + + }}> + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/components/models/term/term-import-form.tsx b/apps/web/src/components/models/term/term-import-form.tsx new file mode 100755 index 0000000..0ff4278 --- /dev/null +++ b/apps/web/src/components/models/term/term-import-form.tsx @@ -0,0 +1,87 @@ +import { + + message, + Button, +} from "antd"; + +import { useContext, useState, useEffect, useCallback } from "react"; + +import DepartmentSelect from "../department/department-select"; +import TermSelect from "../term/term-select"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useTransform } from "@nice/client"; +import ExcelToBase64Uploader from "../../presentation/excel-to-base64-uploader"; +import { TermEditorContext } from "./term-editor"; + +export default function TroubleImportForm() { + const { importTerms } = useTransform(); + const { user } = useAuth(); + const { + canManageAnyTerm, + domainId, + setDomainId, + taxonomyId, + parentId, + setParentId, + } = useContext(TermEditorContext); + const [base64, setBase64] = useState(undefined); + + // Reset fields when type changes + const handleImport = async () => { + if (!base64) { + message.warning("请先上传一个文件"); + } else { + // 在这里处理导入逻辑 + console.log("导入的 Base64:", base64); + try { + message.info("正在导入..."); + await importTerms.mutateAsync({ + base64, + domainId, + taxonomyId, + parentId, + }); + message.success("导入完成"); + } catch (err: any) { + message.error(err.message); + } + } + }; + return ( + <> +
+ { + setBase64(base64); + }} + /> +
+ 父节点: +
+ + setParentId(value as string) + }> +
+ 所属域: + setDomainId(value as string)} + disabled={!canManageAnyTerm} + value={domainId} + className="w-32" + domain={true}> + +
+
+ + ); +} diff --git a/apps/web/src/components/models/term/term-import-modal.tsx b/apps/web/src/components/models/term/term-import-modal.tsx new file mode 100755 index 0000000..1ce0b01 --- /dev/null +++ b/apps/web/src/components/models/term/term-import-modal.tsx @@ -0,0 +1,25 @@ +import { useContext } from "react"; + +import { Modal } from "antd"; +import TermImportForm from "./term-import-form"; +import { TermEditorContext } from "./term-editor"; +export default function TermImportModal() { + const { importModalOpen, setImportModalOpen ,taxonomyName} = + useContext(TermEditorContext); + const handleOk = () => { + // form.submit() + }; + return ( + handleOk()} + open={importModalOpen} + onCancel={() => { + setImportModalOpen(false); + }} + title={`导入${taxonomyName}`} + width={600} + footer={null}> + + + ); +} diff --git a/apps/web/src/components/models/term/term-list.tsx b/apps/web/src/components/models/term/term-list.tsx new file mode 100755 index 0000000..433e7f8 --- /dev/null +++ b/apps/web/src/components/models/term/term-list.tsx @@ -0,0 +1,321 @@ +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { Empty, Tree, Button, message, TreeProps } from "antd"; +import { + DeleteOutlined, + DownOutlined, + EditFilled, + EllipsisOutlined, + ImportOutlined, + PlusOutlined, +} from "@ant-design/icons"; +import { CrudOperation, emitDataChange, useTerm } from "@nice/client"; +import { ObjectType, Term, TreeDataNode } from "@nice/common"; +import DepartmentSelect from "../department/department-select"; +import { TermEditorContext } from "./term-editor"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { api } from "@nice/client" +import { Menu, MenuItem } from "../../presentation/dropdown-menu"; +import AgServerTable from "../../presentation/ag-server-table"; + +import { CustomCellRendererProps } from "ag-grid-react"; +import { ColDef, SortDirection } from "@ag-grid-community/core"; + +const OpreationRenderer = ({ props }: { props: CustomCellRendererProps }) => { + const { setEditId, setTermModalOpen, setImportModalOpen, setParentId } = + useContext(TermEditorContext); + const { softDeleteByIds } = useTerm(); + return ( + + }> + } + onClick={() => { + setParentId(props?.data?.id); + + // setEditId(data?.id) + setImportModalOpen(true); + }}> + } + onClick={() => { + setParentId(props?.data?.id) + // setEditId(data?.id) + setTermModalOpen(true); + }}> + } + onClick={() => { + setEditId(props?.data?.id); + setTermModalOpen(true); + }}> + + { + softDeleteByIds.mutateAsync({ + ids: [props?.data?.id], + }, { + onSettled: () => { + message.success("删除成功"); + emitDataChange(ObjectType.TERM, props.data as any, CrudOperation.DELETED) + }, + }); + }} + icon={}> + + ); +}; + +export default function TermList() { + const { + domainId, + setDomainId, + taxonomyId, + canManageAnyTerm, + setTermModalOpen, + setImportModalOpen, + } = useContext(TermEditorContext); + const { user } = useAuth(); + useEffect(() => { + if (user) { + setDomainId(user.domainId); + } + }, [user]); + const [params, setParams] = useState({ parentId: null, domainId: null, taxonomyId: null }); + useEffect(() => { + if (taxonomyId) { + setParams((prev) => ({ ...prev, taxonomyId })) + } + if (domainId) { + setParams((prev) => ({ ...prev, domainId })) + } else { + setParams((prev) => ({ ...prev, domainId: null })) + } + }, [taxonomyId, domainId]) + const columnDefs = useMemo(() => { + return [ + { + field: "order", + hide: true, + sort: "asc" as SortDirection + }, + { + headerName: "操作", + sortable: true, + cellRenderer: (props: CustomCellRendererProps) => ( + + ), + maxWidth: 80, + }, + ].filter(Boolean); + }, []); + + const autoGroupColumnDef = useMemo(() => ({ + rowDrag: true, + headerName: "术语名", + field: "name", + filter: "agTextColumnFilter", + }), []); + + const getServerSideGroupKey = useCallback((item) => item.id, []); + const isServerSideGroup = useCallback((item) => item.has_children, []); + + return ( +
+
+ 分类项列表 +
+ + setDomainId(value as string) + }> + + + +
+
+ + {/*
+ + {treeData.length > 0 ? ( + { + try { + console.log( + "Drag and Drop operation initiated with info:", + info + ); + + const dropKey = info.node.key; + const dragKey = info.dragNode.key; + const dropPos = info.node.pos.split("-"); + const dropPosition = + info.dropPosition - + Number(dropPos[dropPos.length - 1]); + + console.debug( + `Calculated drop position: ${dropPosition}` + ); + + const data = [...treeData]; + let dragObj; + console.debug( + "Starting tree visitor to find and remove drag object." + ); + + treeVisitor( + data, + dragKey, + (item, index, arr) => { + arr.splice(index, 1); + dragObj = item; + console.debug( + `Removed dragged node: `, + dragObj + ); + } + ); + + let parentNodeId = null; + let siblings = []; + + if (!info.dropToGap) { + console.debug( + "Drop onto node action detected." + ); + treeVisitor(data, dropKey, (item) => { + item.children = item.children || []; + item.children.unshift(dragObj); + parentNodeId = item.key; + siblings = item.children; + console.debug( + `Added drag node as a child of node: ${parentNodeId}` + ); + }); + } else if ( + (info.node.children || []).length > 0 && + info.node.expanded && + dropPosition === 1 + ) { + console.debug( + "Drop after expanded node with children detected." + ); + treeVisitor(data, dropKey, (item) => { + item.children = item.children || []; + item.children.unshift(dragObj); + parentNodeId = item.key; + siblings = item.children; + console.debug( + `Added drag node as a child of node: ${parentNodeId}` + ); + }); + } else { + console.debug("Drop in gap detected."); + let ar = []; + let i = 0; + treeVisitor( + data, + dropKey, + (item, index, arr) => { + ar = arr; + i = index; + } + ); + + if (dropPosition === -1) { + ar.splice(i, 0, dragObj); + } else { + ar.splice(i + 1, 0, dragObj); + } + parentNodeId = ar[0].parentId || null; + siblings = ar; + console.debug( + `Inserted drag node at position: ${i}, under parentNodeId: ${parentNodeId}` + ); + } + + setTreeData(data); + console.debug( + "Tree data updated with new structure." + ); + console.log(siblings); + const { id } = dragObj; + const updatePromises = siblings.map( + (sibling, idx) => { + return update.mutateAsync({ + id: sibling.id, + order: idx, + parentId: parentNodeId, + }); + } + ); + + console.debug( + "Starting update of siblings' order and parentId." + ); + await Promise.all(updatePromises); + console.log( + `Updated node ${id} and its siblings with new order and parentId ${parentNodeId}` + ); + } catch (error) { + console.error( + "An error occurred during the drag and drop operation:", + error + ); + } + }} + checkable + checkStrictly + titleRender={titleRender} + showLine={{ showLeafIcon: false }} + switcherIcon={} + /> + ) : ( +
+ +
+ )} +
*/} +
+ ); +} diff --git a/apps/web/src/components/models/term/term-modal.tsx b/apps/web/src/components/models/term/term-modal.tsx new file mode 100755 index 0000000..20b426c --- /dev/null +++ b/apps/web/src/components/models/term/term-modal.tsx @@ -0,0 +1,28 @@ +import { Button, Drawer, Modal } from "antd"; +import { useContext } from "react"; +import TermForm from "./term-form"; +import { TermEditorContext } from "./term-editor"; + +export default function TermModal() { + const { editId, termForm, termModalOpen, setTermModalOpen, setEditId, setParentId } = useContext(TermEditorContext) + const handleOk = () => { + termForm.submit(); + }; + return ( + <> + handleOk()} + open={termModalOpen} + onCancel={() => { + setTermModalOpen(false); + setEditId(undefined) + setParentId(undefined) + }} + title={editId ? '编辑分类' : '创建分类'} + width={400} + > + + + + ); +} diff --git a/apps/web/src/components/models/term/term-select.tsx b/apps/web/src/components/models/term/term-select.tsx new file mode 100755 index 0000000..045e0e1 --- /dev/null +++ b/apps/web/src/components/models/term/term-select.tsx @@ -0,0 +1,193 @@ +import { TreeSelect, TreeSelectProps } from "antd"; +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { getUniqueItems } from "@nice/common"; +import { api } from "@nice/client"; +import { DefaultOptionType } from "antd/es/select"; + +interface TermSelectProps { + defaultValue?: string | string[]; + value?: string | string[]; + onChange?: (value: string | string[]) => void; + placeholder?: string; + multiple?: boolean; + // rootId?: string; + // domain?: boolean; + taxonomyId?: string; + disabled?: boolean; + className?: string; + domainId?: string; +} + +export default function TermSelect({ + defaultValue, + value, + onChange, + className, + placeholder = "选择单位", + multiple = false, + taxonomyId, + domainId, + // rootId = null, + disabled = false, + // domain = undefined, +}: TermSelectProps) { + const utils = api.useUtils(); + const [listTreeData, setListTreeData] = useState< + Omit[] + >([]); + + const fetchParentTerms = useCallback( + async (termIds: string | string[], taxonomyId?: string) => { + const idsArray = Array.isArray(termIds) + ? termIds + : [termIds].filter(Boolean); + try { + return await utils.term.getParentSimpleTree.fetch({ + termIds: idsArray, + taxonomyId, + domainId, + }); + } catch (error) { + console.error( + "Error fetching parent departments for deptIds", + idsArray, + ":", + error + ); + throw error; + } + }, + [utils] + ); + + const fetchTerms = useCallback(async () => { + try { + const rootDepts = await utils.term.getChildSimpleTree.fetch({ + taxonomyId, + domainId, + }); + let combinedDepts = [...rootDepts]; + if (defaultValue) { + const defaultDepts = await fetchParentTerms( + defaultValue, + taxonomyId + ); + combinedDepts = getUniqueItems( + [...listTreeData, ...combinedDepts, ...defaultDepts] as any, + "id" + ); + } + if (value) { + const valueDepts = await fetchParentTerms(value, taxonomyId); + combinedDepts = getUniqueItems( + [...listTreeData, ...combinedDepts, ...valueDepts] as any, + "id" + ); + } + + setListTreeData(combinedDepts); + } catch (error) { + console.error("Error fetching departments:", error); + } + }, [defaultValue, value, taxonomyId, utils, fetchParentTerms]); + + useEffect(() => { + fetchTerms(); + }, [defaultValue, value, taxonomyId, fetchTerms]); + + const handleChange = (newValue: any) => { + if (onChange) { + const processedValue = + multiple && Array.isArray(newValue) + ? newValue.map((item) => item.value) + : newValue; + onChange(processedValue); + } + }; + + const onLoadData: TreeSelectProps["loadData"] = async ({ id }) => { + try { + const result = await utils.term.getChildSimpleTree.fetch({ + termIds: [id], + taxonomyId, + domainId, + }); + const newItems = getUniqueItems([...listTreeData, ...result], "id"); + setListTreeData(newItems); + } catch (error) { + console.error( + "Error loading data for node with id", + id, + ":", + error + ); + } + }; + + const handleExpand = async (keys: React.Key[]) => { + // console.log(keys); + try { + const allKeyIds = + keys.map((key) => key.toString()).filter(Boolean) || []; + // const expandedNodes = await Promise.all( + // keys.map(async (key) => { + // return await utils.department.getChildSimpleTree.fetch({ + // deptId: key.toString(), + // domain, + // }); + // }) + // ); + // + //上面那样一个个拉会拉爆,必须直接拉deptIds + const expandedNodes = await utils.term.getChildSimpleTree.fetch({ + termIds: allKeyIds, + taxonomyId, + domainId, + }); + const flattenedNodes = expandedNodes.flat(); + const newItems = getUniqueItems( + [...listTreeData, ...flattenedNodes], + "id" + ); + setListTreeData(newItems); + } catch (error) { + console.error("Error expanding nodes with keys", keys, ":", error); + } + }; + + const handleDropdownVisibleChange = async (open: boolean) => { + if (open) { + // This will attempt to expand all nodes and fetch their children when the dropdown opens + const allKeys = listTreeData.map((item) => item.id); + await handleExpand(allKeys); + } + }; + + return ( + handleChange(multiple ? [] : undefined)} + onTreeExpand={handleExpand} + onDropdownVisibleChange={handleDropdownVisibleChange} + /> + ); +} \ No newline at end of file diff --git a/apps/web/src/components/models/term/term-select_BACKUP.tsx b/apps/web/src/components/models/term/term-select_BACKUP.tsx new file mode 100755 index 0000000..1dc7435 --- /dev/null +++ b/apps/web/src/components/models/term/term-select_BACKUP.tsx @@ -0,0 +1,92 @@ + import { TreeSelect } from "antd"; +import { useEffect, useState, useCallback, useMemo } from "react"; +import { api } from "@nice/client" +interface TermSelectProps { + defaultValue?: string | string[]; + value?: string | string[]; + onChange?: (value: string | string[]) => void; + variant?: "outlined" | "borderless" | "filled"; + placeholder?: string; + taxonomyId?: string; + taxonomySlug?: string; + extraOptions?: { value: string | undefined; label: string }[]; + multiple?: boolean; + className?: string; + domainId?: string; + disabled?: boolean; +} + +export default function TermSelect({ + defaultValue, + value, + onChange, + variant = "outlined", + taxonomyId, + taxonomySlug, + domainId = undefined, + placeholder = "选择术语", + multiple = false, + className, + disabled = false, +}: TermSelectProps) { + const { data, error } = api.term.getTreeData.useQuery( + { taxonomyId, taxonomySlug, domainId }, + { + enabled: !!taxonomyId || !!taxonomySlug, + } + ); + + const [selectedValue, setSelectedValue] = useState< + string | string[] | undefined + >(() => defaultValue); + + useEffect(() => { + if (value !== undefined) { + setSelectedValue(value); + } + }, [value]); + + const handleChange = useCallback( + (newValue: string | string[]) => { + setSelectedValue(newValue); + if (onChange) { + onChange(newValue); + } + }, + [onChange] + ); + + const filterTreeNode = useCallback((input: string, node: any) => { + return node?.title?.toLowerCase().indexOf(input.toLowerCase()) >= 0; + }, []); + + const effectivePlaceholder = useMemo(() => { + return error ? "加载失败,请重试" : placeholder; + }, [error, placeholder]); + + return ( + + ); +} diff --git a/apps/web/src/components/models/term/util.ts b/apps/web/src/components/models/term/util.ts new file mode 100755 index 0000000..c54910a --- /dev/null +++ b/apps/web/src/components/models/term/util.ts @@ -0,0 +1,15 @@ +import { TreeDataNode } from "@nice/common" +export const treeVisitor = ( + data: TreeDataNode[], + key: React.Key, + callback: (node: TreeDataNode, i: number, data: TreeDataNode[]) => void +) => { + for (let i = 0; i < data.length; i++) { + if (data[i].key === key) { + return callback(data[i], i, data); + } + if (data[i].children) { + treeVisitor(data[i].children!, key, callback); + } + } +}; \ No newline at end of file diff --git a/apps/web/src/components/presentation/NavBar.tsx b/apps/web/src/components/presentation/NavBar.tsx new file mode 100755 index 0000000..c707bc0 --- /dev/null +++ b/apps/web/src/components/presentation/NavBar.tsx @@ -0,0 +1,58 @@ +// components/NavBar.tsx +import { motion } from "framer-motion"; +import React, { useState } from "react"; + +interface NavItem { + id: string; + icon?: React.ReactNode; + label: string; +} + +interface NavBarProps { + items: NavItem[]; + defaultSelected?: string; + onSelect?: (id: string) => void; +} + +export const NavBar = ({ items, defaultSelected, onSelect }: NavBarProps) => { + const [selected, setSelected] = useState(defaultSelected || items[0]?.id); + + const handleSelect = (id: string) => { + setSelected(id); + onSelect?.(id); + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/presentation/Skeleton.tsx b/apps/web/src/components/presentation/Skeleton.tsx new file mode 100755 index 0000000..19c8234 --- /dev/null +++ b/apps/web/src/components/presentation/Skeleton.tsx @@ -0,0 +1,43 @@ +// components/presentation/Skeleton.tsx +import { motion } from "framer-motion"; +import React from "react"; + +export const SkeletonItem = ({ + className, + delay = 0, +}: { + className: string; + delay?: number; +}) => ( + +); + +export const SkeletonSection = ({ + title, + items, + gridCols = false, +}: { + title?: boolean; + items: number; + gridCols?: boolean; +}) => ( +
+ {title && } +
+ {Array.from({ length: items }).map((_, i) => ( + + ))} +
+
+); diff --git a/apps/web/src/components/presentation/ag-server-table.tsx b/apps/web/src/components/presentation/ag-server-table.tsx new file mode 100755 index 0000000..d7f13af --- /dev/null +++ b/apps/web/src/components/presentation/ag-server-table.tsx @@ -0,0 +1,494 @@ +import React, { + useMemo, + useState, + useCallback, + useRef, + useEffect, +} from "react"; +import { AgGridReact, AgGridReactProps } from "@ag-grid-community/react"; +import { + GetContextMenuItemsParams, + GridApi, + GridReadyEvent, + MenuItemDef, + StatusPanelDef, + StoreRefreshedEvent, + ModuleRegistry, + ColumnRowGroupChangedEvent, + IServerSideGetRowsParams, + IServerSideDatasource, + GridState, +} from "@ag-grid-community/core"; +import { ColumnsToolPanelModule } from "@ag-grid-enterprise/column-tool-panel"; +import { FiltersToolPanelModule } from "@ag-grid-enterprise/filter-tool-panel"; +import { RangeSelectionModule } from "@ag-grid-enterprise/range-selection"; +import { SetFilterModule } from "@ag-grid-enterprise/set-filter"; +import { MasterDetailModule } from "@ag-grid-enterprise/master-detail"; +import { StatusBarModule } from "@ag-grid-enterprise/status-bar"; +import { ClipboardModule } from "@ag-grid-enterprise/clipboard"; +import { MenuModule } from "@ag-grid-enterprise/menu"; +import { ServerSideRowModelModule } from "@ag-grid-enterprise/server-side-row-model"; +import { AG_GRID_LOCALE_CH } from "@web/src/locale/ag-grid-locale"; +import { api, CrudOperation, emitDataChange } from "@nice/client" +import { message } from "antd"; +import { useLocation } from "react-router-dom"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { EventBus } from "@nice/client"; +import { ObjectType } from "@nice/common"; + +ModuleRegistry.registerModules([ + MasterDetailModule, + ColumnsToolPanelModule, + FiltersToolPanelModule, + MenuModule, + SetFilterModule, + RangeSelectionModule, + StatusBarModule, + ClipboardModule, + ServerSideRowModelModule, +]); +interface AgTableSpecificProps { + objectType?: ObjectType; + onChange?: (selectedIds: string[]) => void; + height?: string | number; + defaultExpandedRows?: (string | number)[]; + defaultRowGroupColumns?: string[]; + params?: Record; + rowHeight?: number +} +type AgTableProps = AgTableSpecificProps & + Omit; +const AgServerTable: React.FC = ({ + objectType, + onChange, + height = 400, + defaultExpandedRows = [], + defaultRowGroupColumns = [], + params: queryParams, + rowHeight = 50, + ...restProps // Catch all other passed props +}) => { + const utils = api.useUtils(); + const { sessionId } = useAuth() + const location = useLocation() + // const { agTheme } = useAppTheme(); + const [expandedRows, setExpandedRows] = + useState(defaultExpandedRows); + const gridApi = useRef(null); + const groupFieldsRef = useRef() + const rowRecordRef = useRef>() + const [dragOverNodeId, setDragOverNodeId] = useState(null); + useEffect(() => { + const onDataChange = async ({ operation, data, type }) => { + if (type === objectType) { + console.log(objectType, operation, data) + // 确保 data 转换为数组 + const dataArray = Array.isArray(data) ? data : [data]; + saveExpandedRowsState(); + refreshData(dataArray); + } + } + EventBus.on("dataChanged", onDataChange) + return () => { + EventBus.off("dataChanged", onDataChange) + } + }, []) + const getRows = useCallback( + async (params: IServerSideGetRowsParams) => { + try { + + const request = { ...params.request, ...queryParams }; + console.log(request) + const result = await utils.client[objectType].getRows.query(request as any) + console.log(result) + params.success({ + rowData: result?.rowData, + rowCount: result?.rowCount, + }); + + + } catch (error) { + console.error("Error in getRows function:", error); + params.fail(); + } + }, + [objectType, queryParams, utils] + ); + const datasource = useMemo(() => { + return { + getRows, + }; + }, [getRows]); + useEffect(() => { + if (gridApi.current) { + + gridApi.current.setGridOption("serverSideDatasource", datasource); + } + }, [datasource]); + const statusBar = useMemo<{ + statusPanels: StatusPanelDef[]; + }>(() => { + return { + statusPanels: [ + { statusPanel: "agSelectedRowCountComponent" }, + { statusPanel: "agAggregationComponent" }, + ], + }; + }, []); + const getContextMenuItems = useCallback( + (params: GetContextMenuItemsParams): (string | MenuItemDef)[] => { + return ["copy", "separator", "export"]; + }, + [] + ); + const onFirstDataRendered = useCallback( + (params) => { + restoreExpandedRowsState(); + }, + [expandedRows] + ); + const containerStyle = useMemo( + () => ({ + width: "100%", + display: "flex", + }), + [] + ); + const gridStyle = useMemo( + () => ({ + width: "100%", + flexGrow: 1, + backgroundColor: "#ffffff", + }), + [] + ); + function updateGroupFields(api: GridApi) { + if (restProps.treeData) { + groupFieldsRef.current = ['id'] + } else { + const colState = api.getColumnState(); + const groupedColumns = colState.filter((state) => state.rowGroup); + groupedColumns.sort((a, b) => a.rowGroupIndex! - b.rowGroupIndex!); + groupFieldsRef.current = groupedColumns.map((col) => + col.colId.replace(".", "_") + ) || []; + + } + + } + function onColumnRowGroupChanged(event: ColumnRowGroupChangedEvent) { + + updateGroupFields(event.api) + if (gridApi.current) { + gridApi.current.refreshServerSide({ + purge: true + }); + } + } + + const saveExpandedRowsState = () => { + if (gridApi.current) { + const expandedNodes: string[] = []; + gridApi.current.forEachNode((node) => { + if (node.expanded && (node.key || node.id)) { + expandedNodes.push(node.key || node.id); + } + }); + setExpandedRows(expandedNodes); + return expandedNodes; + } + }; + const restoreExpandedRowsState = () => { + if (gridApi.current) { + gridApi.current.forEachNode((node) => { + if ( + expandedRows.includes(node.key || node.id) || + defaultExpandedRows.includes(node.key || node.id) + ) { + node.setExpanded(true); + } + }); + } + }; + const firstRowIndexRef = useRef(-1) + const initialState = useMemo(() => { + const statekey = `${objectType}-${location.pathname}-${sessionId}-agstate` + const storedState = localStorage.getItem(statekey) + if (storedState) { + const parsedState = JSON.parse(storedState) + return parsedState + } + }, []) + const handleStoreState = useCallback((state: GridState) => { + const statekey = `${objectType}-${location.pathname}-${sessionId}-agstate` + localStorage.setItem(statekey, JSON.stringify({ ...state, rowIndex: firstRowIndexRef.current })) + }, []) + const containerRef = useRef(null) + const containerHeight = useMemo(() => { + if (containerRef.current) { + // console.log('grid view height', containerRef.current.clientHeight - 100) + return containerRef.current.clientHeight - 100 + } + return 700 + }, [containerRef.current]) + const initialRowCount = useMemo(() => { + + if (initialState && !initialState.rowGroup) { + // console.log('rowCount', initialState?.rowIndex + containerHeight / rowHeight) + const rowCount = initialState?.rowIndex + containerHeight / rowHeight + + return rowCount < 31 ? 31 : rowCount + } + }, [containerHeight, initialState]) + const onGridReady = useCallback( + (params: GridReadyEvent) => { + gridApi.current = params.api; + gridApi.current.setGridOption("serverSideDatasource", datasource); + // if (!isInit) { + if (!initialState?.rowGroup && initialState?.rowIndex !== -1) { + gridApi.current.ensureIndexVisible(initialState?.rowIndex, "top") + // setIsInit(true) + } + // } + gridApi.current.addEventListener("gridPreDestroyed", (event) => handleStoreState(event.state)) + + gridApi.current.addEventListener("bodyScroll", (event) => { + firstRowIndexRef.current = Math.round(event.top / rowHeight) + }) + + updateGroupFields(params.api) + // if (defaultRowGroupColumns.length > 0) { + // params.api.applyColumnState({ + // state: defaultRowGroupColumns.map((colId) => ({ + // colId, + // rowGroup: true, + // hide: true, + // })), + // applyOrder: true, + // }); + // } + + }, + [datasource] + ); + + + const refreshData = useCallback((rows: any[]) => { + if (!gridApi.current) return; + const rowData = Object.values(rowRecordRef.current) + // 对于树形数据,需要特殊处理 + const refreshRouteForTreeData = (item: any) => { + // 如果是树形数据,使用父级路径来刷新 + const getParentRoute = (data: any): string[] => { + const route: string[] = []; + let currentParent = data.parent_id; + + while (currentParent) { + const parentNode = rowData?.find(row => row.id === currentParent); + console.log(parentNode) + if (parentNode) { + // 使用父节点的分组字段构建路由 + const parentRoute = groupFieldsRef.current?.map(field => parentNode[field]).filter(Boolean); + if (parentRoute && parentRoute.length) { + route.unshift(...parentRoute); + } + currentParent = parentNode.parent_id; + } else { + break; + } + } + return route; + }; + + // 获取父级路由 + const ancestorRoute = getParentRoute(item); + console.log('ancestor route', ancestorRoute) + // 刷新父级路由 + if (ancestorRoute) { + let parentRoute = [...ancestorRoute] + parentRoute.pop() + if (parentRoute) { + console.log('parent route', parentRoute) + gridApi.current.refreshServerSide({ route: parentRoute }); + } + gridApi.current.refreshServerSide({ route: ancestorRoute }); + + } + + + }; + console.log('refresh data', rows) + console.log('rowdata', rowData) + console.log(groupFieldsRef.current) + console.log('tree fresh', restProps.treeData) + // 处理每一个更新的行 + rows.forEach(item => { + // 检查是否存在于当前数据中 + const existingItem = rowData?.find(row => row.id === item.id); + // console.log('exsit item', existingItem) + if (restProps.treeData) { + + refreshRouteForTreeData(item); + } else { + // 对于非树形数据,使用原有的分组刷新逻辑 + for (let i = 0; i <= (groupFieldsRef.current?.length || 0); i++) { + const newSliceRoute = groupFieldsRef.current + ?.slice(0, i) + .map((field) => item[field]) + .filter(Boolean); + + const oldSliceRoute = groupFieldsRef.current + ?.slice(0, i) + .map((field) => existingItem?.[field]) + .filter(Boolean); + + if (newSliceRoute && oldSliceRoute && + newSliceRoute.join("-") !== oldSliceRoute.join("-")) { + gridApi.current.refreshServerSide({ + route: oldSliceRoute, + }); + } + + if (newSliceRoute) { + gridApi.current.refreshServerSide({ route: newSliceRoute }); + } + } + } + }); + }, [groupFieldsRef.current, gridApi.current, rowRecordRef.current]); + + + return ( +
+
+ { + return data?.child_count; + }} + onColumnRowGroupChanged={onColumnRowGroupChanged} + + statusBar={statusBar} + // theme={agTheme} + initialState={initialState} + rowModelType={"serverSide"} + onStoreRefreshed={(params: StoreRefreshedEvent) => { + restoreExpandedRowsState(); + + }} + isServerSideGroupOpenByDefault={(params) => { + return expandedRows.includes( + params.rowNode.key || params.rowNode.id + ); + }} + + getRowId={(params) => { + let rowId = ""; + + if (params.parentKeys && params.parentKeys.length) { + rowId += params.parentKeys.join("-") + "-"; + } + + const groupCols = params.api.getRowGroupColumns(); + if (groupCols.length > params.level) { + const thisGroupCol = groupCols[params.level]; + rowId += + params.data[ + thisGroupCol.getColDef().field.replace(".", "_") + ] + "-"; + } + + if (params.data.id) { + rowId = params.data.id; + } + + rowRecordRef.current = { ...rowRecordRef.current, [rowId]: params.data } + + // setRowRecord((prevRowRecord) => ({ + // ...prevRowRecord, + // [rowId]: params.data, + // })); + + return rowId; + }} + blockLoadDebounceMillis={100} + onFirstDataRendered={onFirstDataRendered} + detailRowAutoHeight={true} + cellSelection={true} + // loadThemeGoogleFonts={false} + suppressServerSideFullWidthLoadingRow={true} + allowContextMenuWithControlKey={true} + getContextMenuItems={getContextMenuItems} + onGridReady={onGridReady} + + onRowDragEnd={async (event) => { + setDragOverNodeId(undefined); + const { overNode, node: draggedNode } = event; + if (!overNode || !draggedNode) return; + const { id: overId, data: overData } = overNode; + const { id: draggedId, data: draggedData } = draggedNode; + // 合并条件判断,简化逻辑 + if (!overData?.id || !draggedData?.id || overId === draggedId) return; + try { + console.log(overData, draggedData) + if (overData?.parent_id === draggedData?.parent_id) { + message.info("更新排序"); + const result = await utils.client[objectType].updateOrder.mutate({ id: draggedId, overId: overId }); + emitDataChange(objectType, result, CrudOperation.UPDATED) + } + } catch (error) { + console.error("更新排序失败:", error); + message.error("无法更新排序,请稍后重试。"); + } + }} + rowHeight={rowHeight} + cacheBlockSize={30} + onRowDragLeave={(event) => { + setDragOverNodeId(undefined); + }} + + onRowDragEnter={(event) => { + const overNode = event.overNode; + setDragOverNodeId(overNode.id); + + }} + onRowDragMove={(event) => { + setDragOverNodeId(event.overNode.id); + }} + // debug={!import.meta.env.PROD} + rowClassRules={{ + "ag-custom-dragging-class": (params) => { + + return params.data && + params.data.id && + params.data.id === dragOverNodeId + }, + }} + + {...restProps} + + /> +
+
+ ); +}; +export default AgServerTable; diff --git a/apps/web/src/components/presentation/animate-progress.tsx b/apps/web/src/components/presentation/animate-progress.tsx new file mode 100755 index 0000000..ede8413 --- /dev/null +++ b/apps/web/src/components/presentation/animate-progress.tsx @@ -0,0 +1,28 @@ +import { theme } from "antd"; +import { motion } from "framer-motion"; +import { CSSProperties } from "react"; + +export default function AnimateProgress({ progress, text, className, style }: { + progress: number, text?: string, className?: string, style?: CSSProperties +}) { + const { token } = theme.useToken() + return ( +
+ +
+ {text} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/presentation/collapse-section.tsx b/apps/web/src/components/presentation/collapse-section.tsx new file mode 100755 index 0000000..a4ee517 --- /dev/null +++ b/apps/web/src/components/presentation/collapse-section.tsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { theme } from "antd"; +import { motion } from "framer-motion"; // Import Framer Motion +import { CaretRightOutlined } from "@ant-design/icons"; + +// Define types for the props +interface CollapsibleSectionProps { + items: Array; + className?: string; + defaultExpandedKeys?: string[]; +} + +interface MenuItem { + key: string; + link?: string; + blank?: boolean + icon?: React.ReactNode; + label: string; + children?: Array; + extra?: React.ReactNode; +} + +const CollapsibleSection: React.FC = ({ + items, + className, + defaultExpandedKeys = [], +}) => { + const location = useLocation(); + const navigate = useNavigate(); + const currentPath = location.pathname; + const currentSearchParams = new URLSearchParams(location.search); + const { token } = theme.useToken(); + + const [expandedSections, setExpandedSections] = useState<{ + [key: string]: boolean; + }>(() => + defaultExpandedKeys.reduce( + (acc, key) => { + acc[key] = true; + return acc; + }, + {} as { [key: string]: boolean } + ) + ); + + const toggleChildCollapse = (key: string): void => { + setExpandedSections((prevState) => ({ + ...prevState, + [key]: !prevState[key], + })); + }; + + const renderItems = ( + items: Array, + level: number + ): React.ReactNode => { + return items.map((item) => { + const itemUrl = new URL(item.link, window.location.origin); + const itemPath = itemUrl.pathname; + const itemSearchParams = new URLSearchParams(itemUrl.search); + const hasChildren = item.children && item.children.length > 0; + const isActive = + currentPath === itemPath && + Array.from(itemSearchParams.entries()).every( + ([key, value]) => currentSearchParams.get(key) === value + ); + + const isChildCollapsed = !expandedSections[item.key]; + + return ( +
+ { + if (hasChildren) { + toggleChildCollapse(item.key); + } + if (item.link) { + if (!item.blank) { + navigate(item.link, { replace: true }); + } else { + window.open(item.link, "_blank"); + } + } + }} + initial={false} + animate={{ + backgroundColor: isActive ? token.colorPrimaryBorder : token.colorPrimary, + }} + whileHover={{ backgroundColor: token.colorPrimaryHover }} + transition={{ type: "spring", stiffness: 300, damping: 25, duration: 0.3 }} + style={{ marginLeft: `${level * 16}px` }} + > +
+
+ {item.icon && {item.icon}} + {item.label} +
+ {hasChildren && ( + + )} +
+ {item.extra &&
{item.extra}
} +
+ {hasChildren && ( + + {renderItems(item.children, level + 1)} + + )} +
+ ); + }); + }; + + return
{renderItems(items, 0)}
; +}; + +export default CollapsibleSection; diff --git a/apps/web/src/components/presentation/dashboard-card.tsx b/apps/web/src/components/presentation/dashboard-card.tsx new file mode 100755 index 0000000..0ef0921 --- /dev/null +++ b/apps/web/src/components/presentation/dashboard-card.tsx @@ -0,0 +1,62 @@ +import { theme } from "antd"; +import { ReactNode, CSSProperties } from "react"; +import { motion } from "framer-motion"; + +export default function DashboardCard({ + children, + title, + className, + contentClassName, + titleClassName, + extra, + style, + contentStyle, +}: { + contentClassName?: string; + contentStyle?: CSSProperties; + extra?: ReactNode; + title?: ReactNode; + children?: ReactNode; + titleClassName?: string; + className?: string; + style?: CSSProperties; +}) { + const { token } = theme.useToken(); + + return ( + +
+
+ {title} +
+ {extra &&
{extra}
} +
+ {children && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/presentation/dialog.tsx b/apps/web/src/components/presentation/dialog.tsx new file mode 100755 index 0000000..55c088d --- /dev/null +++ b/apps/web/src/components/presentation/dialog.tsx @@ -0,0 +1,229 @@ +import * as React from "react"; +import { + useFloating, + useClick, + useDismiss, + useRole, + useInteractions, + useMergeRefs, + FloatingPortal, + FloatingFocusManager, + FloatingOverlay, + useId +} from "@floating-ui/react"; +import { Button } from "antd"; +import { CloseOutlined } from "@ant-design/icons"; + +interface DialogOptions { + initialOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function useDialog({ + initialOpen = false, + open: controlledOpen, + onOpenChange: setControlledOpen +}: DialogOptions = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); + const [labelId, setLabelId] = React.useState(); + const [descriptionId, setDescriptionId] = React.useState< + string | undefined + >(); + + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = setControlledOpen ?? setUncontrolledOpen; + + const data = useFloating({ + open, + onOpenChange: setOpen + }); + + const context = data.context; + + const click = useClick(context, { + enabled: controlledOpen == null + }); + const dismiss = useDismiss(context, { outsidePressEvent: "mousedown" }); + const role = useRole(context); + + const interactions = useInteractions([click, dismiss, role]); + + return React.useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + labelId, + descriptionId, + setLabelId, + setDescriptionId + }), + [open, setOpen, interactions, data, labelId, descriptionId] + ); +} + +type ContextType = + | (ReturnType & { + setLabelId: React.Dispatch>; + setDescriptionId: React.Dispatch< + React.SetStateAction + >; + }) + | null; + +const DialogContext = React.createContext(null); + +export const useDialogContext = () => { + const context = React.useContext(DialogContext); + + if (context == null) { + throw new Error("Dialog components must be wrapped in "); + } + + return context; +}; + +export function Dialog({ + children, + ...options +}: { + children: React.ReactNode; +} & DialogOptions) { + const dialog = useDialog(options); + return ( + {children} + ); +} + +interface DialogTriggerProps { + children: React.ReactNode; + asChild?: boolean; +} + +export const DialogTrigger = React.forwardRef< + HTMLElement, + React.HTMLProps & DialogTriggerProps +>(function DialogTrigger({ children, asChild = false, ...props }, propRef) { + const context = useDialogContext(); + const childrenRef = (children as any).ref; + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); + + // `asChild` allows the user to pass any element as the anchor + if (asChild && React.isValidElement(children)) { + return React.cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...children.props, + "data-state": context.open ? "open" : "closed" + }) + ); + } + + return ( + + ); +}); + +export const DialogContent = React.forwardRef< + HTMLDivElement, + React.HTMLProps +>(function DialogContent(props, propRef) { + const { context: floatingContext, ...context } = useDialogContext(); + const ref = useMergeRefs([context.refs.setFloating, propRef]); + + if (!floatingContext.open) return null; + + return ( + + + +
+ {props.children} +
+
+
+
+ ); +}); + +export const DialogHeading = React.forwardRef< + HTMLDivElement, + React.HTMLProps +>(function DialogHeading({ children, ...props }, ref) { + const { setLabelId } = useDialogContext(); + const id = useId(); + const { setOpen } = useDialogContext(); + // Only sets `aria-labelledby` on the Dialog root element + // if this component is mounted inside it. + React.useLayoutEffect(() => { + setLabelId(id); + return () => setLabelId(undefined); + }, [id, setLabelId]); + + return ( +
+ {children} +
+ +
+
+ ); +}); + +export const DialogDescription = React.forwardRef< + HTMLDivElement, + React.HTMLProps +>(function DialogDescription({ children, ...props }, ref) { + const { setDescriptionId } = useDialogContext(); + const id = useId(); + + // Only sets `aria-describedby` on the Dialog root element + // if this component is mounted inside it. + React.useLayoutEffect(() => { + setDescriptionId(id); + return () => setDescriptionId(undefined); + }, [id, setDescriptionId]); + + return ( +
+ {children} +
+ ); +}); +export const DialogFooter = React.forwardRef< + HTMLDivElement, + React.HTMLProps +>(function DialogFooter({ children, ...props }, ref) { + + return ( +
+
+ {children} +
+ ); +}); +export const DialogClose = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>(function DialogClose(props, ref) { + const { setOpen } = useDialogContext(); + return ( + + + + {isOpen && ( + +
setIsOpen(false)}>
+ +
+ {children} +
+
+
+ )} +
+
+ + ); +}); + +interface MenuItemProps { + label: string; + disabled?: boolean; + icon?: React.ReactNode; +} + +export const MenuItem = React.forwardRef< + HTMLButtonElement, + MenuItemProps & React.ButtonHTMLAttributes +>(({ label, disabled, icon, ...props }, forwardedRef) => { + const menu = React.useContext(MenuContext); + const item = useListItem({ label: disabled ? null : label }); + const tree = useFloatingTree(); + const isActive = item.index === menu.activeIndex; + + return ( + + ); +}); + +export const Menu = React.forwardRef< + HTMLButtonElement, + MenuProps & React.HTMLProps +>((props, ref) => { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ( + <> + + + ); +}); diff --git a/apps/web/src/components/presentation/excel-to-base64-uploader.tsx b/apps/web/src/components/presentation/excel-to-base64-uploader.tsx new file mode 100755 index 0000000..b198ba5 --- /dev/null +++ b/apps/web/src/components/presentation/excel-to-base64-uploader.tsx @@ -0,0 +1,85 @@ +import React, { useState } from "react"; +import { Upload, message } from "antd"; +import { InboxOutlined, FileExcelOutlined } from "@ant-design/icons"; +import type { RcFile } from "antd/es/upload/interface"; + +interface ExcelToBase64UploaderProps { + onBase64: (base64: string) => void; +} + +const ExcelToBase64Uploader: React.FC = ({ + onBase64, +}) => { + const [fileName, setFileName] = useState(""); + + const beforeUpload = (file: RcFile): boolean => { + const isExcel = + file.type === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + file.type === "application/vnd.ms-excel" || + file.name.endsWith(".xls") || + file.name.endsWith(".xlsx"); + if (!isExcel) { + message.error("请选择一个有效的 Excel 文件"); + return false; + } + return true; + }; + + const handleFileChange = async (file: RcFile) => { + try { + const base64 = await fileToBase64(file); + onBase64(base64); + setFileName(file.name); + } catch (err: any) { + message.error("文件转换失败"); + console.error(err); + } + }; + + const fileToBase64 = (file: RcFile): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result === "string") { + resolve(result.split(",")[1]); // 返回 base64 部分 + } else { + reject(new Error("无法读取文件")); + } + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + return ( +
+ { + handleFileChange(file as RcFile); + onSuccess && onSuccess("ok"); + }} + showUploadList={false} + style={{ + width: "100%", + padding: "20px", + border: "1px dashed #d9d9d9", + borderRadius: "4px", + }}> +

+ {fileName ? : } +

+

+ {fileName || "点击或拖拽文件到此区域进行上传"} +

+ {!fileName && ( +

仅支持 .xls 和 .xlsx 文件

+ )} +
+
+ ); +}; + +export default ExcelToBase64Uploader; diff --git a/apps/web/src/components/presentation/general-dialog.tsx b/apps/web/src/components/presentation/general-dialog.tsx new file mode 100755 index 0000000..96f1e1b --- /dev/null +++ b/apps/web/src/components/presentation/general-dialog.tsx @@ -0,0 +1,64 @@ +// GeneralDialog.tsx +import React, { useState, useImperativeHandle, forwardRef, ReactNode } from 'react'; +import { Button } from "antd"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeading } from './dialog'; + +interface GeneralDialogProps { + title?: string; + handleOk?: () => void; + onClose?: (open: boolean) => void; + children: ReactNode; + confirmText?: string; + initialOpen?: boolean; + trigger?: ReactNode; // New trigger prop +} + +export interface GeneralDialogRef { + open: () => void; + close: () => void; +} + +const GeneralDialog: React.ForwardRefRenderFunction = ({ + children, + handleOk, + title, + onClose, + confirmText, + initialOpen = false, + trigger, // Destructure the trigger prop +}, ref) => { + const [open, setOpen] = useState(initialOpen); + + const handleClose = (value: boolean) => { + setOpen(value); + onClose?.(value); + }; + + // Expose open and close methods to parent component via ref + useImperativeHandle(ref, () => ({ + open: () => setOpen(true), + close: () => setOpen(false), + })); + + return ( + <> + {trigger && React.cloneElement(trigger as React.ReactElement, { onClick: () => setOpen(true) })} + + + {title && {title}} + + {children} + + +
+ + +
+
+
+
+ + ); +}; + +export default forwardRef(GeneralDialog); diff --git a/apps/web/src/components/presentation/id-card.tsx b/apps/web/src/components/presentation/id-card.tsx new file mode 100755 index 0000000..0cde1b4 --- /dev/null +++ b/apps/web/src/components/presentation/id-card.tsx @@ -0,0 +1,25 @@ +import { IdcardOutlined } from "@ant-design/icons"; +import React from "react"; + +interface IdCardProps extends React.HTMLProps { + id: string; +} + +export default function IdCard({ id, ...rest }: IdCardProps) { + return ( +
+ {id ? ( +
+ + {id} +
+ ) : ( + 未录入证件号 + )} +
+ ); +} diff --git a/apps/web/src/components/presentation/nice-img.tsx b/apps/web/src/components/presentation/nice-img.tsx new file mode 100755 index 0000000..d16d0e4 --- /dev/null +++ b/apps/web/src/components/presentation/nice-img.tsx @@ -0,0 +1,80 @@ +import React, { CSSProperties, useRef, useState } from 'react'; + +type NiceImgProps = React.HTMLAttributes & // Allow div props + React.ImgHTMLAttributes & { + fallbackSrc?: string; + shape?: 'circle' | 'square'; // Shape of the avatar + size?: 'small' | 'default' | 'large' | number; // Size of the avatar + className?: string; + }; + +const getSize = (size: 'small' | 'default' | 'large' | number) => { + switch (size) { + case 'small': + return 24; + case 'large': + return 64; + case 'default': + default: + return 30; + } +}; + + +const NiceImg: React.FC = ({ + src, + alt, + fallbackSrc, + shape = 'square', + size = 'default', + style, + className, + ...props +}) => { + const [isError, setIsError] = useState(false); + const imgRef = useRef(null); + const dimension = typeof size === 'number' ? size : getSize(size); + + const combinedStyle: CSSProperties = { + width: dimension, + height: dimension, + borderRadius: shape === 'circle' ? '50%' : '4px', + objectFit: 'cover', + ...style, + }; + + const handleError = () => { + setIsError(true); + }; + + return ( + <> + {isError || !src ? ( +
+ {fallbackSrc && fallback} +
+ ) : ( + {alt} + )} + + ); +}; + +export default NiceImg; diff --git a/apps/web/src/components/presentation/phone-book.tsx b/apps/web/src/components/presentation/phone-book.tsx new file mode 100755 index 0000000..1f41837 --- /dev/null +++ b/apps/web/src/components/presentation/phone-book.tsx @@ -0,0 +1,23 @@ +import { IdcardOutlined, PhoneOutlined } from "@ant-design/icons"; +import React from "react"; + +interface PhoneBookProps extends React.HTMLProps { + phoneNumber: string; +} + +export default function PhoneBook({ phoneNumber, ...rest }: PhoneBookProps) { + return ( +
+ {phoneNumber ? ( +
+ + + {phoneNumber} + +
+ ) : ( + 未录入手机号 + )} +
+ ); +} diff --git a/apps/web/src/components/presentation/round-tag.tsx b/apps/web/src/components/presentation/round-tag.tsx new file mode 100755 index 0000000..c80d057 --- /dev/null +++ b/apps/web/src/components/presentation/round-tag.tsx @@ -0,0 +1,20 @@ +import { CSSProperties, ReactNode, HTMLAttributes } from "react"; + +interface RoundTagProps extends HTMLAttributes { } + +export default function RoundTag({ + className, + style, + children, + ...props +}: RoundTagProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/components/presentation/rounded-rectangle-tag.tsx b/apps/web/src/components/presentation/rounded-rectangle-tag.tsx new file mode 100755 index 0000000..af76af4 --- /dev/null +++ b/apps/web/src/components/presentation/rounded-rectangle-tag.tsx @@ -0,0 +1,19 @@ +import { CSSProperties, ReactNode, HTMLAttributes } from "react"; + +interface RoundedRectangleTagProps extends HTMLAttributes {} + +export default function RoundedRectangleTag({ + className, + style, + children, + ...props +}: RoundedRectangleTagProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/components/presentation/user/Avatar.tsx b/apps/web/src/components/presentation/user/Avatar.tsx new file mode 100755 index 0000000..0693169 --- /dev/null +++ b/apps/web/src/components/presentation/user/Avatar.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +interface AvatarProps { + src?: string; + name?: string; + size?: number; + className?: string; +} + +export function Avatar({ src, name = '', size = 40, className = '' }: AvatarProps) { + const initials = useMemo(() => { + return name + .split(/\s+|(?=[A-Z])/) + .map(word => word[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + }, [name]); + + const backgroundColor = useMemo(() => { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = hash % 360; + return `hsl(${hue}, 70%, 50%)`; + }, [name]); + + return ( +
+ {src ? ( + {name} + ) : ( +
+ {initials} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Brightness.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Brightness.tsx new file mode 100755 index 0000000..2dcaf4d --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Brightness.tsx @@ -0,0 +1,32 @@ +import { SunIcon } from "@heroicons/react/24/solid"; +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; + +export default function Brightness() { + const { brightness, setBrightness } = useContext(VideoPlayerContext); + return ( + <> + {/* 亮度控制 */} +
+ +
+
+ + setBrightness(parseFloat(e.target.value)) + } + className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]" + /> +
+
+
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/FullScreen.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/FullScreen.tsx new file mode 100755 index 0000000..b48b3e5 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/FullScreen.tsx @@ -0,0 +1,29 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { + ArrowsPointingInIcon, + ArrowsPointingOutIcon, +} from "@heroicons/react/24/solid"; + +export default function FullScreen() { + const { videoRef } = useContext(VideoPlayerContext); + return ( + <> + + + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Play.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Play.tsx new file mode 100755 index 0000000..fdff076 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Play.tsx @@ -0,0 +1,25 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { PauseIcon, PlayIcon } from "@heroicons/react/24/solid"; + +export default function Play() { + const { isPlaying, videoRef } = useContext(VideoPlayerContext); + + return ( + <> + + + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Setting.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Setting.tsx new file mode 100755 index 0000000..2ece400 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Setting.tsx @@ -0,0 +1,62 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { AnimatePresence, motion } from "framer-motion"; +import { Cog6ToothIcon } from "@heroicons/react/24/solid"; + +export default function Setting() { + const { + isSettingsOpen, + setIsSettingsOpen, + resolution, + setResolution, + resolutions, + } = useContext(VideoPlayerContext); + + return ( + <> +
+ + + + {isSettingsOpen && ( + + {/* 清晰度选择器 */} +
+
+ 清晰度 +
+ {resolutions.map((res) => ( + + ))} +
+
+ )} +
+
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Speed.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Speed.tsx new file mode 100755 index 0000000..52efb5d --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Speed.tsx @@ -0,0 +1,59 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { ChevronUpDownIcon } from "@heroicons/react/24/solid"; +import { PlaybackSpeed } from "../type"; + +export default function Speed() { + const { + setIsSpeedOpen, + isSpeedOpen, + playbackSpeed, + setPlaybackSpeed, + videoRef, + } = useContext(VideoPlayerContext); + return ( + <> +
+ + {isSpeedOpen && ( +
+
+
+ {[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map( + (speed) => ( + + ) + )} +
+
+
+ )} +
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/TimeLine.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/TimeLine.tsx new file mode 100755 index 0000000..f07c31a --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/TimeLine.tsx @@ -0,0 +1,64 @@ +import React, { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { motion } from "framer-motion"; + +export default function TimeLine() { + const { + currentTime, + duration, + progressRef, + setIsDragging, + videoRef, + isDragging, + isHovering, + } = useContext(VideoPlayerContext); + const handleProgressClick = (e: React.MouseEvent) => { + if (!videoRef.current || !progressRef.current) return; + + const rect = progressRef.current.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + videoRef.current.currentTime = percent * videoRef.current.duration; + }; + return ( + <> +
{ + setIsDragging(true); + handleProgressClick(e); + }}> + {/* 背景条 */} +
+ {/* 播放进度 */} + + {/* 进度球 */} + + {/* 预览进度 */} + +
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/Volume.tsx b/apps/web/src/components/presentation/video-player/ControlButtons/Volume.tsx new file mode 100755 index 0000000..bb25b68 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/Volume.tsx @@ -0,0 +1,43 @@ +import { useContext } from "react"; +import { VideoPlayerContext } from "../VideoPlayer"; +import { SpeakerWaveIcon, SpeakerXMarkIcon } from "@heroicons/react/24/solid"; + +export default function Volume() { + const { isMuted, setIsMuted, volume, setVolume, videoRef } = + useContext(VideoPlayerContext); + return ( + <> + {/* 音量控制 */} +
+ +
+
+ { + const newVolume = parseFloat(e.target.value); + setVolume(newVolume); + if (videoRef.current) { + videoRef.current.volume = newVolume; + } + }} + className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]" + /> +
+
+
+ + ); +} diff --git a/apps/web/src/components/presentation/video-player/ControlButtons/index.ts b/apps/web/src/components/presentation/video-player/ControlButtons/index.ts new file mode 100755 index 0000000..35cbbf0 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/ControlButtons/index.ts @@ -0,0 +1,4 @@ +export * from "./Brightness"; +export * from "./Volume"; +export * from "./Speed"; +export * from "./Play"; diff --git a/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx b/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx new file mode 100755 index 0000000..ddbc6e7 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/LoadingOverlay.tsx @@ -0,0 +1,33 @@ +import React, { useContext, useEffect, useRef, useState } from "react"; +import Hls from "hls.js"; +import { motion, AnimatePresence } from "framer-motion"; +import { VideoPlayerContext } from "./VideoPlayer"; +export const LoadingOverlay = () => { + const { loadingProgress } = useContext(VideoPlayerContext); + return ( + +
+ +

+ {loadingProgress > 0 + ? `加载中... ${loadingProgress}%` + : "准备中..."} +

+
+
+ ); +}; +export default LoadingOverlay; diff --git a/apps/web/src/components/presentation/video-player/VideoControls.tsx b/apps/web/src/components/presentation/video-player/VideoControls.tsx new file mode 100755 index 0000000..93fc540 --- /dev/null +++ b/apps/web/src/components/presentation/video-player/VideoControls.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useRef, useContext, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + PlayIcon, + PauseIcon, + SpeakerWaveIcon, + SpeakerXMarkIcon, + Cog6ToothIcon, + ArrowsPointingOutIcon, + ArrowsPointingInIcon, + ChevronUpDownIcon, + SunIcon, +} from "@heroicons/react/24/solid"; +import { VideoPlayerContext } from "./VideoPlayer"; +import { formatTime } from "./utlis"; +import { PlaybackSpeed } from "./type"; +import Volume from "./ControlButtons/Volume"; +import Brightness from "./ControlButtons/Brightness"; +import Speed from "./ControlButtons/Speed"; +import Play from "./ControlButtons/Play"; +import Setting from "./ControlButtons/Setting"; +import FullScreen from "./ControlButtons/FullScreen"; +import TimeLine from "./ControlButtons/TimeLine"; + +export const Controls = () => { + const { + showControls, + setShowControls, + isSettingsOpen, + setIsSettingsOpen, + playbackSpeed, + setPlaybackSpeed, + videoRef, + isReady, + setIsReady, + isPlaying, + setIsSpeedOpen, + isSpeedOpen, + setIsPlaying, + + bufferingState, + setBufferingState, + volume, + setVolume, + isMuted, + setIsMuted, + loadingProgress, + setLoadingProgress, + currentTime, + setCurrentTime, + duration, + setDuration, + brightness, + setBrightness, + isDragging, + setIsDragging, + isHovering, + isBrightnessOpen, + setIsBrightnessOpen, + setIsHovering, + progressRef, + } = useContext(VideoPlayerContext); + + // 控制栏显示逻辑 + useEffect(() => { + let timer: number; + + if (!isHovering && !isDragging) { + timer = window.setTimeout(() => { + setShowControls(false); + }, 2000); + } + + return () => { + if (timer) window.clearTimeout(timer); + }; + }, [isHovering, isDragging]); + return ( + + {/* 进度条 */} + + + {/* 控制按钮区域 */} +
+
+ {/* 播放/暂停按钮 */} + + + {/* 时间显示 */} + {duration && ( + + {formatTime(currentTime)} / {formatTime(duration)} + + )} +
+ {/* 右侧控制按钮 */} +
+ {/* 音量 */} + + {/* 亮度 */} + + + {/* 倍速控制 */} + + {/* 设置按钮 */} + + + {/* 全屏按钮 */} + +
+
+
+ ); +}; +export default Controls; diff --git a/apps/web/src/components/presentation/video-player/VideoDisplay.tsx b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx new file mode 100755 index 0000000..fb1b40d --- /dev/null +++ b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx @@ -0,0 +1,222 @@ +import React, { useContext, useEffect, useRef, useState } from "react"; +import Hls from "hls.js"; +import { VideoPlayerContext } from "./VideoPlayer"; + +interface VideoDisplayProps { + autoPlay?: boolean; +} +export const VideoDisplay: React.FC = ({ + autoPlay = false, +}) => { + const { + src, + poster, + onError, + videoRef, + setIsReady, + setIsPlaying, + setError, + setBufferingState, + isMuted, + setLoadingProgress, + setCurrentTime, + setDuration, + brightness, + isDragging, + setIsDragging, + progressRef, + resolution, + setResolutions, + } = useContext(VideoPlayerContext); + + // 处理进度条拖拽 + const handleProgressDrag = (e: MouseEvent) => { + if (!isDragging || !videoRef.current || !progressRef.current) return; + const rect = progressRef.current.getBoundingClientRect(); + const percent = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width) + ); + videoRef.current.currentTime = percent * videoRef.current.duration; + }; + + // 添加拖拽事件监听 + useEffect(() => { + const handleMouseUp = () => setIsDragging(false); + const handleMouseMove = (e: MouseEvent) => handleProgressDrag(e); + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + } + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging]); + + // 初始化 HLS 和事件监听 + useEffect(() => { + let hls: Hls; + const initializeHls = async () => { + if (!videoRef.current) return; + + // Reset states + setIsReady(false); + setError(null); + setLoadingProgress(0); + setBufferingState(false); + + // Check for native HLS support (Safari) + if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) { + videoRef.current.src = src; + setIsReady(true); + + // 设置视频时长 + setDuration(videoRef.current.duration); + + if (autoPlay) { + try { + await videoRef.current.play(); + setIsPlaying(true); + } catch (error) { + console.log("Auto-play prevented:", error); + } + } + return; + } + + if (!Hls.isSupported()) { + const errorMessage = "您的浏览器不支持 HLS 视频播放"; + setError(errorMessage); + onError?.(errorMessage); + return; + } + + hls = new Hls({ + maxBufferLength: 30, + maxMaxBufferLength: 600, + enableWorker: true, + debug: false, + }); + + hls.loadSource(src); + hls.attachMedia(videoRef.current); + + hls.on(Hls.Events.MANIFEST_PARSED, async () => { + setIsReady(true); + + // 设置视频时长 + setDuration(videoRef.current?.duration || 0); + + if (autoPlay && videoRef.current) { + try { + await videoRef.current.play(); + setIsPlaying(true); + } catch (error) { + console.log("Auto-play prevented:", error); + } + } + }); + + hls.on(Hls.Events.BUFFER_APPENDING, () => { + setBufferingState(true); + }); + hls.on(Hls.Events.FRAG_BUFFERED, (_, data) => { + setBufferingState(false); + if (data.stats) { + const progress = + (data.stats.loaded / data.stats.total) * 100; + setLoadingProgress(Math.round(progress)); + } + }); + let fatalError; + let networkError; + hls.on(Hls.Events.ERROR, (_, data) => { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + networkError = `网络错误: ${data.details}`; + console.error(networkError); + setError(networkError); + onError?.(networkError); + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + console.error("Media error, attempting to recover"); + setError("视频解码错误,尝试恢复..."); + hls.recoverMediaError(); + break; + default: + fatalError = `加载失败: ${data.details}`; + console.error(fatalError); + setError(fatalError); + onError?.(fatalError); + hls.destroy(); + break; + } + } + }); + }; + + // 事件处理 + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + const handleEnded = () => setIsPlaying(false); + const handleWaiting = () => setBufferingState(true); + const handlePlaying = () => setBufferingState(false); + const handleLoadedMetadata = () => { + if (videoRef.current) { + // 设置视频时长 + setDuration(videoRef.current.duration); + } + }; + + if (videoRef.current) { + videoRef.current.addEventListener("play", handlePlay); + videoRef.current.addEventListener("pause", handlePause); + videoRef.current.addEventListener("ended", handleEnded); + videoRef.current.addEventListener("waiting", handleWaiting); + videoRef.current.addEventListener("playing", handlePlaying); + videoRef.current.addEventListener( + "loadedmetadata", + handleLoadedMetadata + ); + } + + initializeHls(); + + return () => { + if (videoRef.current) { + videoRef.current.removeEventListener("play", handlePlay); + videoRef.current.removeEventListener("pause", handlePause); + videoRef.current.removeEventListener("ended", handleEnded); + videoRef.current.removeEventListener("waiting", handleWaiting); + videoRef.current.removeEventListener("playing", handlePlaying); + videoRef.current.removeEventListener( + "loadedmetadata", + handleLoadedMetadata + ); + } + if (hls) { + hls.destroy(); + } + }; + }, [src, onError, autoPlay]); + + return ( +