From c8dfb4db25432519b057cb8fc40f8fa8df336ed6 Mon Sep 17 00:00:00 2001 From: longdayi <13477510+longdayilongdayi@user.noreply.gitee.com> Date: Mon, 30 Dec 2024 08:26:40 +0800 Subject: [PATCH] 12300826 --- .continue/prompts/comment.prompt | 45 ++ .continue/prompts/error-handler.prompt | 45 ++ .continue/prompts/explain.prompt | 30 + .continue/prompts/react-refact.prompt | 39 + .continue/prompts/refact.prompt | 52 ++ apps/server/package.json | 27 +- apps/server/src/app.module.ts | 52 +- apps/server/src/auth/auth.controller.ts | 67 +- apps/server/src/auth/auth.module.ts | 14 +- apps/server/src/auth/auth.router.ts | 20 + apps/server/src/auth/auth.service.ts | 259 +++--- apps/server/src/auth/config.ts | 9 + apps/server/src/auth/session.service.ts | 61 ++ apps/server/src/auth/types.ts | 9 + apps/server/src/auth/utils.ts | 187 +++++ apps/server/src/env.ts | 5 +- apps/server/src/filters/exceptions.filter.ts | 25 + apps/server/src/init/init.module.ts | 11 - apps/server/src/main.ts | 15 +- .../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 | 562 +++++++++++++ .../src/models/base/base.tree.service.ts | 389 +++++++++ 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 | 240 ++++++ 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 | 7 +- .../models/department/department.router.ts | 83 +- .../department/department.row.service.ts | 91 +++ .../models/department/department.service.ts | 590 +++++++------- apps/server/src/models/department/utils.ts | 68 ++ .../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 | 89 ++ apps/server/src/models/post/post.module.ts | 18 + apps/server/src/models/post/post.router.ts | 77 ++ apps/server/src/models/post/post.service.ts | 146 ++++ apps/server/src/models/post/utils.ts | 44 + .../src/{ => models}/rbac/rbac.module.ts | 10 +- apps/server/src/models/rbac/role.router.ts | 44 + apps/server/src/models/rbac/role.service.ts | 180 +++++ apps/server/src/models/rbac/rolemap.router.ts | 72 ++ .../server/src/models/rbac/rolemap.service.ts | 317 ++++++++ .../src/models/staff/staff.controller.ts | 48 ++ apps/server/src/models/staff/staff.module.ts | 12 +- apps/server/src/models/staff/staff.router.ts | 65 +- .../src/models/staff/staff.row.service.ts | 135 ++++ .../src/models/staff/staff.service.spec.ts | 18 - apps/server/src/models/staff/staff.service.ts | 336 ++++---- .../models/taxonomy/taxonomy.controller.ts | 28 + .../src/models/taxonomy/taxonomy.module.ts | 10 +- .../src/models/taxonomy/taxonomy.router.ts | 81 +- .../src/models/taxonomy/taxonomy.service.ts | 85 +- .../server/src/models/term/term.controller.ts | 28 + apps/server/src/models/term/term.module.ts | 13 +- apps/server/src/models/term/term.router.ts | 124 +-- .../src/models/term/term.row.service.ts | 91 +++ apps/server/src/models/term/term.service.ts | 760 ++++++++++-------- apps/server/src/models/term/utils.ts | 24 + .../transform/transform.module.ts | 24 +- .../transform/transform.router.ts | 17 +- .../src/models/transform/transform.service.ts | 551 +++++++++++++ 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 | 89 ++ .../queue/general/general-queue.listener.ts | 32 - .../queue/general/general-queue.service.ts | 23 - .../src/queue/general/general.service.ts | 17 - apps/server/src/queue/job.interface.ts | 4 + .../src/queue/push/push.queue.service.ts | 52 ++ apps/server/src/queue/push/push.service.ts | 124 +++ apps/server/src/queue/queue.module.ts | 39 +- apps/server/src/queue/worker/processor.ts | 15 +- apps/server/src/rbac/role.router.ts | 37 - apps/server/src/rbac/role.service.ts | 134 --- apps/server/src/rbac/rolemap.router.ts | 53 -- apps/server/src/rbac/rolemap.service.ts | 215 ----- apps/server/src/rbac/roleperms.service.ts | 189 ----- apps/server/src/redis/redis.module.ts | 11 - apps/server/src/redis/redis.service.ts | 73 -- apps/server/src/relation/relation.service.ts | 85 -- .../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 | 31 + apps/server/src/socket/socket.gateway.ts | 28 - 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 | 234 ++++++ apps/server/src/tasks/init/init.module.ts | 16 + .../src/{ => tasks}/init/init.service.ts | 93 ++- apps/server/src/tasks/init/utils.ts | 102 +++ .../src/tasks/reminder/reminder.module.ts | 10 + .../src/tasks/reminder/reminder.service.ts | 80 ++ apps/server/src/tasks/tasks.module.ts | 5 +- .../tasks.service.spec.ts} | 10 +- apps/server/src/tasks/tasks.service.ts | 41 +- .../server/src/transform/transform.service.ts | 496 ------------ apps/server/src/trpc/trpc.module.ts | 38 +- apps/server/src/trpc/trpc.router.ts | 100 ++- apps/server/src/trpc/trpc.service.ts | 60 +- apps/server/src/utils/event-bus.ts | 16 + .../src/{ => utils}/minio/minio.module.ts | 0 .../src/{ => utils}/minio/minio.service.ts | 4 +- 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/src/utils/tusd.ts | 76 -- apps/server/tsconfig.json | 17 +- apps/web/.env.example | 0 apps/web/.eslintrc.cjs | 18 - apps/web/README.md | 42 +- apps/web/entrypoint.sh | 14 + apps/web/eslint.config.js | 28 + apps/web/index.html | 25 +- apps/web/nginx.conf | 0 apps/web/package.json | 79 +- apps/web/postcss.config.js | 0 apps/web/public/params.json | 2 + apps/web/public/vite.svg | 0 apps/web/src/App.css | 42 + apps/web/src/App.tsx | 45 +- apps/web/src/app/admin/base-setting/page.tsx | 189 +++++ apps/web/src/app/admin/department/page.tsx | 6 +- apps/web/src/app/admin/role/page.tsx | 33 +- apps/web/src/app/admin/staff/page.tsx | 14 +- apps/web/src/app/admin/term/page.tsx | 18 +- apps/web/src/app/denied.tsx | 7 + apps/web/src/app/error.tsx | 10 +- apps/web/src/app/layout.tsx | 118 ++- apps/web/src/app/login.tsx | 408 ++++++---- apps/web/src/app/main/page.tsx | 7 +- apps/web/src/assets/react.svg | 0 .../src/components/animation/sine-wave.tsx | 122 +++ .../src/components/button/excel-importer.tsx | 145 ++++ apps/web/src/components/layout/breadcrumb.tsx | 38 + apps/web/src/components/layout/fix-header.tsx | 176 ++++ .../components/layout/resizable-sidebar.tsx | 95 +++ .../src/components/layout/sidebar-content.tsx | 90 +++ .../web/src/components/layout/user-header.tsx | 50 ++ .../models/department/department-drawer.tsx | 40 - .../models/department/department-form.tsx | 192 +++-- .../department/department-import-drawer.tsx | 2 +- .../models/department/department-list.tsx | 299 +++---- .../models/department/department-select.tsx | 248 +++--- .../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/domain/domain-select.tsx | 53 -- .../components/models/role/role-drawer.tsx | 39 - .../models/role/role-editor/assign-list.tsx | 118 +++ .../models/role/role-editor/role-editor.tsx | 82 ++ .../models/role/role-editor/role-form.tsx | 64 ++ .../models/role/role-editor/role-list.tsx | 91 +++ .../models/role/role-editor/role-modal.tsx | 31 + .../role/role-editor/role-staff-modal.tsx | 64 ++ .../src/components/models/role/role-form.tsx | 64 -- .../src/components/models/role/role-list.tsx | 101 --- .../components/models/role/role-map-table.tsx | 256 ------ .../components/models/role/role-select.tsx | 3 +- .../components/models/role/rolemap-drawer.tsx | 41 - .../components/models/role/rolemap-form.tsx | 104 --- .../components/models/staff/staff-drawer.tsx | 41 - .../components/models/staff/staff-editor.tsx | 71 ++ .../components/models/staff/staff-form.tsx | 267 +++--- .../models/staff/staff-import-drawer.tsx | 73 -- .../components/models/staff/staff-list.tsx | 195 +++++ .../components/models/staff/staff-modal.tsx | 27 + .../components/models/staff/staff-select.tsx | 113 ++- .../components/models/staff/staff-table.tsx | 242 ------ .../models/staff/staff-transfer.tsx | 79 ++ .../models/taxonomy/taxonomy-drawer.tsx | 41 - .../models/taxonomy/taxonomy-form.tsx | 32 - .../models/taxonomy/taxonomy-select.tsx | 102 +-- .../models/taxonomy/taxonomy-table.tsx | 175 ---- .../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-drawer.tsx | 43 - .../components/models/term/term-editor.tsx | 94 +++ .../src/components/models/term/term-form.tsx | 179 ++--- .../models/term/term-import-drawer.tsx | 109 --- .../models/term/term-import-form.tsx | 87 ++ .../models/term/term-import-modal.tsx | 25 + .../src/components/models/term/term-list.tsx | 531 ++++++------ .../src/components/models/term/term-modal.tsx | 28 + .../components/models/term/term-select.tsx | 223 +++-- .../models/term/term-select_BACKUP.tsx | 92 +++ apps/web/src/components/models/term/util.ts | 15 + .../presentation/ag-server-table.tsx | 494 ++++++++++++ .../presentation/animate-progress.tsx | 28 + .../presentation/animation/sine-wave.tsx | 110 --- .../presentation/collapse-section.tsx | 134 +++ .../presentation/dashboard-card.tsx | 62 ++ .../src/components/presentation/dialog.tsx | 229 ++++++ .../components/presentation/dropdown-menu.tsx | 293 +++++++ .../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 + apps/web/src/components/svg/rounded-clip.tsx | 28 + .../src/components/utilities/with-auth.tsx | 30 - .../{utilities => utils}/excel-importer.tsx | 10 +- .../src/components/utils/image-uploader.tsx | 111 +++ apps/web/src/components/utils/with-auth.tsx | 62 ++ apps/web/src/env.ts | 22 +- apps/web/src/hooks/useDepartment.ts | 119 --- apps/web/src/hooks/useLocalSetting.ts | 17 + apps/web/src/hooks/useRoleMap.ts | 37 - apps/web/src/hooks/useStaff.ts | 31 - apps/web/src/hooks/useTerm.ts | 101 --- apps/web/src/hooks/useTransform.ts | 29 - apps/web/src/index.css | 109 ++- apps/web/src/io/index.ts | 1 + apps/web/src/io/tusd.ts | 96 +++ apps/web/src/locale/ag-grid-locale.ts | 563 +++++++++++++ apps/web/src/main.tsx | 31 +- apps/web/src/providers/auth-provider.tsx | 404 ++++++---- apps/web/src/providers/params-provider.tsx | 45 ++ apps/web/src/providers/query-provider.tsx | 123 ++- apps/web/src/providers/theme-provider.tsx | 160 +++- apps/web/src/routes/index.tsx | 230 ++++-- apps/web/src/utils/axios-client.ts | 4 +- apps/web/src/utils/idb.ts | 0 apps/web/src/utils/tusd.ts | 95 --- apps/web/src/vite-env.d.ts | 0 apps/web/tailwind.config.js | 32 +- apps/web/tsconfig.app.json | 60 +- apps/web/tsconfig.json | 10 +- apps/web/tsconfig.node.json | 2 + apps/web/vite.config.ts | 7 +- config/backup.sh | 26 + config/redis.conf | 94 +++ packages/client/package.json | 39 + packages/client/src/api/hooks/index.ts | 12 + packages/client/src/api/hooks/useAppConfig.ts | 49 ++ .../client/src/api/hooks/useDepartment.ts | 92 +++ packages/client/src/api/hooks/useMessage.ts | 18 + packages/client/src/api/hooks/usePost.ts | 37 + packages/client/src/api/hooks/useQueryApi.ts | 27 + .../client/src/api}/hooks/useRole.ts | 6 +- packages/client/src/api/hooks/useRoleMap.ts | 47 ++ packages/client/src/api/hooks/useStaff.ts | 43 + .../client/src/api}/hooks/useTaxonomy.ts | 6 +- packages/client/src/api/hooks/useTerm.ts | 71 ++ packages/client/src/api/hooks/useVisitor.ts | 131 +++ packages/client/src/api/index.ts | 3 + .../utils => packages/client/src/api}/trpc.ts | 2 +- .../client/src/api/utils.ts | 14 +- packages/client/src/event/index.ts | 149 ++++ packages/client/src/hooks/index.ts | 4 + .../client}/src/hooks/useAwaitState.ts | 0 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 | 8 + 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 | 36 + packages/client/src/tools/index.ts | 3 + packages/client/src/tools/level.ts | 136 ++++ packages/client/src/tools/number.ts | 76 ++ packages/client/src/tools/objects.ts | 21 + 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 | 28 + packages/client/tsup.config.ts | 10 + packages/common/.env.example | 2 +- packages/common/package.json | 38 +- packages/common/prisma/schema.prisma | 356 +++++--- 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 | 134 +-- packages/common/src/db.ts | 14 +- packages/common/src/enum.ts | 226 +++++- packages/common/src/index.ts | 12 +- packages/common/src/schema.ts | 658 +++++++++------ packages/common/src/select.ts | 69 ++ packages/common/src/type.ts | 52 -- packages/common/src/types.ts | 180 +++++ packages/common/src/utils.ts | 395 +++++++-- packages/common/tsconfig.cjs.json | 21 - packages/common/tsconfig.esm.json | 22 - packages/common/tsconfig.json | 39 + packages/common/tsup.config.ts | 10 + tsconfig.json => tsconfig.base.json | 8 +- 314 files changed, 20634 insertions(+), 7190 deletions(-) create mode 100644 .continue/prompts/comment.prompt create mode 100644 .continue/prompts/error-handler.prompt create mode 100644 .continue/prompts/explain.prompt create mode 100644 .continue/prompts/react-refact.prompt create mode 100644 .continue/prompts/refact.prompt mode change 100644 => 100755 apps/server/src/app.module.ts mode change 100644 => 100755 apps/server/src/auth/auth.controller.ts mode change 100644 => 100755 apps/server/src/auth/auth.module.ts create mode 100755 apps/server/src/auth/auth.router.ts mode change 100644 => 100755 apps/server/src/auth/auth.service.ts create mode 100644 apps/server/src/auth/config.ts create mode 100644 apps/server/src/auth/session.service.ts create mode 100644 apps/server/src/auth/types.ts create mode 100644 apps/server/src/auth/utils.ts create mode 100644 apps/server/src/filters/exceptions.filter.ts delete mode 100644 apps/server/src/init/init.module.ts mode change 100644 => 100755 apps/server/src/main.ts create mode 100644 apps/server/src/models/app-config/app-config.module.ts create mode 100644 apps/server/src/models/app-config/app-config.router.ts create mode 100644 apps/server/src/models/app-config/app-config.service.ts create mode 100644 apps/server/src/models/base/base.service.ts create mode 100644 apps/server/src/models/base/base.tree.service.ts create mode 100644 apps/server/src/models/base/base.type.ts create mode 100644 apps/server/src/models/base/errorMap.prisma.ts create mode 100644 apps/server/src/models/base/row-cache.service.ts create mode 100644 apps/server/src/models/base/row-model.service.ts create mode 100644 apps/server/src/models/base/sql-builder.ts create mode 100644 apps/server/src/models/base/test.sql create mode 100755 apps/server/src/models/department/department.controller.ts create mode 100644 apps/server/src/models/department/department.row.service.ts create mode 100644 apps/server/src/models/department/utils.ts create mode 100755 apps/server/src/models/message/message.controller.ts create mode 100644 apps/server/src/models/message/message.module.ts create mode 100755 apps/server/src/models/message/message.router.ts create mode 100644 apps/server/src/models/message/message.service.ts create mode 100644 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 100644 apps/server/src/models/post/utils.ts rename apps/server/src/{ => models}/rbac/rbac.module.ts (59%) mode change 100644 => 100755 create mode 100755 apps/server/src/models/rbac/role.router.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/staff/staff.controller.ts mode change 100644 => 100755 apps/server/src/models/staff/staff.module.ts create mode 100644 apps/server/src/models/staff/staff.row.service.ts delete mode 100644 apps/server/src/models/staff/staff.service.spec.ts mode change 100644 => 100755 apps/server/src/models/staff/staff.service.ts create mode 100755 apps/server/src/models/taxonomy/taxonomy.controller.ts mode change 100644 => 100755 apps/server/src/models/taxonomy/taxonomy.module.ts mode change 100644 => 100755 apps/server/src/models/taxonomy/taxonomy.router.ts create mode 100755 apps/server/src/models/term/term.controller.ts create mode 100644 apps/server/src/models/term/term.row.service.ts create mode 100644 apps/server/src/models/term/utils.ts rename apps/server/src/{ => models}/transform/transform.module.ts (51%) mode change 100644 => 100755 rename apps/server/src/{ => models}/transform/transform.router.ts (68%) mode change 100644 => 100755 create mode 100755 apps/server/src/models/transform/transform.service.ts create mode 100644 apps/server/src/models/visit/visit.module.ts create mode 100644 apps/server/src/models/visit/visit.router.ts create mode 100644 apps/server/src/models/visit/visit.service.ts delete mode 100644 apps/server/src/queue/general/general-queue.listener.ts delete mode 100644 apps/server/src/queue/general/general-queue.service.ts delete mode 100644 apps/server/src/queue/general/general.service.ts create mode 100755 apps/server/src/queue/job.interface.ts create mode 100755 apps/server/src/queue/push/push.queue.service.ts create mode 100755 apps/server/src/queue/push/push.service.ts mode change 100644 => 100755 apps/server/src/queue/queue.module.ts mode change 100644 => 100755 apps/server/src/queue/worker/processor.ts delete mode 100644 apps/server/src/rbac/role.router.ts delete mode 100644 apps/server/src/rbac/role.service.ts delete mode 100644 apps/server/src/rbac/rolemap.router.ts delete mode 100644 apps/server/src/rbac/rolemap.service.ts delete mode 100755 apps/server/src/rbac/roleperms.service.ts delete mode 100644 apps/server/src/redis/redis.module.ts delete mode 100644 apps/server/src/redis/redis.service.ts delete mode 100644 apps/server/src/relation/relation.service.ts create mode 100644 apps/server/src/socket/base/base-websocket-server.ts create mode 100644 apps/server/src/socket/collaboration/callback.ts create mode 100644 apps/server/src/socket/collaboration/collaboration.module.ts create mode 100644 apps/server/src/socket/collaboration/persistence.ts create mode 100644 apps/server/src/socket/collaboration/types.ts create mode 100644 apps/server/src/socket/collaboration/ws-shared-doc.ts create mode 100644 apps/server/src/socket/collaboration/yjs.server.ts create mode 100644 apps/server/src/socket/realtime/realtime.module.ts create mode 100644 apps/server/src/socket/realtime/realtime.server.ts delete mode 100644 apps/server/src/socket/socket.gateway.ts create mode 100644 apps/server/src/socket/types.ts create mode 100644 apps/server/src/socket/websocket.module.ts create mode 100644 apps/server/src/socket/websocket.service.ts create mode 100644 apps/server/src/tasks/init/gendev.service.ts create mode 100755 apps/server/src/tasks/init/init.module.ts rename apps/server/src/{ => tasks}/init/init.service.ts (51%) mode change 100644 => 100755 create mode 100644 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 mode change 100644 => 100755 apps/server/src/tasks/tasks.module.ts rename apps/server/src/{minio/minio.service.spec.ts => tasks/tasks.service.spec.ts} (56%) mode change 100644 => 100755 mode change 100644 => 100755 apps/server/src/tasks/tasks.service.ts delete mode 100644 apps/server/src/transform/transform.service.ts mode change 100644 => 100755 apps/server/src/trpc/trpc.module.ts mode change 100644 => 100755 apps/server/src/trpc/trpc.router.ts mode change 100644 => 100755 apps/server/src/trpc/trpc.service.ts create mode 100644 apps/server/src/utils/event-bus.ts rename apps/server/src/{ => utils}/minio/minio.module.ts (100%) rename apps/server/src/{ => utils}/minio/minio.service.ts (87%) create mode 100644 apps/server/src/utils/redis/redis.service.ts create mode 100644 apps/server/src/utils/redis/utils.ts create mode 100755 apps/server/src/utils/tool.ts delete mode 100644 apps/server/src/utils/tusd.ts mode change 100644 => 100755 apps/server/tsconfig.json mode change 100644 => 100755 apps/web/.env.example delete mode 100644 apps/web/.eslintrc.cjs mode change 100644 => 100755 apps/web/README.md create mode 100755 apps/web/entrypoint.sh create mode 100755 apps/web/eslint.config.js mode change 100644 => 100755 apps/web/index.html mode change 100644 => 100755 apps/web/nginx.conf mode change 100644 => 100755 apps/web/package.json mode change 100644 => 100755 apps/web/postcss.config.js create mode 100755 apps/web/public/params.json mode change 100644 => 100755 apps/web/public/vite.svg mode change 100644 => 100755 apps/web/src/App.css mode change 100644 => 100755 apps/web/src/App.tsx create mode 100644 apps/web/src/app/admin/base-setting/page.tsx mode change 100644 => 100755 apps/web/src/app/admin/department/page.tsx mode change 100644 => 100755 apps/web/src/app/admin/role/page.tsx mode change 100644 => 100755 apps/web/src/app/admin/staff/page.tsx mode change 100644 => 100755 apps/web/src/app/admin/term/page.tsx create mode 100644 apps/web/src/app/denied.tsx mode change 100644 => 100755 apps/web/src/app/error.tsx mode change 100644 => 100755 apps/web/src/app/main/page.tsx mode change 100644 => 100755 apps/web/src/assets/react.svg create mode 100644 apps/web/src/components/animation/sine-wave.tsx create mode 100755 apps/web/src/components/button/excel-importer.tsx create mode 100644 apps/web/src/components/layout/breadcrumb.tsx create mode 100644 apps/web/src/components/layout/fix-header.tsx create mode 100644 apps/web/src/components/layout/resizable-sidebar.tsx create mode 100644 apps/web/src/components/layout/sidebar-content.tsx create mode 100644 apps/web/src/components/layout/user-header.tsx delete mode 100644 apps/web/src/components/models/department/department-drawer.tsx create mode 100644 apps/web/src/components/models/department/dept-editor.tsx create mode 100644 apps/web/src/components/models/department/dept-import-form.tsx create mode 100644 apps/web/src/components/models/department/dept-import-modal.tsx create mode 100644 apps/web/src/components/models/department/dept-modal.tsx delete mode 100644 apps/web/src/components/models/domain/domain-select.tsx delete mode 100644 apps/web/src/components/models/role/role-drawer.tsx create mode 100644 apps/web/src/components/models/role/role-editor/assign-list.tsx create mode 100644 apps/web/src/components/models/role/role-editor/role-editor.tsx create mode 100644 apps/web/src/components/models/role/role-editor/role-form.tsx create mode 100644 apps/web/src/components/models/role/role-editor/role-list.tsx create mode 100644 apps/web/src/components/models/role/role-editor/role-modal.tsx create mode 100644 apps/web/src/components/models/role/role-editor/role-staff-modal.tsx delete mode 100644 apps/web/src/components/models/role/role-form.tsx delete mode 100644 apps/web/src/components/models/role/role-list.tsx delete mode 100644 apps/web/src/components/models/role/role-map-table.tsx delete mode 100644 apps/web/src/components/models/role/rolemap-drawer.tsx delete mode 100644 apps/web/src/components/models/role/rolemap-form.tsx delete mode 100644 apps/web/src/components/models/staff/staff-drawer.tsx create mode 100644 apps/web/src/components/models/staff/staff-editor.tsx delete mode 100644 apps/web/src/components/models/staff/staff-import-drawer.tsx create mode 100644 apps/web/src/components/models/staff/staff-list.tsx create mode 100644 apps/web/src/components/models/staff/staff-modal.tsx delete mode 100644 apps/web/src/components/models/staff/staff-table.tsx create mode 100644 apps/web/src/components/models/staff/staff-transfer.tsx delete mode 100644 apps/web/src/components/models/taxonomy/taxonomy-drawer.tsx delete mode 100644 apps/web/src/components/models/taxonomy/taxonomy-form.tsx delete mode 100644 apps/web/src/components/models/taxonomy/taxonomy-table.tsx create mode 100644 apps/web/src/components/models/term/taxonomy-form.tsx create mode 100644 apps/web/src/components/models/term/taxonomy-list.tsx create mode 100644 apps/web/src/components/models/term/taxonomy-modal.tsx delete mode 100644 apps/web/src/components/models/term/term-drawer.tsx create mode 100644 apps/web/src/components/models/term/term-editor.tsx delete mode 100644 apps/web/src/components/models/term/term-import-drawer.tsx create mode 100644 apps/web/src/components/models/term/term-import-form.tsx create mode 100644 apps/web/src/components/models/term/term-import-modal.tsx create mode 100644 apps/web/src/components/models/term/term-modal.tsx create mode 100644 apps/web/src/components/models/term/term-select_BACKUP.tsx create mode 100644 apps/web/src/components/models/term/util.ts create mode 100644 apps/web/src/components/presentation/ag-server-table.tsx create mode 100644 apps/web/src/components/presentation/animate-progress.tsx delete mode 100644 apps/web/src/components/presentation/animation/sine-wave.tsx create mode 100644 apps/web/src/components/presentation/collapse-section.tsx create mode 100644 apps/web/src/components/presentation/dashboard-card.tsx create mode 100644 apps/web/src/components/presentation/dialog.tsx create mode 100644 apps/web/src/components/presentation/dropdown-menu.tsx create mode 100644 apps/web/src/components/presentation/excel-to-base64-uploader.tsx create mode 100644 apps/web/src/components/presentation/general-dialog.tsx create mode 100644 apps/web/src/components/presentation/id-card.tsx create mode 100644 apps/web/src/components/presentation/nice-img.tsx create mode 100644 apps/web/src/components/presentation/phone-book.tsx create mode 100644 apps/web/src/components/presentation/round-tag.tsx create mode 100644 apps/web/src/components/presentation/rounded-rectangle-tag.tsx create mode 100644 apps/web/src/components/svg/rounded-clip.tsx delete mode 100644 apps/web/src/components/utilities/with-auth.tsx rename apps/web/src/components/{utilities => utils}/excel-importer.tsx (91%) create mode 100644 apps/web/src/components/utils/image-uploader.tsx create mode 100644 apps/web/src/components/utils/with-auth.tsx mode change 100644 => 100755 apps/web/src/env.ts delete mode 100644 apps/web/src/hooks/useDepartment.ts create mode 100644 apps/web/src/hooks/useLocalSetting.ts delete mode 100644 apps/web/src/hooks/useRoleMap.ts delete mode 100644 apps/web/src/hooks/useStaff.ts delete mode 100644 apps/web/src/hooks/useTerm.ts delete mode 100644 apps/web/src/hooks/useTransform.ts mode change 100644 => 100755 apps/web/src/index.css create mode 100644 apps/web/src/io/index.ts create mode 100644 apps/web/src/io/tusd.ts create mode 100644 apps/web/src/locale/ag-grid-locale.ts mode change 100644 => 100755 apps/web/src/main.tsx create mode 100755 apps/web/src/providers/params-provider.tsx mode change 100644 => 100755 apps/web/src/providers/query-provider.tsx mode change 100644 => 100755 apps/web/src/routes/index.tsx mode change 100644 => 100755 apps/web/src/utils/idb.ts delete mode 100644 apps/web/src/utils/tusd.ts mode change 100644 => 100755 apps/web/src/vite-env.d.ts mode change 100644 => 100755 apps/web/tailwind.config.js mode change 100644 => 100755 apps/web/tsconfig.app.json mode change 100644 => 100755 apps/web/tsconfig.json mode change 100644 => 100755 apps/web/tsconfig.node.json mode change 100644 => 100755 apps/web/vite.config.ts create mode 100755 config/backup.sh create mode 100644 config/redis.conf create mode 100644 packages/client/package.json create mode 100644 packages/client/src/api/hooks/index.ts create mode 100644 packages/client/src/api/hooks/useAppConfig.ts create mode 100755 packages/client/src/api/hooks/useDepartment.ts create mode 100644 packages/client/src/api/hooks/useMessage.ts create mode 100644 packages/client/src/api/hooks/usePost.ts create mode 100644 packages/client/src/api/hooks/useQueryApi.ts rename {apps/web/src => packages/client/src/api}/hooks/useRole.ts (85%) mode change 100644 => 100755 create mode 100755 packages/client/src/api/hooks/useRoleMap.ts create mode 100755 packages/client/src/api/hooks/useStaff.ts rename {apps/web/src => packages/client/src/api}/hooks/useTaxonomy.ts (88%) mode change 100644 => 100755 create mode 100755 packages/client/src/api/hooks/useTerm.ts create mode 100644 packages/client/src/api/hooks/useVisitor.ts create mode 100644 packages/client/src/api/index.ts rename {apps/web/src/utils => packages/client/src/api}/trpc.ts (69%) mode change 100644 => 100755 rename apps/web/src/utils/general.ts => packages/client/src/api/utils.ts (88%) mode change 100755 => 100644 create mode 100644 packages/client/src/event/index.ts create mode 100644 packages/client/src/hooks/index.ts rename {apps/web => packages/client}/src/hooks/useAwaitState.ts (100%) create mode 100755 packages/client/src/hooks/useCheckBox.ts create mode 100755 packages/client/src/hooks/useStack.ts create mode 100644 packages/client/src/hooks/useTimeout.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/io/download.ts create mode 100644 packages/client/src/io/index.ts create mode 100644 packages/client/src/presentation/color.ts create mode 100644 packages/client/src/presentation/index.ts create mode 100644 packages/client/src/providers/index.ts create mode 100644 packages/client/src/singleton/DataHolder.ts create mode 100644 packages/client/src/tools/index.ts create mode 100755 packages/client/src/tools/level.ts create mode 100644 packages/client/src/tools/number.ts create mode 100644 packages/client/src/tools/objects.ts create mode 100644 packages/client/src/websocket/client.ts create mode 100644 packages/client/src/websocket/index.ts create mode 100644 packages/client/src/websocket/types.ts create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/tsup.config.ts mode change 100755 => 100644 packages/common/.env.example mode change 100755 => 100644 packages/common/package.json mode change 100755 => 100644 packages/common/prisma/schema.prisma create mode 100644 packages/common/src/collaboration/index.ts create mode 100644 packages/common/src/collaboration/types.ts create mode 100644 packages/common/src/collaboration/utils.ts create mode 100644 packages/common/src/collaboration/y-auth.ts create mode 100644 packages/common/src/collaboration/y-awareness.ts create mode 100644 packages/common/src/collaboration/y-handler.ts create mode 100644 packages/common/src/collaboration/y-socket.ts create mode 100644 packages/common/src/collaboration/y-sync.ts mode change 100644 => 100755 packages/common/src/constants.ts mode change 100644 => 100755 packages/common/src/enum.ts create mode 100644 packages/common/src/select.ts delete mode 100644 packages/common/src/type.ts create mode 100755 packages/common/src/types.ts mode change 100644 => 100755 packages/common/src/utils.ts delete mode 100755 packages/common/tsconfig.cjs.json delete mode 100755 packages/common/tsconfig.esm.json create mode 100644 packages/common/tsconfig.json create mode 100644 packages/common/tsup.config.ts rename tsconfig.json => tsconfig.base.json (87%) diff --git a/.continue/prompts/comment.prompt b/.continue/prompts/comment.prompt new file mode 100644 index 0000000..cd79c77 --- /dev/null +++ b/.continue/prompts/comment.prompt @@ -0,0 +1,45 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +角色定位: +- 高级软件开发工程师 +- 代码文档化与知识传播专家 + +注释目标: +1. 顶部注释 + - 模块/文件整体功能描述 + - 版本历史 + - 使用场景 + +2. 类注释 + - 类的职责和设计意图 + - 核心功能概述 + - 设计模式解析 + - 使用示例 + +3. 方法/函数注释 + - 功能详细描述 + - 输入参数解析 + - 返回值说明 + - 异常处理机制 + - 算法复杂度 + - 时间/空间性能分析 + +4. 代码块注释 + - 逐行解释代码意图 + - 关键语句原理阐述 + - 高级语言特性解读 + - 潜在的设计考量 + +注释风格要求: +- 全程使用中文 +- 专业、清晰、通俗易懂 +- 面向初学者的知识传递 +- 保持技术严谨性 + +输出约束: +- 仅返回添加注释后的代码 +- 注释与代码完美融合 +- 保持原代码结构不变 + \ No newline at end of file diff --git a/.continue/prompts/error-handler.prompt b/.continue/prompts/error-handler.prompt new file mode 100644 index 0000000..2ec3d74 --- /dev/null +++ b/.continue/prompts/error-handler.prompt @@ -0,0 +1,45 @@ + +角色定位: +- 身份: 高级错误处理与诊断工程师 +- 专业能力: 深入系统异常分析与解决 +- 分析维度: 错误类型、根因追踪、修复策略 + +错误处理分析要求: +1. 错误详细诊断 + - 精确定位错误来源 + - 追踪完整错误调用链 + - 分析潜在影响范围 + +2. 错误分类与解析 + - 错误类型精确分类 + - 技术根因深度剖析 + - 系统架构潜在风险评估 + +3. 修复方案设计 + - 提供多层次修复建议 + - 评估每种方案的优缺点 + - 给出最优实施路径 + +4. 预防性建议 + - 提出系统防御性编程策略 + - 设计错误拦截与处理机制 + - 推荐代码健壮性改进方案 + +输出规范: +- 错误报告格式化文档 +- 中英文专业技术术语精准使用 +- 层次清晰、逻辑严密 +- 技术性、建设性并重 + +报告要素: +1. 错误摘要 +2. 详细诊断报告 +3. 根因分析 +4. 修复方案 +5. 预防建议 + +禁止: +- 避免泛泛而谈 +- 不提供无依据的猜测 +- 严格遵循技术分析逻辑 + diff --git a/.continue/prompts/explain.prompt b/.continue/prompts/explain.prompt new file mode 100644 index 0000000..e771c02 --- /dev/null +++ b/.continue/prompts/explain.prompt @@ -0,0 +1,30 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +角色定位: +- 身份: 高级软件开发工程师 +- 专业能力: 深入代码架构分析 +- 分析维度: 技术、设计、性能、最佳实践 + +分析要求: +1. 代码逐行详细注释 +2. 注释必须包含: + - 代码意图解析 + - 技术原理阐述 + - 数据结构解读 + - 算法复杂度分析 + - 可能的优化建议 + +输出规范: +- 全中文专业技术文档注释 +- 注释风格: 标准文档型 +- 保留原代码结构 +- 注释与代码同步展示 +- 技术性、专业性并重 + +禁止: +- 不返回无关说明 +- 不进行无意义的介绍 +- strictly遵循技术分析本身 + \ No newline at end of file diff --git a/.continue/prompts/react-refact.prompt b/.continue/prompts/react-refact.prompt new file mode 100644 index 0000000..4def8e2 --- /dev/null +++ b/.continue/prompts/react-refact.prompt @@ -0,0 +1,39 @@ +角色定位: +- 身份: 资深前端架构师 +- 专业能力: React组件设计与重构 +- 分析维度: 组件性能、可维护性、代码规范 + +重构分析要求: +1. 组件代码全面评估 +2. 重构目标: + - 提升组件渲染性能 + - 优化代码结构 + - 增强组件复用性 + - 遵循React最佳实践 + +重构评估维度: +- 状态管理是否合理 +- 渲染性能分析 +- Hook使用规范 +- 组件拆分颗粒度 +- 依赖管理 +- 类型安全 + +重构输出要求: +1. 详细重构方案 +2. 每个重构点需包含: + - 当前问题描述 + - 重构建议 + - 重构后代码示例 + - 性能/架构提升说明 + +重构原则: +- 保持原有业务逻辑不变 +- 代码简洁、可读性强 +- 遵循函数式编程思想 +- 类型安全优先 + +禁止: +- 过度工程化 +- 不切实际的重构 +- 损害可读性的过度抽象 \ No newline at end of file diff --git a/.continue/prompts/refact.prompt b/.continue/prompts/refact.prompt new file mode 100644 index 0000000..8d267e0 --- /dev/null +++ b/.continue/prompts/refact.prompt @@ -0,0 +1,52 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +角色定位: +- 高级软件架构师 +- 代码质量与性能改进专家 + +重构核心目标: +1. 代码质量提升 + - 消除代码坏味道 + - 提高可读性 + - 增强可维护性 + - 优化代码结构 + +2. 架构设计优化 + - 应用合适的设计模式 + - 提升代码解耦程度 + - 增强系统扩展性 + - 改进模块间交互 + +3. 性能与资源优化 + - 算法复杂度改进 + - 内存使用效率 + - 计算资源利用率 + - 减少不必要的计算开销 + +4. 健壮性增强 + - 完善异常处理机制 + - 增加错误边界保护 + - 提高代码容错能力 + - 规范化错误处理流程 + +重构原则: +- 保持原始功能不变 +- 遵循SOLID设计原则 +- 代码简洁性 +- 高内聚低耦合 +- 尽量使用语言特性 +- 避免过度设计 + +注释与文档要求: +- 保留原有有效注释 +- 补充专业的中文文档型注释 +- 解释重构的关键决策 +- 说明性能与架构改进点 + +输出约束: +- 仅返回重构后的代码 +- 保持代码原有风格 +- 注释清晰专业 + \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 5fac39f..6ad92f7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,38 +26,47 @@ "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", - "@nestjs/platform-socket.io": "^10.4.1", + "@nestjs/platform-socket.io": "^10.3.10", "@nestjs/schedule": "^4.1.0", "@nestjs/websockets": "^10.3.10", - "@nicestack/common": "workspace:^", + "@nicestack/common": "workspace:*", "@trpc/server": "11.0.0-rc.456", - "axios": "^1.7.3", - "bcrypt": "^5.1.1", + "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", "ioredis": "^5.4.1", - "mime-types": "^2.1.35", + "lib0": "^0.2.97", + "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", "minio": "^8.0.1", + "mitt": "^3.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.7.5", "superjson-cjs": "^2.2.3", - "tus-js-client": "^4.1.0" + "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/bcrypt": "^5.0.2", "@types/exceljs": "^1.3.0", "@types/express": "^4.17.21", "@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", @@ -71,7 +80,7 @@ "ts-loader": "^9.4.3", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" + "typescript": "^5.5.4" }, "jest": { "moduleFileExtensions": [ diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts old mode 100644 new mode 100755 index 0a10d11..e126092 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -1,24 +1,50 @@ import { Module } from '@nestjs/common'; import { TrpcModule } from './trpc/trpc.module'; -import { RedisService } from './redis/redis.service'; - -import { RedisModule } from './redis/redis.module'; -import { SocketGateway } from './socket/socket.gateway'; import { QueueModule } from './queue/queue.module'; -import { TransformModule } from './transform/transform.module'; import { AuthModule } from './auth/auth.module'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ConfigService } from '@nestjs/config'; +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 { MinioModule } from './minio/minio.module'; +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'; @Module({ - imports: [ScheduleModule.forRoot(), JwtModule.register({ - global: true, - secret: env.JWT_SECRET - }), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule, TasksModule, MinioModule], - providers: [RedisService, SocketGateway, ConfigService], + 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 + ], + 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 old mode 100644 new mode 100755 index c945af8..3f7f4c4 --- a/apps/server/src/auth/auth.controller.ts +++ b/apps/server/src/auth/auth.controller.ts @@ -1,38 +1,43 @@ import { Controller, Post, Body, UseGuards, Get, Req } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AuthSchema, JwtPayload } from '@nicestack/common'; -import { z } from '@nicestack/common'; import { AuthGuard } from './auth.guard'; +import { UserProfileService } from './utils'; +import { z } from 'zod'; @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) { } - @UseGuards(AuthGuard) - @Get("user-profile") - async getUserProfile(@Req() request: Request) { - const user: JwtPayload = (request as any).user - return this.authService.getUserProfile(user) - } - @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); - } - @UseGuards(AuthGuard) // Protecting the refreshToken endpoint with AuthGuard - @Post('refresh-token') - async refreshToken(@Body() body: z.infer) { - return this.authService.refreshToken(body); - } - @UseGuards(AuthGuard) // Protecting the logout endpoint with 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); - } + constructor(private readonly authService: AuthService) { } + @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.module.ts b/apps/server/src/auth/auth.module.ts old mode 100644 new mode 100755 index 5982550..5abc663 --- a/apps/server/src/auth/auth.module.ts +++ b/apps/server/src/auth/auth.module.ts @@ -1,15 +1,17 @@ import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { JwtModule } from '@nestjs/jwt'; -import { env } from '@server/env'; import { AuthController } from './auth.controller'; -import { StaffService } from '@server/models/staff/staff.service'; -import { RoleMapService } from '@server/rbac/rolemap.service'; +import { StaffModule } from '@server/models/staff/staff.module'; +import { AuthRouter } from './auth.router'; +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({ - providers: [AuthService, StaffService, RoleMapService, DepartmentService], + imports: [StaffModule, RoleMapModule], + providers: [AuthService, AuthRouter, TrpcService, DepartmentService, SessionService], + exports: [AuthRouter, AuthService], controllers: [AuthController], - exports: [AuthService] }) export class AuthModule { } diff --git a/apps/server/src/auth/auth.router.ts b/apps/server/src/auth/auth.router.ts new file mode 100755 index 0000000..1392d41 --- /dev/null +++ b/apps/server/src/auth/auth.router.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { AuthSchema, ObjectModelMethodSchema } from '@nicestack/common'; +import { AuthService } from './auth.service'; + +@Injectable() +export class AuthRouter { + constructor( + private readonly trpc: TrpcService, + private readonly authService: AuthService, + ) { } + router = this.trpc.router({ + signUp: this.trpc.procedure + .input(AuthSchema.signUpRequest) + .mutation(async ({ input }) => { + const result = await this.authService.signUp(input); + return result; + }), + }); +} diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts old mode 100644 new mode 100755 index 9009b7d..5b93b06 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -2,170 +2,187 @@ import { Injectable, UnauthorizedException, BadRequestException, - NotFoundException, + Logger, + InternalServerErrorException, } from '@nestjs/common'; +import { StaffService } from '../models/staff/staff.service'; +import { + db, + AuthSchema, + JwtPayload, +} from '@nicestack/common'; +import * as argon2 from 'argon2'; import { JwtService } from '@nestjs/jwt'; -import * as bcrypt from 'bcrypt'; -import { AuthSchema, db, z } from '@nicestack/common'; -import { StaffService } from '@server/models/staff/staff.service'; -import { JwtPayload } from '@nicestack/common'; -import { RoleMapService } from '@server/rbac/rolemap.service'; +import { redis } from '@server/utils/redis/redis.service'; +import { UserProfileService } from './utils'; +import { SessionInfo, SessionService } from './session.service'; +import { tokenConfig } from './config'; +import { z } from 'zod'; @Injectable() export class AuthService { + private logger = new Logger(AuthService.name) constructor( - private readonly jwtService: JwtService, private readonly staffService: StaffService, - private readonly roleMapService: RoleMapService + private readonly jwtService: JwtService, + private readonly sessionService: SessionService, ) { } - async signIn(data: z.infer) { + 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; - // Find the staff by either username or phoneNumber - const staff = await db.staff.findFirst({ - where: { - OR: [ - { username }, - { phoneNumber } - ] - } + + let staff = await db.staff.findFirst({ + where: { OR: [{ username }, { phoneNumber }], deletedAt: null }, }); - if (!staff) { - throw new UnauthorizedException('Invalid username/phone number or password'); + if (!staff && phoneNumber) { + staff = await this.signUp({ + showname: '新用户', + username: phoneNumber, + phoneNumber, + password: phoneNumber, + }); + } else if (!staff) { + throw new UnauthorizedException('帐号不存在'); } - - const isPasswordMatch = await bcrypt.compare(password, staff.password); + if (!staff.enabled) { + throw new UnauthorizedException('帐号已禁用'); + } + const isPasswordMatch = phoneNumber || await argon2.verify(staff.password, password); if (!isPasswordMatch) { - throw new UnauthorizedException('Invalid username/phone number or password'); + throw new UnauthorizedException('帐号或密码错误'); } - const payload: JwtPayload = { sub: staff.id, username: staff.username }; - const accessToken = await this.jwtService.signAsync(payload, { expiresIn: '1h' }); - const refreshToken = await this.generateRefreshToken(staff.id); + try { + const payload = { sub: staff.id, username: staff.username }; + const { accessToken, refreshToken } = await this.generateTokens(payload); - // Calculate expiration dates - const accessTokenExpiresAt = new Date(); - accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 1); + 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 refreshTokenExpiresAt = new Date(); - refreshTokenExpiresAt.setDate(refreshTokenExpiresAt.getDate() + 7); - - // Store the refresh token in the database - await db.refreshToken.create({ - data: { - staffId: staff.id, - token: refreshToken, + const existingUser = await db.staff.findFirst({ + where: { + OR: [{ username }, { officerId }, { phoneNumber }], + deletedAt: null }, }); - return { - access_token: accessToken, - access_token_expires_at: accessTokenExpiresAt, - refresh_token: refreshToken, - refresh_token_expires_at: refreshTokenExpiresAt, - }; - } + if (existingUser) { + throw new BadRequestException('帐号或证件号已存在'); + } - async generateRefreshToken(userId: string): Promise { - const payload = { sub: userId }; - return this.jwtService.signAsync(payload, { expiresIn: '7d' }); // Set an appropriate expiration + return this.staffService.create({ + data: { + ...data, + domainId: data.deptId, + } + }); } - async refreshToken(data: z.infer) { - const { refreshToken } = data; + const { refreshToken, sessionId } = data; let payload: JwtPayload; try { payload = this.jwtService.verify(refreshToken); - } catch (error) { - throw new UnauthorizedException('Invalid refresh token'); + } catch { + throw new UnauthorizedException('用户会话已过期'); } - const storedToken = await db.refreshToken.findUnique({ where: { token: refreshToken } }); - if (!storedToken) { - throw new UnauthorizedException('Invalid refresh token'); + 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 } }); + const user = await db.staff.findUnique({ where: { id: payload.sub, deletedAt: null } }); if (!user) { - throw new UnauthorizedException('Invalid refresh token'); + throw new UnauthorizedException('用户不存在'); } - const newAccessToken = await this.jwtService.signAsync( - { sub: user.id, username: user.username }, - { expiresIn: '1h' }, + 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, ); - - // Calculate new expiration date - const accessTokenExpiresAt = new Date(); - accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 1); - + await redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub)); return { - access_token: newAccessToken, - access_token_expires_at: accessTokenExpiresAt, + access_token: accessToken, + access_token_expires_at: updatedSession.access_token_expires_at, }; } - - async signUp(data: z.infer) { - const { username, password, phoneNumber } = data; - - const existingUserByUsername = await db.staff.findUnique({ where: { username } }); - if (existingUserByUsername) { - throw new BadRequestException('Username is already taken'); - } - if (phoneNumber) { - const existingUserByPhoneNumber = await db.staff.findUnique({ where: { phoneNumber } }); - if (existingUserByPhoneNumber) { - throw new BadRequestException('Phone number is already taken'); - } - } - const hashedPassword = await bcrypt.hash(password, 10); - const staff = await this.staffService.create({ - username, - phoneNumber, - password: hashedPassword, - }); - - return staff; - } - async logout(data: z.infer) { - const { refreshToken } = data; - await db.refreshToken.deleteMany({ where: { token: refreshToken } }); - return { message: 'Logout successful' }; - } - async changePassword(data: z.infer) { - const { oldPassword, newPassword, username } = data; - const user = await db.staff.findUnique({ where: { username } }); + const { newPassword, phoneNumber, username } = data; + const user = await db.staff.findFirst({ + where: { OR: [{ username }, { phoneNumber }], deletedAt: null }, + }); if (!user) { - throw new NotFoundException('User not found'); + throw new UnauthorizedException('用户不存在'); } - - const isPasswordMatch = await bcrypt.compare(oldPassword, user.password); - if (!isPasswordMatch) { - throw new UnauthorizedException('Old password is incorrect'); - } - - const hashedNewPassword = await bcrypt.hash(newPassword, 10); - await this.staffService.update({ id: user.id, password: hashedNewPassword }); - - return { message: 'Password successfully changed' }; - } - async getUserProfile(data: JwtPayload) { - const { sub } = data - const staff = await db.staff.findUnique({ - where: { id: sub }, include: { - department: true, - domain: true + await this.staffService.update({ + where: { id: user?.id }, + data: { + password: newPassword, } - }) - const staffPerms = await this.roleMapService.getPermsForObject({ - domainId: staff.domainId, - staffId: staff.id, - deptId: staff.deptId, }); - return { ...staff, permissions: staffPerms } + + 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: '注销成功' }; + } + +} \ No newline at end of file diff --git a/apps/server/src/auth/config.ts b/apps/server/src/auth/config.ts new file mode 100644 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 100644 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 100644 index 0000000..3404d3c --- /dev/null +++ b/apps/server/src/auth/types.ts @@ -0,0 +1,9 @@ +export interface TokenConfig { + accessToken: { + expirationMs: number; + expirationTTL: number; + }; + refreshToken: { + expirationMs: number; + }; +} diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts new file mode 100644 index 0000000..234a3ae --- /dev/null +++ b/apps/server/src/auth/utils.ts @@ -0,0 +1,187 @@ +import { DepartmentService } from '@server/models/department/department.service'; +import { + UserProfile, + db, + JwtPayload, + RolePerms, + ObjectType, +} from '@nicestack/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'; + +interface ProfileResult { + staff: UserProfile | undefined; + error?: string; +} + +interface TokenVerifyResult { + id?: string; + error?: string; +} + +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 index 8c648d7..74aecae 100755 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -1,4 +1,3 @@ -export const env: { JWT_SECRET: string, APP_URL: string } = { - JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=', - APP_URL: process.env.APP_URL || 'http://localhost:5173' +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 100644 index 0000000..9c82fdd --- /dev/null +++ b/apps/server/src/filters/exceptions.filter.ts @@ -0,0 +1,25 @@ +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/init/init.module.ts b/apps/server/src/init/init.module.ts deleted file mode 100644 index e51a559..0000000 --- a/apps/server/src/init/init.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { InitService } from './init.service'; -import { AuthModule } from '@server/auth/auth.module'; -import { MinioModule } from '@server/minio/minio.module'; - -@Module({ - imports: [AuthModule, MinioModule], - providers: [InitService], - exports: [InitService] -}) -export class InitModule { } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts old mode 100644 new mode 100755 index 10abb60..ff75e3f --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,16 +1,23 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { TrpcRouter } from './trpc/trpc.router'; -import { env } from './env'; +import { WebSocketService } from './socket/websocket.service'; async function bootstrap() { const app = await NestFactory.create(AppModule); + + // 启用 CORS 并允许所有来源 app.enableCors({ - origin: [env.APP_URL], - credentials: true + origin: "*", }); + const wsService = app.get(WebSocketService); + await wsService.initialize(app.getHttpServer()); const trpc = app.get(TrpcRouter); trpc.applyMiddleware(app); - await app.listen(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 100644 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 100644 index 0000000..3adf36e --- /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 '@nicestack/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 100644 index 0000000..7915667 --- /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 '@nicestack/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 100644 index 0000000..457c91c --- /dev/null +++ b/apps/server/src/models/base/base.service.ts @@ -0,0 +1,562 @@ +import { db, Prisma, PrismaClient } from '@nicestack/common'; +import { + Operations, + DelegateArgs, + DelegateReturnTypes, + DataArgs, + WhereArgs, + DelegateFuncs, + UpdateOrderArgs, + TransactionType, +} 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; + } 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; + } 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; + } 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; + } 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; + } 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; + } 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; + } 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'); + throw error; // Re-throw the error to be handled higher up + } + } + /** + * 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; + }): Promise { + const { page, pageSize, where } = args; + try { + return this.getModel().findMany({ + where, + skip: (page - 1) * pageSize, + take: pageSize, + } as any) as Promise; + } 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 }> { + // 解构查询参数,设置默认每页取10条记录 + const { cursor, take = 6, where, orderBy, select } = args as any; + + try { + + /** + * 执行查询 + * @description 查询条件包含: + * 1. where - 过滤条件 + * 2. orderBy - 排序规则,除了传入的排序外,还加入updatedAt和id的降序作为稳定排序 + * 3. select - 选择返回的字段 + * 4. take - 实际取n+1条记录,用于判断是否还有下一页 + * 5. cursor - 游标定位,基于updatedAt和id的组合 + */ + 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 100644 index 0000000..2a1cc7b --- /dev/null +++ b/apps/server/src/models/base/base.tree.service.ts @@ -0,0 +1,389 @@ +import { Prisma, PrismaClient } from '@nicestack/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']) { + const anyArgs = args as any + return this.prisma.$transaction(async (transaction) => { + if (this.enableOrder) { + // 获取新节点的 order + anyArgs.data.order = await this.getNextOrder( + transaction, + anyArgs?.data.parentId ?? null + ); + } + // 创建节点 + const result: any = await super.create(anyArgs, { tx: transaction }); + + // 更新父节点的 hasChildren 状态 + 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; + }) as Promise; + } + + + /** + * 更新现有单位,并在parentId更改时管理DeptAncestry关系。 + * @param data - 用于更新现有单位的数据。 + * @returns 更新后的单位对象。 + */ + async update(args: A['update']) { + 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; + } + + + +} \ No newline at end of file diff --git a/apps/server/src/models/base/base.type.ts b/apps/server/src/models/base/base.type.ts new file mode 100644 index 0000000..8a98d1d --- /dev/null +++ b/apps/server/src/models/base/base.type.ts @@ -0,0 +1,44 @@ +import { db, Prisma, PrismaClient } from "@nicestack/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 100644 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 100644 index 0000000..8430974 --- /dev/null +++ b/apps/server/src/models/base/row-cache.service.ts @@ -0,0 +1,183 @@ +import { UserProfile, RowModelRequest, RowRequestSchema } from "@nicestack/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 100644 index 0000000..1148209 --- /dev/null +++ b/apps/server/src/models/base/row-model.service.ts @@ -0,0 +1,240 @@ +import { Logger } from "@nestjs/common"; +import { UserProfile, db, getUniqueItems, ObjectWithId, Prisma, RowModelRequest } from "@nicestack/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: ObjectWithId, staff?: UserProfile): Promise { + return row; + } + protected async getRowsSqlWrapper(sql: string, request?: RowModelRequest, staff?: UserProfile) { + return SQLBuilder.join([sql, this.getLimitSql(request)]) + } + protected getLimitSql(request: RowModelRequest) { + return SQLBuilder.limit(request.endRow - request.startRow, request.startRow) + } + abstract createJoinSql(request?: RowModelRequest): string[]; + async getRows(request: RowModelRequest, staff?: UserProfile): Promise<{ rowCount: number, rowData: any[] }> { + try { + // this.logger.debug('request', request) + 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) || []; + + let rowDataDto = await Promise.all(results.map(row => this.getRowDto(row, staff))) + + // if (this.getGroupByColumns(request).length === 0) + // rowDataDto = getUniqueItems(rowDataDto, "id") + // this.logger.debug('result', results.length, this.getRowCount(request, rowDataDto)) + return { rowCount: this.getRowCount(request, rowDataDto) || 0, rowData: rowDataDto }; + } catch (error: any) { + this.logger.error('Error executing getRows:', error); + // throw new Error(`Failed to get rows: ${error.message}`); + } + } + 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 = [] + 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 = []; + + 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 { + let 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); + + let 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 = 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 100644 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 100644 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..2f49d4e --- /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 '@nicestack/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 index 99cc160..59e0f8e 100755 --- a/apps/server/src/models/department/department.module.ts +++ b/apps/server/src/models/department/department.module.ts @@ -2,9 +2,12 @@ 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, TrpcService], - exports: [DepartmentService, DepartmentRouter] + 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 index 2bced54..a0890b4 100755 --- a/apps/server/src/models/department/department.router.ts +++ b/apps/server/src/models/department/department.router.ts @@ -1,66 +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 { DepartmentSchema, z } from '@nicestack/common'; +import { DepartmentMethodSchema, Prisma, UpdateOrderSchema } from '@nicestack/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, // inject DepartmentService - ) {} + private readonly departmentService: DepartmentService, // 注入 DepartmentService + private readonly departmentRowService: DepartmentRowService + ) { } router = this.trpc.router({ + // 创建部门 create: this.trpc.protectProcedure - .input(DepartmentSchema.create) // expect input according to the schema + .input(DepartmentCreateArgsSchema) // 根据 schema 期望输入 .mutation(async ({ input }) => { return this.departmentService.create(input); }), + // 更新部门 update: this.trpc.protectProcedure - .input(DepartmentSchema.update) // expect input according to the schema + .input(DepartmentUpdateArgsSchema) // 根据 schema 期望输入 .mutation(async ({ input }) => { return this.departmentService.update(input); }), - - delete: this.trpc.protectProcedure - .input(DepartmentSchema.delete) // expect input according to the schema + // 根据 ID 列表软删除部门 + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // 根据 schema 期望输入 .mutation(async ({ input }) => { - return this.departmentService.delete(input); - }), - getDepartmentDetails: this.trpc.procedure - .input(z.object({ deptId: z.string() })) // expect an object with deptId - .query(async ({ input }) => { - return this.departmentService.getDepartmentDetails(input.deptId); - }), - getAllChildDeptIds: this.trpc.procedure - .input(z.object({ deptId: z.string() })) // expect an object with deptId - .query(async ({ input }) => { - return this.departmentService.getAllChildDeptIds(input.deptId); - }), - getAllParentDeptIds: this.trpc.procedure - .input(z.object({ deptId: z.string() })) // expect an object with deptId - .query(async ({ input }) => { - return this.departmentService.getAllParentDeptIds(input.deptId); - }), - getChildren: this.trpc.procedure - .input(z.object({ parentId: z.string().nullish() })) - .query(async ({ input }) => { - return this.departmentService.getChildren(input.parentId); - }), - getDomainDepartments: this.trpc.procedure - .input(z.object({ query: z.string().nullish() })) - .query(async ({ input }) => { - const { query } = input; - return this.departmentService.getDomainDepartments(query); + 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(DepartmentSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword + .input(DepartmentFindManyArgsSchema) // 假设 StaffMethodSchema.findMany 是根据关键字查找员工的 Zod schema .query(async ({ input }) => { return await this.departmentService.findMany(input); }), - paginate: this.trpc.procedure - .input(DepartmentSchema.paginate) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword + // 查询第一个部门 + findFirst: this.trpc.procedure + .input(DepartmentFindFirstArgsSchema) // 假设 StaffMethodSchema.findMany 是根据关键字查找员工的 Zod schema .query(async ({ input }) => { - return await this.departmentService.paginate(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 100644 index 0000000..c4056c8 --- /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 '@nicestack/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 index bcff592..3d23d09 100755 --- a/apps/server/src/models/department/department.service.ts +++ b/apps/server/src/models/department/department.service.ts @@ -1,33 +1,90 @@ import { Injectable } from '@nestjs/common'; -import { db, z, DepartmentSchema } from '@nicestack/common'; - +import { + db, + DepartmentMethodSchema, + DeptAncestry, + getUniqueItems, + ObjectType, + Prisma, +} from '@nicestack/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 { - /** - * 获取某单位的所有子单位(平铺结构)。 - * @param deptId - 单位的唯一标识符。 - * @returns 包含所有子单位的数组,如果未传递deptId则返回undefined。 - */ - async getFlatChildDepts(deptId: string) { - if (!deptId) return undefined; - - return await db.deptAncestry.findMany({ - where: { ancestorId: deptId }, +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列表 + let 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列表 + let descendantDomainIds = domainAncestries.map( + (ancestry) => ancestry.descendantId, + ); + // 根据参数决定是否包含祖先域ID + if (includeAncestorDomain && ancestorDomainId) { + descendantDomainIds.push(ancestorDomainId); + } + + return descendantDomainIds; } /** * 获取指定DOM下的对应name的单位 - * @param domId + * @param domainId * @param name * @returns - * - * */ - - async findByNameInDom(domId: string, name: string) { + async findInDomain(domainId: string, name: string) { const subDepts = await db.deptAncestry.findMany({ where: { - ancestorId: domId, + ancestorId: domainId, }, include: { descendant: true, @@ -35,251 +92,38 @@ export class DepartmentService { }); const dept = subDepts.find((item) => item.descendant.name === name); - return dept?.descendant; - } - /** - * 获取某单位的所有父单位(平铺结构)。 - * @param deptId - 单位的唯一标识符。 - * @returns 包含所有父单位的数组,如果未传递deptId则返回undefined。 - */ - async getFlatParentDepts(deptId: string) { - if (!deptId) return undefined; - - return await db.deptAncestry.findMany({ - where: { descendantId: deptId }, - }); + return dept.descendant; } - /** - * 获取某单位的所有子单位ID。 - * @param deptId - 单位的唯一标识符。 - * @returns 包含所有子单位ID的数组。 - */ - async getAllChildDeptIds(deptId: string) { - const res = await this.getFlatChildDepts(deptId); - return res?.map((dept) => dept.descendantId) || []; + private async setDomainId(parentId: string) { + const parent = await this.findUnique({ where: { id: parentId } }); + return parent.isDomain ? parentId : parent.domainId; } - /** - * 获取某单位的所有父单位ID。 - * @param deptId - 单位的唯一标识符。 - * @returns 包含所有父单位ID的数组。 - */ - async getAllParentDeptIds(deptId: string) { - const res = await this.getFlatParentDepts(deptId); - return res?.map((dept) => dept.ancestorId) || []; - } - - /** - * 获取单位及其直接子单位的详细信息。 - * @param deptId - 要获取的单位的唯一标识符。 - * @returns 包含单位详细信息的对象,包括其子单位和员工信息。 - */ - async getDepartmentDetails(deptId: string) { - const department = await db.department.findUnique({ - where: { id: deptId }, - include: { children: true, deptStaffs: true }, - }); - - const childrenData = await db.deptAncestry.findMany({ - where: { ancestorId: deptId, relDepth: 1 }, - include: { descendant: { include: { children: true } } }, - }); - - const children = childrenData.map(({ descendant }) => ({ - id: descendant.id, - name: descendant.name, - order: descendant.order, - parentId: descendant.parentId, - hasChildren: Boolean(descendant.children?.length), - childrenCount: descendant.children?.length || 0, - })); - - return { - id: department?.id, - name: department?.name, - order: department?.order, - parentId: department?.parentId, - children, - staffs: department?.deptStaffs, - hasChildren: !!children.length, - }; - } - - /** - * 获取某单位的所有直接子单位。 - * @param parentId - 父单位的唯一标识符,如果未传递则获取顶级单位。 - * @returns 包含所有直接子单位信息的数组。 - */ - async getChildren(parentId?: string) { - const departments = await db.department.findMany({ - where: { parentId: parentId ?? null }, - include: { children: true, deptStaffs: true }, - }); - - return departments.map((dept) => ({ - ...dept, - hasChildren: dept.children.length > 0, - staffs: dept.deptStaffs, - })); - } - async paginate(data: z.infer) { - const { page, pageSize, ids } = data; - const [items, totalCount] = await Promise.all([ - db.department.findMany({ - skip: (page - 1) * pageSize, - take: pageSize, - where: { - deletedAt: null, - OR: [{ id: { in: ids } }], - }, - include: { deptStaffs: true, parent: true }, - orderBy: { order: 'asc' }, - }), - db.department.count({ - where: { - deletedAt: null, - OR: [{ id: { in: ids } }], - }, - }), - ]); - - return { items, totalCount }; - } - async findMany(data: z.infer) { - const { keyword = '', ids } = data; - - const departments = await db.department.findMany({ - where: { - deletedAt: null, - OR: [{ name: { contains: keyword! } }, ids ? { id: { in: ids } } : {}], - }, - include: { deptStaffs: true }, - orderBy: { order: 'asc' }, - take: 20, - }); - - return departments.map((dept) => ({ - ...dept, - staffs: dept.deptStaffs, - })); - } - - /** - * 获取所有域内单位,根据查询条件筛选结果。 - * @param query - 可选的查询条件,用于模糊匹配单位名称。 - * @returns 包含符合条件的域内单位信息的数组。 - */ - async getDomainDepartments(query?: string) { - return await db.department.findMany({ - where: { isDomain: true, name: { contains: query } }, - take: 10, - }); - } - - async getDeptIdsByStaffIds(ids: string[]) { - const staffs = await db.staff.findMany({ - where: { id: { in: ids } }, - }); - - return staffs.map((staff) => staff.deptId); - } - - /** - * 创建一个新的单位并管理DeptAncestry关系。 - * @param data - 用于创建新单位的数据。 - * @returns 新创建的单位对象。 - */ - async create(data: z.infer) { - let newOrder = 0; - - // 确定新单位的顺序 - const siblingDepartments = await db.department.findMany({ - where: { parentId: data.parentId ?? null }, - orderBy: { order: 'desc' }, - take: 1, - }); - - if (siblingDepartments.length > 0) { - newOrder = siblingDepartments[0].order + 1; + async create(args: Prisma.DepartmentCreateArgs) { + if (args.data.parentId) { + args.data.domainId = await this.setDomainId(args.data.parentId); } - - // 根据计算的顺序创建新单位 - const newDepartment = await db.department.create({ - data: { ...data, order: newOrder }, + const result = await super.create(args); + EventBus.emit('dataChanged', { + type: this.objectType, + operation: CrudOperation.CREATED, + data: result, }); - - // 如果存在parentId,则更新DeptAncestry关系 - if (data.parentId) { - const parentAncestries = await db.deptAncestry.findMany({ - where: { descendantId: data.parentId }, - orderBy: { relDepth: 'asc' }, - }); - - // 为新单位创建新的祖先记录 - const newAncestries = parentAncestries.map((ancestry) => ({ - ancestorId: ancestry.ancestorId, - descendantId: newDepartment.id, - relDepth: ancestry.relDepth + 1, - })); - - newAncestries.push({ - ancestorId: data.parentId, - descendantId: newDepartment.id, - relDepth: 1, - }); - - await db.deptAncestry.createMany({ data: newAncestries }); - } - - return newDepartment; + return result; } - /** - * 更新现有单位,并在parentId更改时管理DeptAncestry关系。 - * @param data - 用于更新现有单位的数据。 - * @returns 更新后的单位对象。 - */ - async update(data: z.infer) { - return await db.$transaction(async (transaction) => { - const currentDepartment = await transaction.department.findUnique({ - where: { id: data.id }, - }); - if (!currentDepartment) throw new Error('Department not found'); - - const updatedDepartment = await transaction.department.update({ - where: { id: data.id }, - data: data, - }); - - if (data.parentId !== currentDepartment.parentId) { - await transaction.deptAncestry.deleteMany({ - where: { descendantId: data.id }, - }); - - if (data.parentId) { - const parentAncestries = await transaction.deptAncestry.findMany({ - where: { descendantId: data.parentId }, - }); - - const newAncestries = parentAncestries.map((ancestry) => ({ - ancestorId: ancestry.ancestorId, - descendantId: data.id, - relDepth: ancestry.relDepth + 1, - })); - - newAncestries.push({ - ancestorId: data.parentId, - descendantId: data.id, - relDepth: 1, - }); - - await transaction.deptAncestry.createMany({ data: newAncestries }); - } - } - - return updatedDepartment; + 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; } /** @@ -287,78 +131,208 @@ export class DepartmentService { * @param data - 用于删除现有单位的数据。 * @returns 删除的单位对象。 */ - async delete(data: z.infer) { - const deletedDepartment = await db.department.update({ - where: { id: data.id }, - data: { deletedAt: new Date() }, + 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; + } - await db.deptAncestry.deleteMany({ - where: { OR: [{ ancestorId: data.id }, { descendantId: data.id }] }, - }); - return deletedDepartment; - } - async getStaffsByDeptIds(ids: string[]) { - const depts = await db.department.findMany({ - where: { id: { in: ids } }, - include: { deptStaffs: true }, - }); - return depts.flatMap((dept) => dept.deptStaffs); - } /** * 获取指定部门及其所有子部门的员工。 * @param deptIds - 要获取员工ID的部门ID数组。 * @returns 包含所有员工ID的数组。 */ - async getAllStaffsByDepts(deptIds: string[]) { - const allDeptIds = new Set(deptIds); - for (const deptId of deptIds) { - const childDeptIds = await this.getAllChildDeptIds(deptId); - childDeptIds.forEach((id) => allDeptIds.add(id)); - } - return await this.getStaffsByDeptIds(Array.from(allDeptIds)); + async getStaffsInDepts(deptIds: string[]) { + const allDeptIds = await this.getDescendantIds(deptIds, true); + return await getStaffsByDeptIds(Array.from(allDeptIds)); } - - /** - * 根据部门名称和域ID获取部门ID。 - * - * @param {string} name - 部门名称。 - * @param {string} domainId - 域标识符。 - * @returns {Promise} - 如果找到则返回部门ID,否则返回null。 - */ - async getDeptIdByName(name: string, domainId: string): Promise { - const dept = await db.department.findFirst({ - where: { - name, - ancestors: { - some: { - ancestorId: domainId - } - } - } - }); - return dept ? dept.id : null; + async getStaffIdsInDepts(deptIds: string[]) { + const result = await this.getStaffsInDepts(deptIds); + return result.map((s) => s.id); } - /** * 根据部门名称列表和域ID获取多个部门的ID。 - * - * @param {string[]} names - 部门名称列表。 - * @param {string} domainId - 域标识符。 - * @returns {Promise>} - 一个从部门名称到对应ID或null的记录。 + * + * @param {string[]} names - 部门名称列表 + * @param {string} domainId - 域ID + * @returns {Promise>} - 返回一个对象,键为部门名称,值为部门ID或null */ - async getDeptIdsByNames(names: string[], domainId: string): Promise> { + 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 = {}; - // 遍历每个部门名称并获取对应的部门ID + // 遍历传入的部门名称列表 for (const name of names) { - // 使用之前定义的函数根据名称获取部门ID - const deptId = await this.getDeptIdByName(name, domainId); - result[name] = deptId; + // 从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 100644 index 0000000..4295fff --- /dev/null +++ b/apps/server/src/models/department/utils.ts @@ -0,0 +1,68 @@ +import { UserProfile, db, DeptSimpleTreeNode, TreeDataNode } from "@nicestack/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); +} \ No newline at end of file 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..e86a115 --- /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 '@nicestack/common'; + +@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, + visitType: 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, + visitType: 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 100644 index 0000000..ce83a6e --- /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..1cb12a1 --- /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 { ChangedRows, Prisma } from '@nicestack/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 100644 index 0000000..68b640a --- /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 '@nicestack/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, + visitType: 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 100644 index 0000000..8ebbf40 --- /dev/null +++ b/apps/server/src/models/message/utils.ts @@ -0,0 +1,20 @@ +import { Message, UserProfile, VisitType, db } from "@nicestack/common" +export async function setMessageRelation( + data: Message, + staff?: UserProfile, +): Promise { + + const readed = + (await db.visit.count({ + where: { + messageId: data.id, + visitType: 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..86b21fb --- /dev/null +++ b/apps/server/src/models/post/post.controller.ts @@ -0,0 +1,89 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; + +import { PostService } from './post.service'; +import { AuthGuard } from '@server/auth/auth.guard'; +import { db } from '@nicestack/common'; + +@Controller('post') +export class PostController { + constructor(private readonly postService: PostService) { } + @UseGuards(AuthGuard) + @Get('find-last-one') + async findLastOne(@Query('trouble-id') troubleId: string) { + try { + const result = await this.postService.findFirst({ + where: { referenceId: troubleId }, + orderBy: { createdAt: 'desc' } + }); + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } + @UseGuards(AuthGuard) + @Get('find-all') + async findAll(@Query('trouble-id') troubleId: string) { + try { + const result = await db.post.findMany({ + where: { + OR: [{ referenceId: troubleId }], + }, + orderBy: { createdAt: 'desc' }, + select: { + title: true, + content: true, + attachments: true, + type: true, + author: { + select: { + id: true, + showname: true, + username: true, + }, + }, + }, + }); + return { + data: result, + errmsg: 'success', + errno: 0, + }; + } catch (e) { + return { + data: {}, + errmsg: (e as any)?.message || 'error', + errno: 1, + }; + } + } + @UseGuards(AuthGuard) + @Get('count') + async count(@Query('trouble-id') troubleId: string) { + try { + const result = await db.post.count({ + where: { + OR: [{ referenceId: troubleId }], + }, + }); + 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/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..9245377 --- /dev/null +++ b/apps/server/src/models/post/post.router.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { ChangedRows, ObjectType, Prisma } from '@nicestack/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 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); + }), + 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); + }), + }); +} 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..50a2fe4 --- /dev/null +++ b/apps/server/src/models/post/post.service.ts @@ -0,0 +1,146 @@ +import { Injectable } from '@nestjs/common'; +import { + db, + Prisma, + UserProfile, + VisitType, + Post, + PostType, + RolePerms, + ResPerm, + ObjectType, +} from '@nicestack/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'; + +@Injectable() +export class PostService extends BaseService { + constructor( + private readonly messageService: MessageService, + private readonly departmentService: DepartmentService, + ) { + super(db, ObjectType.POST); + } + async create( + args: Prisma.PostCreateArgs, + params: { staff?: UserProfile; tx?: Prisma.PostDelegate }, + ) { + 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; + return super.update(args); + } + async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { + args.where.OR = await this.preFilter(args.where.OR, staff); + + // console.log(`findwithcursor_post ${JSON.stringify(args.where)}`) + return this.wrapResult(super.findManyWithCursor(args), async (result) => { + let { 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) { + 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 && { + watchStaffs: { + some: { + id: staff.id, + }, + }, + }, + deptId && { + watchDepts: { + some: { + id: { + in: parentDeptIds, + }, + }, + }, + }, + + { + AND: [ + { + watchStaffs: { + none: {}, // 匹配 watchStaffs 为空 + }, + }, + { + watchDepts: { + none: {}, // 匹配 watchDepts 为空 + }, + }, + ], + }, + ].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 100644 index 0000000..a2e1f10 --- /dev/null +++ b/apps/server/src/models/post/utils.ts @@ -0,0 +1,44 @@ +import { db, Post, PostType, UserProfile, VisitType } from "@nicestack/common"; +import { getTroubleWithRelation } from "../trouble/utils"; +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, + visitType: VisitType.READED, + visitorId: staff?.id, + }, + })) > 0; + const readedCount = await db.visit.count({ + where: { + postId: data.id, + visitType: VisitType.READED, + }, + }); + const trouble = await getTroubleWithRelation(data.referenceId, staff) + Object.assign(data, { + readed, + readedCount, + limitedComments, + commentsCount, + trouble + }) + +} \ No newline at end of file diff --git a/apps/server/src/rbac/rbac.module.ts b/apps/server/src/models/rbac/rbac.module.ts old mode 100644 new mode 100755 similarity index 59% rename from apps/server/src/rbac/rbac.module.ts rename to apps/server/src/models/rbac/rbac.module.ts index 9174e0b..9f3e0f8 --- a/apps/server/src/rbac/rbac.module.ts +++ b/apps/server/src/models/rbac/rbac.module.ts @@ -4,13 +4,11 @@ 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 '@server/models/department/department.module'; -import { RolePermsService } from './roleperms.service'; -import { RelationService } from '@server/relation/relation.service'; +import { DepartmentModule } from '../department/department.module'; @Module({ imports: [DepartmentModule], - providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter, RolePermsService, RelationService], - exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter, RolePermsService] + providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter], + exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter] }) -export class RbacModule { } +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..a31316e --- /dev/null +++ b/apps/server/src/models/rbac/role.router.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common"; +import { TrpcService } from "@server/trpc/trpc.service"; +import { RoleService } from "./role.service"; +import { RoleMethodSchema } from "@nicestack/common"; +import { z } from "zod"; + +@Injectable() +export class RoleRouter { + constructor( + private readonly trpc: TrpcService, + private readonly roleService: RoleService + ) { } + + router = this.trpc.router({ + create: this.trpc.protectProcedure.input(RoleMethodSchema.create).mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.create(input); + }), + deleteMany: this.trpc.protectProcedure.input(RoleMethodSchema.deleteMany).mutation(async ({ input }) => { + return await this.roleService.deleteMany(input); + }), + update: this.trpc.protectProcedure.input(RoleMethodSchema.update).mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.update(input); + }), + paginate: this.trpc.protectProcedure.input(RoleMethodSchema.paginate).query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.paginate(input); + }), + findById: this.trpc.protectProcedure + .input(z.object({ id: z.string().nullish() })) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.roleService.findById(input.id); + }), + findMany: this.trpc.procedure + .input(RoleMethodSchema.findMany) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.roleService.findMany(input); + }) + } + ) +} + 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..a44b7d2 --- /dev/null +++ b/apps/server/src/models/rbac/role.service.ts @@ -0,0 +1,180 @@ +import { Injectable } from '@nestjs/common'; +import { db, RoleMethodSchema, RowModelRequest, UserProfile, RowRequestSchema, ObjectWithId, ObjectType } from "@nicestack/common"; +import { DepartmentService } from '@server/models/department/department.service'; +import EventBus, { CrudOperation } from '@server/utils/event-bus'; + +import { TRPCError } from '@trpc/server'; +import { RowModelService } from '../base/row-model.service'; +import { isFieldCondition, LogicalCondition } from '../base/sql-builder'; +import { z } from 'zod'; +@Injectable() +export class RoleService extends RowModelService { + 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: ObjectWithId, 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 []; + } + constructor( + private readonly departmentService: DepartmentService + ) { + super("role") + } + /** + * 创建角色 + * @param data 包含创建角色所需信息的数据 + * @returns 创建的角色 + */ + async create(data: z.infer) { + const result = await db.role.create({ data }) + EventBus.emit('dataChanged', { + type: ObjectType.ROLE, + operation: CrudOperation.CREATED, + data: result, + }); + return result + } + async findById(id: string) { + return await db.role.findUnique({ + where: { + id + } + }) + } + /** + * 更新角色 + * @param data 包含更新角色所需信息的数据 + * @returns 更新后的角色 + */ + async update(data: z.infer) { + const { id, ...others } = data; + // 开启事务 + const result = await db.role.update({ + where: { id }, + data: { ...others } + }); + + EventBus.emit('dataChanged', { + type: ObjectType.ROLE, + operation: CrudOperation.UPDATED, + data: result, + }); + return result + } + /** + * 批量删除角色 + * @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 result = await db.$transaction(async (prisma) => { + await prisma.roleMap.deleteMany({ + where: { + roleId: { + in: ids + } + } + }); + const deletedRoles = await prisma.role.deleteMany({ + where: { + id: { in: ids } + } + }); + return { success: true, count: deletedRoles.count }; + }); + EventBus.emit('dataChanged', { + type: ObjectType.ROLE, + operation: CrudOperation.DELETED, + data: result, + }); + return result + } + /** + * 分页获取角色 + * @param data 包含分页信息的数据 + * @returns 分页结果,包含角色列表和总数 + */ + async paginate(data: z.infer) { + const { page, pageSize } = data; + const [items, totalCount] = await Promise.all([ + db.role.findMany({ + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { name: "asc" }, + where: { deletedAt: null }, + include: { + roleMaps: true, + } + }), + db.role.count({ where: { deletedAt: null } }), + ]); + const result = { items, totalCount }; + return result; + } + /** + * 根据关键字查找多个角色 + * @param data 包含关键字的数据 + * @returns 查找到的角色列表 + */ + async findMany(data: z.infer) { + const { keyword = '' } = data + return await db.role.findMany({ + where: { + deletedAt: null, + OR: [ + { + name: { + contains: keyword + } + } + ] + }, + orderBy: { createdAt: "asc" }, + take: 10 + }) + } +} 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..d0ecd08 --- /dev/null +++ b/apps/server/src/models/rbac/rolemap.router.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { TrpcService } from '@server/trpc/trpc.service'; +import { + ChangedRows, + ObjectType, + RoleMapMethodSchema, +} from '@nicestack/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..f026b9a --- /dev/null +++ b/apps/server/src/models/rbac/rolemap.service.ts @@ -0,0 +1,317 @@ +import { Injectable } from '@nestjs/common'; +import { + db, + RoleMapMethodSchema, + ObjectType, + Prisma, + RowModelRequest, + UserProfile, + ObjectWithId, +} from '@nicestack/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: ObjectWithId, + 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/staff/staff.controller.ts b/apps/server/src/models/staff/staff.controller.ts new file mode 100755 index 0000000..45e8349 --- /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 '@nicestack/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 old mode 100644 new mode 100755 index c7240ed..fa681dc --- a/apps/server/src/models/staff/staff.module.ts +++ b/apps/server/src/models/staff/staff.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; -import { StaffRouter } from './staff.router'; import { StaffService } from './staff.service'; +import { StaffRouter } from './staff.router'; import { TrpcService } from '@server/trpc/trpc.service'; -import { DepartmentService } from '../department/department.service'; +import { DepartmentModule } from '../department/department.module'; +import { StaffController } from './staff.controller'; +import { StaffRowService } from './staff.row.service'; @Module({ - providers: [StaffRouter, StaffService, TrpcService, DepartmentService], - exports: [StaffRouter, StaffService] + 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 index f08dde0..5ea3015 100755 --- a/apps/server/src/models/staff/staff.router.ts +++ b/apps/server/src/models/staff/staff.router.ts @@ -1,48 +1,79 @@ import { Injectable } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; import { StaffService } from './staff.service'; // Adjust the import path as necessary -import { z, StaffSchema } from '@nicestack/common'; - +import { StaffMethodSchema, Prisma, UpdateOrderSchema } from '@nicestack/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(StaffSchema.create) // Assuming StaffSchema.create is the Zod schema for creating staff + .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(StaffSchema.update) // Assuming StaffSchema.update is the Zod schema for updating staff + .input(StaffUpdateArgsSchema) // Assuming StaffMethodSchema.update is the Zod schema for updating staff .mutation(async ({ input }) => { return await this.staffService.update(input); }), - - batchDelete: this.trpc.procedure - .input(StaffSchema.batchDelete) // Assuming StaffSchema.batchDelete is the Zod schema for batch deleting staff - .mutation(async ({ input }) => { - return await this.staffService.batchDelete(input); + updateUserDomain: this.trpc.protectProcedure + .input( + z.object({ + domainId: z.string() + }), + ) + .mutation(async ({ input, ctx }) => { + return await this.staffService.updateUserDomain(input, ctx.staff); }), - - paginate: this.trpc.procedure - .input(StaffSchema.paginate) // Define the input schema for pagination - .query(async ({ input }) => { - return await this.staffService.paginate(input); + 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(StaffSchema.findByDept) + .input(StaffMethodSchema.findByDept) .query(async ({ input }) => { return await this.staffService.findByDept(input); }), findMany: this.trpc.procedure - .input(StaffSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword + .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 100644 index 0000000..830f54c --- /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 '@nicestack/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.spec.ts b/apps/server/src/models/staff/staff.service.spec.ts deleted file mode 100644 index d653df4..0000000 --- a/apps/server/src/models/staff/staff.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { StaffService } from './staff.service'; - -describe('StaffService', () => { - let service: StaffService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [StaffService], - }).compile(); - - service = module.get(StaffService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/apps/server/src/models/staff/staff.service.ts b/apps/server/src/models/staff/staff.service.ts old mode 100644 new mode 100755 index 20dcac2..c9ccd02 --- a/apps/server/src/models/staff/staff.service.ts +++ b/apps/server/src/models/staff/staff.service.ts @@ -1,178 +1,180 @@ import { Injectable } from '@nestjs/common'; -import { db, ObjectType, Staff, StaffSchema, z } from '@nicestack/common'; -import { TRPCError } from '@trpc/server'; +import { + db, + StaffMethodSchema, + ObjectType, + UserProfile, + Prisma, +} from '@nicestack/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 { - constructor(private readonly departmentService: DepartmentService) { } +export class StaffService extends BaseService { - /** - * 获取某一单位下所有staff的记录 - * @param deptId 单位的id - * @returns 查到的staff记录 - */ - async findByDept(data: z.infer) { - const { deptId, domainId } = data; - const childDepts = await this.departmentService.getAllChildDeptIds(deptId); - const result = await db.staff.findMany({ - where: { - deptId: { in: [...childDepts, deptId] }, - domainId, - }, + 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 } }) + } }); - return result; - } - /** - * 创建新的员工记录 - * @param data 员工创建信息 - * @returns 新创建的员工记录 - */ - async create(data: z.infer) { - const { ...others } = data; - - try { - return await db.$transaction(async (transaction) => { - // 获取当前最大order值 - const maxOrder = await transaction.staff.aggregate({ - _max: { order: true }, - }); - // 新员工的order值比现有最大order值大1 - const newOrder = (maxOrder._max.order ?? -1) + 1; - // 创建新员工记录 - const newStaff = await transaction.staff.create({ - data: { ...others, order: newOrder }, - include: { domain: true, department: true }, - }); - return newStaff; - }); - } catch (error) { - console.error('Failed to create staff:', error); - throw error; + if (count > 0) { + throw new Error(errorMsg(data[field])); } + } } - /** - * 更新员工记录 - * @param data 包含id和其他更新字段的对象 - * @returns 更新后的员工记录 - */ - async update(data: z.infer) { - const { id, ...others } = data; - try { - return await db.$transaction(async (transaction) => { - // 更新员工记录 - const updatedStaff = await transaction.staff.update({ - where: { id }, - data: others, - include: { domain: true, department: true }, - }); - return updatedStaff; - }); - } catch (error) { - console.error('Failed to update staff:', error); - throw error; - } - } - /** - * 批量删除员工记录(软删除) - * @param data 包含要删除的员工ID数组的对象 - * @returns 删除操作结果,包括删除的记录数 - */ - async batchDelete(data: z.infer) { - const { ids } = data; - - if (!ids || ids.length === 0) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'No IDs provided for deletion.', - }); - } - const deletedStaffs = await db.staff.updateMany({ - where: { id: { in: ids } }, - data: { deletedAt: new Date() }, - }); - if (!deletedStaffs.count) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'No taxonomies were found with the provided IDs.', - }); - } - return { success: true, count: deletedStaffs.count }; - } - /** - * 分页查询员工 - * @param data 包含分页参数、域ID和部门ID的对象 - * @returns 员工列表及总记录数 - */ - async paginate(data: z.infer) { - const { page, pageSize, domainId, deptId, ids } = data; - const childDepts = await this.departmentService.getAllChildDeptIds(deptId); - const [items, totalCount] = await Promise.all([ - db.staff.findMany({ - skip: (page - 1) * pageSize, - take: pageSize, - orderBy: { order: 'asc' }, - where: { - id: ids ? { in: ids } : undefined, - deletedAt: null, - domainId, - deptId: deptId ? { in: [...childDepts, deptId] } : undefined, - }, - include: { domain: true, department: true }, - }), - db.staff.count({ - where: { - deletedAt: null, - domainId, - deptId: deptId ? { in: [...childDepts, deptId] } : undefined, - }, - }), - ]); - const processedItems = await Promise.all( - items.map((item) => this.genStaffDto(item)), - ); - return { items: processedItems, totalCount }; - } - /** - * 根据关键词或ID集合查找员工 - * @param data 包含关键词、域ID和ID集合的对象 - * @returns 匹配的员工记录列表 - */ - async findMany(data: z.infer) { - const { keyword, domainId, ids } = data; - - return await db.staff.findMany({ - where: { - deletedAt: null, - domainId, - OR: [ - { username: { contains: keyword } }, - { - id: { in: ids }, - }, - ], - }, - orderBy: { order: 'asc' }, - take: 10, - }); - } - /** - * 生成员工的数据传输对象(DTO) - * @param staff 员工记录 - * @returns 含角色ID列表的员工DTO - */ - private async genStaffDto(staff: Staff) { - const roleMaps = await db.roleMap.findMany({ - where: { - domainId: staff.domainId, - objectId: staff.id, - objectType: ObjectType.STAFF, - }, - include: { role: true }, - }); - const roleIds = roleMaps.map((roleMap) => roleMap.role.id); - return { ...staff, roleIds }; - } + } + 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..00e0b6f --- /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 '@nicestack/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 old mode 100644 new mode 100755 index a8aa9ab..64e6f55 --- a/apps/server/src/models/taxonomy/taxonomy.module.ts +++ b/apps/server/src/models/taxonomy/taxonomy.module.ts @@ -2,11 +2,11 @@ import { Module } from '@nestjs/common'; import { TaxonomyRouter } from './taxonomy.router'; import { TaxonomyService } from './taxonomy.service'; import { TrpcService } from '@server/trpc/trpc.service'; -import { RedisModule } from '@server/redis/redis.module'; +import { TaxonomyController } from './taxonomy.controller'; @Module({ - imports: [RedisModule], - providers: [TaxonomyRouter, TaxonomyService, TrpcService], - exports: [TaxonomyRouter, TaxonomyService] + providers: [TaxonomyRouter, TaxonomyService, TrpcService], + exports: [TaxonomyRouter, TaxonomyService], + controllers: [TaxonomyController], }) -export class TaxonomyModule { } +export class TaxonomyModule {} diff --git a/apps/server/src/models/taxonomy/taxonomy.router.ts b/apps/server/src/models/taxonomy/taxonomy.router.ts old mode 100644 new mode 100755 index c575bb3..1f302a7 --- a/apps/server/src/models/taxonomy/taxonomy.router.ts +++ b/apps/server/src/models/taxonomy/taxonomy.router.ts @@ -1,42 +1,55 @@ import { Injectable } from '@nestjs/common'; import { TrpcService } from '@server/trpc/trpc.service'; import { TaxonomyService } from './taxonomy.service'; -import { TaxonomySchema } from '@nicestack/common'; +import { TaxonomyMethodSchema } from '@nicestack/common'; @Injectable() export class TaxonomyRouter { - constructor( - private readonly trpc: TrpcService, - private readonly taxonomyService: TaxonomyService - ) { } + constructor( + private readonly trpc: TrpcService, + private readonly taxonomyService: TaxonomyService, + ) { } - router = this.trpc.router({ - create: this.trpc.procedure.input(TaxonomySchema.create).mutation(async ({ input }) => { - return this.taxonomyService.create(input); - }), - - findById: this.trpc.procedure.input(TaxonomySchema.findById).query(async ({ input }) => { - return this.taxonomyService.findById(input); - }), - - update: this.trpc.procedure.input(TaxonomySchema.update).mutation(async ({ input }) => { - return this.taxonomyService.update(input); - }), - - delete: this.trpc.procedure.input(TaxonomySchema.delete).mutation(async ({ input }) => { - return this.taxonomyService.delete(input); - }), - - batchDelete: this.trpc.procedure.input(TaxonomySchema.batchDelete).mutation(async ({ input }) => { - return this.taxonomyService.batchDelete(input); - }), - - paginate: this.trpc.procedure.input(TaxonomySchema.paginate!).query(async ({ input }) => { - return this.taxonomyService.paginate(input); - }), - - getAll: this.trpc.procedure.query(() => { - return this.taxonomyService.getAll(); - }) - }); + 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 index 9655a1d..c43914b 100755 --- a/apps/server/src/models/taxonomy/taxonomy.service.ts +++ b/apps/server/src/models/taxonomy/taxonomy.service.ts @@ -1,18 +1,19 @@ import { Injectable } from '@nestjs/common'; -import { db, TaxonomySchema, z } from '@nicestack/common'; -import { RedisService } from '@server/redis/redis.service'; +import { db, TaxonomyMethodSchema, Prisma } from '@nicestack/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(private readonly redis: RedisService) {} + constructor() { } /** * 清除分页缓存,删除所有以'taxonomies:page:'开头的键 */ private async invalidatePaginationCache() { - const keys = await this.redis.keys('taxonomies:page:*'); - await Promise.all(keys.map((key) => this.redis.deleteKey(key))); + deleteByPattern('taxonomies:page:*') } /** @@ -20,7 +21,7 @@ export class TaxonomyService { * @param input 分类创建信息 * @returns 新创建的分类记录 */ - async create(input: z.infer) { + async create(input: z.infer) { // 获取当前分类数量,设置新分类的order值为count + 1 const count = await db.taxonomy.count(); const taxonomy = await db.taxonomy.create({ @@ -28,7 +29,7 @@ export class TaxonomyService { }); // 删除该分类的缓存及分页缓存 - await this.redis.deleteKey(`taxonomy:${taxonomy.id}`); + await redis.del(`taxonomy:${taxonomy.id}`); await this.invalidatePaginationCache(); return taxonomy; } @@ -38,16 +39,29 @@ export class TaxonomyService { * @param input 包含分类name的对象 * @returns 查找到的分类记录 */ - async findByName(input: z.infer) { + async findByName(input: z.infer) { const { name } = input; const cacheKey = `taxonomy:${name}`; - let cachedTaxonomy = await this.redis.getValue(cacheKey); + let cachedTaxonomy = await redis.get(cacheKey); if (cachedTaxonomy) { return JSON.parse(cachedTaxonomy); } - const taxonomy = await db.taxonomy.findUnique({ where: { name: name } }); + const taxonomy = await db.taxonomy.findUnique({ where: { name } }); if (taxonomy) { - await this.redis.setWithExpiry(cacheKey, JSON.stringify(taxonomy), 60); + await redis.setex(cacheKey, 60, JSON.stringify(taxonomy)); + } + return taxonomy; + } + async findBySlug(input: z.infer) { + const { slug } = input; + const cacheKey = `taxonomy-slug:${slug}`; + let 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; } @@ -56,15 +70,15 @@ export class TaxonomyService { * @param input 包含分类ID的对象 * @returns 查找到的分类记录 */ - async findById(input: z.infer) { + async findById(input: z.infer) { const cacheKey = `taxonomy:${input.id}`; - let cachedTaxonomy = await this.redis.getValue(cacheKey); + let cachedTaxonomy = await redis.get(cacheKey); if (cachedTaxonomy) { return JSON.parse(cachedTaxonomy); } const taxonomy = await db.taxonomy.findUnique({ where: { id: input.id } }); if (taxonomy) { - await this.redis.setWithExpiry(cacheKey, JSON.stringify(taxonomy), 60); + await redis.setex(cacheKey, 60, JSON.stringify(taxonomy)); } return taxonomy; } @@ -79,7 +93,7 @@ export class TaxonomyService { const updatedTaxonomy = await db.taxonomy.update({ where: { id }, data }); // 删除该分类的缓存及分页缓存 - await this.redis.deleteKey(`taxonomy:${updatedTaxonomy.id}`); + await redis.del(`taxonomy:${updatedTaxonomy.id}`); await this.invalidatePaginationCache(); return updatedTaxonomy; } @@ -96,7 +110,7 @@ export class TaxonomyService { }); // 删除该分类的缓存及分页缓存 - await this.redis.deleteKey(`taxonomy:${deletedTaxonomy.id}`); + await redis.del(`taxonomy:${deletedTaxonomy.id}`); await this.invalidatePaginationCache(); return deletedTaxonomy; } @@ -106,7 +120,7 @@ export class TaxonomyService { * @param input 包含要删除的分类ID数组的对象 * @returns 删除操作结果,包括删除的记录数 */ - async batchDelete(input: any) { + async deleteMany(input: any) { const { ids } = input; if (!ids || ids.length === 0) { throw new TRPCError({ @@ -120,16 +134,9 @@ export class TaxonomyService { }, data: { deletedAt: new Date() }, }); - if (!deletedTaxonomies.count) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'No taxonomies were found with the provided IDs.', - }); - } - // 删除每个分类的缓存及分页缓存 await Promise.all( - ids.map(async (id: string) => this.redis.deleteKey(`taxonomy:${id}`)), + ids.map(async (id: string) => redis.del(`taxonomy:${id}`)), ); await this.invalidatePaginationCache(); return { success: true, count: deletedTaxonomies.count }; @@ -142,7 +149,7 @@ export class TaxonomyService { */ async paginate(input: any) { const cacheKey = `taxonomies:page:${input.page}:size:${input.pageSize}`; - let cachedData = await this.redis.getValue(cacheKey); + let cachedData = await redis.get(cacheKey); if (cachedData) { return JSON.parse(cachedData); } @@ -159,7 +166,7 @@ export class TaxonomyService { const result = { items, totalCount }; // 缓存结果并设置过期时间 - await this.redis.setWithExpiry(cacheKey, JSON.stringify(result), 60); + await redis.setex(cacheKey, 60, JSON.stringify(result)); return result; } @@ -167,10 +174,30 @@ export class TaxonomyService { * 获取所有未删除的分类记录 * @returns 分类记录列表 */ - async getAll() { + 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: { deletedAt: null }, + 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..12a165d --- /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 '@nicestack/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 index 3017928..850dbc1 100755 --- a/apps/server/src/models/term/term.module.ts +++ b/apps/server/src/models/term/term.module.ts @@ -3,13 +3,14 @@ import { TermService } from './term.service'; import { TermRouter } from './term.router'; import { TrpcService } from '@server/trpc/trpc.service'; import { DepartmentModule } from '../department/department.module'; -import { RbacModule } from '@server/rbac/rbac.module'; -import { RelationService } from '@server/relation/relation.service'; - +import { TermController } from './term.controller'; +import { RoleMapModule } from '../rbac/rbac.module'; +import { TermRowService } from './term.row.service'; @Module({ - imports: [DepartmentModule, RbacModule], - providers: [TermService, TermRouter, TrpcService, RelationService], - exports: [TermService, TermRouter] + 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 index 48eb10d..ba44e35 100755 --- a/apps/server/src/models/term/term.router.ts +++ b/apps/server/src/models/term/term.router.ts @@ -1,55 +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 { z, TermSchema } from '@nicestack/common'; - +import { Prisma, TermMethodSchema, UpdateOrderSchema } from '@nicestack/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, - ) { } - router = this.trpc.router({ - create: this.trpc.protectProcedure - .input(TermSchema.create) - .mutation(async ({ input, ctx }) => { - const { staff } = ctx - return this.termService.create(staff, input); - }), - update: this.trpc.protectProcedure - .input(TermSchema.update) - .mutation(async ({ input }) => { - return this.termService.update(input); - }), - delete: this.trpc.protectProcedure - .input(TermSchema.delete) - .mutation(async ({ input }) => { - return this.termService.delete(input); - }), - findById: this.trpc.procedure.input(z.object({ - id: z.string() - })).query(async ({ input, ctx }) => { - const { staff } = ctx - return this.termService.findUnique(staff, input.id) + 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()), }), - batchDelete: this.trpc.protectProcedure.input(z.object({ - ids: z.array(z.string()) - })).mutation(async ({ input }) => { - const { ids } = input - return this.termService.batchDelete(ids) - }), - getChildren: this.trpc.procedure.input(TermSchema.getChildren).query(async ({ input, ctx }) => { - const { staff } = ctx - return this.termService.getChildren(staff, input) - }), - getAllChildren: this.trpc.procedure.input(TermSchema.getChildren).query(async ({ input, ctx }) => { - const { staff } = ctx - return this.termService.getAllChildren(staff, input) - }), - findMany: this.trpc.procedure - .input(TermSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.termService.findMany(input); - }), - }); + ) + .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 100644 index 0000000..a518f9b --- /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 '@nicestack/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 index 96d6719..c8ce22f 100755 --- a/apps/server/src/models/term/term.service.ts +++ b/apps/server/src/models/term/term.service.ts @@ -1,367 +1,425 @@ import { Injectable } from '@nestjs/common'; -import { z, TermSchema, db, Staff, Term, RelationType, ObjectType, Prisma, TermDto } from '@nicestack/common'; -import { RolePermsService } from '@server/rbac/roleperms.service'; -import { RelationService } from '@server/relation/relation.service'; +import { + TermMethodSchema, + db, + Staff, + Term, + Prisma, + TermDto, + TreeDataNode, + UserProfile, + getUniqueItems, + RolePerms, + TaxonomySlug, + ObjectType, + TermAncestry, +} from '@nicestack/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'; -/** - * Service for managing terms and their ancestries. - */ @Injectable() -export class TermService { - constructor(private readonly permissionService: RolePermsService, private readonly relations: RelationService) { } +export class TermService extends BaseTreeService { + constructor() { + super(db, ObjectType.TERM, 'termAncestry', true); + } - /** - * 生成TermDto对象,包含权限和关系信息 - * @param staff 当前操作的工作人员 - * @param term 当前处理的术语对象,包含其子节点 - * @returns 完整的TermDto对象 - */ - async genTermDto(staff: Staff, term: Term & { children: Term[] }): Promise { - const { children, ...others } = term as any; - const permissions = this.permissionService.getTermPerms(staff, term); - const relationTypes = [ - { type: RelationType.WATCH, object: ObjectType.DEPARTMENT, key: 'watchDeptIds', limit: undefined }, - { type: RelationType.WATCH, object: ObjectType.STAFF, key: 'watchStaffIds', limit: undefined } - ] as const; + 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; + } - type RelationResult = { - [key in typeof relationTypes[number]['key']]: string[]; - }; + /** + * 删除现有单位并清理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; + } - const promises = relationTypes.map(async ({ type, object, key, limit }) => ({ - [key]: await this.relations.getEROBids(ObjectType.TERM, type, object, term.id, limit) - })); + // 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 = await Promise.all(promises); - const mergedResults = Object.assign({}, ...(results as Partial[])); + // const results = getUniqueItems( + // [...initialTerms, ...terms].filter(Boolean), + // 'id', + // ); + // return results; + // } + // /** - return { ...others, ...mergedResults, permissions, hasChildren: term.children.length > 0 }; + 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' }], + }); } - - /** - * 获取特定父节点下新的排序值。 - * - * @param parentId 父节点ID,如果是根节点则为null - * @returns 下一个排序值 - */ - private async getNextOrder(parentId?: string) { - let newOrder = 0; - - if (parentId) { - const siblingTerms = await db.term.findMany({ - where: { parentId }, - orderBy: { order: 'desc' }, - take: 1, - }); - - if (siblingTerms.length > 0) { - newOrder = siblingTerms[0].order + 1; - } - } else { - const rootTerms = await db.term.findMany({ - where: { parentId: null }, - orderBy: { order: 'desc' }, - take: 1, - }); - - if (rootTerms.length > 0) { - newOrder = rootTerms[0].order + 1; - } + // 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[]; + } - return newOrder; - } + 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; - /** - * 创建关系数据用于批量插入。 - * - * @param termId 术语ID - * @param watchDeptIds 监控部门ID数组 - * @param watchStaffIds 监控员工ID数组 - * @returns 关系数据数组 - */ - private createRelations( - termId: string, - watchDeptIds: string[], - watchStaffIds: string[] - ) { - const relationsData = [ - ...watchDeptIds.map(bId => this.relations.buildRelation(termId, bId, ObjectType.TERM, ObjectType.DEPARTMENT, RelationType.WATCH)), - ...watchStaffIds.map(bId => this.relations.buildRelation(termId, bId, ObjectType.TERM, ObjectType.STAFF, RelationType.WATCH)), - ]; - return relationsData; - } - - /** - * 创建一个新的术语并根据需要创建祖先关系。 - * - * @param data 创建新术语的数据 - * @returns 新创建的术语 - */ - async create(staff: Staff, data: z.infer) { - const { parentId, watchDeptIds = [], watchStaffIds = [], ...others } = data; - - return await db.$transaction(async (trx) => { - const order = await this.getNextOrder(parentId); - - const newTerm = await trx.term.create({ - data: { - ...others, - parentId, - order, - createdBy: staff.id - }, - }); - - if (parentId) { - const parentTerm = await trx.term.findUnique({ - where: { id: parentId }, - include: { ancestors: true }, - }); - - const ancestries = parentTerm.ancestors.map((ancestor) => ({ - ancestorId: ancestor.ancestorId, - descendantId: newTerm.id, - relDepth: ancestor.relDepth + 1, - })); - - ancestries.push({ - ancestorId: parentTerm.id, - descendantId: newTerm.id, - relDepth: 1, - }); - - await trx.termAncestry.createMany({ data: ancestries }); - } - - const relations = this.createRelations(newTerm.id, watchDeptIds, watchStaffIds); - await trx.relation.createMany({ data: relations }); - return newTerm; - }); - } - - /** - * 更新现有术语的数据,并在parentId改变时管理术语祖先关系。 - * - * @param data 更新术语的数据 - * @returns 更新后的术语 - */ - async update(data: z.infer) { - return await db.$transaction(async (prisma) => { - const currentTerm = await prisma.term.findUnique({ - where: { id: data.id }, - }); - if (!currentTerm) throw new Error('Term not found'); - console.log(data) - const updatedTerm = await prisma.term.update({ - where: { id: data.id }, - data, - }); - - if (data.parentId !== currentTerm.parentId) { - await prisma.termAncestry.deleteMany({ - where: { descendantId: data.id }, - }); - - if (data.parentId) { - const parentAncestries = await prisma.termAncestry.findMany({ - where: { descendantId: data.parentId }, - }); - - const newAncestries = parentAncestries.map(ancestry => ({ - ancestorId: ancestry.ancestorId, - descendantId: data.id, - relDepth: ancestry.relDepth + 1, - })); - - newAncestries.push({ - ancestorId: data.parentId, - descendantId: data.id, - relDepth: 1, - }); - - await prisma.termAncestry.createMany({ - data: newAncestries, - }); - - const order = await this.getNextOrder(data.parentId); - await prisma.term.update({ - where: { id: data.id }, - data: { order }, - }); - } - } - - if (data.watchDeptIds || data.watchStaffIds) { - await prisma.relation.deleteMany({ where: { aId: data.id, relationType: { in: [RelationType.WATCH] } } }); - - const relations = this.createRelations( - data.id, - data.watchDeptIds ?? [], - data.watchStaffIds ?? [] - ); - - await prisma.relation.createMany({ data: relations }); - } - - return updatedTerm; - }); - } - - /** - * 根据ID删除现有术语。 - * - * @param data 删除术语的数据 - * @returns 被删除的术语 - */ - async delete(data: z.infer) { - const { id } = data; - - await db.termAncestry.deleteMany({ - where: { OR: [{ ancestorId: id }, { descendantId: id }] }, - }); - - const deletedTerm = await db.term.update({ - where: { id }, - data: { - deletedAt: new Date(), - }, - }); - - return deletedTerm; - } - - /** - * 批量删除术语。 - * - * @param ids 要删除的术语ID数组 - * @returns 已删除的术语列表 - */ - async batchDelete(ids: string[]) { - await db.termAncestry.deleteMany({ - where: { OR: [{ ancestorId: { in: ids } }, { descendantId: { in: ids } }] }, - }); - - const deletedTerms = await db.term.updateMany({ - where: { id: { in: ids } }, - data: { - deletedAt: new Date(), - } - }); - - return deletedTerms; - } - - /** - * 查找唯一术语并生成TermDto对象。 - * - * @param staff 当前操作的工作人员 - * @param id 术语ID - * @returns 包含详细信息的术语对象 - */ - async findUnique(staff: Staff, id: string) { - const term = await db.term.findUnique({ + 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: { - id, - }, - include: { - domain: true, - children: true, - }, - }); - return await this.genTermDto(staff, term); - } - - /** - * 获取指定条件下的术语子节点。 - * - * @param staff 当前操作的工作人员 - * @param data 查询条件 - * @returns 子节点术语列表 - */ - async getChildren(staff: Staff, data: z.infer) { - const { parentId, domainId, taxonomyId, cursor, limit = 10 } = data; - const extraCondition = await this.permissionService.getTermExtraConditions(staff); - let queryCondition: Prisma.TermWhereInput = { taxonomyId, parentId: parentId === undefined ? null : parentId, domainId, deletedAt: null } - const whereCondition: Prisma.TermWhereInput = { - AND: [extraCondition, queryCondition], - }; - console.log(JSON.stringify(whereCondition)) - const terms = await db.term.findMany({ - where: whereCondition, - 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.genTermDto(staff, item))); - return { - items: termDtos, - nextCursor, - }; - } - /** - * 获取指定条件下的所有术语子节点。 - * - * @param staff 当前操作的工作人员 - * @param data 查询条件 - * @returns 子节点术语列表 - */ - async getAllChildren(staff: Staff, data: z.infer) { - const { parentId, domainId, taxonomyId } = data; - const extraCondition = await this.permissionService.getTermExtraConditions(staff); - let queryCondition: Prisma.TermWhereInput = { taxonomyId, parentId: parentId === undefined ? null : parentId, domainId, deletedAt: null } - - const whereCondition: Prisma.TermWhereInput = { - AND: [extraCondition, queryCondition], - }; - console.log(JSON.stringify(whereCondition)) - const terms = await db.term.findMany({ - where: whereCondition, - include: { - children: { - where: { - deletedAt: null, - }, - }, - }, - }); - return await Promise.all(terms.map((item) => this.genTermDto(staff, item))); - - } - - /** - * 根据关键词或ID集合查找术语 - * @param data 包含关键词、域ID和ID集合的对象 - * @returns 匹配的术语记录列表 - */ - async findMany(data: z.infer) { - const { keyword, taxonomyId, ids } = data; - - return await db.term.findMany({ - where: { - deletedAt: null, - taxonomyId, + ...(termIds && { OR: [ - { name: { contains: keyword } }, - { - id: { in: ids } - } - ] + ...(validTermIds.length + ? [{ id: { in: validTermIds } }] + : []), + ], + }), + taxonomyId: taxonomyId, + // 动态权限控制条件 + ...(hasAnyPerms + ? {} // 当有全局权限时,不添加任何额外条件 + : { + // 当无全局权限时,添加域ID过滤 + OR: [ + { domainId: null }, // 通用记录 + { domainId: domainId }, // 特定域记录 + ], + }), }, - orderBy: { order: "asc" }, - take: 20 - }); - } + 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 100644 index 0000000..8271d15 --- /dev/null +++ b/apps/server/src/models/term/utils.ts @@ -0,0 +1,24 @@ +import { TreeDataNode } from '@nicestack/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/transform/transform.module.ts b/apps/server/src/models/transform/transform.module.ts old mode 100644 new mode 100755 similarity index 51% rename from apps/server/src/transform/transform.module.ts rename to apps/server/src/models/transform/transform.module.ts index 20e5e3b..e7d2550 --- a/apps/server/src/transform/transform.module.ts +++ b/apps/server/src/models/transform/transform.module.ts @@ -1,15 +1,21 @@ import { Module } from '@nestjs/common'; -import { TransformService } from './transform.service'; import { TransformRouter } from './transform.router'; -import { TrpcService } from '@server/trpc/trpc.service'; -import { DepartmentModule } from '@server/models/department/department.module'; -import { StaffModule } from '@server/models/staff/staff.module'; -import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module'; +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, TaxonomyModule, TermModule], + imports: [ + DepartmentModule, + StaffModule, + TermModule, + TaxonomyModule, + ], providers: [TransformService, TransformRouter, TrpcService], - exports: [TransformRouter] + exports: [TransformRouter, TransformService], + // controllers:[TransformController] }) -export class TransformModule { } +export class TransformModule {} diff --git a/apps/server/src/transform/transform.router.ts b/apps/server/src/models/transform/transform.router.ts old mode 100644 new mode 100755 similarity index 68% rename from apps/server/src/transform/transform.router.ts rename to apps/server/src/models/transform/transform.router.ts index 6183933..69356c5 --- a/apps/server/src/transform/transform.router.ts +++ b/apps/server/src/models/transform/transform.router.ts @@ -1,32 +1,35 @@ import { Injectable } from '@nestjs/common'; import { TransformService } from './transform.service'; -import { TransformSchema } from '@nicestack/common'; -import { TrpcService } from '../trpc/trpc.service'; +import { TransformMethodSchema} from '@nicestack/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(TransformSchema.importTerms) // expect input according to the schema + .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(TransformSchema.importDepts) // expect input according to the schema + .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(TransformSchema.importStaffs) // expect input according to the schema + .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..51efdc0 --- /dev/null +++ b/apps/server/src/models/transform/transform.service.ts @@ -0,0 +1,551 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as ExcelJS from 'exceljs'; +import { + TroubleType, + TroubleState, + TransformMethodSchema, + db, + Prisma, + Staff, + +} from '@nicestack/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 100644 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 100644 index 0000000..1a274a0 --- /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 { ChangedRows, ObjectType, Prisma } from '@nicestack/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 100644 index 0000000..690c318 --- /dev/null +++ b/apps/server/src/models/visit/visit.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { BaseService } from '../base/base.service'; +import { + UserProfile, + db, + ObjectType, + Prisma, + VisitType, +} from '@nicestack/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, troubleId, messageId } = args.data; + const visitorId = args.data.visitorId || staff?.id; + let result; + const existingVisit = await db.visit.findFirst({ + where: { + visitType: args.data.visitType, + visitorId, + OR: [{ postId }, { troubleId }, { messageId }], + }, + }); + if (!existingVisit) { + result = await super.create(args); + } else if (args.data.visitType === VisitType.READED) { + result = await super.update({ + where: { id: existingVisit.id }, + data: { + ...args.data, + views: existingVisit.views + 1, + }, + }); + } + + if (troubleId && args.data.visitType === 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 = []; + const createData = []; + await Promise.all( + data.map(async (item) => { + item.visitorId = item.visitorId || staff?.id; + const { postId, troubleId, messageId, visitorId } = item; + const existingVisit = await db.visit.findFirst({ + where: { + visitorId, + OR: [{ postId }, { troubleId }, { 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/general/general-queue.listener.ts b/apps/server/src/queue/general/general-queue.listener.ts deleted file mode 100644 index e31328d..0000000 --- a/apps/server/src/queue/general/general-queue.listener.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - QueueEventsListener, - QueueEventsHost, - OnQueueEvent, - InjectQueue, -} from '@nestjs/bullmq'; -import { SocketGateway } from '@server/socket/socket.gateway'; -import { Queue } from 'bullmq'; - - -@QueueEventsListener('general') -export class GeneralQueueEvents extends QueueEventsHost { - constructor(@InjectQueue('general') private generalQueue: Queue, private socketGateway: SocketGateway) { - super() - } - - @OnQueueEvent('completed') - async onCompleted({ - jobId, - returnvalue - }: { - jobId: string; - returnvalue: string; - prev?: string; - }) { - - } - @OnQueueEvent("progress") - async onProgress({ jobId, data }: { jobId: string, data: any }) { - - } -} \ No newline at end of file diff --git a/apps/server/src/queue/general/general-queue.service.ts b/apps/server/src/queue/general/general-queue.service.ts deleted file mode 100644 index e5ba137..0000000 --- a/apps/server/src/queue/general/general-queue.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { InjectQueue } from '@nestjs/bullmq'; -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { Job, Queue } from 'bullmq'; -import { SocketGateway } from '@server/socket/socket.gateway'; -@Injectable() -export class GeneralQueueService implements OnModuleInit { - private readonly logger = new Logger(GeneralQueueService.name,) - constructor(@InjectQueue('general') private generalQueue: Queue, private socketGateway: SocketGateway) { } - onModuleInit() { - this.logger.log(`general queue service init at pid=${process.pid}`) - - } - async addJob(data: any) { - this.logger.log('add embedding job', data.title) - - await this.generalQueue.add('embedding', data, { debounce: { id: data.id } }); - - } - async getWaitingJobs() { - const waitingJobs = await this.generalQueue.getJobs(["waiting"]) - return waitingJobs - } -} diff --git a/apps/server/src/queue/general/general.service.ts b/apps/server/src/queue/general/general.service.ts deleted file mode 100644 index 6d44fab..0000000 --- a/apps/server/src/queue/general/general.service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import axios, { AxiosInstance } from 'axios'; -import { Injectable, Logger } from '@nestjs/common'; - -@Injectable() -export class GeneralService { - private axiosInstance: AxiosInstance; - private logger: Logger; - - constructor() { - const PYTHON_ENDPOINT = process.env.PYTHON_URL || 'http://localhost:8000'; - this.logger = new Logger(GeneralService.name); - this.axiosInstance = axios.create({ - baseURL: PYTHON_ENDPOINT, - timeout: 120000, // 设置请求超时时间 - }); - } -} diff --git a/apps/server/src/queue/job.interface.ts b/apps/server/src/queue/job.interface.ts new file mode 100755 index 0000000..df8b45e --- /dev/null +++ b/apps/server/src/queue/job.interface.ts @@ -0,0 +1,4 @@ +export type CustomJobType = "pushMessage" | "updateTroubleViewCount" +export type updateViewCountJobData = { + id: string +} \ No newline at end of file diff --git a/apps/server/src/queue/push/push.queue.service.ts b/apps/server/src/queue/push/push.queue.service.ts new file mode 100755 index 0000000..f5717bb --- /dev/null +++ b/apps/server/src/queue/push/push.queue.service.ts @@ -0,0 +1,52 @@ +import { InjectQueue } from "@nestjs/bullmq"; +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { Queue } from "bullmq"; +import { db, getUniqueItems, MessageDto, ObjectType } from "@nicestack/common" +import { MessageContent } from "./push.service"; +import EventBus, { CrudOperation } from "@server/utils/event-bus"; +export interface PushMessageJobData { + id: string + registerToken: string + messageContent: MessageContent +} +@Injectable() +export class PushQueueService implements OnModuleInit { + private readonly logger = new Logger(PushQueueService.name) + constructor(@InjectQueue('general') private generalQueue: Queue) { } + onModuleInit() { + EventBus.on("dataChanged", async ({ data, type, operation }) => { + if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) { + const message = data as Partial + const uniqueStaffs = getUniqueItems(message.receivers, "id") + uniqueStaffs.forEach(item => { + const token = item.registerToken + if (token) { + this.addPushMessageJob({ + registerToken: token, + messageContent: { + data: { + title: message.title, + content: message.content, + click_action: { + intent: message.intent, + url: message.url + } + }, + option: message.option as any + }, + id: message.id + }) + } else { + this.logger.warn(`用户 ${item.username} 尚未注册registerToken取消消息推送`) + } + + }) + } + }) + } + async addPushMessageJob(data: PushMessageJobData) { + this.logger.log("add push message task", data.registerToken) + await this.generalQueue.add('pushMessage', data, { debounce: { id: data.id } }) + } + +} \ No newline at end of file diff --git a/apps/server/src/queue/push/push.service.ts b/apps/server/src/queue/push/push.service.ts new file mode 100755 index 0000000..f6c146d --- /dev/null +++ b/apps/server/src/queue/push/push.service.ts @@ -0,0 +1,124 @@ +import { Injectable, InternalServerErrorException, BadRequestException, UnauthorizedException, NotFoundException } from '@nestjs/common'; +import axios, { AxiosResponse } from 'axios'; +interface LoginResponse { + retcode: string; + message: string; + authtoken?: string; +} + +interface MessagePushResponse { + retcode: string; + message: string; + messageid?: string; +} + +interface Notification { + title: string; // 通知标题(不超过128字节) / Title of notification (upper limit is 128 bytes) + content?: string; // 通知内容(不超过256字节) / Content of notification (upper limit is 256 bytes) + click_action?: { + url?: string; // 点击通知栏消息,打开指定的URL地址 / URL to open when notification is clicked + intent?: string; // 点击通知栏消息,用户收到通知栏消息后点击通知栏消息打开应用定义的这个Intent页面 / Intent page to open in the app when notification is clicked + }; +} + +interface Option { + key: string; + value: string; +} + +export interface MessageContent { + data: Notification; + option?: Option; +} + +@Injectable() +export class PushService { + private readonly baseURL = process.env.PUSH_URL; + private readonly appid = process.env.PUSH_APPID; + private readonly appsecret = process.env.PUSH_APPSECRET; + private authToken: string | null = null; + async login(): Promise { + if (this.authToken) { + return { retcode: '200', message: 'Already logged in', authtoken: this.authToken }; + } + const url = `${this.baseURL}/push/1.0/login`; + const response: AxiosResponse = await axios.post(url, { + appid: this.appid, + appsecret: this.appsecret, + }); + this.handleError(response.data.retcode); + + this.authToken = response.data.authtoken; + return response.data; + } + + async messagePush( + registerToken: string, + messageContent: MessageContent, + ): Promise { + if (!this.authToken) { + await this.login(); + } + const url = `${this.baseURL}/push/1.0/messagepush`; + const payload = { + appid: this.appid, + appsecret: this.appsecret, + authtoken: this.authToken, + registertoken: registerToken, + messagecontent: JSON.stringify(messageContent), + }; + const response: AxiosResponse = await axios.post(url, payload); + this.handleError(response.data.retcode); + return response.data; + } + + private handleError(retcode: string): void { + switch (retcode) { + case '000': + case '200': + return; + case '001': + throw new BadRequestException('JID is illegal'); + case '002': + throw new BadRequestException('AppID is illegal'); + case '003': + throw new BadRequestException('Protoversion is mismatch'); + case '010': + throw new BadRequestException('The application of AppID Token is repeated'); + case '011': + throw new BadRequestException('The number of applications for token exceeds the maximum'); + case '012': + throw new BadRequestException('Token is illegal'); + case '013': + throw new UnauthorizedException('Integrity check failed'); + case '014': + throw new BadRequestException('Parameter is illegal'); + case '015': + throw new InternalServerErrorException('Internal error'); + case '202': + throw new UnauthorizedException('You are already logged in'); + case '205': + return; + case '500': + throw new InternalServerErrorException('Internal Server Error'); + case '502': + throw new InternalServerErrorException('Session loading error'); + case '503': + throw new InternalServerErrorException('Service Unavailable'); + case '504': + throw new NotFoundException('Parameters not found'); + case '505': + throw new BadRequestException('Parameters are empty or not as expected'); + case '506': + throw new InternalServerErrorException('Database error'); + case '508': + throw new InternalServerErrorException('NoSuchAlgorithmException'); + case '509': + throw new UnauthorizedException('Authentication Failed'); + case '510': + throw new UnauthorizedException('Illegal token... client does not exist'); + default: + throw new InternalServerErrorException('Unexpected error occurred'); + } + } +} diff --git a/apps/server/src/queue/queue.module.ts b/apps/server/src/queue/queue.module.ts old mode 100644 new mode 100755 index 4b38394..8729029 --- a/apps/server/src/queue/queue.module.ts +++ b/apps/server/src/queue/queue.module.ts @@ -1,22 +1,31 @@ import { BullModule } from '@nestjs/bullmq'; import { Logger, Module } from '@nestjs/common'; - +import { ConfigModule, ConfigService } from '@nestjs/config'; import { join } from 'path'; -import { SocketGateway } from '@server/socket/socket.gateway'; +import { PushService } from './push/push.service'; +import { PushQueueService } from './push/push.queue.service'; @Module({ - imports: [ - BullModule.forRoot({ - connection: { - host: 'localhost', - port: 6379, - }, - }), BullModule.registerQueue({ - name: 'general', - processors: [join(__dirname, 'worker/processor.js')], - }) - ], - providers: [Logger, SocketGateway], - exports: [] + 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')], + }) + ], + providers: [Logger, PushService, PushQueueService], + exports: [PushService, PushQueueService] + }) export class QueueModule { } diff --git a/apps/server/src/queue/worker/processor.ts b/apps/server/src/queue/worker/processor.ts old mode 100644 new mode 100755 index 9f1ef68..c6620c0 --- a/apps/server/src/queue/worker/processor.ts +++ b/apps/server/src/queue/worker/processor.ts @@ -1,5 +1,18 @@ import { Job } from 'bullmq'; -export default async function (job: Job) { +import { Logger } from '@nestjs/common'; +import { CustomJobType } from '../job.interface'; +import { PushService } from '@server/queue/push/push.service'; +const logger = new Logger("QueueWorker"); +const pushService = new PushService() +export default async function (job: Job) { + + switch (job.name) { + case "pushMessage": + + logger.log(`push message ${job.data.id}`) + pushService.messagePush(job.data.registerToken, job.data.messageContent) + break + } } \ No newline at end of file diff --git a/apps/server/src/rbac/role.router.ts b/apps/server/src/rbac/role.router.ts deleted file mode 100644 index 274229f..0000000 --- a/apps/server/src/rbac/role.router.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { TrpcService } from "@server/trpc/trpc.service"; -import { RoleService } from "./role.service"; -import { z, RoleSchema } from "@nicestack/common"; - -@Injectable() -export class RoleRouter { - constructor( - private readonly trpc: TrpcService, - private readonly roleService: RoleService - ) { } - - router = this.trpc.router({ - create: this.trpc.protectProcedure.input(RoleSchema.create).mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.roleService.create(input); - }), - batchDelete: this.trpc.protectProcedure.input(RoleSchema.batchDelete).mutation(async ({ input }) => { - return await this.roleService.batchDelete(input); - }), - update: this.trpc.protectProcedure.input(RoleSchema.update).mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.roleService.update(input); - }), - paginate: this.trpc.protectProcedure.input(RoleSchema.paginate).query(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.roleService.paginate(input); - }), - findMany: this.trpc.procedure - .input(RoleSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.roleService.findMany(input); - }) - } - ) -} - diff --git a/apps/server/src/rbac/role.service.ts b/apps/server/src/rbac/role.service.ts deleted file mode 100644 index 685f739..0000000 --- a/apps/server/src/rbac/role.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { db, z, RoleSchema, ObjectType, Role, RoleMap } from "@nicestack/common"; -import { DepartmentService } from '@server/models/department/department.service'; -import { TRPCError } from '@trpc/server'; - -@Injectable() -export class RoleService { - constructor( - private readonly departmentService: DepartmentService - ) { } - - /** - * 创建角色 - * @param data 包含创建角色所需信息的数据 - * @returns 创建的角色 - */ - async create(data: z.infer) { - - // 开启事务 - return await db.$transaction(async (prisma) => { - // 创建角色 - return await prisma.role.create({ data }); - }); - } - - /** - * 更新角色 - * @param data 包含更新角色所需信息的数据 - * @returns 更新后的角色 - */ - async update(data: z.infer) { - const { id, ...others } = data; - - // 开启事务 - return await db.$transaction(async (prisma) => { - // 更新角色 - const updatedRole = await prisma.role.update({ - where: { id }, - data: { ...others } - }); - - return updatedRole; - }); - } - - /** - * 批量删除角色 - * @param data 包含要删除的角色ID列表的数据 - * @returns 删除结果 - * @throws 如果未提供ID,将抛出错误 - */ - async batchDelete(data: z.infer) { - const { ids } = data; - if (!ids || ids.length === 0) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'No IDs provided for deletion.' - }); - } - - // 开启事务 - return await db.$transaction(async (prisma) => { - const deletedRoles = await prisma.role.updateMany({ - where: { - id: { in: ids } - }, - data: { deletedAt: new Date() } - }); - - await prisma.roleMap.deleteMany({ - where: { - roleId: { - in: ids - } - } - }); - - if (!deletedRoles.count) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'No roles were found with the provided IDs.' - }); - } - - return { success: true, count: deletedRoles.count }; - }); - } - - /** - * 分页获取角色 - * @param data 包含分页信息的数据 - * @returns 分页结果,包含角色列表和总数 - */ - async paginate(data: z.infer) { - const { page, pageSize } = data; - const [items, totalCount] = await Promise.all([ - db.role.findMany({ - skip: (page - 1) * pageSize, - take: pageSize, - orderBy: { name: "asc" }, - where: { deletedAt: null }, - include: { - roleMaps: true, - } - }), - db.role.count({ where: { deletedAt: null } }), - ]); - const result = { items, totalCount }; - return result; - } - - /** - * 根据关键字查找多个角色 - * @param data 包含关键字的数据 - * @returns 查找到的角色列表 - */ - async findMany(data: z.infer) { - const { keyword } = data - return await db.role.findMany({ - where: { - deletedAt: null, - OR: [ - { - name: { - contains: keyword - } - } - ] - }, - orderBy: { name: "asc" }, - take: 10 - }) - } -} diff --git a/apps/server/src/rbac/rolemap.router.ts b/apps/server/src/rbac/rolemap.router.ts deleted file mode 100644 index ac335b4..0000000 --- a/apps/server/src/rbac/rolemap.router.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { TrpcService } from '@server/trpc/trpc.service'; -import { RoleMapSchema } from '@nicestack/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(RoleMapSchema.deleteWithObject) - .mutation(({ input }) => - this.roleMapService.deleteAllRolesForObject(input), - ), - setRoleForObject: this.trpc.protectProcedure - .input(RoleMapSchema.create) - .mutation(({ input }) => this.roleMapService.setRoleForObject(input)), - createManyObjects: this.trpc.protectProcedure - .input(RoleMapSchema.createManyObjects) - .mutation(({ input }) => this.roleMapService.createManyObjects(input)), - setRolesForObject: this.trpc.protectProcedure - .input(RoleMapSchema.createManyRoles) - .mutation(({ input }) => this.roleMapService.setRolesForObject(input)), - - getPermsForObject: this.trpc.procedure - .input(RoleMapSchema.getPermsForObject) - .query(({ input }) => this.roleMapService.getPermsForObject(input)), - batchDelete: this.trpc.protectProcedure - .input(RoleMapSchema.batchDelete) // Assuming RoleMapSchema.batchDelete is the Zod schema for batch deleting staff - .mutation(async ({ input }) => { - return await this.roleMapService.batchDelete(input); - }), - - paginate: this.trpc.procedure - .input(RoleMapSchema.paginate) // Define the input schema for pagination - .query(async ({ input }) => { - return await this.roleMapService.paginate(input); - }), - update: this.trpc.protectProcedure - .input(RoleMapSchema.update) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.roleMapService.update(input); - }), - getRoleMapDetail: this.trpc.procedure - .input(RoleMapSchema.getRoleMapDetail) - .query(async ({ input }) => { - return await this.roleMapService.getRoleMapDetail(input); - }), - }); -} diff --git a/apps/server/src/rbac/rolemap.service.ts b/apps/server/src/rbac/rolemap.service.ts deleted file mode 100644 index ec5dc89..0000000 --- a/apps/server/src/rbac/rolemap.service.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { db, z, RoleMapSchema, ObjectType } from '@nicestack/common'; -import { DepartmentService } from '@server/models/department/department.service'; -import { TRPCError } from '@trpc/server'; - -@Injectable() -export class RoleMapService { - constructor(private readonly departmentService: DepartmentService) { } - - /** - * 删除某对象的所有角色 - * @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 createManyObjects( - data: z.infer, - ) { - const { domainId, roleId, objectIds, objectType } = data; - const roleMaps = objectIds.map((id) => ({ - domainId, - objectId: id, - roleId, - objectType, - })); - - // 开启事务 - return await db.$transaction(async (prisma) => { - // 首先,删除现有的角色映射 - await prisma.roleMap.deleteMany({ - where: { - domainId, - roleId, - objectType, - }, - }); - // 然后,创建新的角色映射 - return await prisma.roleMap.createMany({ - data: roleMaps, - }); - }); - } - - /** - * 为某对象设置多个角色 - * @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; - - let ancestorDeptIds = []; - if (deptId) { - ancestorDeptIds = - await this.departmentService.getAllParentDeptIds(deptId); - } - - const userRoles = await db.roleMap.findMany({ - where: { - AND: { - domainId, - OR: [ - { - objectId: staffId, - objectType: ObjectType.STAFF - }, - (deptId ? { - objectId: { in: [deptId, ...ancestorDeptIds] }, - objectType: ObjectType.DEPARTMENT, - } : {}), - ], - }, - }, - include: { role: true }, - }); - - return userRoles.flatMap((userRole) => userRole.role.permissions); - } - - /** - * 批量删除角色映射 - * @param data 包含要删除的角色映射ID列表的数据 - * @returns 删除结果 - * @throws 如果未提供ID,将抛出错误 - */ - async batchDelete(data: z.infer) { - const { ids } = data; - - if (!ids || ids.length === 0) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'No IDs provided for deletion.', - }); - } - - const deletedRoleMaps = await db.roleMap.deleteMany({ - where: { id: { in: ids } }, - }); - - if (!deletedRoleMaps.count) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'No taxonomies were found with the provided IDs.', - }); - } - - return { success: true, count: deletedRoleMaps.count }; - } - - /** - * 分页获取角色映射 - * @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 }; - } - - /** - * 更新角色映射 - * @param data 包含更新信息的数据 - * @returns 更新后的角色映射 - */ - async update(data: z.infer) { - const { id, ...others } = data; - - // 开启事务 - return await db.$transaction(async (prisma) => { - // 更新角色映射 - const updatedRoleMap = await prisma.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/rbac/roleperms.service.ts b/apps/server/src/rbac/roleperms.service.ts deleted file mode 100755 index 8a93a8f..0000000 --- a/apps/server/src/rbac/roleperms.service.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - Prisma, - ObjectType, - RolePerms, - RelationType, - db, - Staff, - Term, - GenPerms, -} from '@nicestack/common'; -import { DepartmentService } from '@server/models/department/department.service'; -import { RelationService } from '@server/relation/relation.service'; -import { RoleMapService } from './rolemap.service'; -type RolePermsHandlers = { - [key in RolePerms]?: (permissions: GenPerms) => void; -}; - -@Injectable() -export class RolePermsService { - constructor( - private readonly relations: RelationService, - private readonly departments: DepartmentService, - private readonly rbac: RoleMapService, - ) { } - private readonly logger = new Logger(RolePermsService.name); - async getStaffPerms(staff: Staff) { - const staffPerms = await this.rbac.getPermsForObject({ - domainId: staff.domainId, - staffId: staff.id, - deptId: staff.deptId, - }); - return staffPerms; - } - async getTermPerms(staff: Staff, term: Term) { - const termPerms: GenPerms = { - delete: false, - edit: false, - read: false, - }; - const staffPerms = await this.getStaffPerms(staff) - const isInDomain = staff.domainId === term.domainId; - const watchDeptIds = await this.relations.getEROBids( - ObjectType.TERM, - RelationType.WATCH, - ObjectType.DEPARTMENT, - term.id, - ); - const watchStaffIds = await this.relations.getEROBids( - ObjectType.TERM, - RelationType.WATCH, - ObjectType.STAFF, - term.id, - ); - const canWatch = - watchDeptIds.includes(staff.deptId) || watchStaffIds.includes(staff.id); - if (canWatch) { - Object.assign(termPerms, { read: true }); - } - const applyRolePerms = (perm: RolePerms) => { - const handlers: RolePermsHandlers = { - [RolePerms.EDIT_ANY_TERM]: (p) => Object.assign(p, { edit: true }), - [RolePerms.EDIT_DOM_TERM]: (p) => - isInDomain && Object.assign(p, { edit: true }), - [RolePerms.READ_DOM_TERM]: (p) => - isInDomain && Object.assign(p, { read: true }), - [RolePerms.READ_ANY_TERM]: (p) => Object.assign(p, { read: true }), - [RolePerms.DELETE_ANY_TERM]: (p) => Object.assign(p, { delete: true }), - [RolePerms.DELETE_DOM_TERM]: (p) => - isInDomain && Object.assign(p, { delete: true }), - }; - handlers[perm]?.(termPerms); - }; - staffPerms.forEach(applyRolePerms); - return termPerms; - } - - /** - * Build conditions for querying message comments. - * @param staff - The staff details to build conditions. - * @returns A string representing the SQL condition for message comments. - */ - async buildCommentExtraQuery( - staff: Staff, - aId: string, - aType: ObjectType, - relationType: RelationType, - ): Promise { - const { id: staffId, deptId } = staff; - const ancestorDeptIds = await this.departments.getAllParentDeptIds(deptId); - let queryString = ''; - if (relationType === RelationType.MESSAGE) { - queryString = ` - c.id IN ( - SELECT "aId" - FROM relations - WHERE ( - "bId" = '${staffId}' AND - "bType" = '${ObjectType.STAFF}' AND - "aType" = '${ObjectType.COMMENT}' AND - "relationType" = '${RelationType.MESSAGE}' - ) - `; - - if (ancestorDeptIds.length > 0) { - queryString += ` - OR ( - "bId" IN (${[...ancestorDeptIds, deptId].map((id) => `'${id}'`).join(', ')}) AND - "bType" = '${ObjectType.DEPARTMENT}' AND - "aType" = '${ObjectType.COMMENT}' AND - "relationType" = '${RelationType.MESSAGE}' - ) - `; - } - - queryString += `)`; - } else { - queryString = ` - c.id IN ( - SELECT "bId" - FROM relations - WHERE ( - "aId" = '${aId}' AND - "aType" = '${aType}' AND - "bType" = '${ObjectType.COMMENT}' AND - "relationType" = '${relationType}' - ) - `; - queryString += `)`; - } - - return queryString; - } - async getTermExtraConditions(staff: Staff) { - const { domainId, id: staffId, deptId } = staff; - const staffPerms = await this.getStaffPerms(staff) - - const ancestorDeptIds = await this.departments.getAllParentDeptIds(deptId); - - if (staffPerms.includes(RolePerms.READ_ANY_TERM)) { - return {}; - } - const relevantRelations = await db.relation.findMany({ - where: { - OR: [ - { - bId: staffId, - bType: ObjectType.STAFF, - aType: ObjectType.TERM, - relationType: RelationType.WATCH, - }, - { - bId: { in: ancestorDeptIds }, - bType: ObjectType.DEPARTMENT, - aType: ObjectType.TERM, - relationType: RelationType.WATCH, - }, - ], - }, - select: { aId: true }, - }); - - const termIds = relevantRelations.map((relation) => relation.aId); - const ownedTermIds = await db.term.findMany({ - select: { - id: true, - }, - where: { - createdBy: staffId, - }, - }); - const conditions: Prisma.TermWhereInput = { - OR: [ - { - id: { - in: [...termIds, ...ownedTermIds.map((item) => item.id)], - }, - }, - ], - }; - - if (domainId && staffPerms.includes(RolePerms.READ_DOM_TERM)) { - conditions.OR.push({ - OR: [{ domainId: null }, { domainId: domainId }], - }); - } - return conditions; - } -} diff --git a/apps/server/src/redis/redis.module.ts b/apps/server/src/redis/redis.module.ts deleted file mode 100644 index f5759a9..0000000 --- a/apps/server/src/redis/redis.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -// redis.module.ts -import { Module } from '@nestjs/common'; -import { RedisService } from './redis.service'; -import { ConfigService } from '@nestjs/config'; - -@Module({ - providers: [RedisService, ConfigService], // 注册 RedisService 作为提供者 - exports: [RedisService], // 导出 RedisService - -}) -export class RedisModule { } diff --git a/apps/server/src/redis/redis.service.ts b/apps/server/src/redis/redis.service.ts deleted file mode 100644 index efc0bc6..0000000 --- a/apps/server/src/redis/redis.service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import Redis from 'ioredis'; - -@Injectable() -export class RedisService { - private readonly redisClient: Redis; - - constructor(private readonly configService: ConfigService) { - this.redisClient = new Redis({ - host: configService.get('REDIS_HOST'), - port: configService.get('REDIS_PORT'), // Redis 服务器的端口 - }); - } - - 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) { - return this.redisClient.del(key); - } - setHashField(key: string, field: string, value: string) { - return this.redisClient.hset(key, field, value); - } - //获取key中的field字段数据 - getHashField(key: string, field: string) { - return this.redisClient.hget(key, field); - } - //获取key中所有数据 - 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) { - console.error('Subscription error', err); - } else { - console.log(`Subscribed to ${count} channels`); - } - }); - - this.redisClient.on('message', (channel, message) => { - console.log(`Received message ${message} from channel ${channel}`); - messageHandler(channel, message); - }); - } - - // 取消订阅指定的频道 - unsubscribeFromChannel(channel: string) { - return this.redisClient.unsubscribe(channel); - } - - // 取消订阅所有频道 - unsubscribeAll() { - return this.redisClient.quit(); - } -} - diff --git a/apps/server/src/relation/relation.service.ts b/apps/server/src/relation/relation.service.ts deleted file mode 100644 index 1ca9bbf..0000000 --- a/apps/server/src/relation/relation.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ObjectType, RelationType, db, Relation } from "@nicestack/common"; - -/** - * Service dealing with relation entities. - */ -@Injectable() -export class RelationService { - - /** - * Create a new relation object. - * - * @param {string} aId - The ID of the related entity. - * @param {string} bId - The ID of the target object. - * @param {ObjectType} bType - The type of the target object. - * @param {RelationType} relationType - The type of the relation. - * @returns {{aId: string, bId: string, aType:ObjectType, bType: ObjectType, relationType: RelationType}} An object representing the created relation. - */ - buildRelation(aId: string, bId: string, aType: ObjectType, bType: ObjectType, relationType: RelationType): { aId: string; bId: string; aType: ObjectType; bType: ObjectType; relationType: RelationType; } { - return { - aId, - bId, - aType, - bType, - relationType - }; - } - - /** - * Find relations based on entity type, relation type, object type, and entity ID. - * - * @param {ObjectType} aType - The type of the entity. - * @param {RelationType} relationType - The type of the relation. - * @param {ObjectType} bType - The type of the object. - * @param {string} aId - The ID of the entity to find relations for. - * @param {number} [limit] - Optional limit on the number of results. - * @returns {Promise} A promise that resolves to an array of relation objects. - */ - async getERO(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string, limit?: number): Promise> { - return await db.relation.findMany({ - where: { - aType, - relationType, - bType, - aId - }, - take: limit // Add the limit if provided - }); - } - /** - * Find relations based on entity type, relation type, object type, and entity ID. - * - * @param {ObjectType} aType - The type of the entity. - * @param {RelationType} relationType - The type of the relation. - * @param {ObjectType} bType - The type of the object. - * @param {string} aId - The ID of the entity to find relations for. - * @param {number} [limit] - Optional limit on the number of results. - * @returns {Promise} A promise that resolves to an array of relation objects. - */ - async getEROCount(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string): Promise { - return await db.relation.count({ - where: { - aType, - relationType, - bType, - aId - } - }); - } - - /** - * Get the IDs of objects related to a specific entity. - * - * @param {ObjectType} aType - The type of the entity. - * @param {RelationType} relationType - The type of the relation. - * @param {ObjectType} bType - The type of the object. - * @param {string} aId - The ID of the entity to get related object IDs for. - * @param {number} [limit] - Optional limit on the number of results. - * @returns {Promise>} A promise that resolves to an array of object IDs. - */ - async getEROBids(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string, limit?: number): Promise> { - const res = await this.getERO(aType, relationType, bType, aId, limit); - return res.map(relation => relation.bId); - } -} 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 100644 index 0000000..babe3ac --- /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 '@nicestack/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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..9c2b3cf --- /dev/null +++ b/apps/server/src/socket/collaboration/ws-shared-doc.ts @@ -0,0 +1,158 @@ +import { readSyncMessage } from '@nicestack/common'; +import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates, writeSyncStep1, writeUpdate } from '@nicestack/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 '@nicestack/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 100644 index 0000000..969d646 --- /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 "@nicestack/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 100644 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 100644 index 0000000..c39e562 --- /dev/null +++ b/apps/server/src/socket/realtime/realtime.server.ts @@ -0,0 +1,31 @@ +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, TroubleDto, MessageDto, PostDto, PostType } from "@nicestack/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.TROUBLE) { + const trouble = data as Partial + this.sendToRoom('troubles', { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.TROUBLE } }) + this.sendToRoom(trouble.id, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.TROUBLE } }) + } + if (type === ObjectType.POST) { + const post = data as Partial + if (post.type === PostType.TROUBLE_INSTRUCTION || post.type === PostType.TROUBLE_PROGRESS) { + this.sendToRoom(post.referenceId, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.POST } }) + } + } + }) + + } + public get serverType(): WebSocketType { + return WebSocketType.REALTIME; + } +} diff --git a/apps/server/src/socket/socket.gateway.ts b/apps/server/src/socket/socket.gateway.ts deleted file mode 100644 index a84f182..0000000 --- a/apps/server/src/socket/socket.gateway.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; -import { Server, Socket } from 'socket.io'; - -@WebSocketGateway(3001, { - namespace: 'library-events', - cors: { - origin: '*', // 或者你可以指定特定的来源,例如 "http://localhost:3000" - methods: ['GET', 'POST'], - credentials: true - } -}) -export class SocketGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { - - @WebSocketServer() server: Server; - - afterInit(server: Server) { - console.log('WebSocket initialized'); - } - - handleConnection(client: Socket, ...args: any[]) { - console.log(`Client connected: ${client.id}`); - } - - handleDisconnect(client: Socket) { - console.log(`Client disconnected: ${client.id}`); - } - -} diff --git a/apps/server/src/socket/types.ts b/apps/server/src/socket/types.ts new file mode 100644 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 100644 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 100644 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 100644 index 0000000..a63b6ae --- /dev/null +++ b/apps/server/src/tasks/init/gendev.service.ts @@ -0,0 +1,234 @@ +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, + TroubleType, +} from '@nicestack/common'; +import * as argon2 from 'argon2'; +import EventBus from '@server/utils/event-bus'; +import { + calculateTroubleAttributes, + capitalizeFirstLetter, + determineState, + DevDataCounts, + getCounts, + getRandomImageLinks +} 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.UNIT]: [], + [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, + currentDepth: number, + maxDepth: number, + count: number, + total: number, + ) { + if (currentDepth > maxDepth) return; + + for (let i = 0; i < count; i++) { + const deptName = `${parentId?.slice(0, 4) || '根'}公司${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 staff = await this.staffService.create({ + data: { + showname: `${dept.name}-user${i}`, + username: `${dept.name}-user${i}`, + 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, + currentDepth: number = 1, + ) { + const department = await this.departmentService.create({ + data: { + name, + isDomain: currentDepth === 1 ? true : false, + parentId, + } + }); + return department; + } + private async createTerms( + domain: Department, + 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..0f9a46c --- /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/init/init.service.ts b/apps/server/src/tasks/init/init.service.ts old mode 100644 new mode 100755 similarity index 51% rename from apps/server/src/init/init.service.ts rename to apps/server/src/tasks/init/init.service.ts index c08701b..b44376e --- a/apps/server/src/init/init.service.ts +++ b/apps/server/src/tasks/init/init.service.ts @@ -1,19 +1,25 @@ import { Injectable, Logger } from '@nestjs/common'; -import { db, InitRoles, InitTaxonomies, ObjectType } from "@nicestack/common"; +import { db, InitAppConfigs, InitRoles, InitTaxonomies, ObjectType } from "@nicestack/common"; import { AuthService } from '@server/auth/auth.service'; -import { MinioService } from '@server/minio/minio.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 authService: AuthService, private readonly minioService: MinioService) { } + 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({ @@ -24,50 +30,69 @@ export class InitService { } } } - private async createTaxonomy() { + private async createOrUpdateTaxonomy() { this.logger.log('Checking existing taxonomies'); + const existingTaxonomies = await db.taxonomy.findMany(); - const existingTaxonomyNames = existingTaxonomies.map(taxonomy => taxonomy.name); + const existingTaxonomyMap = new Map(existingTaxonomies.map(taxonomy => [taxonomy.name, taxonomy])); + for (const [index, taxonomy] of InitTaxonomies.entries()) { - if (!existingTaxonomyNames.includes(taxonomy.name)) { - this.logger.log(`Creating taxonomy: ${taxonomy.name}`); + 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 { - this.logger.log(`Taxonomy already exists: ${taxonomy.name}`); + // 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 createBucket() { - await this.minioService.createBucket('app') - } 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' + phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000', }, { - username: 'root' - } - ] + username: 'root', + }, + ], }, }); + if (!rootAccountExists) { this.logger.log('Creating root account'); const rootStaff = await this.authService.signUp({ username: 'root', - password: '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({ @@ -84,18 +109,40 @@ export class InitService { 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() { this.logger.log('Initializing system roles'); await this.createRoles(); - this.logger.log('Initializing root account'); await this.createRoot(); - this.logger.log('Initializing taxonomies'); - await this.createTaxonomy(); - + await this.createOrUpdateTaxonomy() this.logger.log('Initialize minio') await this.createBucket() + this.logger.log('Initializing appConfigs'); + await this.initAppConfigs(); + if (process.env.NODE_ENV === 'development') { + try { + await this.genDevService.genDataEvent(); + } catch (err: any) { + this.logger.error(err.message); + } + } + + } } diff --git a/apps/server/src/tasks/init/utils.ts b/apps/server/src/tasks/init/utils.ts new file mode 100644 index 0000000..f7b84ff --- /dev/null +++ b/apps/server/src/tasks/init/utils.ts @@ -0,0 +1,102 @@ +import { db, getRandomElement, getRandomIntInRange, getRandomTimeInterval, ObjectType, TroubleType } from '@nicestack/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; +} + +export function calculateTroubleAttributes(type: TroubleType) { + const probability = type === TroubleType.RISK ? getRandomIntInRange(1, 10) : getRandomIntInRange(25, 100); + const severity = type === TroubleType.RISK ? getRandomIntInRange(1, 10) : getRandomIntInRange(25, 100); + const impact = type === TroubleType.TROUBLE ? getRandomIntInRange(25, 100) : null; + const cost = type === TroubleType.TROUBLE ? getRandomIntInRange(25, 100) : null; + const deadline = type !== TroubleType.RISK ? getRandomTimeInterval(2024).endDate : null; + + let level; + if (type === TroubleType.TROUBLE) { + level = getTroubleLevel(probability, severity, impact, cost, deadline); + } else if (type === TroubleType.RISK) { + level = getRiskLevel(probability, severity); + } else { + level = getRandomIntInRange(1, 4); + } + + return { probability, severity, impact, cost, deadline, level }; +} + +export function determineState(type: TroubleType): number { + if (type === TroubleType.TROUBLE) { + return getRandomElement([0, 1, 2, 3]); + } else { + return getRandomElement([0, 4, 5]); + } +} + +export function getTroubleLevel( + probability: number, + severity: number, + impact: number, + cost: number, + deadline: string | Date +) { + const deadlineDays = dayjs().diff(dayjs(deadline), "day"); + let deadlineScore = 25; + if (deadlineDays > 365) { + deadlineScore = 100; + } else if (deadlineDays > 90) { + deadlineScore = 75; + } else if (deadlineDays > 30) { + deadlineScore = 50; + } + let total = + 0.257 * probability + + 0.325 * severity + + 0.269 * impact + + 0.084 * deadlineScore + + 0.065 * cost; + if (total > 90) { + return 4; + } else if (total > 60) { + return 3; + } else if (total > 30) { + return 2; + } else if (probability * severity * impact * cost !== 1) { + return 1; + } else { + return 0; + } +} +export function getRiskLevel(probability: number, severity: number) { + if (probability * severity > 70) { + return 4; + } else if (probability * severity > 42) { + return 3; + } else if (probability * severity > 21) { + return 2; + } + return 1; +} \ No newline at end of file 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..cbdf931 --- /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..425e0b9 --- /dev/null +++ b/apps/server/src/tasks/reminder/reminder.service.ts @@ -0,0 +1,80 @@ +/** + * @file reminder.service.ts + * @description 提醒服务,用于处理问题截止日期提醒相关的业务逻辑 + * @author xxx + * @date 2023-xx-xx + */ + +import { Injectable, Logger } from '@nestjs/common'; +import dayjs from 'dayjs'; +import { db, getUniqueItems, MessageMethodSchema, TroubleType, truncateString } from '@nicestack/common'; +import { MessageService } from '@server/models/message/message.service'; +import { extractUniqueStaffIds } from '@server/models/department/utils'; + +/** + * 提醒服务类 + */ +@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() { + + } +} diff --git a/apps/server/src/tasks/tasks.module.ts b/apps/server/src/tasks/tasks.module.ts old mode 100644 new mode 100755 index eb9717f..64e430f --- a/apps/server/src/tasks/tasks.module.ts +++ b/apps/server/src/tasks/tasks.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { TasksService } from './tasks.service'; -import { InitModule } from '@server/init/init.module'; +import { InitModule } from '@server/tasks/init/init.module'; +import { ReminderModule } from "@server/tasks/reminder/reminder.module" @Module({ - imports: [InitModule], + imports: [InitModule, ReminderModule], providers: [TasksService] }) export class TasksModule { } diff --git a/apps/server/src/minio/minio.service.spec.ts b/apps/server/src/tasks/tasks.service.spec.ts old mode 100644 new mode 100755 similarity index 56% rename from apps/server/src/minio/minio.service.spec.ts rename to apps/server/src/tasks/tasks.service.spec.ts index 8cc5a96..cb48230 --- a/apps/server/src/minio/minio.service.spec.ts +++ b/apps/server/src/tasks/tasks.service.spec.ts @@ -1,15 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { MinioService } from './minio.service'; +import { TasksService } from './tasks.service'; -describe('MinioService', () => { - let service: MinioService; +describe('TasksService', () => { + let service: TasksService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [MinioService], + providers: [TasksService], }).compile(); - service = module.get(MinioService); + service = module.get(TasksService); }); it('should be defined', () => { diff --git a/apps/server/src/tasks/tasks.service.ts b/apps/server/src/tasks/tasks.service.ts old mode 100644 new mode 100755 index dcb3dbb..c81172c --- a/apps/server/src/tasks/tasks.service.ts +++ b/apps/server/src/tasks/tasks.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; -import { InitService } from '@server/init/init.service'; +import { InitService } from '@server/tasks/init/init.service'; +import { ReminderService } from '@server/tasks/reminder/reminder.service'; import { CronJob } from 'cron'; @Injectable() @@ -10,18 +11,42 @@ export class TasksService implements OnModuleInit { 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(); + try { + await this.initService.init(); + this.logger.log('Initialization successful'); + } catch (err) { + this.logger.error('Database not deployed or initialization error', err); + // Optionally rethrow the error if you want to halt further execution + // throw err; + } - const handleCronJob = new CronJob('0 * * * *', () => { - this.logger.log('cron job test'); - }); + try { + const cronExpression = process.env.DEADLINE_CRON; + if (!cronExpression) { + throw new Error('DEADLINE_CRON environment variable is not set'); + } - this.schedulerRegistry.addCronJob('cronJob', handleCronJob); - this.logger.log('Start cron job'); - handleCronJob.start(); + 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/transform/transform.service.ts b/apps/server/src/transform/transform.service.ts deleted file mode 100644 index 5df1cb2..0000000 --- a/apps/server/src/transform/transform.service.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import * as ExcelJS from 'exceljs'; -import { - z, - db, - Prisma, - Staff, - TransformSchema -} from '@nicestack/common'; -import * as dayjs from 'dayjs'; -import { StaffService } from '../models/staff/staff.service'; -import { DepartmentService } from '../models/department/department.service'; -import { TaxonomyService } from '@server/models/taxonomy/taxonomy.service'; - -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.findByNameInDom( - domainId, - name, - ); - if (dept) depts.push(dept.id); - } - } else { - const dept = await this.departmentService.findByNameInDom( - 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); - console.log(`${indent}${node.value}`); - 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 importsStaffSchema = 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 as string; - const deptName = row.getCell(3).value as string; - try { - importsStaffSchema.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(); - // 为员工数据添加部门ID - const staffsToCreate = staffs.map((staff, index) => ({ - showname: staff.name, - username: staff.phoneNumber, - phoneNumber: staff.phoneNumber, - password: "123456", - deptId: deptIdsMap[staff.deptName], - domainId, - order: index + count, - })); - // 批量创建员工数据 - const createdStaffs = await db.staff.createMany({ - data: staffsToCreate, - }); - return createdStaffs; - } - async importTerms( - staff: Staff, - data: z.infer, - ) { - const { base64, domainId, taxonomyId, parentId } = data; - this.logger.log('开始'); - 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 db.taxonomy.findUnique({ - where: { id: taxonomyId }, - }); - if (!taxonomy) { - throw new Error('未找到对应分类'); - } - const count = await db.term.count({ where: { taxonomyId: taxonomyId } }); - let termIndex = 0; - this.logger.log(count); - - const gatherTermsData = async (nodes: TreeNode[], depth = 0) => { - let currentIndex = 0; - console.log(`depth:${depth}`); - 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 - console.log(`Prepared Term Data:`, termData); - - await gatherTermsData(node.children, depth + 1); - currentIndex++; - } - }; - await gatherTermsData(root.children); - console.log('最后准备的数据 Terms Data:', termsData); - let createdTerms: { id: string; name: string }[] = []; - try { - createdTerms = await db.term.createManyAndReturn({ - data: termsData, - select: { id: true, name: true }, - }); - // Debug: Log created terms - console.log('创建的Terms:', createdTerms); - } catch (error) { - console.error('创建Terms报错:', error); - throw new Error('创建失败'); - } - const termsUpdate = []; - - const gatherAncestryData = ( - nodes: TreeNode[], - ancestors: string[] = parentId ? [parentId] : [], - depth = 0, - ) => { - let currentIndex = 0; - - console.log(`depth:${depth}`); - 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, - }; - termAncestriesData.push(ancestryData); - console.log(`准备好的闭包表数据DeptAncestryData:`, 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) => - db.term.update(update), - ); - await Promise.all(updatePromises); - await db.termAncestry.createMany({ data: termAncestriesData }); - - console.log('Term闭包表 已创建:', termAncestriesData.length); - return { count: createdTerms.length }; - } catch (error) { - console.error('Error 更新Term或者创建Terms闭包表失败:', error); - throw new Error('更新术语信息或者创建术语闭包表失败'); - } - } - async importDepts( - staff: Staff, - data: z.infer, - ) { - const { base64, domainId, parentId } = data; - - this.logger.log('开始', parentId); - const buffer = Buffer.from(base64, 'base64'); - const { tree: root } = await this.generateTreeFromFile(buffer); - - this.printTree(root); - - const deptsData: Prisma.DepartmentCreateManyInput[] = []; - const deptAncestriesData: Prisma.DeptAncestryCreateManyInput[] = []; - const count = await db.department.count({ where: {} }); - let deptIndex = 0; - // this.logger.log(count); - - const gatherDeptsData = async ( - nodes: TreeNode[], - depth = 0, - dept?: string, - ) => { - let currentIndex = 0; - // console.log(`depth:${depth}`); - 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 - console.log(`Prepared Dept Data:`, deptData); - - await gatherDeptsData(node.children, depth + 1); - currentIndex++; - } - }; - await gatherDeptsData(root.children); - console.log('最后准备的数据 Depts Data:', deptsData); - let createdDepts: { id: string; name: string }[] = []; - try { - createdDepts = await db.department.createManyAndReturn({ - data: deptsData, - select: { id: true, name: true }, - }); - // Debug: Log created terms - console.log('创建的Depts:', createdDepts); - } catch (error) { - console.error('创建Depts报错:', error); - throw new Error('创建失败'); - } - const deptsUpdate = []; - const gatherAncestryData = ( - nodes: TreeNode[], - ancestors: string[] = parentId ? [parentId] : [], - depth = 0, - ) => { - let currentIndex = 0; - console.log(`depth:${depth}`); - 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, - }; - deptAncestriesData.push(ancestryData); - console.log(`准备好的闭包表数据DeptAncestryData:`, ancestryData); - } - const newAncestors = [...ancestors, dept.id]; - gatherAncestryData(node.children, newAncestors, depth + 1); - } - // } - // else { - // const dept = createdDepts.find((dept) => dept.name === node.value); - - // gatherAncestryData( - // node.children, - // [createdDepts.find((dept) => dept.name === node.value).id], - // depth + 1, - // ); - // } - currentIndex++; - } - }; - gatherAncestryData(root.children); - - this.logger.log('准备好闭包表数据 Ancestries Data:', deptAncestriesData); - try { - const updatePromises = deptsUpdate.map((update) => - db.department.update(update), - ); - await Promise.all(updatePromises); - await db.deptAncestry.createMany({ data: deptAncestriesData }); - console.log('Dept闭包表 已创建:', deptAncestriesData.length); - return { count: createdDepts.length }; - } catch (error) { - console.error('Error 更新Dept或者创建Depts闭包表失败:', error); - throw new Error('更新单位信息或者创建单位闭包表失败'); - } - } - -} diff --git a/apps/server/src/trpc/trpc.module.ts b/apps/server/src/trpc/trpc.module.ts old mode 100644 new mode 100755 index 8113006..5a08555 --- a/apps/server/src/trpc/trpc.module.ts +++ b/apps/server/src/trpc/trpc.module.ts @@ -1,20 +1,36 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; import { TrpcService } from './trpc.service'; import { TrpcRouter } from './trpc.router'; -import { DepartmentRouter } from '@server/models/department/department.router'; -import { TransformRouter } from '@server/transform/transform.router'; -import { StaffRouter } from '@server/models/staff/staff.router'; -import { StaffModule } from '../models/staff/staff.module'; +import { QueueModule } from '@server/queue/queue.module'; import { DepartmentModule } from '@server/models/department/department.module'; -import { TransformModule } from '@server/transform/transform.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 { RbacModule } from '@server/rbac/rbac.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'; @Module({ - imports: [StaffModule, DepartmentModule, TransformModule, TermModule, TaxonomyModule, RbacModule], + imports: [ + AuthModule, + QueueModule, + DepartmentModule, + StaffModule, + TermModule, + TaxonomyModule, + RoleMapModule, + TransformModule, + MessageModule, + AppConfigModule, + PostModule, + VisitModule, + WebSocketModule + ], controllers: [], - providers: [TrpcService, TrpcRouter], + 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 old mode 100644 new mode 100755 index 1219b86..2ac7ab4 --- a/apps/server/src/trpc/trpc.router.ts +++ b/apps/server/src/trpc/trpc.router.ts @@ -1,44 +1,72 @@ -import { INestApplication, Injectable } from '@nestjs/common'; -import { TransformRouter } from '@server/transform/transform.router'; +import { INestApplication, Injectable, Logger } from '@nestjs/common'; import { DepartmentRouter } from '@server/models/department/department.router'; import { StaffRouter } from '@server/models/staff/staff.router'; -import { TrpcService } from '@server/trpc/trpc.service'; -import * as trpcExpress from '@trpc/server/adapters/express'; import { TaxonomyRouter } from '@server/models/taxonomy/taxonomy.router'; import { TermRouter } from '@server/models/term/term.router'; -import { RoleRouter } from '@server/rbac/role.router'; -import { RoleMapRouter } from '@server/rbac/rolemap.router'; - +import { TrpcService } from '@server/trpc/trpc.service'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import { AuthRouter } from '@server/auth/auth.router'; +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'; @Injectable() export class TrpcRouter { - constructor( - private readonly trpc: TrpcService, - 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, - ) { } - appRouter = this.trpc.router({ - transform: this.transform.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, - }); - async applyMiddleware(app: INestApplication) { - app.use( - `/trpc`, - trpcExpress.createExpressMiddleware({ - router: this.appRouter, - createContext: this.trpc.createContext - }), - ); - } -} + 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 auth: AuthRouter, + private readonly app_config: AppConfigRouter, + private readonly message: MessageRouter, + private readonly visitor: VisitRouter, + // private readonly websocketService: WebSocketService + ) { } + appRouter = this.trpc.router({ + auth: this.auth.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 + }); + 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, + // }); + } +} export type AppRouter = TrpcRouter[`appRouter`]; diff --git a/apps/server/src/trpc/trpc.service.ts b/apps/server/src/trpc/trpc.service.ts old mode 100644 new mode 100755 index 137bf10..b04bab1 --- a/apps/server/src/trpc/trpc.service.ts +++ b/apps/server/src/trpc/trpc.service.ts @@ -2,53 +2,40 @@ 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 { env } from '@server/env'; -import { db, Staff, JwtPayload } from "@nicestack/common" -import { JwtService } from '@nestjs/jwt'; - -type Context = Awaited>; +import { db, JwtPayload, UserProfile, RolePerms } from '@nicestack/common'; +import { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws'; +import { UserProfileService } from '@server/auth/utils'; +type Context = Awaited>; @Injectable() export class TrpcService { + private readonly logger = new Logger(TrpcService.name); - async createContext({ - req, - res, - }: trpcExpress.CreateExpressContextOptions) { - const token = req.headers.authorization?.split(' ')[1]; - let tokenData: JwtPayload | undefined = undefined; - let staff: Staff | undefined = undefined; - console.log(token) - if (token) { - try { - const jwtService = new JwtService() - tokenData = await jwtService.verifyAsync(token, { secret: env.JWT_SECRET }) as JwtPayload; - if (tokenData) { - // Fetch staff details from the database using tokenData.id - staff = await db.staff.findUnique({ where: { id: tokenData.sub } }); - if (!staff) { - throw new TRPCError({ code: 'UNAUTHORIZED', message: "User not found" }); - } - } - } catch (error) { - // Enhanced error handling for invalid session data or token verification failure - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: "Invalid session data or token" }); - } - } - - return { - staff - }; - }; + async createExpressContext(opts: trpcExpress.CreateExpressContextOptions): Promise<{ staff: UserProfile | undefined }> { + const token = opts.req.headers.authorization?.split(' ')[1]; + return await UserProfileService.instance.getUserProfileByToken(token); + } + 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: "Unauthorized request" }); + if (!ctx?.staff) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: "未授权请求" }); } return next({ ctx: { @@ -56,5 +43,6 @@ export class TrpcService { staff: ctx.staff, }, }); + }); } diff --git a/apps/server/src/utils/event-bus.ts b/apps/server/src/utils/event-bus.ts new file mode 100644 index 0000000..c52464b --- /dev/null +++ b/apps/server/src/utils/event-bus.ts @@ -0,0 +1,16 @@ +import mitt from 'mitt'; +import { ObjectType, ChangedRows, UserProfile, MessageDto } from '@nicestack/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/minio/minio.module.ts b/apps/server/src/utils/minio/minio.module.ts similarity index 100% rename from apps/server/src/minio/minio.module.ts rename to apps/server/src/utils/minio/minio.module.ts diff --git a/apps/server/src/minio/minio.service.ts b/apps/server/src/utils/minio/minio.service.ts similarity index 87% rename from apps/server/src/minio/minio.service.ts rename to apps/server/src/utils/minio/minio.service.ts index 4751298..8f402ee 100644 --- a/apps/server/src/minio/minio.service.ts +++ b/apps/server/src/utils/minio/minio.service.ts @@ -7,8 +7,8 @@ export class MinioService { private readonly minioClient: Minio.Client; constructor() { this.minioClient = new Minio.Client({ - endPoint: 'localhost', - port: 9000, + 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' diff --git a/apps/server/src/utils/redis/redis.service.ts b/apps/server/src/utils/redis/redis.service.ts new file mode 100644 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 100644 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..8577f4d --- /dev/null +++ b/apps/server/src/utils/tool.ts @@ -0,0 +1,148 @@ +import { createReadStream } from "fs"; +import { createInterface } from "readline"; + +import { db } from '@nicestack/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/src/utils/tusd.ts b/apps/server/src/utils/tusd.ts deleted file mode 100644 index be73fed..0000000 --- a/apps/server/src/utils/tusd.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as tus from 'tus-js-client'; -import { promises as fs } from 'fs'; -import * as mime from 'mime-types'; - -export const uploader = async ( - endpoint: string = 'http://localhost:8080', - input: Buffer | string, - externalFileName: string = 'unknown', // 允许外部传入文件名 - onProgress?: (percentage: number) => void, - onSuccess?: (url: string) => void, - onError?: (error: Error) => void -) => { - let fileBuffer: Buffer; - let fileName: string; - let fileType: string; - - // 确定输入是Buffer还是文件路径 - if (typeof input === 'string') { - try { - fileBuffer = await fs.readFile(input); - fileName = input.split('/').pop() || 'unknown'; - fileType = mime.lookup(input) || 'application/octet-stream'; - } catch (error: any) { - console.error("读取文件失败: " + error.message); - if (onError) onError(error); - return; - } - } else { - fileBuffer = input; - fileName = externalFileName; // 使用外部传入的文件名 - // 尝试获取文件类型,这里简化处理,实际应用中可能需要更复杂的逻辑 - fileType = mime.lookup(fileName) || 'application/octet-stream'; - } - - const upload = new tus.Upload(fileBuffer as any, { - endpoint: `${endpoint}/files/`, - retryDelays: [0, 3000, 5000, 10000, 20000], - metadata: { filename: fileName, filetype: fileType }, - onError: (error) => { - console.error("上传失败: " + error.message); - if (onError) onError(error); - }, - onProgress: (bytesUploaded, bytesTotal) => { - const percentage = (bytesUploaded / bytesTotal) * 100; - if (onProgress) onProgress(percentage); - }, - onSuccess: () => { - console.log("上传完成"); - if (onSuccess) onSuccess(upload.url!); - }, - }); - - // 寻找并继续之前的上传 - upload.findPreviousUploads().then((previousUploads) => { - if (previousUploads && previousUploads.length > 0) { - upload.resumeFromPreviousUpload(previousUploads[0]!); - } - }); - - return upload; -}; - -export const uploaderPromise = ( - endpoint: string, - input: Buffer | string, - externalFileName: string = 'unknown', // 允许外部传入文件名 - onProgress?: (percentage: number) => void -): Promise => { - return new Promise((resolve, reject) => { - uploader(endpoint, input, externalFileName, onProgress, resolve, reject) - .then((upload) => { - upload!.start(); - }) - .catch(reject); - }); -}; \ No newline at end of file diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json old mode 100644 new mode 100755 index 51b120f..858dbf5 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "module": "commonjs", "declaration": true, @@ -7,17 +7,12 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "ES2021", + "target": "ES2020", "sourceMap": true, "outDir": "./dist", - "strictNullChecks": false, - // "baseUrl": "./", - // "incremental": true, + "strict": true, + "esModuleInterop": true, + "incremental": true, // "skipLibCheck": true, - // "strictNullChecks": false, - // "noImplicitAny": false, - // "strictBindCallApply": false, - // "forceConsistentCasingInFileNames": false, - // "noFallthroughCasesInSwitch": false - } + }, } \ No newline at end of file diff --git a/apps/web/.env.example b/apps/web/.env.example old mode 100644 new mode 100755 diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs deleted file mode 100644 index d6c9537..0000000 --- a/apps/web/.eslintrc.cjs +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, -} diff --git a/apps/web/README.md b/apps/web/README.md old mode 100644 new mode 100755 index 0d6babe..74872fd --- a/apps/web/README.md +++ b/apps/web/README.md @@ -14,17 +14,37 @@ If you are developing a production application, we recommend updating the config - Configure the top-level `parserOptions` property like this: ```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, }, -} +}) ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/apps/web/entrypoint.sh b/apps/web/entrypoint.sh new file mode 100755 index 0000000..647c384 --- /dev/null +++ b/apps/web/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# 使用envsubst替换index.html中的环境变量占位符 +envsubst < /usr/share/nginx/html/index.html > /usr/share/nginx/html/index.html.tmp +mv /usr/share/nginx/html/index.html.tmp /usr/share/nginx/html/index.html +# 运行serve来提供静态文件 +exec nginx -g "daemon off;" +# 使用 sed 替换 index.html 中的环境变量占位符 +# for var in $(env | cut -d= -f1); do +# sed -i "s|\${$var}|$(eval echo \$$var)|g" /usr/share/nginx/html/index.html +# done + +# # 运行 nginx 来提供静态文件 +# exec nginx -g "daemon off;" \ No newline at end of file diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100755 index 0000000..092408a --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,28 @@ +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 }, + ], + }, + }, +) diff --git a/apps/web/index.html b/apps/web/index.html old mode 100644 new mode 100755 index 9bb5a6a..ee9e38a --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,21 +2,22 @@ - - - - Vite + React + TS - + + + + + 两道防线管理后台 -
- +
+ \ No newline at end of file diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf old mode 100644 new mode 100755 diff --git a/apps/web/package.json b/apps/web/package.json old mode 100644 new mode 100755 index 0be0234..ff4e432 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,54 +1,71 @@ { - "name": "web-client", + "name": "web", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "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.1.0", - "@dnd-kit/modifiers": "^7.0.0", - "@dnd-kit/sortable": "^8.0.0", - "@dnd-kit/utilities": "^3.2.2", + "@floating-ui/react": "^0.26.25", "@nicestack/common": "workspace:^", + "@nicestack/client": "workspace:^", + "@nicestack/iconer": "workspace:^", + "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", + "dayjs": "^1.11.12", + "framer-motion": "^11.11.9", + "idb-keyval": "^6.2.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-resizable": "^3.0.5", + "react-router-dom": "^6.24.1", + "superjson": "^2.2.1", "@tanstack/query-async-storage-persister": "^5.51.9", - "@tanstack/react-query": "^5.51.1", + "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-persist-client": "^5.51.9", - "@tanstack/react-virtual": "^3.8.3", - "@tanstack/zod-form-adapter": "^0.26.3", "@trpc/client": "11.0.0-rc.456", "@trpc/react-query": "11.0.0-rc.456", "@trpc/server": "11.0.0-rc.456", - "antd": "^5.20.6", - "axios": "^1.7.3", - "browser-image-compression": "^2.0.2", - "idb-keyval": "^6.2.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.24.1", - "superjson": "^2.2.1", - "tus-js-client": "^4.1.0", "zod": "^3.23.8", - "zustand": "^4.5.5" + "yjs": "^13.6.20", + "mitt": "^3.0.1" }, "devDependencies": { - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^7.13.1", - "@typescript-eslint/parser": "^7.13.1", + "@eslint/js": "^9.9.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "@vitejs/plugin-react-swc": "^3.5.0", - "autoprefixer": "^10.4.19", - "eslint": "^8.57.0", - "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.7", - "postcss": "^8.4.39", - "tailwindcss": "^3.4.4", - "typescript": "^5.2.2", - "vite": "^5.3.1" + "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 old mode 100644 new mode 100755 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 old mode 100644 new mode 100755 diff --git a/apps/web/src/App.css b/apps/web/src/App.css old mode 100644 new mode 100755 index e69de29..f309ab4 --- a/apps/web/src/App.css +++ 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 old mode 100644 new mode 100755 index 61b094f..b0525a9 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,25 +1,40 @@ -import './App.css' -import { - RouterProvider, -} from "react-router-dom"; -import QueryProvider from './providers/query-provider' -import { router } from './routes'; +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 ThemeProvider from './providers/theme-provider'; -import { App as AntdApp } from 'antd'; + +dayjs.locale("zh-cn"); function App() { return ( - - - - - + + + + + + + - ) + ); } -export default App +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 100644 index 0000000..9537650 --- /dev/null +++ b/apps/web/src/app/admin/base-setting/page.tsx @@ -0,0 +1,189 @@ +import { + AppConfigSlug, + BaseSetting, + RolePerms, +} from "@nicestack/common"; +import { useContext, useEffect, useState } from "react"; +import { + Button, + Form, + Input, + message, + theme, +} from "antd"; +import { useAppConfig } from "@nicestack/client"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { MainLayoutContext } from "../../layout"; +import FixedHeader from "@web/src/components/layout/fix-header"; +import { useForm } from "antd/es/form/Form"; +import { api } from "@nicestack/client" + +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 old mode 100644 new mode 100755 index 3fc7751..a453c39 --- a/apps/web/src/app/admin/department/page.tsx +++ b/apps/web/src/app/admin/department/page.tsx @@ -1,7 +1,7 @@ -import DepartmentList from "@web/src/components/models/department/department-list"; +import DeptEditor from "@web/src/components/models/department/dept-editor"; export default function DepartmentAdminPage() { - return
- + return
+
} \ No newline at end of file diff --git a/apps/web/src/app/admin/role/page.tsx b/apps/web/src/app/admin/role/page.tsx old mode 100644 new mode 100755 index d63cbda..4a7b4ff --- a/apps/web/src/app/admin/role/page.tsx +++ b/apps/web/src/app/admin/role/page.tsx @@ -1,30 +1,13 @@ +import FixedHeader from "@web/src/components/layout/fix-header"; +import RoleEditor from "@web/src/components/models/role/role-editor/role-editor"; -import RoleList from "@web/src/components/models/role/role-list"; -import RoleMapTable from "@web/src/components/models/role/role-map-table"; -import { Divider, Empty } from "antd"; -import { useState } from "react"; export default function RoleAdminPage() { - const [roleId, setRoleId] = useState(undefined); - const [roleName, setRoleName] = useState(undefined); return ( -
-
- { - console.log(id); - setRoleId(id); - setRoleName(name); - }}> -
- -
- {roleId && ( - - )} - {!roleId && } -
-
+ <> + + + + + ); } diff --git a/apps/web/src/app/admin/staff/page.tsx b/apps/web/src/app/admin/staff/page.tsx old mode 100644 new mode 100755 index c3e712f..35d3c2c --- a/apps/web/src/app/admin/staff/page.tsx +++ b/apps/web/src/app/admin/staff/page.tsx @@ -1,6 +1,8 @@ -import StaffTable from "@web/src/components/models/staff/staff-table"; -export default function StaffAdminPage() { - return
- -
-} \ No newline at end of file +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 old mode 100644 new mode 100755 index 416119c..a756fbf --- a/apps/web/src/app/admin/term/page.tsx +++ b/apps/web/src/app/admin/term/page.tsx @@ -1,11 +1,11 @@ -import TaxonomyTable from "@web/src/components/models/taxonomy/taxonomy-table"; -import TermList from "@web/src/components/models/term/term-list"; + +import FixedHeader from "@web/src/components/layout/fix-header"; +import TermEditor from "@web/src/components/models/term/term-editor"; export default function TermAdminPage() { - return
-
- -
-
-
-} \ No newline at end of file + return (<> + + + + ); +} diff --git a/apps/web/src/app/denied.tsx b/apps/web/src/app/denied.tsx new file mode 100644 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 old mode 100644 new mode 100755 index a9b046d..c32c11b --- a/apps/web/src/app/error.tsx +++ b/apps/web/src/app/error.tsx @@ -1,3 +1,11 @@ +import { useRouteError } from "react-router-dom"; + export default function ErrorPage() { - return <>404 + const error: any = useRouteError(); + return
+
+
哦?页面似乎出错了...
+
{error?.statusText || error?.message}
+
+
} \ No newline at end of file diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 700a0b9..9aad05e 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,115 @@ -import { Outlet } from "react-router-dom"; +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 { Icon } from "@nicestack/iconer"; +import { env } from "@web/src/env"; +import RoundedClip from "@web/src/components/svg/rounded-clip"; +import {useTerm} from "@nicestack/client" -export default function LayoutPage() { - return <> -} \ No newline at end of file +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 || "loop sys"} +
+ +
+
+ + + +
+ +
+ + +
+
+
+
+
+ ); +}; + +export default MainLayoutPage; diff --git a/apps/web/src/app/login.tsx b/apps/web/src/app/login.tsx index 704781e..f2a9f1b 100644 --- a/apps/web/src/app/login.tsx +++ b/apps/web/src/app/login.tsx @@ -1,153 +1,273 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { Form, Input, Button, message } from 'antd'; -import { Link, useLocation, useNavigate } from 'react-router-dom'; -import DepartmentSelect from '../components/models/department/department-select'; -import { useAuth } from '../providers/auth-provider'; -import SineWave from '../components/presentation/animation/sine-wave'; - +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 [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 onFinishLogin = async (values: any) => { - try { - const { username, password } = values; - await login(username, password); - message.success('登录成功!'); - } catch (err) { - message.error('用户名或密码错误!'); - 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); + } + }; - const onFinishRegister = async (values: any) => { - setRegisterLoading(true); - const { username, password, phoneNumber } = values; - try { - await signup(username, password, phoneNumber); - message.success('注册成功!'); - setShowLogin(true); - loginFormRef.current.submit(); - } catch (err) { - console.error(err); - } finally { - setRegisterLoading(false); - } - }; + useEffect(() => { + if (isAuthenticated) { + const params = new URLSearchParams(location.search); + const redirectUrl = params.get("redirect_url") || "/"; + navigate(redirectUrl, { replace: true }); + } + }, [isAuthenticated, location]); - 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 ? ( + <> +
+ 登录 +
+
+ + + - return ( -
-
-
- {showLogin ? ( - <> -
- 登录 -
- - - - + + + +
+ +
+ + + ) : ( +
+
+ 注册 +
+
+ + + + + + + + + + + + + + + + + + + + + + ({ + validator(_, value) { + if ( + !value || + getFieldValue( + "password" + ) === value + ) { + return Promise.resolve(); + } + return Promise.reject( + new Error( + "两次输入的密码不一致" + ) + ); + }, + }), + ]}> + + - - - -
- -
-
- - ) : ( -
- -
注册
-
- - - - - - - - - - ({ - validator(_, value) { - if (!value || getFieldValue('password') === value) { - return Promise.resolve(); - } - return Promise.reject(new Error('两次输入的密码不一致')); - } - }) - ]}> - - - -
- -
-
-
- )} -
-
- {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" - > - 返回登录 -
- -
- )} -
-
-
- ); +
+ +
+ +
+ )} +
+
+
+ ); }; export default LoginPage; diff --git a/apps/web/src/app/main/page.tsx b/apps/web/src/app/main/page.tsx old mode 100644 new mode 100755 index e0ad288..ca4cb4a --- a/apps/web/src/app/main/page.tsx +++ b/apps/web/src/app/main/page.tsx @@ -1,6 +1,3 @@ -import { useAuth } from "@web/src/providers/auth-provider" - export default function MainPage() { - const { user } = useAuth() - return <>hello,{user?.username} -} + return
main
+} \ No newline at end of file diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg old mode 100644 new mode 100755 diff --git a/apps/web/src/components/animation/sine-wave.tsx b/apps/web/src/components/animation/sine-wave.tsx new file mode 100644 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/button/excel-importer.tsx b/apps/web/src/components/button/excel-importer.tsx new file mode 100755 index 0000000..1086856 --- /dev/null +++ b/apps/web/src/components/button/excel-importer.tsx @@ -0,0 +1,145 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Button, message } from "antd"; +import { useMemo, useRef, useState } from "react"; +import { Buffer } from "buffer"; +import { useTransform } from "@nicestack/client"; +import { SizeType } from "antd/es/config-provider/SizeContext"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { api } from "@nicestack/client" +export function ExcelImporter({ + type = "trouble", + className, + name = "导入", + taxonomyId, + parentId, + size = "small", + domainId, + refresh = true, + disabled = false, + ghost = true, +}: { + disabled?: boolean; + type?: "trouble" | "term" | "dept" | "staff"; + className?: string; + name?: string; + domainId?: string; + taxonomyId?: string; + parentId?: string; + size?: SizeType; + refresh?: boolean; + ghost?: boolean; +}) { + const fileInput = useRef(null); + const [file, setFile] = useState(); + const [loading, setLoading] = useState(false); + const { user } = useAuth(); + const { importTrouble, importTerms, importDepts, importStaffs } = + useTransform(); + const utils = api.useUtils() + // const queryKey = getQueryKey(api.trouble); + // const domainId = useMemo(() => { + // if (staff && staff?.domainId) return staff?.domainId; + // }, [staff]); + return ( +
+ + { + const files = Array.from(e.target.files || []); + if (!files.length) return; // 如果没有文件被选中, 直接返回 + + const file = files[0]; + if (file) { + const isExcel = + file.type === "application/vnd.ms-excel" || + file.type === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + file.type === "application/wps-office.xlsx"; + + if (!isExcel) { + message.warning("请选择Excel文件"); + return; + } + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const bufferToBase64 = buffer.toString("base64"); + + try { + setLoading(true); + let data = undefined; + if (type === "trouble") { + data = await importTrouble.mutateAsync({ + base64: bufferToBase64, + domainId, + }); + } + if (type === "term") { + data = await importTerms.mutateAsync({ + base64: bufferToBase64, + domainId, + taxonomyId, + parentId, + }); + } + if (type === "dept") { + data = await importDepts.mutateAsync({ + base64: bufferToBase64, + domainId, + parentId, + }); + } + if (type === "staff") { + data = await importStaffs.mutateAsync({ + base64: bufferToBase64, + domainId, + }); + } + // const data = res.data; + console.log(`%cdata:${data}`, "color:red"); + if (!data?.error) { + + message.success(`已经导入${data.count}条数据`); + utils.trouble.invalidate() + + if (refresh && type !== "trouble") { + setTimeout(() => { + window.location.reload(); + }, 700); + } + } else { + console.log( + `%cerror:${JSON.stringify(data.error)}`, + "color:red" + ); + console.log(JSON.stringify(data.error)); + message.error(JSON.stringify(data.error)); + } + } catch (error) { + console.error(`${error}`); + message.error(`${error}`); + } finally { + if (fileInput.current) { + fileInput.current.value = ""; // 清空文件输入 + } + setLoading(false); + } + } + }} + style={{ display: "none" }} + /> +
+ ); +} diff --git a/apps/web/src/components/layout/breadcrumb.tsx b/apps/web/src/components/layout/breadcrumb.tsx new file mode 100644 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/fix-header.tsx b/apps/web/src/components/layout/fix-header.tsx new file mode 100644 index 0000000..af578e8 --- /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 "@nicestack/common"; +import { lightenColor } from "@nicestack/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 100644 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 100644 index 0000000..3bc93a5 --- /dev/null +++ b/apps/web/src/components/layout/sidebar-content.tsx @@ -0,0 +1,90 @@ +import { Avatar, Divider, Dropdown, theme } from "antd"; +import { Icon } from "@nicestack/iconer"; + +import CollapsibleSection from "../presentation/collapse-section"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { RolePerms } from "@nicestack/common"; + +export default function SidebarContent() { + const { logout, user, isAuthenticated, hasSomePermissions } = useAuth(); + + return ( +
+
+ , + // link: "/", + // }, + { + key: "trouble", + label: "问题列表", + icon: , + link: "/troubles", + }, + hasSomePermissions( + RolePerms.MANAGE_ANY_DEPT, + RolePerms.MANAGE_ANY_STAFF, + RolePerms.MANAGE_ANY_ROLE, + RolePerms.MANAGE_DOM_STAFF, + RolePerms.MANAGE_BASE_SETTING + ) && { + key: "4", + label: "系统设置", + icon: , + 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 100644 index 0000000..eaca058 --- /dev/null +++ b/apps/web/src/components/layout/user-header.tsx @@ -0,0 +1,50 @@ +import { Avatar, Button, Dropdown, theme } from "antd"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { Icon } from "@nicestack/iconer"; +import { useNavigate } from "react-router-dom"; + +export default function UserHeader() { + const { logout, user, isAuthenticated } = useAuth(); + const { token } = theme.useToken(); + + const navigate = useNavigate(); + return ( +
+
+
+
{ + // if (user?.pilot?.id) { + // navigate(`/pilots/${user?.pilot.id}`); + // } + }}> + + + {(user?.showname || user?.username) + ?.slice(0, 1) + .toUpperCase()} + + {user?.showname || user?.username} + {user?.department && <> + + {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"> + + 注销 +
+
+
+ ); +} diff --git a/apps/web/src/components/models/department/department-drawer.tsx b/apps/web/src/components/models/department/department-drawer.tsx deleted file mode 100644 index f4c3c04..0000000 --- a/apps/web/src/components/models/department/department-drawer.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button, Drawer } from "antd"; -import React, { useState } from "react"; -import type { ButtonProps } from "antd"; -import { Department } from "@nicestack/common"; -import DepartmentForm from "./department-form"; - -interface DepartmentDrawerProps extends ButtonProps { - title: string; - data?: Partial; - parentId?: string; -} - -export default function DepartmentDrawer({ - data, - parentId, - title, - ...buttonProps -}: DepartmentDrawerProps) { - const [open, setOpen] = useState(false); - - const handleTrigger = () => { - setOpen(true); - }; - - return ( - <> - - { - setOpen(false); - }} - title={title} - width={400} - > - - - - ); -} diff --git a/apps/web/src/components/models/department/department-form.tsx b/apps/web/src/components/models/department/department-form.tsx index 8a99842..328826f 100644 --- a/apps/web/src/components/models/department/department-form.tsx +++ b/apps/web/src/components/models/department/department-form.tsx @@ -1,62 +1,136 @@ -import { Button, Form, Input, InputNumber, Checkbox } from "antd"; -import { FormInstance } from "antd"; -import { useEffect, useRef, useState } from "react"; -import { Department } from "@nicestack/common"; -import { useDepartment } from "@web/src/hooks/useDepartment"; +import { Spin, message, Input, InputNumber, Checkbox, Form } from "antd"; +import { useDepartment, api } from "@nicestack/client"; +import { Department, DepartmentDto, ObjectType, mapArrayToObjectArray } from "@nicestack/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({ - data = undefined, - parentId, -}: { - data?: Partial; - parentId?: string; -}) { - const { create, update, addFetchParentId } = useDepartment(); - const [loading, setLoading] = useState(false); - const formRef = useRef(null); - useEffect(() => { - if (parentId) formRef.current?.setFieldValue("parentId", parentId); - }, [parentId]); - return ( -
{ - setLoading(true); - addFetchParentId(values.parentId); - console.log(values) - if (data) { - console.log(values); - await update.mutateAsync({ id: data.id, ...values }); - } else { - await create.mutateAsync(values); - formRef.current?.resetFields(); - if (parentId) formRef.current?.setFieldValue("parentId", parentId); - } - setLoading(false); - }} - > - - - - - - - - - - - 是否为域 - -
- -
-
- ); +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 index 42a08c9..b33faaf 100644 --- a/apps/web/src/components/models/department/department-import-drawer.tsx +++ b/apps/web/src/components/models/department/department-import-drawer.tsx @@ -2,7 +2,7 @@ import { Button, Drawer, Form } from "antd"; import React, { useRef, useState } from "react"; import type { ButtonProps, FormInstance } from "antd"; import { Department } from "@nicestack/common"; -import { ExcelImporter } from "../../utilities/excel-importer"; +import { ExcelImporter } from "../../utils/excel-importer"; import DepartmentSelect from "./department-select"; diff --git a/apps/web/src/components/models/department/department-list.tsx b/apps/web/src/components/models/department/department-list.tsx index ec633a2..f61260c 100644 --- a/apps/web/src/components/models/department/department-list.tsx +++ b/apps/web/src/components/models/department/department-list.tsx @@ -1,211 +1,110 @@ -import React, { useEffect, useState } from "react"; -import { Button, Empty, Tree } from "antd"; - -import { - BranchesOutlined, - DownOutlined, - NodeIndexOutlined, - PlusOutlined, -} from "@ant-design/icons"; -import { DataNode } from "@nicestack/common"; -import { useDepartment } from "@web/src/hooks/useDepartment"; -import DepartmentDrawer from "./department-drawer"; -import DepartmentImportDrawer from "./department-import-drawer"; +import React, { useMemo, useContext, useCallback, useState } from "react"; +import { ObjectType } from "@nicestack/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 "@nicestack/client"; export default function DepartmentList() { - const [customTreeData, setCustomTreeData] = useState([]); - const { treeData, addFetchParentId, update, deleteDepartment } = - useDepartment(); - - useEffect(() => { - if (treeData) { - const processedTreeData = processTreeData(treeData); - setCustomTreeData(processedTreeData); - } - }, [treeData]); - - const renderTitle = (node: DataNode) => ( -
- - {node.data.isDomain && } - {node.title} - -
- - - } - title="子节点" - parentId={node.key} - /> - - - -
-
- ); - - const processTreeData = (nodes: DataNode[]): DataNode[] => { - return nodes.map((node) => ({ - ...node, - title: renderTitle(node), - children: - node.children && node.children.length > 0 - ? processTreeData(node.children) - : [], - })); - }; - - const onLoadData = async ({ key }: any) => { - console.log(key); - addFetchParentId(key); - }; - - const onExpand = ( - expandedKeys: React.Key[], - { expanded, node }: { expanded: boolean; node: any } - ) => { - if (expanded) { - addFetchParentId(node.key); - } - }; - - const onDrop = async (info: any) => { - console.log(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.log(dropPosition); - - const loop = ( - data: DataNode[], - key: React.Key, - callback: (node: DataNode, i: number, data: DataNode[]) => void - ) => { - for (let i = 0; i < data.length; i++) { - if (data[i].key === key) { - return callback(data[i], i, data); - } - if (data[i].children) { - loop(data[i].children!, key, callback); - } - } + 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 data = [...customTreeData]; - let dragObj: DataNode | undefined; - loop(data, dragKey, (item, index, arr) => { - arr.splice(index, 1); - dragObj = item; - }); - - let parentNodeId: any = null; - let siblings: DataNode[] = []; - - if (!info.dropToGap) { - loop(data, dropKey, (item) => { - item.children = item.children || []; - item.children.unshift(dragObj!); - parentNodeId = item.key; - siblings = item.children; - }); - } else if ( - (info.node.children || []).length > 0 && - info.node.expanded && - dropPosition === 1 - ) { - loop(data, dropKey, (item) => { - item.children = item.children || []; - item.children.unshift(dragObj!); - parentNodeId = item.key; - siblings = item.children; - }); - } else { - let ar: DataNode[] = []; - let i: number = 0; - loop(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].data.parentId || null; - siblings = ar; + const handleCreate = () => { + setParentId(props.data?.id) + setModalOpen(true); } - - setCustomTreeData(data); - - const { id } = dragObj!.data; - console.log(JSON.parse(JSON.stringify(siblings))); - - const updatePromises = siblings.map((sibling, idx) => { - return update.mutateAsync({ - id: sibling.data.id, - order: idx, - parentId: parentNodeId, - }); - }); - - await Promise.all(updatePromises); - console.log( - `Updated node ${id} and its siblings with new order and parentId ${parentNodeId}` + 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 onDragEnter = () => { }; + 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 ( -
-
- - -
- {customTreeData.length > 0 ? ( - } - onExpand={onExpand} - /> - ) : ( - - )} -
+ ); } diff --git a/apps/web/src/components/models/department/department-select.tsx b/apps/web/src/components/models/department/department-select.tsx index 09ec3d0..848367b 100644 --- a/apps/web/src/components/models/department/department-select.tsx +++ b/apps/web/src/components/models/department/department-select.tsx @@ -1,144 +1,178 @@ -import { Button, TreeSelect, TreeSelectProps } from "antd"; -import { useEffect, useState } from "react"; -import { DataNode, findNodeByKey } from "@nicestack/common"; -import { useDepartment } from "@web/src/hooks/useDepartment"; -import { api } from "@web/src/utils/trpc"; +import { TreeSelect, TreeSelectProps } from "antd"; +import React, { useEffect, useState, useCallback } from "react"; +import { getUniqueItems } from "@nicestack/common"; +import { api } from "@nicestack/client" +import { DefaultOptionType } from "antd/es/select"; interface DepartmentSelectProps { defaultValue?: string | string[]; value?: string | string[]; onChange?: (value: string | string[]) => void; - width?: number | string; placeholder?: string; multiple?: boolean; rootId?: string; - extraOptions?: { value: string | undefined; label: string }[]; + domain?: boolean; + disabled?: boolean; + className?: string; } export default function DepartmentSelect({ defaultValue, value, onChange, - width = "100%", + className, placeholder = "选择单位", multiple = false, - rootId, + rootId = null, + disabled = false, + domain = undefined, }: DepartmentSelectProps) { - const { treeData, addFetchParentId } = useDepartment(); - api.useQueries((t) => { - if (Array.isArray(defaultValue)) { - return defaultValue?.map((id) => - t.department.getDepartmentDetails({ deptId: id }) - ); - } else { - return []; - } - }); - const [filteredTreeData, setFilteredTreeData] = useState([]); - const [selectedValue, setSelectedValue] = useState(() => { - if (value) { - if (Array.isArray(value)) { - return value.map((item) => ({ - label: item, - value: item, - })); - } else { - return { label: value, value: value }; - } - } - return undefined; // 如果没有提供defaultValue,返回null或者合适的初始值 - }); + const utils = api.useUtils(); + const [listTreeData, setListTreeData] = useState< + Omit[] + >([]); - const findNodeByKey = (data: DataNode[], key: string): DataNode | null => { - for (let node of data) { - if (node.key === key) return node; - if (node.children) { - const found = findNodeByKey(node.children, key); - if (found) return found; - } - } - return null; - }; - - useEffect(() => { - if (rootId && treeData.length > 0) { - const rootNode = findNodeByKey(treeData, rootId); - if (rootNode) { - setFilteredTreeData([rootNode]); - } else { - setFilteredTreeData([]); - } - } else { - setFilteredTreeData(treeData); - } - }, [rootId, treeData]); - - useEffect(() => { - if (rootId) { - setSelectedValue(undefined); - addFetchParentId(rootId); - } - }, [rootId]); - - useEffect(() => { - if (defaultValue) { - if (Array.isArray(defaultValue)) { - setSelectedValue( - defaultValue.map((item) => ({ label: item, value: item })) + 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 ); - } else { - setSelectedValue({ label: defaultValue, value: defaultValue }); + throw error; } - } - if (value) { - if (Array.isArray(value)) { - setSelectedValue( - value.map((item) => ({ label: item, value: item })) + }, + [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" ); - } else { - setSelectedValue({ label: value, value: value }); } + 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]); + }, [defaultValue, value, rootId, utils, fetchParentDepts]); + + useEffect(() => { + fetchDepts(); + }, [defaultValue, value, rootId, fetchDepts]); const handleChange = (newValue: any) => { - setSelectedValue(newValue); if (onChange) { - if (multiple && Array.isArray(newValue)) { - onChange(newValue.map((item) => item.value)); - } else { - onChange(newValue); - } + const processedValue = + multiple && Array.isArray(newValue) + ? newValue.map((item) => item.value) + : newValue; + onChange(processedValue); } }; const onLoadData: TreeSelectProps["loadData"] = async ({ id }) => { - addFetchParentId(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 = (expandedKeys: React.Key[]) => { - (expandedKeys as string[]).forEach((id: string) => - addFetchParentId(id) - ); + 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} - /> - + 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 100644 index 0000000..38a1bf8 --- /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 "@nicestack/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 100644 index 0000000..3a9a6c7 --- /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 "@nicestack/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 100644 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 100644 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/domain/domain-select.tsx b/apps/web/src/components/models/domain/domain-select.tsx deleted file mode 100644 index 15ad781..0000000 --- a/apps/web/src/components/models/domain/domain-select.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useState } from 'react'; -import { Select, Spin } from 'antd'; -import type { SelectProps } from 'antd'; -import { api } from '@web/src/utils/trpc'; - -interface DomainSelectProps { - value?: string; - onChange?: (value: string | undefined) => void; - style?: React.CSSProperties; - showAll?: boolean; // New prop to control inclusion of '全部' -} - -export default function DomainSelect({ value, onChange, style, showAll = false }: DomainSelectProps) { - const [query, setQuery] = useState(''); - const { data, isLoading } = api.department.getDomainDepartments.useQuery({ query }); - - const handleSearch = (value: string) => { - setQuery(value); - }; - - const handleChange = (value: string | undefined) => { - if (onChange) { - if (value === 'all') { - onChange(undefined) - } else { - onChange(value === undefined ? null : value); - } - } - }; - - const options: SelectProps['options'] = [ - ...(showAll ? [{ value: 'all', label: '全部' }] : []), - ...(data?.map((domain: any) => ({ - value: domain.id, - label: domain.name, - })) || []), - ]; - - return ( - + + + - - - - - - - - - - { - setSelectedDomainId(value); - formRef.current?.setFieldValue('domainId', value); - }} - /> - - - - - - -
- -
- - ); +import { api } from "@nicestack/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-import-drawer.tsx b/apps/web/src/components/models/staff/staff-import-drawer.tsx deleted file mode 100644 index f50f630..0000000 --- a/apps/web/src/components/models/staff/staff-import-drawer.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Button, Drawer, Form } from "antd"; -import React, { useEffect, useRef, useState } from "react"; -import type { ButtonProps, FormInstance } from "antd"; -import { Term } from "@nicestack/common"; -import DomainSelect from "../domain/domain-select"; -import { ExcelImporter } from "../../utilities/excel-importer"; - - -interface TermDrawerProps extends ButtonProps { - title: string; - data?: Partial; - parentId?: string; - - domainId?: string; -} - -export default function StaffImportDrawer({ - data, - - title, - - domainId, - ...buttonProps -}: TermDrawerProps) { - const [open, setOpen] = useState(false); - const handleTrigger = () => { - setOpen(true); - }; - - const [staffDomainId, setStaffDomainId] = useState( - domainId - ); - - const formRef = useRef(null); - useEffect(() => { - if (domainId) { - formRef.current?.setFieldValue("domainId", domainId); - setStaffDomainId(domainId); - } - }, [domainId]); - return ( - <> - - { - setOpen(false); - }} - title={title} - width={400}> -
- - { - setStaffDomainId(value); - }}> - -
-
- -
-
- - ); -} 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 100644 index 0000000..2f295a5 --- /dev/null +++ b/apps/web/src/components/models/staff/staff-list.tsx @@ -0,0 +1,195 @@ +import { Icon } from "@nicestack/iconer"; 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 "@nicestack/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 "@nicestack/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 100644 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 index 96763fe..24e8b16 100644 --- a/apps/web/src/components/models/staff/staff-select.tsx +++ b/apps/web/src/components/models/staff/staff-select.tsx @@ -1,47 +1,84 @@ -import { useState } from 'react'; -import { Select, Spin } from 'antd'; -import type { SelectProps } from 'antd'; -import { api } from '@web/src/utils/trpc'; - +import { useMemo, useState } from "react"; +import { Button, Select, Spin } from "antd"; +import type { SelectProps } from "antd"; +import { api } from "@nicestack/client"; interface StaffSelectProps { - value?: string | string[]; - onChange?: (value: string | string[]) => void; - style?: React.CSSProperties; - multiple?: boolean; - domainId?: string + value?: string | string[]; + onChange?: (value: string | string[]) => void; + style?: React.CSSProperties; + multiple?: boolean; + domainId?: string; + placeholder?: string; } -export default function StaffSelect({ value, onChange, style, multiple, domainId }: StaffSelectProps) { - const [keyword, setQuery] = useState(''); +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 = Array.isArray(value) ? value : undefined; + // 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({ keyword, domainId, ids }); + // 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, - const handleSearch = (value: string) => { - setQuery(value); - }; + }, + select: { id: true, showname: true, username: true }, + take: 30, + orderBy: { order: "asc" } + }); - const options: SelectProps['options'] = data?.map((staff: any) => ({ - value: staff.id, - label: staff.showname, - })) || []; + const handleSearch = (value: string) => { + setQuery(value); + }; - return ( - : 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-table.tsx b/apps/web/src/components/models/staff/staff-table.tsx deleted file mode 100644 index 2e2286d..0000000 --- a/apps/web/src/components/models/staff/staff-table.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import React, { useContext, useMemo, useEffect, useState } from "react"; -import { DeleteOutlined, HolderOutlined } from "@ant-design/icons"; -import type { DragEndEvent } from "@dnd-kit/core"; -import { DndContext } from "@dnd-kit/core"; -import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"; -import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { - arrayMove, - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { Button, Table, Space, Divider, Typography } from "antd"; -import type { TableColumnsType } from "antd"; -import { Staff } from "@nicestack/common"; -import { useStaff } from "@web/src/hooks/useStaff"; -import { api } from "@web/src/utils/trpc"; -import { TableRowSelection } from "antd/es/table/interface"; -import DepartmentSelect from "../department/department-select"; -import DomainSelect from "../domain/domain-select"; -import StaffDrawer from "./staff-drawer"; -import StaffImportDrawer from "./staff-import-drawer"; - - -interface RowContextProps { - setActivatorNodeRef?: (element: HTMLElement | null) => void; - listeners?: SyntheticListenerMap; -} - -const RowContext = React.createContext({}); - -const DragHandle: React.FC = () => { - const { setActivatorNodeRef, listeners } = useContext(RowContext); - return ( - - - {/* Display total number of staff */} - -
- - 共查询到{data?.totalCount}条记录 - - - i.id)} - strategy={verticalListSortingStrategy}> - { - setCurrentPage(page); - setPageSize(pageSize); - }, - }} - components={{ body: { row: Row } }} - columns={columns} - dataSource={dataSource} - loading={isLoading} - rowSelection={rowSelection} - /> - - - - ); -}; - -export default StaffTable; diff --git a/apps/web/src/components/models/staff/staff-transfer.tsx b/apps/web/src/components/models/staff/staff-transfer.tsx new file mode 100644 index 0000000..a99b259 --- /dev/null +++ b/apps/web/src/components/models/staff/staff-transfer.tsx @@ -0,0 +1,79 @@ +import { Avatar, theme, Transfer, TransferProps } from "antd"; +import { StaffDto } from "@nicestack/common"; +import React, { forwardRef, useImperativeHandle, useMemo, useState } from "react"; + +// Define the ref type +export interface StaffTransferRef { + resetSelection: () => void; +} + +interface StaffTransferProps { + staffs?: StaffDto[]; + onChange?: (targetKeys: string[]) => void; +} + +interface TransferRecordType { + key: string; + title: string; + description?: string; + chosen: boolean; +} + +const StaffTransfer = forwardRef(({ staffs = [], onChange: externalOnChange }, ref) => { + const [targetKeys, setTargetKeys] = useState([]); + const { token } = theme.useToken(); + + const dataSource = useMemo(() => { + // console.log(staffs) + return staffs.map(staff => ({ + key: staff.id, + title: staff.showname || staff.username, + description: staff.officerId, + chosen: false + })); + }, [staffs]); + + const handleChange: TransferProps['onChange'] = (newTargetKeys, direction, moveKeys) => { + setTargetKeys(newTargetKeys); + // console.log(newTargetKeys); + if (externalOnChange) { + externalOnChange(newTargetKeys as string[]); + } + }; + + const filterOption = (inputValue: string, item: TransferRecordType) => + item.title.toLowerCase().includes(inputValue.toLowerCase()) || + item.description?.toLowerCase().includes(inputValue.toLowerCase()); + + useImperativeHandle(ref, () => ({ + resetSelection: () => { + setTargetKeys([]); + } + })); + + return ( + ( +
+ + {item.title?.slice(0, 1).toUpperCase()} + + {item.title} + {item.description} +
+ )} + /> + ); +}); + +export default StaffTransfer; diff --git a/apps/web/src/components/models/taxonomy/taxonomy-drawer.tsx b/apps/web/src/components/models/taxonomy/taxonomy-drawer.tsx deleted file mode 100644 index 548ce5b..0000000 --- a/apps/web/src/components/models/taxonomy/taxonomy-drawer.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Button, ButtonProps, Drawer } from "antd"; -import { useMemo, useState } from "react"; -import { Taxonomy } from "@nicestack/common"; -import TaxonomyForm from "./taxonomy-form"; -interface TaxonomyDrawerProps extends ButtonProps { - data?: Partial; - title: string; -} - -export default function TaxonomyDrawer({ - data, - title, - ...buttonProps -}: TaxonomyDrawerProps) { - const [open, setOpen] = useState(false); - const drawerTitle = useMemo(() => { - return data ? '编辑分类法' : '创建分类法'; - }, [data]); - - const handleTrigger = () => { - setOpen(true); - }; - - return ( - <> - - { - setOpen(false); - }} - title={drawerTitle} - width={400} - > - - - - ); -} \ No newline at end of file diff --git a/apps/web/src/components/models/taxonomy/taxonomy-form.tsx b/apps/web/src/components/models/taxonomy/taxonomy-form.tsx deleted file mode 100644 index 86df3c5..0000000 --- a/apps/web/src/components/models/taxonomy/taxonomy-form.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Button, Form, Input } from "antd"; -import { FormInstance } from "antd"; -import { useRef, useState } from "react"; -import { Taxonomy } from "@nicestack/common" -import { useTaxonomy } from "@web/src/hooks/useTaxonomy"; -export default function TaxonomyForm({ data = undefined }: { data?: Partial }) { - const { create, update } = useTaxonomy() - const [loading, setLoading] = useState(false) - const formRef = useRef(null) - return
{ - console.log(values) - setLoading(true) - if (data) { - await update.mutateAsync({ id: data.id, ...values }) - } else { - await create.mutateAsync(values) - formRef.current?.resetFields() - } - setLoading(false) - - }}> - - - - {/* - - */} -
- -
- -} \ No newline at end of file diff --git a/apps/web/src/components/models/taxonomy/taxonomy-select.tsx b/apps/web/src/components/models/taxonomy/taxonomy-select.tsx index c726a7a..a6f32bd 100644 --- a/apps/web/src/components/models/taxonomy/taxonomy-select.tsx +++ b/apps/web/src/components/models/taxonomy/taxonomy-select.tsx @@ -1,63 +1,67 @@ -import { api } from "@web/src/utils/trpc"; -import { Select } from "antd"; -import React from "react"; +import { Button, Select } from "antd"; +import { api } from "@nicestack/client" import { useEffect, useState } from "react"; // 定义组件的 props 类型 interface TaxonomySelectProps { - defaultValue?: string; - value?: string; - onChange?: (value: string) => void; - width?: number | string; // 修改类型,支持百分比 - placeholder?: string; - extraOptions?: { value: string | undefined, label: string }[]; // 新增 extraOptions 属性 + defaultValue?: string; + value?: string; + onChange?: (value: string) => void; + width?: number | string; // 修改类型,支持百分比 + placeholder?: string; + extraOptions?: { value: string | undefined; label: string }[]; // 新增 extraOptions 属性 } export default function TaxonomySelect({ - defaultValue, - value, - onChange, - width = '100%', // 默认设置为 100% - placeholder = "选择分类", - extraOptions = [] // 默认值为空数组 + defaultValue, + value, + onChange, + width = "100%", // 默认设置为 100% + placeholder = "选择分类", + extraOptions = [], // 默认值为空数组 }: TaxonomySelectProps) { - const { data: taxonomies, isLoading: isTaxLoading } = api.taxonomy.getAll.useQuery(); + const { data: taxonomies, isLoading: isTaxLoading } = + api.taxonomy.getAll.useQuery({}); - const [selectedValue, setSelectedValue] = useState(defaultValue); + const [selectedValue, setSelectedValue] = useState( + defaultValue + ); - // 当 defaultValue 或 value 改变时,将其设置为 selectedValue - useEffect(() => { - if (value !== undefined) { - setSelectedValue(value); - } else if (defaultValue !== undefined) { - setSelectedValue(defaultValue); - } - }, [defaultValue, value]); + // 当 defaultValue 或 value 改变时,将其设置为 selectedValue + useEffect(() => { + if (value !== undefined) { + setSelectedValue(value); + } else if (defaultValue !== undefined) { + setSelectedValue(defaultValue); + } + }, [defaultValue, value]); - // 内部处理选择变化,并调用外部传入的 onChange 回调(如果有的话) - const handleChange = (newValue: string) => { - setSelectedValue(newValue); - if (onChange) { - onChange(newValue); - } - }; + // 内部处理选择变化,并调用外部传入的 onChange 回调(如果有的话) + const handleChange = (newValue: string) => { + setSelectedValue(newValue); + if (onChange) { + onChange(newValue); + } + }; - return ( - ({ + value: tax.id, + label: tax.name, + })) || []), + ...extraOptions, // 添加额外选项 + ]} + loading={isTaxLoading} + placeholder={placeholder} + onChange={handleChange} + /> + + ); } diff --git a/apps/web/src/components/models/taxonomy/taxonomy-table.tsx b/apps/web/src/components/models/taxonomy/taxonomy-table.tsx deleted file mode 100644 index 824d671..0000000 --- a/apps/web/src/components/models/taxonomy/taxonomy-table.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React, { useContext, useMemo, useEffect, useState } from 'react'; -import { DeleteOutlined, HolderOutlined, PlusOutlined } from '@ant-design/icons'; -import type { DragEndEvent } from '@dnd-kit/core'; -import { DndContext } from '@dnd-kit/core'; -import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; -import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; -import { - arrayMove, - SortableContext, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { Button, Table, Space, Divider } from 'antd'; -import type { TableColumnsType } from 'antd'; -import { Taxonomy } from "@nicestack/common" -import { TableRowSelection } from 'antd/es/table/interface'; -import { useTaxonomy } from '@web/src/hooks/useTaxonomy'; -import { api } from '@web/src/utils/trpc'; -import TaxonomyDrawer from './taxonomy-drawer'; - -interface RowContextProps { - setActivatorNodeRef?: (element: HTMLElement | null) => void; - listeners?: SyntheticListenerMap; -} - -const RowContext = React.createContext({}); - -const DragHandle: React.FC = () => { - const { setActivatorNodeRef, listeners } = useContext(RowContext); - return ( -
- - ); -}; - -const TaxonomyTable: React.FC = () => { - const [dataSource, setDataSource] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - - const { data, isLoading } = api.taxonomy.paginate.useQuery({ page: currentPage, pageSize }); - - const [selectedIds, setSelectedRowKeys] = useState([]); - const onSelectChange = (newSelectedRowKeys: React.Key[]) => { - setSelectedRowKeys(newSelectedRowKeys as string[]); - }; - const { batchDelete, update } = useTaxonomy(); - const rowSelection: TableRowSelection = { - selectedRowKeys: selectedIds, - onChange: onSelectChange, - }; - - useEffect(() => { - if (data) { - setDataSource(data.items); - } - }, [data]); - - const onDragEnd = ({ active, over }: DragEndEvent) => { - if (active.id !== over?.id) { - setDataSource((prevState) => { - const activeIndex = prevState.findIndex((record) => record.id === active?.id); - const overIndex = prevState.findIndex((record) => record.id === over?.id); - const newItems = arrayMove(prevState, activeIndex, overIndex); - handleUpdateOrder(JSON.parse(JSON.stringify(newItems))); - return newItems; - }); - } - }; - - const columns: TableColumnsType = [ - { key: 'sort', align: 'center', width: 80, render: () => }, - { title: '名称', dataIndex: 'name', render: (text) => text }, - // { title: '别名', dataIndex: 'slug', key: 'slug' }, - { - title: '操作', - render: (_, record) => ( - - - - ), - }, - ]; - - const handleDelete = async () => { - if (selectedIds.length > 0) { - await batchDelete.mutateAsync({ ids: selectedIds }); - } - }; - - const handleUpdateOrder = async (newItems: Taxonomy[]) => { - const orderedItems = newItems.sort((a, b) => a.order - b.order); - await Promise.all( - orderedItems.map((item, index) => { - if (item.order !== newItems[index].order) { - return update.mutateAsync({ id: newItems[index].id, order: item.order }); - } - }) - ); - }; - - return ( -
-
- - - -
- - i.id)} strategy={verticalListSortingStrategy}> -
{ - setCurrentPage(page); - setPageSize(pageSize); - } - }} - components={{ body: { row: Row } }} - columns={columns} - dataSource={dataSource} - loading={isLoading} - rowSelection={rowSelection} - /> - - - - ); -}; - -export default TaxonomyTable; 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 100644 index 0000000..8332675 --- /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 "@nicestack/common"; +import { useTaxonomy } from "@nicestack/client"; +import { TermEditorContext } from "./term-editor"; +import { api } from "@nicestack/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 100644 index 0000000..52822d6 --- /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 "@nicestack/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 100644 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-drawer.tsx b/apps/web/src/components/models/term/term-drawer.tsx deleted file mode 100644 index 6892f18..0000000 --- a/apps/web/src/components/models/term/term-drawer.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Button, Drawer } from "antd"; -import React, { useState } from "react"; -import type { ButtonProps } from "antd"; -import { Term } from "@nicestack/common"; -import TermForm from "./term-form"; - -interface TermDrawerProps extends ButtonProps { - title: string; - data?: Partial; - parentId?: string; - taxonomyId: string, - domainId?: string -} - -export default function TermDrawer({ - data, - parentId, - title, - taxonomyId, - domainId, - ...buttonProps -}: TermDrawerProps) { - const [open, setOpen] = useState(false); - const handleTrigger = () => { - setOpen(true); - }; - - return ( - <> - - { - setOpen(false); - }} - title={title} - width={400} - > - - - - ); -} 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 100644 index 0000000..028396f --- /dev/null +++ b/apps/web/src/components/models/term/term-editor.tsx @@ -0,0 +1,94 @@ +import { createContext, useMemo, useState } from "react"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { RolePerms } from "@nicestack/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 index 8c23ccc..a114037 100644 --- a/apps/web/src/components/models/term/term-form.tsx +++ b/apps/web/src/components/models/term/term-form.tsx @@ -1,102 +1,85 @@ -import { Button, Form, Input, message, Checkbox } from "antd"; -import { FormInstance } from "antd"; -import { useEffect, useRef, useState } from "react"; -import { Term } from "@nicestack/common"; // Adjust the import path if necessary -import { useTerm } from "@web/src/hooks/useTerm"; -import DepartmentSelect from "../department/department-select"; -import DomainSelect from "../domain/domain-select"; -import StaffSelect from "../staff/staff-select"; -import TaxonomySelect from "../taxonomy/taxonomy-select"; +import { Button, Form, Input, message, Checkbox, Spin } from "antd"; +import { useContext, useEffect, useRef, useState } from "react"; +import { useTerm } from "@nicestack/client"; import TermSelect from "./term-select"; +import { TermEditorContext } from "./term-editor"; +import { api } from "@nicestack/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); -export default function TermForm({ - data, - taxonomyId, - parentId, - domainId -}: { - data?: Partial; - taxonomyId: string; - parentId?: string; - domainId?: string -}) { - const { create, update, addFetchParentId } = useTerm(); // Ensure you have these methods in your hooks - const [loading, setLoading] = useState(false); - const formRef = useRef(null); - const [selectedDomainId, setSelectedDomainId] = useState(domainId); - useEffect(() => { - if (taxonomyId) formRef.current?.setFieldValue("taxonomyId", taxonomyId); - }, [taxonomyId]); - useEffect(() => { - if (domainId) { - formRef.current?.setFieldValue("domainId", domainId); - setSelectedDomainId(domainId) - } - }, [domainId]); - return ( - { - setLoading(true); - addFetchParentId(values.parentId) - if (data) { - try { - await update.mutateAsync({ id: data.id, ...values }); - } catch (err) { - message.error("更新失败"); - } - } else { - try { - await create.mutateAsync(values); - formRef.current?.resetFields(); - if (taxonomyId) - formRef.current?.setFieldValue("taxonomyId", taxonomyId); - if (domainId) - formRef.current?.setFieldValue("domainId", domainId); - } catch (err) { - message.error("创建失败"); - } - } - setLoading(false); - }} - > - - { - setSelectedDomainId(value); - formRef.current?.setFieldValue('domainId', value); - }}> - + } - - - - - - - {/* - - */} - - - - - - - - - -
- -
- - ); + + }}> + + + + + + + + +
+ ); } diff --git a/apps/web/src/components/models/term/term-import-drawer.tsx b/apps/web/src/components/models/term/term-import-drawer.tsx deleted file mode 100644 index 2c62a70..0000000 --- a/apps/web/src/components/models/term/term-import-drawer.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Button, Drawer, Form } from "antd"; -import React, { useEffect, useRef, useState } from "react"; -import type { ButtonProps, FormInstance } from "antd"; -import { Term } from "@nicestack/common"; -import DomainSelect from "../domain/domain-select"; -import TaxonomySelect from "../taxonomy/taxonomy-select"; -import TermSelect from "./term-select"; -import { ExcelImporter } from "../../utilities/excel-importer"; - -interface TermDrawerProps extends ButtonProps { - title: string; - data?: Partial; - parentId?: string; - taxonomyId: string; - domainId?: string; -} - -export default function TermImportDrawer({ - data, - parentId, - title, - taxonomyId, - domainId, - ...buttonProps -}: TermDrawerProps) { - const [open, setOpen] = useState(false); - const handleTrigger = () => { - setOpen(true); - }; - - const [termDomainId, setTermDomainId] = useState( - domainId - ); - const [termTaxonomyId, setTermTaxonomyId] = useState( - taxonomyId - ); - - const [termId, setTermId] = useState(parentId); - const formRef = useRef(null); - useEffect(() => { - if (parentId) { - formRef.current?.setFieldValue("termId", taxonomyId); - setTermId(parentId); - } - }, [parentId]); - useEffect(() => { - if (taxonomyId) { - formRef.current?.setFieldValue("taxonomyId", taxonomyId); - setTermTaxonomyId(taxonomyId); - } - }, [taxonomyId]); - useEffect(() => { - if (domainId) { - formRef.current?.setFieldValue("domainId", domainId); - setTermDomainId(domainId); - } - }, [domainId]); - return ( - <> - - { - setOpen(false); - }} - title={title} - width={400}> -
- - { - setTermDomainId(value); - }}> - - - { - setTermTaxonomyId(value); - }}> - - - { - setTermId(value); - }} - taxonomyId={termTaxonomyId}> - - -
- -
-
- - ); -} 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 100644 index 0000000..d4e4ab5 --- /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 "@nicestack/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 100644 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 index f29c816..082a781 100644 --- a/apps/web/src/components/models/term/term-list.tsx +++ b/apps/web/src/components/models/term/term-list.tsx @@ -1,254 +1,321 @@ -import React, { useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Empty, Tree, Button, message, TreeProps } from "antd"; -import { PlusOutlined, DownOutlined } from "@ant-design/icons"; -import { useTerm } from "@web/src/hooks/useTerm"; -import { api } from "@web/src/utils/trpc"; +import { + DeleteOutlined, + DownOutlined, + EditFilled, + EllipsisOutlined, + ImportOutlined, + PlusOutlined, +} from "@ant-design/icons"; +import { CrudOperation, emitDataChange, useTerm } from "@nicestack/client"; +import { ObjectType, Term, TreeDataNode } from "@nicestack/common"; +import DepartmentSelect from "../department/department-select"; +import { TermEditorContext } from "./term-editor"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { api } from "@nicestack/client" +import { Menu, MenuItem } from "../../presentation/dropdown-menu"; +import AgServerTable from "../../presentation/ag-server-table"; -import DomainSelect from "../domain/domain-select"; -import TaxonomySelect from "../taxonomy/taxonomy-select"; -import TermDrawer from "./term-drawer"; -import TermImportDrawer from "./term-import-drawer"; -import { DataNode } from "@nicestack/common"; +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 [customTreeData, setCustomTreeData] = useState([]); - const [checkedTermIds, setCheckedTermIds] = useState([]); const { - treeData, - update, - batchDelete, - taxonomyId, - setTaxonomyId, domainId, setDomainId, - addFetchParentId, - } = useTerm(); - const { data: taxonomies } = api.taxonomy.getAll.useQuery(); + taxonomyId, + canManageAnyTerm, + setTermModalOpen, + setImportModalOpen, + } = useContext(TermEditorContext); + const { user } = useAuth(); useEffect(() => { - if (treeData && taxonomyId) { - const processedTreeData = processTreeData(treeData).filter( - (node) => node.data.taxonomyId === taxonomyId - ); - console.log(treeData); - console.log(processedTreeData); - setCustomTreeData(processedTreeData); + if (user) { + setDomainId(user.domainId); } - }, [treeData, taxonomyId]); - + }, [user]); + const [params, setParams] = useState({ parentId: null, domainId: null, taxonomyId: null }); useEffect(() => { - if (taxonomies && taxonomies.length > 0) { - setTaxonomyId(taxonomies[0].id); + if (taxonomyId) { + setParams((prev) => ({ ...prev, taxonomyId })) } - }, [taxonomies]); - - const renderTitle = (node: DataNode) => ( -
- {node.title} -
- } - title="子节点" - parentId={node.key} - /> - - -
-
- ); - - const processTreeData = (nodes: DataNode[]): DataNode[] => { - return nodes.map((node) => ({ - ...node, - title: renderTitle(node), - children: - node.children && node.children.length > 0 - ? processTreeData(node.children) - : [], - })); - }; - - const onLoadData = async ({ key }: any) => { - console.log(key); - addFetchParentId(key); - }; - - const onDragEnter = () => { }; - - const onDrop = async (info: any) => { - console.log(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.log(dropPosition); - - const loop = ( - data: DataNode[], - key: React.Key, - callback: (node: DataNode, i: number, data: DataNode[]) => void - ) => { - for (let i = 0; i < data.length; i++) { - if (data[i].key === key) { - return callback(data[i], i, data); - } - if (data[i].children) { - loop(data[i].children!, key, callback); - } - } - }; - - const data = [...customTreeData]; - let dragObj: DataNode | undefined; - loop(data, dragKey, (item, index, arr) => { - arr.splice(index, 1); - dragObj = item; - }); - - let parentNodeId: any = null; - let siblings: DataNode[] = []; - - if (!info.dropToGap) { - loop(data, dropKey, (item) => { - item.children = item.children || []; - item.children.unshift(dragObj!); - parentNodeId = item.key; - siblings = item.children; - }); - } else if ( - (info.node.children || []).length > 0 && - info.node.expanded && - dropPosition === 1 - ) { - loop(data, dropKey, (item) => { - item.children = item.children || []; - item.children.unshift(dragObj!); - parentNodeId = item.key; - siblings = item.children; - }); + if (domainId) { + setParams((prev) => ({ ...prev, domainId })) } else { - let ar: DataNode[] = []; - let i: number = 0; - loop(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].data.parentId || null; - siblings = ar; + 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); + }, []); - setCustomTreeData(data); + const autoGroupColumnDef = useMemo(() => ({ + rowDrag: true, + headerName: "术语名", + field: "name", + filter: "agTextColumnFilter", + }), []); - const { id } = dragObj!.data; - console.log(JSON.parse(JSON.stringify(siblings))); - - const updatePromises = siblings.map((sibling, idx) => { - return update.mutateAsync({ - id: sibling.data.id, - order: idx, - parentId: parentNodeId, - }); - }); - - await Promise.all(updatePromises); - console.log( - `Updated node ${id} and its siblings with new order and parentId ${parentNodeId}` - ); - }; - - const onExpand = ( - expandedKeys: React.Key[], - { expanded, node }: { expanded: boolean; node: any } - ) => { - if (expanded) { - addFetchParentId(node.key); - } - }; - - const onCheck: TreeProps["onCheck"] = (checkedKeysValue: any) => { - console.log("onCheck", checkedKeysValue); - setCheckedTermIds(checkedKeysValue.checked); - }; - - const handleBatchDelete = async () => { - try { - await batchDelete.mutateAsync({ ids: checkedTermIds }); - setCheckedTermIds([]); - message.success("成功删除所选术语"); - } catch (error) { - message.error("删除失败"); - } - }; + const getServerSideGroupKey = useCallback((item) => item.id, []); + const isServerSideGroup = useCallback((item) => item.has_children, []); return ( -
-
- - setTaxonomyId(value)} - defaultValue={taxonomyId} - width={200} - /> +
+
+ 分类项列表 +
+ + setDomainId(value as string) + }> - - - + + +
- {customTreeData.length > 0 ? ( - } - /> - ) : ( - - )} + + {/*
+ + {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 100644 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 index 496efb7..d968f97 100644 --- a/apps/web/src/components/models/term/term-select.tsx +++ b/apps/web/src/components/models/term/term-select.tsx @@ -1,72 +1,181 @@ import { TreeSelect, TreeSelectProps } from "antd"; -import { useEffect, useState } from "react"; -import { DataNode } from "@nicestack/common"; -import { useTerm } from "@web/src/hooks/useTerm"; +import React, { useEffect, useState, useCallback } from "react"; +import { getUniqueItems } from "@nicestack/common"; +import { api } from "@nicestack/client"; +import { DefaultOptionType } from "antd/es/select"; interface TermSelectProps { - defaultValue?: string; - value?: string; - onChange?: (value: string) => void; - width?: number | string; - placeholder?: string; - taxonomyId: string; - extraOptions?: { value: string | undefined, label: string }[]; + 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; } export default function TermSelect({ - defaultValue, - value, - onChange, - width = '100%', - taxonomyId, - placeholder = "选择术语" + defaultValue, + value, + onChange, + className, + placeholder = "选择单位", + multiple = false, + taxonomyId, + // rootId = null, + disabled = false, + // domain = undefined, }: TermSelectProps) { - const [customTreeData, setCustomTreeData] = useState([]); - const { treeData, addFetchParentId } = useTerm(); + const utils = api.useUtils(); + const [listTreeData, setListTreeData] = useState< + Omit[] + >([]); - useEffect(() => { - if (treeData && taxonomyId) { - const processedTreeData = treeData.filter(node => node.data.taxonomyId === taxonomyId); - setCustomTreeData(processedTreeData); - } - }, [treeData, taxonomyId]); + 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, + }); + } catch (error) { + console.error( + "Error fetching parent departments for deptIds", + idsArray, + ":", + error + ); + throw error; + } + }, + [utils] + ); - const [selectedValue, setSelectedValue] = useState(defaultValue); + const fetchTerms = useCallback(async () => { + try { + const rootDepts = await utils.term.getChildSimpleTree.fetch({ + taxonomyId, + }); + 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" + ); + } - useEffect(() => { - if (value) { - setSelectedValue(value); - } else if (defaultValue) { - setSelectedValue(defaultValue); - } - }, [defaultValue, value]); + setListTreeData(combinedDepts); + } catch (error) { + console.error("Error fetching departments:", error); + } + }, [defaultValue, value, taxonomyId, utils, fetchParentTerms]); - const handleChange = (newValue: string) => { - setSelectedValue(newValue); - if (onChange) { - onChange(newValue); - } - }; + useEffect(() => { + fetchTerms(); + }, [defaultValue, value, taxonomyId, fetchTerms]); - const onLoadData: TreeSelectProps['loadData'] = async ({ id }) => { - addFetchParentId(id); - }; + const handleChange = (newValue: any) => { + if (onChange) { + const processedValue = + multiple && Array.isArray(newValue) + ? newValue.map((item) => item.value) + : newValue; + onChange(processedValue); + } + }; - const handleExpand = (expandedKeys: React.Key[]) => { - console.log(expandedKeys) - // addFetchParentId(node.key as string); - }; + const onLoadData: TreeSelectProps["loadData"] = async ({ id }) => { + try { + const result = await utils.term.getChildSimpleTree.fetch({ + termIds: [id], + taxonomyId, + }); + const newItems = getUniqueItems([...listTreeData, ...result], "id"); + setListTreeData(newItems); + } catch (error) { + console.error( + "Error loading data for node with id", + id, + ":", + error + ); + } + }; - return ( - - ); + 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, + }); + 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/term/term-select_BACKUP.tsx b/apps/web/src/components/models/term/term-select_BACKUP.tsx new file mode 100644 index 0000000..efac917 --- /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 "@nicestack/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 100644 index 0000000..e9bf8bf --- /dev/null +++ b/apps/web/src/components/models/term/util.ts @@ -0,0 +1,15 @@ +import { TreeDataNode } from "@nicestack/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/ag-server-table.tsx b/apps/web/src/components/presentation/ag-server-table.tsx new file mode 100644 index 0000000..42ea345 --- /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 "@nicestack/client" +import { message } from "antd"; +import { useLocation } from "react-router-dom"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { EventBus } from "@nicestack/client"; +import { ObjectType } from "@nicestack/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 100644 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/animation/sine-wave.tsx b/apps/web/src/components/presentation/animation/sine-wave.tsx deleted file mode 100644 index 2bf368f..0000000 --- a/apps/web/src/components/presentation/animation/sine-wave.tsx +++ /dev/null @@ -1,110 +0,0 @@ -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/presentation/collapse-section.tsx b/apps/web/src/components/presentation/collapse-section.tsx new file mode 100644 index 0000000..0dc241d --- /dev/null +++ b/apps/web/src/components/presentation/collapse-section.tsx @@ -0,0 +1,134 @@ +import React, { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Icon } from "@nicestack/iconer"; +import { theme } from "antd"; +import { motion } from "framer-motion"; // Import Framer Motion + +// 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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/svg/rounded-clip.tsx b/apps/web/src/components/svg/rounded-clip.tsx new file mode 100644 index 0000000..3d9005d --- /dev/null +++ b/apps/web/src/components/svg/rounded-clip.tsx @@ -0,0 +1,28 @@ +import { theme } from "antd"; + +export default function RoundedClip() { + const { token } = theme.useToken(); + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/apps/web/src/components/utilities/with-auth.tsx b/apps/web/src/components/utilities/with-auth.tsx deleted file mode 100644 index 3abad9d..0000000 --- a/apps/web/src/components/utilities/with-auth.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useAuth } from '@web/src/providers/auth-provider'; -import { RolePerms } from '@nicestack/common'; -import { ReactNode } from 'react'; -import { Navigate, useLocation } from "react-router-dom"; -// Define a type for the props that the HOC will accept. -interface WithAuthProps { - permissions?: RolePerms[]; -} - -// Create the HOC function. -export default function WithAuth({ options = {}, children }: { children: ReactNode, options?: WithAuthProps }) { - const { isAuthenticated, user, isLoading } = useAuth(); - const location = useLocation() - if (isLoading) { - return
Loading...
; - } - // If the user is not authenticated, redirect them to the login page. - if (!isAuthenticated) { - return - - } - if (options.permissions && user) { - const hasPermissions = options.permissions.every(permission => user.permissions.includes(permission)); - if (!hasPermissions) { - return
You do not have the required permissions to view this page.
; - } - } - // Return a new functional component. - return children -} diff --git a/apps/web/src/components/utilities/excel-importer.tsx b/apps/web/src/components/utils/excel-importer.tsx similarity index 91% rename from apps/web/src/components/utilities/excel-importer.tsx rename to apps/web/src/components/utils/excel-importer.tsx index aa927d5..9b32974 100644 --- a/apps/web/src/components/utilities/excel-importer.tsx +++ b/apps/web/src/components/utils/excel-importer.tsx @@ -1,13 +1,7 @@ -// import { api } from "@/trpc/react"; -import { useQueryClient } from "@tanstack/react-query"; -import { getQueryKey } from "@trpc/react-query"; import { Button, message } from "antd"; import { useMemo, useRef, useState } from "react"; - import { SizeType } from "antd/es/config-provider/SizeContext"; -import { useTransform } from "@web/src/hooks/useTransform"; -import { api } from "@web/src/utils/trpc"; - +import { useTransform } from "@nicestack/client"; export function ExcelImporter({ type = "trouble", className, @@ -62,7 +56,7 @@ export function ExcelImporter({ const isExcel = file.type === "application/vnd.ms-excel" || file.type === - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || file.type === "application/wps-office.xlsx"; if (!isExcel) { diff --git a/apps/web/src/components/utils/image-uploader.tsx b/apps/web/src/components/utils/image-uploader.tsx new file mode 100644 index 0000000..6f56a6b --- /dev/null +++ b/apps/web/src/components/utils/image-uploader.tsx @@ -0,0 +1,111 @@ +import { message, Progress, Spin, theme } from 'antd'; +import React, { useState, useEffect, useRef, ReactNode, CSSProperties } from 'react'; +import { useLocalSettings } from '@web/src/hooks/useLocalSetting'; +import { uploaderPromise } from '@web/src/io'; + +interface ImageUploaderProps { + value?: string; + onChange?: (url: string) => void; + className?: string; // Add className prop + placeholder?: ReactNode + style?: CSSProperties +} + +const ImageUploader: React.FC = ({ style, value, onChange, className, placeholder = '点击上传' }) => { + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(value || ''); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadSpeed, setUploadSpeed] = useState(0); + const [uploading, setUploading] = useState(false) + const inputRef = useRef(null); + const { token } = theme.useToken() + const { tusUrl } = useLocalSettings() + const handleFileChange = async (event: React.ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (selectedFile) { + setFile(selectedFile); + setPreviewUrl(URL.createObjectURL(selectedFile)); + } + }; + + useEffect(() => { + const handleUpload = async () => { + if (!file) return; + try { + const endpoint = tusUrl; + setUploading(true) + const resultUrl = await uploaderPromise(endpoint, file, (percentage, speed) => { + setUploadProgress(percentage); + setUploadSpeed(speed); + }); + + setUploadProgress(0); // Reset upload progress to hide overlay + + // Call the onChange callback with the uploaded URL + if (onChange) { + onChange(resultUrl); + } + + } catch (error) { + message.error('图片上传失败'); + } + finally { + setUploading(false) + } + }; + + if (file) { + handleUpload(); + } + }, [file]); + + // Effect to update previewUrl when the value prop changes + useEffect(() => { + if (!value) { + setFile(null); + setPreviewUrl(''); + } else { + setPreviewUrl(value); + } + }, [value]); + + const handleAreaClick = () => { + inputRef.current?.click(); + }; + + return ( + <> + +
+ {previewUrl ? ( + <> + Selected + {uploading && ( +
+ + + +
+ )} + + ) : ( + + {placeholder} + + )} +
+ + ); +}; + +export default ImageUploader; diff --git a/apps/web/src/components/utils/with-auth.tsx b/apps/web/src/components/utils/with-auth.tsx new file mode 100644 index 0000000..12354cc --- /dev/null +++ b/apps/web/src/components/utils/with-auth.tsx @@ -0,0 +1,62 @@ +import { useAuth } from "@web/src/providers/auth-provider"; +import { RolePerms } from "@nicestack/common"; +import { ReactNode, useEffect, useState } from "react"; +import { Navigate, useLocation } from "react-router-dom"; +import DeniedPage from "@web/src/app/denied"; +import { Spin } from "antd"; + +// Define a type for the props that the HOC will accept. +interface WithAuthProps { + orPermissions?: RolePerms[]; + andPermissions?: RolePerms[]; +} + +// Create the HOC function. +export default function WithAuth({ + options = {}, + children, +}: { + children: ReactNode; + options?: WithAuthProps; +}) { + const { + isAuthenticated, + user, + hasEveryPermissions, + hasSomePermissions, + isLoading, + } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + // If the user is not authenticated, redirect them to the login page. + if (!isAuthenticated) { + return ; + } + if (user) { + // Check orPermissions + if ( + options.orPermissions && + !hasSomePermissions(...options.orPermissions) + ) { + return ; + } + + // Check andPermissions + if ( + options.andPermissions && + !hasEveryPermissions(...options.andPermissions) + ) { + return ; + } + } + // Return the children if all permission checks pass. + return children; +} diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts old mode 100644 new mode 100755 index e4f679f..d364783 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -1,13 +1,15 @@ export const env: { - TUS_URL: string; - API_URL: string; + APP_NAME: string; + SERVER_IP: string; + VERSION: string; } = { - TUS_URL: - import.meta.env.PROD - ? (window as any).env.VITE_APP_TUS_URL - : import.meta.env.VITE_APP_TUS_URL, - API_URL: - import.meta.env.PROD - ? (window as any).env.VITE_APP_API_URL - : import.meta.env.VITE_APP_API_URL, + APP_NAME: import.meta.env.PROD + ? (window as any).env.VITE_APP_APP_NAME + : import.meta.env.VITE_APP_APP_NAME, + SERVER_IP: import.meta.env.PROD + ? (window as any).env.VITE_APP_SERVER_IP + : import.meta.env.VITE_APP_SERVER_IP, + VERSION: import.meta.env.PROD + ? (window as any).env.VITE_APP_VERSION + : import.meta.env.VITE_APP_VERSION, }; diff --git a/apps/web/src/hooks/useDepartment.ts b/apps/web/src/hooks/useDepartment.ts deleted file mode 100644 index ab495b0..0000000 --- a/apps/web/src/hooks/useDepartment.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { getQueryKey } from "@trpc/react-query"; -import { api } from "../utils/trpc"; -import { DataNode, DepartmentDto } from "@nicestack/common"; -import { ReactNode, useEffect, useMemo, useState } from "react"; -import { findQueryData } from "../utils/general"; - -export function useDepartment() { - const queryClient = useQueryClient(); - const queryKey = getQueryKey(api.department); - const [fetchParentIds, setFetchParentIds] = useState([null]); - const queries = api.useQueries((t) => { - return fetchParentIds.map((id) => - t.department.getChildren({ parentId: id }) - ); - }); - const addFetchParentId = (newId: string) => { - setFetchParentIds((prevIds) => { - // Check if the newId already exists in the array - if (!prevIds.includes(newId)) { - // If not, add it to the array - return [...prevIds, newId]; - } - // Otherwise, return the array as is - return prevIds; - }); - }; - const [treeData, setTreeData] = useState([]); - const queriesFetched = useMemo(() => { - return queries.every((query) => query.isFetched); - }, [queries]); - const queriesFetching = useMemo(() => { - return queries.some((query) => query.isFetching); - }, [queries]); - useEffect(() => { - if (queriesFetched) { - const rawTreeData = getTreeData(); - setTreeData(rawTreeData); - } - }, [queriesFetching]); - - const create = api.department.create.useMutation({ - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - const findById = (id: string) => { - return api.department.getDepartmentDetails.useQuery({ deptId: id }); - }; - - const update = api.department.update.useMutation({ - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - const deleteDepartment = api.department.delete.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - const buildTree = ( - data: DepartmentDto[], - parentId: string | null = null - ): DataNode[] => { - return data - .filter((department) => department.parentId === parentId) - .sort((a, b) => a.order - b.order) - .map((department) => { - const node: DataNode = { - title: department.name, - key: department.id, - value: department.id, - isLeaf: !department.hasChildren, - children: department.hasChildren - ? buildTree(data, department.id) - : undefined, - data: department, - }; - return node; - }); - }; - - const getTreeData = () => { - const cacheArray = queryClient.getQueriesData({ - queryKey: getQueryKey(api.department.getChildren), - }); - const data: DepartmentDto[] = cacheArray - .flatMap((cache) => cache.slice(1)) - .flat() - .filter((item) => item !== undefined) as any; - const uniqueDataMap = new Map(); - - data.forEach((item) => { - if (item && item.id) { - uniqueDataMap.set(item.id, item); - } - }); - // Convert the Map back to an array - const uniqueData: DepartmentDto[] = Array.from(uniqueDataMap.values()); - const treeData: DataNode[] = buildTree(uniqueData); - return treeData; - }; - const getDept = (key: string) => { - return findQueryData(queryClient, api.department, key); - }; - return { - deleteDepartment, - update, - findById, - create, - getTreeData, - addFetchParentId, - fetchParentIds, - treeData, - getDept, - }; -} diff --git a/apps/web/src/hooks/useLocalSetting.ts b/apps/web/src/hooks/useLocalSetting.ts new file mode 100644 index 0000000..03b4057 --- /dev/null +++ b/apps/web/src/hooks/useLocalSetting.ts @@ -0,0 +1,17 @@ + +import { useCallback, useMemo } from "react"; +import { env } from "../env"; +export function useLocalSettings() { + const getBaseUrl = useCallback((protocol: string, port: number) => { + return `${protocol}://${env.SERVER_IP}:${port}`; + }, []); + const tusUrl = useMemo(() => getBaseUrl('http', 8080), [getBaseUrl]); + const apiUrl = useMemo(() => getBaseUrl('http', 3000), [getBaseUrl]); + const websocketUrl = useMemo(() => getBaseUrl('ws', 3000), [getBaseUrl]); + const checkIsTusUrl = useCallback((url: string) => { + return url.startsWith(tusUrl) + }, [tusUrl]) + return { + apiUrl, websocketUrl, checkIsTusUrl, tusUrl + } +} \ No newline at end of file diff --git a/apps/web/src/hooks/useRoleMap.ts b/apps/web/src/hooks/useRoleMap.ts deleted file mode 100644 index 0cbefa4..0000000 --- a/apps/web/src/hooks/useRoleMap.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getQueryKey } from "@trpc/react-query"; -import { api } from "../utils/trpc"; // Adjust path as necessary -import { useQueryClient } from "@tanstack/react-query"; -import { RoleMapSchema, z } from "@nicestack/common"; -export function useRoleMap() { - const queryClient = useQueryClient(); - const queryKey = getQueryKey(api.rolemap); - - const create = api.rolemap.setRoleForObject.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - const createManyObjects = api.rolemap.createManyObjects.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - const update = api.rolemap.update.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - const batchDelete = api.rolemap.batchDelete.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - return { - create, - update, - createManyObjects, - batchDelete, - - }; -} diff --git a/apps/web/src/hooks/useStaff.ts b/apps/web/src/hooks/useStaff.ts deleted file mode 100644 index a2b4098..0000000 --- a/apps/web/src/hooks/useStaff.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getQueryKey } from "@trpc/react-query"; -import { api } from "../utils/trpc"; // Adjust path as necessary -import { useQueryClient } from "@tanstack/react-query"; -export function useStaff() { - const queryClient = useQueryClient(); - const queryKey = getQueryKey(api.staff); - - const create = api.staff.create.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - - const update = api.staff.update.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - const batchDelete = api.staff.batchDelete.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }) - } - }) - - return { - create, - update, - batchDelete - }; -} diff --git a/apps/web/src/hooks/useTerm.ts b/apps/web/src/hooks/useTerm.ts deleted file mode 100644 index 80da979..0000000 --- a/apps/web/src/hooks/useTerm.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { getQueryKey } from "@trpc/react-query"; -import { api } from "../utils/trpc"; // Adjust path as necessary -import { useQueryClient } from "@tanstack/react-query"; -import { DataNode, TermDto } from "@nicestack/common" -import { useEffect, useMemo, useState } from "react"; -import { getCacheDataFromQuery } from "../utils/general"; -export function useTerm() { - const queryClient = useQueryClient(); - const queryKey = getQueryKey(api.term); - const [fetchParentIds, setFetchParentIds] = useState([null]); - const [domainId, setDomainId] = useState(undefined) - const [taxonomyId, setTaxonomyId] = useState(undefined) - const queries = api.useQueries(t => { - return fetchParentIds.map(id => t.term.getAllChildren({ parentId: id, domainId, taxonomyId })) - }) - const addFetchParentId = (newId: string) => { - setFetchParentIds((prevIds) => { - // Check if the newId already exists in the array - if (!prevIds.includes(newId)) { - // If not, add it to the array - return [...prevIds, newId]; - } - // Otherwise, return the array as is - return prevIds; - }); - - }; - const [treeData, setTreeData] = useState([]); - const queriesFetching = useMemo(() => { - return queries.some(query => query.isFetching) - }, [queries]) - useEffect(() => { - if (!queriesFetching) { - const rawTreeData = getTreeData(); - setTreeData(rawTreeData); - } - }, [queriesFetching]); - - const create = api.term.create.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - const findById = (id: string) => { - return api.term.findById.useQuery({ id }); - }; - - const update = api.term.update.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - const deleteTerm = api.term.delete.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - const batchDelete = api.term.batchDelete.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }) - } - }) - const buildTree = (data: TermDto[], parentId: string | null = null): DataNode[] => { - return data - .filter(term => term.parentId === parentId).sort((a, b) => a.order - b.order) - .map(term => { - const node: DataNode = { - title: term.name, - key: term.id, - value: term.id, - isLeaf: !term.hasChildren, - children: term.hasChildren ? buildTree(data, term.id) : undefined, - data: term - }; - return node; - }); - }; - - const getTreeData = () => { - const uniqueData: any = getCacheDataFromQuery(queryClient, api.term, "id") - console.log(uniqueData) - const treeData: DataNode[] = buildTree(uniqueData); - return treeData; - }; - - return { - create, - findById, - update, - deleteTerm, - batchDelete, - treeData, - addFetchParentId, - setDomainId, - domainId, - taxonomyId, setTaxonomyId - }; -} diff --git a/apps/web/src/hooks/useTransform.ts b/apps/web/src/hooks/useTransform.ts deleted file mode 100644 index aceeab1..0000000 --- a/apps/web/src/hooks/useTransform.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getQueryKey } from "@trpc/react-query"; -import { api } from "../utils/trpc"; // Adjust path as necessary -import { useQueryClient } from "@tanstack/react-query"; -export function useTransform() { - const queryClient = useQueryClient(); - const queryKey = getQueryKey(api.transform); - const importTerms = api.transform.importTerms.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - const importDepts = api.transform.importDepts.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - const importStaffs = api.transform.importStaffs.useMutation({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); - }, - }); - - return { - importTerms, - importDepts, - importStaffs, - - }; -} diff --git a/apps/web/src/index.css b/apps/web/src/index.css old mode 100644 new mode 100755 index bd6213e..b5d22fb --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,3 +1,110 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +.ag-custom-dragging-class { + @apply border-b-2 border-primaryHover; +} + + +.ant-popover-inner { + padding: 0 !important; + @apply border border-gray-300; +} + +.ag-header-viewport { + z-index: 0; +} + +.ag-cell { + display: flex; + align-items: center; + /* 垂直居中 */ +} + +.ag-cell .ag-cell-wrapper { + display: flex; + align-items: center; + /* 垂直居中 */ +} + +/* styles.css */ +.ant-table { + background-color: transparent !important; +} + +.ant-table-thead > tr > th { + background-color: transparent !important; +} + +.ant-table-tbody > tr > td { + background-color: transparent !important; + border-bottom-color: transparent !important; +} + +.ant-table-cell { + background-color: transparent !important; + border-bottom-color: transparent !important; +} + +/* 滚动条轨道 */ +::-webkit-scrollbar-track { + border-radius: 10px; + @apply bg-gray-200; +} + +/* 滚动块 */ +::-webkit-scrollbar-thumb { + /* background-color: #888; */ + + border-radius: 10px; + border: 2px solid #f0f0f0; + @apply hover:bg-primaryHover transition-all bg-gray-400 ease-in-out rounded-full; +} + +/* 鼠标悬停在滚动块上 */ +::-webkit-scrollbar-thumb:hover { + background-color: "tr"; +} + +::-webkit-scrollbar { + height: 8px; + width: 10px; + /* 滚动条宽度 */ +} + +/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/ +.ant-radio-button-wrapper-checked:not( + .ant-radio-button-wrapper-disabled + )::before { + background-color: unset !important; +} + +.state-select.ant-select-selector { + cursor: pointer !important; + /* 强制覆盖默认的禁止样式 */ +} + +.ant-tabs-nav-operations { + display: none !important; +} + +.no-wrap-header .ant-table-thead > tr > th { + white-space: nowrap; +} + +.custom-table .ant-table-cell { + white-space: normal; /* 允许换行 */ + word-wrap: break-word; /* 强制单词换行 */ +} +.custom-table .ant-table-cell { + border: 1px solid #ddd; /* 设置单元格边框 */ +} + +.custom-table .ant-table-tbody > tr > td { + border-bottom: 1px solid #ddd; /* 设置表格行底部边框 */ +} + +.custom-table .ant-table-tbody > tr:last-child > td { + border-bottom: none; /* 去除最后一行的底部边框 */ +} diff --git a/apps/web/src/io/index.ts b/apps/web/src/io/index.ts new file mode 100644 index 0000000..a8bab8d --- /dev/null +++ b/apps/web/src/io/index.ts @@ -0,0 +1 @@ +export * from "./tusd" \ No newline at end of file diff --git a/apps/web/src/io/tusd.ts b/apps/web/src/io/tusd.ts new file mode 100644 index 0000000..a3e13c1 --- /dev/null +++ b/apps/web/src/io/tusd.ts @@ -0,0 +1,96 @@ +import * as tus from "tus-js-client"; +import imageCompression from "browser-image-compression"; +export const uploader = async ( + endpoint: string, + file: File, + onProgress?: (percentage: number, speed: number) => void, + onSuccess?: (url: string) => void, + onError?: (error: Error) => void +) => { + let previousUploadedSize = 0; + let previousTimestamp = Date.now(); + + // 压缩图像为WebP格式 + const compressImage = async (file: File): Promise => { + const options = { + maxSizeMB: 0.8, // 最大文件大小(MB) + maxWidthOrHeight: 1920, // 最大宽高 + useWebWorker: true, + fileType: "image/webp", // 输出文件格式 + }; + const compressedFile = await imageCompression(file, options); + return new File([compressedFile], `${file.name.split(".")[0]}.webp`, { + type: "image/webp", + }); + }; + + let fileToUpload: File; + + // 检查并压缩图片文件 + if (file.type.startsWith("image/")) { + try { + fileToUpload = await compressImage(file); + } catch (error: any) { + console.error("图像压缩失败: " + error.message); + if (onError) onError(error); + throw error; // 如果压缩失败,抛出错误并终止上传 + } + } else { + fileToUpload = file; // 非图片文件,不进行压缩 + } + + const upload = new tus.Upload(fileToUpload, { + // Replace this with tusd's upload creation URL + endpoint: `${endpoint}/files/`, + retryDelays: [0, 3000, 5000, 10000, 20000], + metadata: { + filename: fileToUpload.name, + filetype: fileToUpload.type, + }, + onError: function (error) { + console.error("上传失败: " + error.message); + if (onError) onError(error); + }, + onProgress: function (bytesUploaded: number, bytesTotal: number) { + const currentTimestamp = Date.now(); + const timeElapsed = (currentTimestamp - previousTimestamp) / 1000; // in seconds + const bytesUploadedSinceLastTime = + bytesUploaded - previousUploadedSize; + const speed = bytesUploadedSinceLastTime / timeElapsed; // bytes per second + previousUploadedSize = bytesUploaded; + previousTimestamp = currentTimestamp; + const percentage = (bytesUploaded / bytesTotal) * 100; + if (onProgress) onProgress(percentage, speed); + }, + onSuccess: function () { + console.log("上传文件类型", fileToUpload.type); + console.log("上传文件名称", fileToUpload.name); + if (onSuccess) onSuccess(upload.url!); + console.log("Download %s from %s", fileToUpload.name, upload.url); + }, + }); + + // Check if there are any previous uploads to continue. + upload.findPreviousUploads().then(function (previousUploads) { + // Found previous uploads so we select the first one. + if (previousUploads && previousUploads.length > 0) { + upload.resumeFromPreviousUpload(previousUploads[0]!); + } + }); + + return upload; +}; + +export const uploaderPromise = ( + endpoint: string, + file: File, + onProgress?: (percentage: number, speed: number) => void +): Promise => { + return new Promise((resolve, reject) => { + uploader(endpoint, file, onProgress, resolve, reject) + .then((upload) => { + upload.start(); + }) + .catch(reject); + }); +}; diff --git a/apps/web/src/locale/ag-grid-locale.ts b/apps/web/src/locale/ag-grid-locale.ts new file mode 100644 index 0000000..be79444 --- /dev/null +++ b/apps/web/src/locale/ag-grid-locale.ts @@ -0,0 +1,563 @@ +// Example locale file for English, give this to your locale team to translate + +export const AG_GRID_LOCALE_CH = { + // Set Filter + selectAll: "全选", + selectAllSearchResults: "全选搜索结果", + addCurrentSelectionToFilter: "将当前选择添加到过滤器", + searchOoo: "搜索...", + blanks: "空白", + noMatches: "没有匹配项", + autosize: "自适应大小", + // Number Filter & Text Filter + filterOoo: "筛选...", + equals: "等于", + notEqual: "不等于", + blank: "空白", + notBlank: "非空白", + empty: "选择一个", + + // Number Filter + lessThan: "小于", + greaterThan: "大于", + lessThanOrEqual: "小于或等于", + greaterThanOrEqual: "大于或等于", + inRange: "介于", + inRangeStart: "从", + inRangeEnd: "到", + + // Text Filter + contains: "包含", + notContains: "不包含", + startsWith: "以...开头", + endsWith: "以...结尾", + + // Date Filter + dateFormatOoo: "yyyy-mm-dd", + before: "之前", + after: "之后", + + // Filter Conditions + andCondition: "并且", + orCondition: "或者", + + // Filter Buttons + applyFilter: "应用", + resetFilter: "重置", + clearFilter: "清除", + cancelFilter: "取消", + + // Filter Titles + textFilter: "文本过滤器", + numberFilter: "数字过滤器", + dateFilter: "日期过滤器", + setFilter: "设置过滤器", + + // Group Column Filter + groupFilterSelect: "选择字段:", + + // Advanced Filter + advancedFilterContains: "包含", + advancedFilterNotContains: "不包含", + advancedFilterTextEquals: "等于", + advancedFilterTextNotEqual: "不等于", + advancedFilterStartsWith: "以...开头", + advancedFilterEndsWith: "以...结尾", + advancedFilterBlank: "空白", + advancedFilterNotBlank: "非空白", + advancedFilterEquals: "=", + advancedFilterNotEqual: "!=", + advancedFilterGreaterThan: ">", + advancedFilterGreaterThanOrEqual: ">=", + advancedFilterLessThan: "<", + advancedFilterLessThanOrEqual: "<=", + advancedFilterTrue: "为真", + advancedFilterFalse: "为假", + advancedFilterAnd: "并且", + advancedFilterOr: "或者", + advancedFilterApply: "应用", + advancedFilterBuilder: "构建器", + advancedFilterValidationMissingColumn: "缺少列", + advancedFilterValidationMissingOption: "缺少选项", + advancedFilterValidationMissingValue: "缺少值", + advancedFilterValidationInvalidColumn: "找不到列", + advancedFilterValidationInvalidOption: "找不到选项", + advancedFilterValidationMissingQuote: "值缺少结束引号", + advancedFilterValidationNotANumber: "值不是数字", + advancedFilterValidationInvalidDate: "值不是有效的日期", + advancedFilterValidationMissingCondition: "缺少条件", + advancedFilterValidationJoinOperatorMismatch: "条件内的连接符必须相同", + advancedFilterValidationInvalidJoinOperator: "找不到连接符", + advancedFilterValidationMissingEndBracket: "缺少结束括号", + advancedFilterValidationExtraEndBracket: "结束括号过多", + advancedFilterValidationMessage: + "表达式有错误。 ${variable} - ${variable}.", + advancedFilterValidationMessageAtEnd: + "表达式有错误。 ${variable} 在表达式末尾。", + advancedFilterBuilderTitle: "高级过滤器", + advancedFilterBuilderApply: "应用", + advancedFilterBuilderCancel: "取消", + advancedFilterBuilderAddButtonTooltip: "添加过滤器或组", + advancedFilterBuilderRemoveButtonTooltip: "删除", + advancedFilterBuilderMoveUpButtonTooltip: "上移", + advancedFilterBuilderMoveDownButtonTooltip: "下移", + advancedFilterBuilderAddJoin: "添加组", + advancedFilterBuilderAddCondition: "添加过滤器", + advancedFilterBuilderSelectColumn: "选择列", + advancedFilterBuilderSelectOption: "选择选项", + advancedFilterBuilderEnterValue: "输入值...", + advancedFilterBuilderValidationAlreadyApplied: "当前过滤器已应用。", + advancedFilterBuilderValidationIncomplete: "并非所有条件都已完成。", + advancedFilterBuilderValidationSelectColumn: "必须选择列。", + advancedFilterBuilderValidationSelectOption: "必须选择选项。", + advancedFilterBuilderValidationEnterValue: "必须输入值。", + + // Side Bar + columns: "列", + filters: "过滤器", + + // columns tool panel + pivotMode: "透视模式", + groups: "行组", + rowGroupColumnsEmptyMessage: "拖放到此处设置行组", + values: "值", + valueColumnsEmptyMessage: "拖放到此处进行聚合", + pivots: "列标签", + pivotColumnsEmptyMessage: "拖放到此处设置列标签", + + // Header of the Default Group Column + group: "组", + + // Row Drag + rowDragRow: "行", + rowDragRows: "行", + + // Other + loadingOoo: "加载中...", + loadingError: "错误", + noRowsToShow: "暂无数据", + enabled: "启用", + + // Menu + pinColumn: "固定列", + pinLeft: "固定到左边", + pinRight: "固定到右边", + noPin: "不固定", + valueAggregation: "值聚合", + noAggregation: "无", + autosizeThisColumn: "自动调整此列大小", + autosizeAllColumns: "自动调整所有列大小", + groupBy: "按...分组", + ungroupBy: "取消按...分组", + ungroupAll: "取消所有分组", + addToValues: "将 ${variable} 添加到值", + removeFromValues: "从值中删除 ${variable}", + addToLabels: "将 ${variable} 添加到标签", + removeFromLabels: "从标签中删除 ${variable}", + resetColumns: "重置列", + expandAll: "展开所有行组", + collapseAll: "关闭所有行组", + copy: "复制", + ctrlC: "Ctrl+C", + ctrlX: "Ctrl+X", + copyWithHeaders: "复制带标题", + copyWithGroupHeaders: "复制带组标题", + cut: "剪切", + paste: "粘贴", + ctrlV: "Ctrl+V", + export: "导出", + csvExport: "CSV 导出", + excelExport: "Excel 导出", + columnFilter: "列过滤器", + columnChooser: "选择列", + sortAscending: "升序排列", + sortDescending: "降序排列", + sortUnSort: "清除排序", + + // Enterprise Menu Aggregation and Status Bar + sum: "求和", + first: "第一个", + last: "最后一个", + min: "最小值", + max: "最大值", + none: "无", + count: "计数", + avg: "平均值", + filteredRows: "已筛选", + selectedRows: "已选择", + totalRows: "总行数", + totalAndFilteredRows: "行", + more: "更多", + to: "到", + of: "的", + page: "页", + pageLastRowUnknown: "?", + nextPage: "下一页", + lastPage: "上一页", + firstPage: "第一页", + previousPage: "上一页", + pageSizeSelectorLabel: "页面大小:", + footerTotal: "总计", + + // Pivoting + pivotColumnGroupTotals: "总计", + + // Enterprise Menu (Charts) + pivotChartAndPivotMode: "透视图表和透视模式", + pivotChart: "透视图表", + chartRange: "图表范围", + + columnChart: "柱形图", + groupedColumn: "分组柱形图", + stackedColumn: "堆积柱形图", + normalizedColumn: "100% 堆积柱形图", + + barChart: "条形图", + groupedBar: "分组条形图", + stackedBar: "堆积条形图", + normalizedBar: "100% 堆积条形图", + + pieChart: "饼图", + pie: "饼图", + donut: "环形图", + + line: "折线图", + + xyChart: "X Y(散点图)", + scatter: "散点图", + bubble: "气泡图", + + areaChart: "面积图", + area: "面积图", + stackedArea: "堆积面积图", + normalizedArea: "100% 堆积面积图", + + histogramChart: "直方图", + histogramFrequency: "频率", + + polarChart: "极坐标图", + radarLine: "雷达线图", + radarArea: "雷达面积图", + nightingale: "南丁格尔玫瑰图", + radialColumn: "径向柱形图", + radialBar: "径向条形图", + + statisticalChart: "统计图", + boxPlot: "箱线图", + rangeBar: "范围条形图", + rangeArea: "范围面积图", + + hierarchicalChart: "层级图", + treemap: "矩形树图", + sunburst: "旭日图", + + specializedChart: "专用图", + waterfall: "瀑布图", + heatmap: "热力图", + + combinationChart: "组合图", + columnLineCombo: "柱形和折线图", + AreaColumnCombo: "面积和柱形图", + + // 图表 + pivotChartTitle: "透视图表", + rangeChartTitle: "范围图表", + settings: "图表", + data: "数据", + format: "格式", + categories: "类别", + defaultCategory: "(无)", + series: "系列", + switchCategorySeries: "切换类别/系列", + categoryValues: "类别值", + seriesLabels: "系列标签", + aggregate: "聚合", + xyValues: "X Y 值", + paired: "配对模式", + axis: "轴", + xAxis: "水平轴", + yAxis: "垂直轴", + polarAxis: "极坐标轴", + radiusAxis: "半径轴", + navigator: "导航器", + zoom: "缩放", + animation: "动画", + crosshair: "十字线", + color: "颜色", + thickness: "厚度", + preferredLength: "首选长度", + xType: "X 类型", + axisType: "轴类型", + automatic: "自动", + category: "类别", + number: "数字", + time: "时间", + timeFormat: "时间格式", + autoRotate: "自动旋转", + labelRotation: "旋转", + circle: "圆", + polygon: "多边形", + orientation: "方向", + fixed: "固定", + parallel: "平行", + perpendicular: "垂直", + radiusAxisPosition: "位置", + ticks: "刻度线", + gridLines: "网格线", + width: "宽度", + height: "高度", + length: "长度", + padding: "填充", + spacing: "间距", + chart: "图表", + title: "标题", + titlePlaceholder: "图表标题 - 双击编辑", + background: "背景", + font: "字体", + top: "顶部", + right: "右侧", + bottom: "底部", + left: "左侧", + labels: "标签", + calloutLabels: "标注标签", + sectorLabels: "扇区标签", + positionRatio: "位置比例", + size: "大小", + shape: "形状", + minSize: "最小尺寸", + maxSize: "最大尺寸", + legend: "图例", + position: "位置", + markerSize: "标记大小", + markerStroke: "标记描边", + markerPadding: "标记填充", + itemSpacing: "项目间距", + itemPaddingX: "项目填充 X", + itemPaddingY: "项目填充 Y", + layoutHorizontalSpacing: "水平间距", + layoutVerticalSpacing: "垂直间距", + strokeWidth: "描边宽度", + offset: "偏移", + offsets: "偏移量", + tooltips: "工具提示", + callout: "标注", + markers: "标记", + shadow: "阴影", + blur: "模糊", + xOffset: "X 偏移", + yOffset: "Y 偏移", + lineWidth: "线宽", + lineDash: "线段", + lineDashOffset: "线段偏移", + scrollingZoom: "滚动", + scrollingStep: "滚动步长", + selectingZoom: "选择", + durationMillis: "持续时间(毫秒)", + crosshairLabel: "标签", + crosshairSnap: "捕捉到节点", + normal: "常规", + bold: "粗体", + italic: "斜体", + boldItalic: "粗斜体", + predefined: "预定义", + fillOpacity: "填充透明度", + strokeColor: "线颜色", + strokeOpacity: "线透明度", + miniChart: "迷你图表", + histogramBinCount: "柱数", + connectorLine: "连接线", + seriesItems: "系列项", + seriesPilotItemType: "项类型", + seriesItemPositive: "正值", + seriesItemNegative: "负值", + seriesItemLabels: "项标签", + columnGroup: "柱形", + barGroup: "条形", + pieGroup: "饼图", + lineGroup: "折线", + scatterGroup: "X Y (散点)", + areaGroup: "面积", + polarGroup: "极坐标", + statisticalGroup: "统计", + hierarchicalGroup: "层级", + specializedGroup: "专用", + combinationGroup: "组合", + groupedColumnTooltip: "分组", + stackedColumnTooltip: "堆积", + normalizedColumnTooltip: "100% 堆积", + groupedBarTooltip: "分组", + stackedBarTooltip: "堆积", + normalizedBarTooltip: "100% 堆积", + pieTooltip: "饼图", + donutTooltip: "环形", + lineTooltip: "折线", + groupedAreaTooltip: "面积", + stackedAreaTooltip: "堆积", + normalizedAreaTooltip: "100% 堆积", + scatterTooltip: "散点", + bubbleTooltip: "气泡", + histogramTooltip: "直方图", + radialColumnTooltip: "径向柱形", + radialBarTooltip: "径向条形", + radarLineTooltip: "雷达线", + radarAreaTooltip: "雷达面积", + nightingaleTooltip: "南丁格尔玫瑰", + rangeBarTooltip: "范围条形", + rangeAreaTooltip: "范围面积", + boxPlotTooltip: "箱线", + treemapTooltip: "矩形树", + sunburstTooltip: "旭日", + waterfallTooltip: "瀑布", + heatmapTooltip: "热力", + columnLineComboTooltip: "柱形和折线", + areaColumnComboTooltip: "面积和柱形", + customComboTooltip: "自定义组合", + innerRadius: "内半径", + startAngle: "起始角度", + endAngle: "结束角度", + reverseDirection: "反向", + groupPadding: "组填充", + seriesPadding: "系列填充", + tile: "平铺", + whisker: "须", + cap: "帽", + capLengthRatio: "帽长比", + labelPlacement: "位置", + inside: "内部", + outside: "外部", + noDataToChart: "无数据可供绘制图表。", + pivotChartRequiresPivotMode: "透视图表需要启用透视图模式。", + chartSettingsToolbarTooltip: "菜单", + chartLinkToolbarTooltip: "与网格链接", + chartUnlinkToolbarTooltip: "与网格取消链接", + chartDownloadToolbarTooltip: "下载图表", + chartEdit: "编辑图表", + chartAdvancedSettings: "高级设置", + chartLink: "链接到网格", + chartUnlink: "从网格取消链接", + chartDownload: "下载图表", + seriesChartType: "系列图表类型", + seriesType: "系列类型", + secondaryAxis: "次要轴", + seriesAdd: "添加系列", + categoryAdd: "添加类别", + advancedSettings: "高级设置", + + // ARIA + ariaAdvancedFilterBuilderItem: + "${variable}. 级别 ${variable}. 按 ENTER 编辑。", + ariaAdvancedFilterBuilderItemValidation: + "${variable}. 级别 ${variable}. ${variable} 按 ENTER 编辑。", + ariaAdvancedFilterBuilderList: "高级筛选器构建器列表", + ariaAdvancedFilterBuilderFilterItem: "筛选条件", + ariaAdvancedFilterBuilderGroupItem: "筛选组", + ariaAdvancedFilterBuilderColumn: "列", + ariaAdvancedFilterBuilderOption: "选项", + ariaAdvancedFilterBuilderValueP: "值", + ariaAdvancedFilterBuilderJoinOperator: "连接运算符", + ariaAdvancedFilterInput: "高级筛选器输入", + ariaChecked: "选中", + ariaColumn: "列", + ariaColumnGroup: "列组", + ariaColumnFiltered: "列已筛选", + ariaColumnSelectAll: "切换全选列", + ariaDateFilterInput: "日期筛选器输入", + ariaDefaultListName: "列表", + ariaFilterColumnsInput: "筛选列输入", + ariaFilterFromValue: "筛选起始值", + ariaFilterInput: "筛选器输入", + ariaFilterList: "筛选器列表", + ariaFilterToValue: "筛选结束值", + ariaFilterValue: "筛选器值", + ariaFilterMenuOpen: "打开筛选器菜单", + ariaFilteringOperator: "筛选器运算符", + ariaHidden: "隐藏", + ariaIndeterminate: "不确定", + ariaInputEditor: "输入编辑器", + ariaMenuColumn: "按 ALT + 下箭头打开列菜单", + ariaFilterColumn: "按 CTRL + ENTER 打开筛选器", + ariaRowDeselect: "按空格键取消选中此行", + ariaRowSelectAll: "按空格键切换所有行的选中状态", + ariaRowToggleSelection: "按空格键切换行的选中状态", + ariaRowSelect: "按空格键选中此行", + ariaRowSelectionDisabled: "此行的行选择已禁用", + ariaSearch: "搜索", + ariaSortableColumn: "按 ENTER 排序", + ariaToggleVisibility: "按空格键切换可见性", + ariaToggleCellValue: "按空格键切换单元格值", + ariaUnchecked: "未选中", + ariaVisible: "可见", + ariaSearchFilterValues: "搜索筛选器值", + ariaPageSizeSelectorLabel: "页面大小", + ariaChartMenuClose: "关闭图表编辑菜单", + + // ARIA Labels for Drop Zones + ariaRowGroupDropZonePanelLabel: "行组", + ariaValuesDropZonePanelLabel: "值", + ariaPivotDropZonePanelLabel: "列标签", + ariaDropZoneColumnComponentDescription: "按 DELETE 键删除", + ariaDropZoneColumnValueItemDescription: "按 ENTER 键更改聚合类型", + ariaDropZoneColumnGroupItemDescription: "按 ENTER 键排序", + // used for aggregate drop zone, format: {aggregation}{ariaDropZoneColumnComponentAggFuncSeparator}{column name} + ariaDropZoneColumnComponentAggFuncSeparator: " of ", + ariaDropZoneColumnComponentSortAscending: "ascending", + ariaDropZoneColumnComponentSortDescending: "descending", + + // ARIA Labels for Dialogs + ariaLabelColumnMenu: "列菜单", + ariaLabelColumnFilter: "列筛选器", + ariaLabelCellEditor: "单元格编辑器", + ariaLabelDialog: "对话框", + ariaLabelSelectField: "选择字段", + ariaLabelRichSelectField: "富文本选择字段", + ariaLabelTooltip: "工具提示", + ariaLabelContextMenu: "上下文菜单", + ariaLabelSubMenu: "子菜单", + ariaLabelAggregationFunction: "聚合函数", + ariaLabelAdvancedFilterAutocomplete: "高级筛选器自动完成", + ariaLabelAdvancedFilterBuilderAddField: "高级筛选器构建器添加字段", + ariaLabelAdvancedFilterBuilderColumnSelectField: + "高级筛选器构建器列选择字段", + ariaLabelAdvancedFilterBuilderOptionSelectField: + "高级筛选器构建器选项选择字段", + ariaLabelAdvancedFilterBuilderJoinSelectField: + "高级筛选器构建器连接运算符选择字段", + + // ARIA Labels for the Side Bar + ariaColumnPanelList: "列列表", + ariaFilterPanelList: "筛选器列表", + + // Number Format (Status Bar, Pagination Panel) + thousandSeparator: ",", + decimalSeparator: ".", + + // Data types + true: "真", + false: "假", + invalidDate: "无效日期", + invalidNumber: "无效数字", + january: "一月", + february: "二月", + march: "三月", + april: "四月", + may: "五月", + june: "六月", + july: "七月", + august: "八月", + september: "九月", + october: "十月", + november: "十一月", + december: "十二月", + + // Time formats + timeFormatSlashesDDMMYYYY: "DD/MM/YYYY", + timeFormatSlashesMMDDYYYY: "MM/DD/YYYY", + timeFormatSlashesDDMMYY: "DD/MM/YY", + timeFormatSlashesMMDDYY: "MM/DD/YY", + timeFormatDotsDDMYY: "DD.M.YY", + timeFormatDotsMDDYY: "M.DD.YY", + timeFormatDashesYYYYMMDD: "YYYY-MM-DD", + timeFormatSpacesDDMMMMYYYY: "DD MMMM YYYY", + timeFormatHHMMSS: "HH:MM:SS", + timeFormatHHMMSSAmPm: "HH:MM:SS AM/PM", +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx old mode 100644 new mode 100755 index d97b57e..c0ee5ee --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,11 +1,22 @@ -import "@web/src/polyfills" -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.js' -import './index.css' +import "@web/src/polyfills"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.js"; +import "./index.css"; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) +import { ModuleRegistry } from "@ag-grid-community/core"; +import { LicenseManager } from "@ag-grid-enterprise/core"; + +import { ClientSideRowModelModule } from "@ag-grid-community/client-side-row-model"; + + +ModuleRegistry.registerModules([ClientSideRowModelModule]); + +LicenseManager.setLicenseKey( + 'LICENSE_KEY_BODY[version=v3][0102]_EXPIRY_NDg4NDc0ODcwNTExMw==094bf1c7852b11df1841f4d14457ae96' +); +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/apps/web/src/providers/auth-provider.tsx b/apps/web/src/providers/auth-provider.tsx index a548566..6a353fa 100644 --- a/apps/web/src/providers/auth-provider.tsx +++ b/apps/web/src/providers/auth-provider.tsx @@ -1,188 +1,254 @@ -import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; -import apiClient from '../utils/axios-client'; -import { UserProfile } from '@nicestack/common'; - +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + ReactNode, +} from "react"; +import apiClient from "../utils/axios-client"; +import { AuthSchema, RolePerms, UserProfile } from "@nicestack/common"; +import { z } from "zod"; interface AuthContextProps { - accessToken: string | null; - refreshToken: string | null; - isAuthenticated: boolean; - isLoading: boolean; - user: UserProfile | null; - login: (username: string, password: string) => Promise; - logout: () => Promise; - signup: (username: string, password: string, phoneNumber?: string) => Promise; - refreshAccessToken: () => Promise; - initializeAuth: () => void; - startTokenRefreshInterval: () => void; - fetchUserProfile: () => Promise; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + sessionId: string | null; + isLoading: boolean; + user: UserProfile | null; + isRoot: boolean | null; + login: (username: string, password: string) => Promise; + logout: () => Promise; + signup: (data: z.infer) => Promise; + refreshAccessToken: () => Promise; + initializeAuth: () => void; + startTokenRefreshInterval: () => void; + fetchUserProfile: () => Promise; + hasSomePermissions: (...permissions: string[]) => boolean; + hasEveryPermissions: (...permissions: string[]) => boolean; + isSameDomain: (deptId: string) => boolean; } const AuthContext = createContext(undefined); export function useAuth(): AuthContextProps { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -}; + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} interface AuthProviderProps { - children: ReactNode; + children: ReactNode; } export function AuthProvider({ children }: AuthProviderProps) { - const [accessToken, setAccessToken] = useState(localStorage.getItem('access_token')); - const [refreshToken, setRefreshToken] = useState(localStorage.getItem('refresh_token')); - const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem('access_token')); - const [isLoading, setIsLoading] = useState(false); - const [intervalId, setIntervalId] = useState(null); - const [user, setUser] = useState(JSON.parse(localStorage.getItem('user_profile') || 'null')); + const [accessToken, setAccessToken] = useState( + localStorage.getItem("access_token") + ); + const [sessionId, setSessionId] = useState( + localStorage.getItem("session_id") + ); + const [refreshToken, setRefreshToken] = useState( + localStorage.getItem("refresh_token") + ); + const [isAuthenticated, setIsAuthenticated] = useState( + !!localStorage.getItem("access_token") + ); + const [isLoading, setIsLoading] = useState(false); + const [intervalId, setIntervalId] = useState(null); + const [user, setUser] = useState( + JSON.parse(localStorage.getItem("user-profile") || "null") + ); + const [isRoot, setIsRoot] = useState(false); + const initializeAuth = useCallback(() => { + const storedAccessToken = localStorage.getItem("access_token"); + const storedRefreshToken = localStorage.getItem("refresh_token"); + const storedSessionId = localStorage.getItem("session_id"); + setAccessToken(storedAccessToken); + setRefreshToken(storedRefreshToken); + setSessionId(storedSessionId); + setIsAuthenticated(!!storedAccessToken); + if (storedRefreshToken && storedSessionId) { + startTokenRefreshInterval(); + } + if (storedAccessToken) { + fetchUserProfile(); + } + }, []); - const initializeAuth = useCallback(() => { - const storedAccessToken = localStorage.getItem('access_token'); - const storedRefreshToken = localStorage.getItem('refresh_token'); - setAccessToken(storedAccessToken); - setRefreshToken(storedRefreshToken); - setIsAuthenticated(!!storedAccessToken); - if (storedRefreshToken) { - startTokenRefreshInterval(); - } - if (storedAccessToken) { - fetchUserProfile(); - } - }, []); + const refreshAccessToken = useCallback(async () => { + if (!refreshToken) return; + try { + setIsLoading(true); + const response = await apiClient.post(`/auth/refresh-token`, { + refreshToken, + sessionId, + }); + const { access_token, access_token_expires_at } = response.data; + localStorage.setItem("access_token", access_token); + localStorage.setItem( + "access_token_expires_at", + access_token_expires_at + ); + setAccessToken(access_token); + setIsAuthenticated(true); + fetchUserProfile(); + } catch (err: any) { + console.error("Token refresh failed", err); + logout(); + } finally { + setIsLoading(false); + } + }, [refreshToken]); - const refreshAccessToken = useCallback(async () => { - if (!refreshToken) return; - try { - setIsLoading(true); - const response = await apiClient.post(`/auth/refresh-token`, { refreshToken }); - const { access_token, access_token_expires_at } = response.data; - localStorage.setItem('access_token', access_token); - localStorage.setItem('access_token_expires_at', access_token_expires_at); - setAccessToken(access_token); - setIsAuthenticated(true); - fetchUserProfile(); - } catch (err) { - console.error("Token refresh failed", err); - logout(); - } finally { - setIsLoading(false); - } - }, [refreshToken]); + const startTokenRefreshInterval = useCallback(async () => { + if (intervalId) { + clearInterval(intervalId); + } + await refreshAccessToken(); + const newIntervalId = setInterval(refreshAccessToken, 60 * 60 * 1000); + setIntervalId(newIntervalId); + }, [intervalId, refreshAccessToken]); - const startTokenRefreshInterval = useCallback(async () => { - if (intervalId) { - clearInterval(intervalId); - } - await refreshAccessToken(); - const newIntervalId = setInterval(refreshAccessToken, 10 * 60 * 1000); // 10 minutes - setIntervalId(newIntervalId); - }, [intervalId, refreshAccessToken]); + const login = async (username: string, password: string): Promise => { + try { + setIsLoading(true); + const response = await apiClient.post(`/auth/login`, { + username, + password, + }); + const { + access_token, + refresh_token, + access_token_expires_at, + refresh_token_expires_at, + session_id, + } = response.data; + localStorage.setItem("access_token", access_token); + localStorage.setItem("refresh_token", refresh_token); + localStorage.setItem("session_id", session_id); + localStorage.setItem( + "access_token_expires_at", + access_token_expires_at + ); + localStorage.setItem( + "refresh_token_expires_at", + refresh_token_expires_at + ); + setAccessToken(access_token); + setRefreshToken(refresh_token); + setSessionId(session_id); + setIsAuthenticated(true); + startTokenRefreshInterval(); + fetchUserProfile(); + } catch (err: any) { + throw err; + } finally { + setIsLoading(false); + } + }; - const login = async (username: string, password: string): Promise => { - try { - setIsLoading(true); - const response = await apiClient.post(`/auth/login`, { username, password }); - const { access_token, refresh_token, access_token_expires_at, refresh_token_expires_at } = response.data; - localStorage.setItem('access_token', access_token); - localStorage.setItem('refresh_token', refresh_token); - localStorage.setItem('access_token_expires_at', access_token_expires_at); - localStorage.setItem('refresh_token_expires_at', refresh_token_expires_at); - setAccessToken(access_token); - setRefreshToken(refresh_token); - setIsAuthenticated(true); - startTokenRefreshInterval(); - fetchUserProfile(); - } catch (err) { - console.error("Login failed", err); - throw new Error("Login failed"); - } finally { - setIsLoading(false); - } - }; + const signup = async ( + data: z.infer + ): Promise => { + try { + setIsLoading(true); + await apiClient.post(`/auth/signup`, data); + } catch (err: any) { + throw err; + } finally { + setIsLoading(false); + } + }; + useEffect(() => { + if (user) + setIsRoot(user.permissions.includes(RolePerms.MANAGE_ANY_STAFF)); + }, [user]); + const logout = async (): Promise => { + try { + setIsLoading(true); + const storedRefreshToken = localStorage.getItem("refresh_token"); + const storedSessionId = localStorage.getItem("session_id"); + localStorage.removeItem("session_id"); + localStorage.removeItem("refresh_token"); + localStorage.removeItem("access_token_expires_at"); + localStorage.removeItem("refresh_token_expires_at"); + localStorage.removeItem("user-profile"); + localStorage.removeItem("access_token"); + // localStorage.clear() + await apiClient.post(`/auth/logout`, { + refreshToken: storedRefreshToken, + sessionId: storedSessionId, + }); - const signup = async (username: string, password: string, phoneNumber?: string): Promise => { - try { - setIsLoading(true); - const response = await apiClient.post(`/auth/signup`, { username, password, phoneNumber }); - // const { access_token, refresh_token, access_token_expires_at, refresh_token_expires_at } = response.data; - // localStorage.setItem('access_token', access_token); - // localStorage.setItem('refresh_token', refresh_token); - // localStorage.setItem('access_token_expires_at', access_token_expires_at); - // localStorage.setItem('refresh_token_expires_at', refresh_token_expires_at); - // setAccessToken(access_token); - // setRefreshToken(refresh_token); - // setIsAuthenticated(true); - // startTokenRefreshInterval(); - // fetchUserProfile(); - } catch (err) { - console.error("Signup failed", err); - throw new Error("Signup failed"); - } finally { - setIsLoading(false); - } - }; + setAccessToken(null); + setRefreshToken(null); + setSessionId(null); + setIsAuthenticated(false); + setUser(null); + setIsRoot(false); + if (intervalId) { + clearInterval(intervalId); + setIntervalId(null); + } + } catch (err: any) { + console.error("Logout failed", err); + } finally { + setIsLoading(false); + window.location.reload(); + } + }; - const logout = async (): Promise => { - try { - setIsLoading(true); - const storedRefreshToken = localStorage.getItem('refresh_token'); - await apiClient.post(`/auth/logout`, { refreshToken: storedRefreshToken }); + const fetchUserProfile = useCallback(async () => { + try { + const response = await apiClient.get(`/auth/user-profile`); + const userProfile = response.data; + setUser(userProfile); - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - localStorage.removeItem('access_token_expires_at'); - localStorage.removeItem('refresh_token_expires_at'); - localStorage.removeItem('user_profile'); + localStorage.setItem("user-profile", JSON.stringify(userProfile)); + } catch (err: any) { + console.error(err); + } + }, []); + useEffect(() => { + initializeAuth(); + }, [initializeAuth]); + const hasSomePermissions = (...permissions: string[]) => { + return permissions.some((perm) => + user?.permissions?.includes(perm as RolePerms) + ); + }; + const hasEveryPermissions = (...permissions: string[]) => { + return permissions.every((perm) => + user?.permissions.includes(perm as RolePerms) + ); + }; + const isSameDomain = (deptId: string) => { + return user?.domainId === deptId; + }; + const value: AuthContextProps = { + hasSomePermissions, + hasEveryPermissions, + accessToken, + isSameDomain, + refreshToken, + isAuthenticated, + isLoading, + user, + isRoot, + login, + logout, + signup, + refreshAccessToken, + initializeAuth, + startTokenRefreshInterval, + fetchUserProfile, + sessionId, + }; - setAccessToken(null); - setRefreshToken(null); - setIsAuthenticated(false); - setUser(null); - - if (intervalId) { - clearInterval(intervalId); - setIntervalId(null); - } - } catch (err) { - console.error("Logout failed", err); - throw new Error("Logout failed"); - } finally { - setIsLoading(false); - } - }; - - const fetchUserProfile = useCallback(async () => { - try { - const response = await apiClient.get(`/auth/user-profile`); - const userProfile = response.data; - setUser(userProfile); - localStorage.setItem('user_profile', JSON.stringify(userProfile)); - } catch (err) { - console.error("Fetching user profile failed", err); - } - }, []); - - useEffect(() => { - initializeAuth(); - }, [initializeAuth]); - - const value: AuthContextProps = { - accessToken, - refreshToken, - isAuthenticated, - isLoading, - user, - login, - logout, - signup, - refreshAccessToken, - initializeAuth, - startTokenRefreshInterval, - fetchUserProfile - }; - - return {children}; -}; + return ( + {children} + ); +} diff --git a/apps/web/src/providers/params-provider.tsx b/apps/web/src/providers/params-provider.tsx new file mode 100755 index 0000000..448ac0c --- /dev/null +++ b/apps/web/src/providers/params-provider.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState, createContext, useContext } from "react"; +import { z } from "zod"; // 引入Zod Schema库 + +// 定义Zod Schema +const ParamsSchema = z.object({ + test: z.string() + // ...其他验证规则 +}); +type ParamsType = z.infer; +// 创建Context +const ParamsContext = createContext(undefined); +// Provider组件 +export function ParamsProvider({ children }: { children: React.ReactNode }) { + const [params, setParams] = useState(undefined); + + useEffect(() => { + const loadParams = async () => { + const response = await fetch(`/params.json`); + const data = await response.text(); + const parsedData = JSON.parse(data); + const validData = ParamsSchema.parse(parsedData); // 使用Zod验证数据 + setParams(validData); + }; + loadParams(); + }, []); + + if (!params) { + return ( + <>数据加载中 + ); + } + + return ( + {children} + ); +} + +// useParams自定义钩子,用于获取Context值 +export const useAppParams = () => { + const params = useContext(ParamsContext); + if (!params) { + throw new Error("useParams must be used within a ParamsProvider"); + } + return params; +}; diff --git a/apps/web/src/providers/query-provider.tsx b/apps/web/src/providers/query-provider.tsx old mode 100644 new mode 100755 index e7eac82..40f8dd5 --- a/apps/web/src/providers/query-provider.tsx +++ b/apps/web/src/providers/query-provider.tsx @@ -1,39 +1,84 @@ -import { QueryClient } from '@tanstack/react-query'; -import { unstable_httpBatchStreamLink, loggerLink } from '@trpc/client'; -import React, { useEffect, useMemo, useState } from 'react'; -import { api } from '../utils/trpc'; -import superjson from 'superjson'; -import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' -import { createIDBPersister } from '../utils/idb'; -import { env } from '../env'; -import { useAuth } from './auth-provider'; -export default function QueryProvider({ children }: { children: React.ReactNode }) { - const [queryClient] = useState(() => new QueryClient()); - const { accessToken } = useAuth() - const trpcClient = useMemo(() => - api.createClient({ - links: [ - unstable_httpBatchStreamLink({ - url: `${env.API_URL}/trpc`, - headers: async () => ({ - ...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}), - }), - transformer: superjson - }), - loggerLink({ - enabled: (opts) => - (process.env.NODE_ENV === 'development' && - typeof window !== 'undefined') || - (opts.direction === 'down' && opts.result instanceof Error), - }), - ], - }), [accessToken] - ); - return ( - - - {children} - - - ); -} \ No newline at end of file +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + wsLink, + loggerLink, + httpBatchLink, + createWSClient, + splitLink, +} from "@trpc/client"; +import { api } from "@nicestack/client" +import { useEffect, useMemo, useState } from "react"; +import superjson from "superjson"; +import { useAuth } from "./auth-provider"; +import { useLocalSettings } from "../hooks/useLocalSetting"; +export default function QueryProvider({ children }) { + const { accessToken } = useAuth(); + const { apiUrl, websocketUrl } = useLocalSettings(); + // Set the default query options including staleTime. + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 10, // 5 minutes + }, + }, + })); + // 使用useEffect来延迟设置isReady + const wsClient = useMemo(() => { + return createWSClient({ + url: `${websocketUrl}/trpc`, + connectionParams: accessToken ? { + token: accessToken + } : {} + }); + }, [websocketUrl, accessToken]); + + useEffect(() => { + return () => { + if (wsClient) { + wsClient.close(); + } + }; + }, []); + + const trpcClient = useMemo(() => { + const headers = async () => ({ + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }); + + const links = [ + splitLink({ + condition: (operation) => operation.type === "subscription", + true: wsClient ? wsLink({ + client: wsClient, + transformer: superjson, + }) : httpBatchLink({ + url: `${apiUrl}/trpc`, + headers, + transformer: superjson, + }), + false: httpBatchLink({ + url: `${apiUrl}/trpc`, + headers, + transformer: superjson, + }), + }), + loggerLink({ + enabled: (opts) => + (import.meta.env.DEV && typeof window !== "undefined") || + (opts.direction === "down" && opts.result instanceof Error), + }), + ]; + + return api.createClient({ + links, + }); + }, [accessToken, wsClient, apiUrl]); + + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/providers/theme-provider.tsx b/apps/web/src/providers/theme-provider.tsx index 282a883..226a47c 100644 --- a/apps/web/src/providers/theme-provider.tsx +++ b/apps/web/src/providers/theme-provider.tsx @@ -1,45 +1,133 @@ -import { ConfigProvider, theme } from "antd"; -import { ReactNode, useEffect, useMemo } from "react"; - +import React, { + createContext, + useContext, + ReactNode, + useEffect, + useMemo, +} from "react"; +import { theme } from "antd"; export interface TailwindTheme { - [key: string]: string; + [key: string]: string; } +// Create a context for the agTheme +const AppThemeContext = createContext<{}>( + null +); + +export const useAppTheme = () => useContext(AppThemeContext); + export default function ThemeProvider({ children }: { children: ReactNode }) { - const token = theme.getDesignToken(); + const { token } = theme.useToken(); - const applyTheme = (tailwindTheme: TailwindTheme) => { - for (let key in tailwindTheme) { - document.documentElement.style.setProperty(key, tailwindTheme[key]); - } - }; + const applyTheme = (tailwindTheme: TailwindTheme) => { + for (let key in tailwindTheme) { + document.documentElement.style.setProperty(key, tailwindTheme[key]); + } + }; - const tailwindTheme: TailwindTheme = useMemo(() => ({ - '--color-primary': token.colorPrimary, - '--color-text-secondary': token.colorTextSecondary, - '--color-text-tertiary': token.colorTextTertiary, - '--bg-container': token.colorBgContainer, - '--bg-layout': token.colorBgLayout, - '--bg-mask': token.colorBgMask, - '--primary-bg': token.colorPrimaryBg, - '--color-text': token.colorText, - '--color-text-quaternary': token.colorTextQuaternary, - '--color-text-placeholder': token.colorTextPlaceholder, - '--color-text-description': token.colorTextDescription, - '--color-border': token.colorBorder, - '--primary-text': token.colorPrimaryText - }), [token]); + // const agTheme = useMemo( + // () => + // themeQuartz.withPart(iconSetQuartzLight).withParams({ + // accentColor: token.colorPrimary, + // backgroundColor: token.colorBgContainer, + // borderColor: token.colorBorderSecondary, + // // borderRadius: 2, + // browserColorScheme: "light", + // cellHorizontalPaddingScale: 0.7, - useEffect(() => { - applyTheme(tailwindTheme); - }, [tailwindTheme]); + // fontSize: token.fontSize, + // foregroundColor: token.colorText, + // headerBackgroundColor: token.colorFillQuaternary, + // headerFontSize: token.fontSize, + // headerFontWeight: 600, + // headerTextColor: token.colorPrimary, + // rowBorder: true, + // rowVerticalPaddingScale: 0.9, + // sidePanelBorder: true, + // spacing: 6, + // oddRowBackgroundColor: token.colorFillQuaternary, + // wrapperBorder: true, + // wrapperBorderRadius: 0, - return ( - - {children} - - ); + // // headerRowBorder: true, + // // columnBorder: true, + // // headerRowBorder: true, + // pinnedRowBorder: true + // }), + // [token] + // ); + // const subTableTheme = useMemo( + // () => + // themeQuartz.withPart(iconSetQuartzLight).withParams({ + // accentColor: token.colorTextSecondary, // 可以使用不同的强调色 + // backgroundColor: token.colorBgLayout, + // borderColor: token.colorBorderSecondary, + // fontSize: token.fontSizeSM, // 可以使用不同的字体大小 + // foregroundColor: token.colorTextSecondary, + // headerBackgroundColor: token.colorFillSecondary, + // headerFontSize: token.fontSizeSM, + // headerFontWeight: 500, // 可以使用不同的字体粗细 + // headerTextColor: token.colorTextTertiary, + // rowBorder: false, // 可以选择不显示行边框 + // rowVerticalPaddingScale: 0.6, + // sidePanelBorder: false, + // spacing: 4, + // oddRowBackgroundColor: token.colorFillQuaternary, + // wrapperBorder: false, + // wrapperBorderRadius: 0, + // columnBorder: false, + // }), + // [token] + // ); + const tailwindTheme: TailwindTheme = useMemo( + () => ({ + "--color-primary": token.colorPrimary, + "--color-primary-active": token.colorPrimaryActive, + "--color-primary-hover": token.colorPrimaryHover, + "--color-bg-primary-hover": token.colorPrimaryBgHover, + "--color-text-secondary": token.colorTextSecondary, + "--color-text-tertiary": token.colorTextTertiary, + "--color-bg-text-hover": token.colorBgTextHover, + "--color-bg-container": token.colorBgContainer, + "--color-bg-layout": token.colorBgLayout, + "--color-bg-mask": token.colorBgMask, + "--color-bg-primary": token.colorPrimary, + "--color-text": token.colorText, + "--color-text-heading": token.colorTextHeading, + "--color-text-label": token.colorTextLabel, + "--color-text-lightsolid": token.colorTextLightSolid, + "--color-text-quaternary": token.colorTextQuaternary, + "--color-text-placeholder": token.colorTextPlaceholder, + "--color-text-description": token.colorTextDescription, + "--color-border": token.colorBorder, + "--color-border-secondary": token.colorBorderSecondary, + "--color-border-primary": token.colorPrimaryBorder, + "--color-text-primary": token.colorPrimaryText, + "--color-error": token.colorError, + "--color-warning": token.colorWarning, + "--color-info": token.colorInfo, + "--color-success": token.colorSuccess, + "--color-error-bg": token.colorErrorBg, + "--color-warning-bg": token.colorWarningBg, + "--color-info-bg": token.colorInfoBg, + "--color-success-bg": token.colorSuccessBg, + "--color-link": token.colorLink, + "--color-highlight": token.colorHighlight, + '--color-fill-quaternary': token.colorFillQuaternary, + "--color-fill-tertiary": token.colorFillTertiary, + "--color-fill-secondary": token.colorFillSecondary + }), + [token] + ); + + useEffect(() => { + applyTheme(tailwindTheme); + }, [tailwindTheme]); + + return ( + + {children} + + ); } diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx old mode 100644 new mode 100755 index 6ac429f..f9eb2a8 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,86 +1,166 @@ import { - createBrowserRouter, - IndexRouteObject, - NonIndexRouteObject + createBrowserRouter, + IndexRouteObject, + Link, + NonIndexRouteObject, } from "react-router-dom"; -import MainPage from "../app/main/page"; +import { RolePerms } from "@nicestack/common"; import ErrorPage from "../app/error"; -import LayoutPage from "../app/layout"; -import LoginPage from "../app/login"; import DepartmentAdminPage from "../app/admin/department/page"; -import RoleAdminPage from "../app/admin/role/page"; -import StaffAdminPage from "../app/admin/staff/page"; import TermAdminPage from "../app/admin/term/page"; -import WithAuth from "../components/utilities/with-auth"; +import StaffAdminPage from "../app/admin/staff/page"; +import RoleAdminPage from "../app/admin/role/page"; +import MainLayoutPage from "../app/layout"; +import WithAuth from "../components/utils/with-auth"; +import LoginPage from "../app/login"; +import BaseSettingPage from "../app/admin/base-setting/page"; interface CustomIndexRouteObject extends IndexRouteObject { - name?: string; - breadcrumb?: string; + name?: string; + breadcrumb?: string; +} +interface CustomIndexRouteObject extends IndexRouteObject { + name?: string; + breadcrumb?: string; } -interface CustomNonIndexRouteObject extends NonIndexRouteObject { - name?: string; - children?: CustomRouteObject[]; - breadcrumb?: string; +export interface CustomNonIndexRouteObject extends NonIndexRouteObject { + name?: string; + children?: CustomRouteObject[]; + breadcrumb?: string; + handle?: { + crumb: (data?: any) => void; + }; } -type CustomRouteObject = CustomIndexRouteObject | CustomNonIndexRouteObject; -export const routes = [ - { - path: "/", - element: , - errorElement: , - children: [ - { - index: true, - element: - }, - { - path: "admin", - children: [ - { - path: "department", - breadcrumb: "单位管理", - element: ( - - - - ), - }, - { - path: "staff", - breadcrumb: "人员管理", - element: ( - - - - ), - }, - { - path: "term", - breadcrumb: "术语管理", - element: ( - - - - ), - }, +export type CustomRouteObject = + | CustomIndexRouteObject + | CustomNonIndexRouteObject; +export const routes: CustomRouteObject[] = [ + { + path: "/", + element: , + errorElement: , + handle: { + crumb() { + return 主页; + }, + }, + children: [ + { + path: "admin", + children: [ + { + path: "base-setting", + element: ( + + + + ), + handle: { + crumb() { + return ( + + 基本设置 + + ); + }, + }, + }, + { + path: "department", + breadcrumb: "单位管理", + element: ( + + + + ), + handle: { + crumb() { + return ( + + 组织架构 + + ); + }, + }, + }, + { + path: "staff", + element: ( + + + + ), + handle: { + crumb() { + return ( + 用户管理 + ); + }, + }, + }, + { + path: "term", + breadcrumb: "分类配置", + element: ( + + + + ), + handle: { + crumb() { + return 分类配置; + }, + }, + }, + { + path: "role", + breadcrumb: "角色管理", + element: ( + + + + ), + handle: { + crumb() { + return 角色管理; + }, + }, + }, + ], + }, + ], + }, + { + path: "/login", + breadcrumb: "登录", + element: , + }, +]; - { - path: "role", - breadcrumb: "角色管理", - element: ( - - - - ), - } - ], - }, - ], - }, - { - path: '/login', - element: - } - -] export const router = createBrowserRouter(routes); diff --git a/apps/web/src/utils/axios-client.ts b/apps/web/src/utils/axios-client.ts index da8905c..23877df 100644 --- a/apps/web/src/utils/axios-client.ts +++ b/apps/web/src/utils/axios-client.ts @@ -1,9 +1,9 @@ import axios from 'axios'; import { env } from '../env'; -const BASE_URL = env.API_URL; // Replace with your backend URL +const BASE_URL = `http://${env.SERVER_IP}:3000` const apiClient = axios.create({ baseURL: BASE_URL, - withCredentials: true, + // withCredentials: true, }); // Add a request interceptor to attach the access token apiClient.interceptors.request.use( diff --git a/apps/web/src/utils/idb.ts b/apps/web/src/utils/idb.ts old mode 100644 new mode 100755 diff --git a/apps/web/src/utils/tusd.ts b/apps/web/src/utils/tusd.ts deleted file mode 100644 index 8b3ffb1..0000000 --- a/apps/web/src/utils/tusd.ts +++ /dev/null @@ -1,95 +0,0 @@ -import * as tus from "tus-js-client"; -import imageCompression from "browser-image-compression"; -export const uploader = async ( - endpoint: string, - file: File, - onProgress?: (percentage: number, speed: number) => void, - onSuccess?: (url: string) => void, - onError?: (error: Error) => void -) => { - let previousUploadedSize = 0; - let previousTimestamp = Date.now(); - - // 压缩图像为WebP格式 - const compressImage = async (file: File): Promise => { - const options = { - maxSizeMB: 0.6, // 最大文件大小(MB) - maxWidthOrHeight: 2560, // 最大宽高 - useWebWorker: true, - fileType: "image/webp", // 输出文件格式 - }; - const compressedFile = await imageCompression(file, options); - return new File([compressedFile], `${file.name.split(".")[0]}.webp`, { - type: "image/webp", - }); - }; - - let fileToUpload: File; - - // 检查并压缩图片文件 - if (file.type.startsWith("image/")) { - try { - fileToUpload = await compressImage(file); - } catch (error: any) { - console.error("图像压缩失败: " + error.message); - if (onError) onError(error); - throw error; // 如果压缩失败,抛出错误并终止上传 - } - } else { - fileToUpload = file; // 非图片文件,不进行压缩 - } - - const upload = new tus.Upload(fileToUpload, { - // Replace this with tusd's upload creation URL - endpoint: `${endpoint}/files/`, - retryDelays: [0, 3000, 5000, 10000, 20000], - metadata: { - filename: fileToUpload.name, - filetype: fileToUpload.type, - }, - onError: function (error) { - console.error("上传失败: " + error.message); - if (onError) onError(error); - }, - onProgress: function (bytesUploaded: number, bytesTotal: number) { - const currentTimestamp = Date.now(); - const timeElapsed = (currentTimestamp - previousTimestamp) / 1000; // in seconds - const bytesUploadedSinceLastTime = bytesUploaded - previousUploadedSize; - const speed = bytesUploadedSinceLastTime / timeElapsed; // bytes per second - previousUploadedSize = bytesUploaded; - previousTimestamp = currentTimestamp; - const percentage = (bytesUploaded / bytesTotal) * 100; - if (onProgress) onProgress(percentage, speed); - }, - onSuccess: function () { - console.log("上传文件类型", fileToUpload.type); - console.log("上传文件名称", fileToUpload.name); - if (onSuccess) onSuccess(upload.url!); - console.log("Download %s from %s", fileToUpload.name, upload.url); - }, - }); - - // Check if there are any previous uploads to continue. - upload.findPreviousUploads().then(function (previousUploads) { - // Found previous uploads so we select the first one. - if (previousUploads && previousUploads.length > 0) { - upload.resumeFromPreviousUpload(previousUploads[0]!); - } - }); - - return upload; -}; - -export const uploaderPromise = ( - endpoint: string, - file: File, - onProgress?: (percentage: number, speed: number) => void -): Promise => { - return new Promise((resolve, reject) => { - uploader(endpoint, file, onProgress, resolve, reject) - .then((upload) => { - upload.start(); - }) - .catch(reject); - }); -}; \ No newline at end of file diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts old mode 100644 new mode 100755 diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js old mode 100644 new mode 100755 index 5303830..92cbb80 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -1,5 +1,5 @@ /** @type {import('tailwindcss').Config} */ -export default { +module.exports = { content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", @@ -8,11 +8,25 @@ export default { extend: { colors: { primary: "var(--color-primary)", + primaryActive: "var(--color-primary-active)", + primaryHover: "var(--color-primary-hover)", + error: "var(--color-error)", + warning: "var(--color-warning)", + info: "var(--color-info)", + success: "var(--color-success)", + link: "var(--color-link)", + highlight: "var(--color-highlight)", }, backgroundColor: { - layout: "var(--bg-layout)", - mask: "var(--bg-mask)", - container: "var(--bg-container)", + layout: "var(--color-bg-layout)", + mask: "var(--color-bg-mask)", + container: "var(--color-bg-container)", + textHover: "var(--color-bg-text-hover)", + primary: "var(--color-bg-primary)", + error: "var(--color-error-bg)", + warning: "var(--color-warning-bg)", + info: "var(--color-info-bg)", + success: "var(--color-success-bg)", }, textColor: { default: "var(--color-text)", @@ -21,9 +35,17 @@ export default { description: "var(--color-text-description)", secondary: "var(--color-text-secondary)", tertiary: "var(--color-text-tertiary)", + primary: "var(--color-text-primary)", + heading: "var(--color-text-heading)", + label: "var(--color-text-label)", + lightSolid: "var(--color-text-lightsolid)" }, borderColor: { - colorDefault: "var(--color-border)", + default: "var(--color-border)", + }, + boxShadow: { + elegant: '0 3px 6px -2px rgba(46, 117, 182, 0.10), 0 2px 4px -1px rgba(46, 117, 182, 0.05)' + } }, }, diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json old mode 100644 new mode 100755 index 17da72b..c97b841 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -1,32 +1,32 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", - "useDefineForClassFields": true, - "lib": [ - "ES2020", - "DOM", - "DOM.Iterable" - ], - "module": "ESNext", - "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - /* Linting */ - "strict": true, - "strictNullChecks": false, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - }, - "include": [ - "src" - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "strict": true, + "allowJs": true, + "target": "ESNext", + "esModuleInterop": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + // "useDefineForClassFields": true, + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "node", + "allowImportingTsExtensions": true, + // "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "incremental": true + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + ] } \ No newline at end of file diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json old mode 100644 new mode 100755 index 65f670c..1ffef60 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,11 +1,7 @@ { "files": [], "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.node.json" - } + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } ] -} \ No newline at end of file +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json old mode 100644 new mode 100755 index 8a58917..9b589c4 --- a/apps/web/tsconfig.node.json +++ b/apps/web/tsconfig.node.json @@ -6,6 +6,8 @@ "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, + "noUnusedLocals": false, // 关闭未使用局部变量的警告 + "noUnusedParameters": false, // 关闭未使用函数参数的警告 "strict": true, "noEmit": true }, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts old mode 100644 new mode 100755 index cb316a4..0543796 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,12 +1,17 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' import path from 'path'; +const projectRoot = __dirname; + +// This can be replaced with `find-yarn-workspace-root` +const monorepoRoot = path.resolve(projectRoot, '../..'); // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], resolve: { alias: { '@web': path.resolve(__dirname), + '.prisma/client/index-browser': path.resolve(monorepoRoot, 'node_modules/@prisma/client/index-browser.js') } } -}) +}) \ No newline at end of file diff --git a/config/backup.sh b/config/backup.sh new file mode 100755 index 0000000..04582cc --- /dev/null +++ b/config/backup.sh @@ -0,0 +1,26 @@ +#!/bin/sh +if ! restic snapshots > /dev/null 2>&1; then + echo 'Initializing repository'; + restic init; +else + echo 'Repository exists'; +fi; + +while true; do + echo 'Starting backup...'; + restic backup /data; + echo 'Backup complete. Running forget and prune...'; + restic forget --keep-last 3 --prune; + + SECONDS_UNTIL_NEXT_BACKUP=86400; # 24 hours in seconds + + while [ ${SECONDS_UNTIL_NEXT_BACKUP} -gt 0 ]; do + HOURS=$((SECONDS_UNTIL_NEXT_BACKUP / 3600)); + MINUTES=$(((SECONDS_UNTIL_NEXT_BACKUP % 3600) / 60)); + SECONDS=$((SECONDS_UNTIL_NEXT_BACKUP % 60)); + + echo "Time until next backup: ${HOURS}h ${MINUTES}m ${SECONDS}s"; + sleep 60; + SECONDS_UNTIL_NEXT_BACKUP=$((SECONDS_UNTIL_NEXT_BACKUP - 60)); + done +done diff --git a/config/redis.conf b/config/redis.conf new file mode 100644 index 0000000..3d8c6ab --- /dev/null +++ b/config/redis.conf @@ -0,0 +1,94 @@ +# Redis 配置文件 +# 官方文档: https://redis.io/topics/config + +################################## 常规设置 ##################################### + +# 绑定到网络接口。默认情况下,Redis 仅在本地接口上监听。 +# 为了安全起见,只允许本地访问。需要远程访问的情况下,添加其他IP地址或使用0.0.0.0绑定所有接口。 +# bind 127.0.0.1 +# 指定Redis实例监听的端口号。默认端口是6379。 +# port 6379 +# 限制外部连接只允许通过特定的IP进行。默认为空,表示不限制。 +#bind 127.0.0.1 192.168.1.100 +# 设置后台运行模式。若启用,请将此选项设置为 yes。 +# daemonize yes +################################## 安全性 ##################################### +# 设置Redis密码,必须使用客户端认证机制提供此密码才能连接。 +requirepass Letusdoit000 + +# 设置客户端闲置多长时间后关闭连接(秒)。默认值为0,表示不关闭连接。 +# timeout 0 + +################################## 日志记录 #################################### +# 日志级别。可选值:debug、verbose、notice、warning。生产环境建议设置为 notice 或 warning。 +loglevel debug + +# 指定日志文件存放的位置。如果设置为 stdout,则日志输出到标准输出。 +# logfile redis-server.log +# logfile /var/log/redis/redis-server.log + +################################## 数据库 ###################################### +# Redis 默认包含16个数据库(编号从0到15),可以通过 SELECT 来选择数据库。 +databases 16 + +################################## 快照 ####################################### + +# 保存数据到RDB文件配置。格式为 : +# 表示在 秒内发生 次修改时触发保存。 +save 900 1 +save 300 10 +save 60 10000 + +# RDB 文件的名称和存放路径。 +dbfilename dump.rdb +# dir /var/lib/redis + +################################## AOF持久化 ################################## + +# 是否启用AOF(Append Only File)持久化机制。建议开启。 +appendonly yes + +# 设置AOF文件名。 +appendfilename "appendonly.aof" + +# AOF 写入策略:always, everysec, no。其中 everysec 是推荐的折衷方案。 +appendfsync everysec + +# 重写日志文件大小比率,当AOF文件大小达到上一次重写后的文件大小的指定倍数时,触发AOF重写操作。 +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb + +################################## 内存管理 #################################### + +# 设置Redis使用的最大内存量。当达到此值时,会根据maxmemory-policy进行淘汰策略。 +maxmemory 2gb + +# 超过 maxmemory 时的内存淘汰策略。可选值: +# noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。 +# allkeys-lru:移除最少使用的键来腾出空间,用于所有键。 +# volatile-lru:移除最少使用的键来腾出空间,只对设置了过期时间的键生效。 +# allkeys-random:随机移除一些键来腾出空间,用于所有键。 +# volatile-random:随机移除一些键来腾出空间,只对设置了过期时间的键生效。 +# volatile-ttl:移除即将过期的键。 +maxmemory-policy allkeys-lru + +################################## 高可用 ##################################### + +# 主从复制相关配置: +# slaveof +# masterauth +# 启用Redis Sentinel以实现高可用性。Sentinel配置需单独文件定义。 +################################## 高级功能 #################################### +# Lua脚本执行时间限制,以防止长时间运行脚本。单位为毫秒。 +lua-time-limit 5000 + +# 集群相关配置: +# cluster-enabled yes +# cluster-config-file nodes.conf +# cluster-node-timeout 15000 + +# 开启自动故障转移功能 +# cluster-require-full-coverage yes +# 线程池数量配置。可以提高并发性能,但需要根据实际情况调整。 +# io-threads-do-reads no +# io-threads 4 \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..dd61f85 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,39 @@ +{ + "name": "@nicestack/client", + "version": "1.0.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rimraf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "peerDependencies": { + "react": "18.2.0", + "@nicestack/common": "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", + "lib0": "^0.2.98", + "yjs": "^13.6.20", + "mitt": "^3.0.1" + }, + "devDependencies": { + "typescript": "^5.5.4", + "tsup": "^8.3.5", + "rimraf": "^6.0.1" + } +} \ No newline at end of file diff --git a/packages/client/src/api/hooks/index.ts b/packages/client/src/api/hooks/index.ts new file mode 100644 index 0000000..50da38b --- /dev/null +++ b/packages/client/src/api/hooks/index.ts @@ -0,0 +1,12 @@ +export * from "./useAppConfig" +export * from "./useDepartment" +export * from "./useStaff" +export * from "./useTerm" +export * from "./useRole" +export * from "./useRoleMap" +export * from "./useTransform" +export * from "./useTrouble" +export * from "./useTaxonomy" +export * from "./useVisitor" +export * from "./useMessage" +export * from "./usePost" \ No newline at end of file diff --git a/packages/client/src/api/hooks/useAppConfig.ts b/packages/client/src/api/hooks/useAppConfig.ts new file mode 100644 index 0000000..91de6f3 --- /dev/null +++ b/packages/client/src/api/hooks/useAppConfig.ts @@ -0,0 +1,49 @@ +import { api } from "../trpc"; +import { AppConfigSlug, BaseSetting } from "@nicestack/common"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +export function useAppConfig() { + const utils = api.useUtils() + const [baseSetting, setBaseSetting] = useState(); + + const { data, isLoading }: { data: any; isLoading: boolean } = + api.app_config.findFirst.useQuery({ + where: { slug: AppConfigSlug.BASE_SETTING } + }); + const handleMutationSuccess = useCallback(() => { + utils.app_config.invalidate() + }, [utils]); + + // Use the generic success handler in mutations + const create: any = api.app_config.create.useMutation({ + onSuccess: handleMutationSuccess, + }); + const update: any = api.app_config.update.useMutation({ + onSuccess: handleMutationSuccess, + }); + const deleteMany = api.app_config.deleteMany.useMutation({ + onSuccess: handleMutationSuccess, + }); + useEffect(() => { + if (data?.meta) { + setBaseSetting(JSON.parse(data?.meta)); + } + + }, [data, isLoading]); + const splashScreen = useMemo(() => { + return baseSetting?.appConfig?.splashScreen; + }, [baseSetting]); + const devDept = useMemo(() => { + return baseSetting?.appConfig?.devDept; + }, [baseSetting]); + return { + + create, + deleteMany, + update, + baseSetting, + splashScreen, + devDept, + isLoading, + }; +} diff --git a/packages/client/src/api/hooks/useDepartment.ts b/packages/client/src/api/hooks/useDepartment.ts new file mode 100755 index 0000000..e0efd83 --- /dev/null +++ b/packages/client/src/api/hooks/useDepartment.ts @@ -0,0 +1,92 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { getQueryKey } from "@trpc/react-query"; +import { DataNode, DepartmentDto, ObjectType } from "@nicestack/common"; +import { api } from "../trpc"; +import { findQueryData, getCacheDataFromQuery } from "../utils"; +import { CrudOperation, emitDataChange } from "../../event"; +export function useDepartment() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.department); + const create = api.department.create.useMutation({ + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.DEPARTMENT, result as any, CrudOperation.CREATED) + }, + }); + + const update = api.department.update.useMutation({ + onSuccess: (result) => { + + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.DEPARTMENT, result as any, CrudOperation.UPDATED) + }, + }); + + const softDeleteByIds = api.department.softDeleteByIds.useMutation({ + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.DEPARTMENT, result as any, CrudOperation.DELETED) + }, + }); + + const buildTree = ( + data: DepartmentDto[], + parentId: string | null = null + ): DataNode[] => { + return data + .filter((department) => department.parentId === parentId) + .sort((a, b) => a.order - b.order) + .map((department) => { + const node: DataNode = { + title: department.name, + key: department.id, + value: department.id, + hasChildren: department.hasChildren, + children: department.hasChildren + ? buildTree(data, department.id) + : undefined, + data: department, + }; + return node; + }); + }; + + // const getTreeData = () => { + // const uniqueData: DepartmentDto[] = getCacheDataFromQuery( + // queryClient, + // api.department + // ); + // const treeData: DataNode[] = buildTree(uniqueData); + // return treeData; + // }; + // const getTreeData = () => { + // const cacheArray = queryClient.getQueriesData({ + // queryKey: getQueryKey(api.department.getChildren), + // }); + // const data: DepartmentDto[] = cacheArray + // .flatMap((cache) => cache.slice(1)) + // .flat() + // .filter((item) => item !== undefined) as any; + // const uniqueDataMap = new Map(); + + // data?.forEach((item) => { + // if (item && item.id) { + // uniqueDataMap.set(item.id, item); + // } + // }); + // // Convert the Map back to an array + // const uniqueData: DepartmentDto[] = Array.from(uniqueDataMap.values()); + // const treeData: DataNode[] = buildTree(uniqueData); + // return treeData; + // }; + const getDept = (key: string) => { + return findQueryData(queryClient, api.department, key); + }; + return { + softDeleteByIds, + update, + create, + // getTreeData, + getDept + }; +} diff --git a/packages/client/src/api/hooks/useMessage.ts b/packages/client/src/api/hooks/useMessage.ts new file mode 100644 index 0000000..c620920 --- /dev/null +++ b/packages/client/src/api/hooks/useMessage.ts @@ -0,0 +1,18 @@ +import { api } from "../trpc"; +import { useQueryClient } from "@tanstack/react-query"; +import { getQueryKey } from "@trpc/react-query"; +import { Prisma } from "packages/common/dist"; + +export function useMessage() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.message); + const create:any = api.message.create.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + return { + create + }; +} \ No newline at end of file diff --git a/packages/client/src/api/hooks/usePost.ts b/packages/client/src/api/hooks/usePost.ts new file mode 100644 index 0000000..259b62c --- /dev/null +++ b/packages/client/src/api/hooks/usePost.ts @@ -0,0 +1,37 @@ +import { api } from "../trpc"; + +export function usePost() { + const utils = api.useUtils(); + const create = api.post.create.useMutation({ + onSuccess: () => { + utils.post.invalidate(); + }, + }); + const update = api.post.update.useMutation({ + onSuccess: () => { + utils.post.invalidate(); + }, + }); + const deleteMany = api.post.deleteMany.useMutation({ + onSuccess: () => { + utils.post.invalidate(); + }, + }); + const softDeleteByIds = api.post.softDeleteByIds.useMutation({ + onSuccess: () => { + utils.post.invalidate(); + }, + }); + const restoreByIds = api.post.restoreByIds.useMutation({ + onSuccess: () => { + utils.post.invalidate(); + }, + }) + return { + create, + update, + deleteMany, + softDeleteByIds, + restoreByIds + }; +} diff --git a/packages/client/src/api/hooks/useQueryApi.ts b/packages/client/src/api/hooks/useQueryApi.ts new file mode 100644 index 0000000..fbe9d77 --- /dev/null +++ b/packages/client/src/api/hooks/useQueryApi.ts @@ -0,0 +1,27 @@ +import type { SkipToken } from "@tanstack/react-query"; +import type { TRPCClientErrorLike } from "@trpc/client"; +import type { + UseTRPCQueryOptions, + UseTRPCQueryResult, +} from "@trpc/react-query/shared"; +import type { DecoratedQuery } from "node_modules/@trpc/react-query/dist/createTRPCReact"; + +export const useQueryApi = < + T extends DecoratedQuery<{ + input: any; + output: any; + transformer: any; + errorShape: any; + }>, + U extends T extends DecoratedQuery ? R : never, +>( + query: T, + input: U["input"] | SkipToken, + opts?: UseTRPCQueryOptions< + U["output"], + U["input"], + TRPCClientErrorLike, + U["output"] + >, +): UseTRPCQueryResult> => + query.useQuery(input, opts); diff --git a/apps/web/src/hooks/useRole.ts b/packages/client/src/api/hooks/useRole.ts old mode 100644 new mode 100755 similarity index 85% rename from apps/web/src/hooks/useRole.ts rename to packages/client/src/api/hooks/useRole.ts index 30e9b59..e31870b --- a/apps/web/src/hooks/useRole.ts +++ b/packages/client/src/api/hooks/useRole.ts @@ -1,5 +1,5 @@ import { getQueryKey } from "@trpc/react-query"; -import { api } from "../utils/trpc"; // Adjust path as necessary +import { api } from "../trpc"; // Adjust path as necessary import { useQueryClient } from "@tanstack/react-query"; export function useRole() { @@ -19,7 +19,7 @@ export function useRole() { }, }); - const batchDelete = api.role.batchDelete.useMutation({ + const deleteMany = api.role.deleteMany.useMutation({ onSuccess: () => { queryClient.invalidateQueries({ queryKey }) } @@ -32,6 +32,6 @@ export function useRole() { create, update, paginate, - batchDelete + deleteMany }; } diff --git a/packages/client/src/api/hooks/useRoleMap.ts b/packages/client/src/api/hooks/useRoleMap.ts new file mode 100755 index 0000000..dad4b2b --- /dev/null +++ b/packages/client/src/api/hooks/useRoleMap.ts @@ -0,0 +1,47 @@ +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../trpc"; // Adjust path as necessary +import { useQueryClient } from "@tanstack/react-query"; +import { CrudOperation, emitDataChange, EventBus } from "../../event"; +import { ObjectType } from "@nicestack/common"; +export function useRoleMap() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.rolemap); + + const create = api.rolemap.setRoleForObject.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const setRoleForObjects = api.rolemap.setRoleForObjects.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const addRoleForObjects = api.rolemap.addRoleForObjects.useMutation({ + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.ROLE_MAP, result as any, CrudOperation.CREATED) + }, + }); + const update = api.rolemap.update.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const deleteMany = api.rolemap.deleteMany.useMutation({ + onSuccess: (result) => { + + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.ROLE_MAP, result as any, CrudOperation.DELETED) + + }, + }); + + return { + create, + update, + setRoleForObjects, + deleteMany, + addRoleForObjects + }; +} diff --git a/packages/client/src/api/hooks/useStaff.ts b/packages/client/src/api/hooks/useStaff.ts new file mode 100755 index 0000000..5a09277 --- /dev/null +++ b/packages/client/src/api/hooks/useStaff.ts @@ -0,0 +1,43 @@ +import { getQueryKey } from "@trpc/react-query"; +import { api } from "../trpc"; // Adjust path as necessary +import { useQueryClient } from "@tanstack/react-query"; +import { ObjectType, Staff } from "@nicestack/common"; +import { findQueryData } from "../utils"; +import { CrudOperation, emitDataChange } from "../../event"; +export function useStaff() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.staff); + + const create = api.staff.create.useMutation({ + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.STAFF, result as any, CrudOperation.CREATED) + }, + }); + const updateUserDomain = api.staff.updateUserDomain.useMutation({ + onSuccess: async (result) => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const update = api.staff.update.useMutation({ + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.STAFF, result as any, CrudOperation.UPDATED) + }, + }); + const softDeleteByIds = api.staff.softDeleteByIds.useMutation({ + onSuccess: (result, variables) => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const getStaff = (key: string) => { + return findQueryData(queryClient, api.staff, key); + }; + return { + create, + update, + softDeleteByIds, + getStaff, + updateUserDomain + }; +} diff --git a/apps/web/src/hooks/useTaxonomy.ts b/packages/client/src/api/hooks/useTaxonomy.ts old mode 100644 new mode 100755 similarity index 88% rename from apps/web/src/hooks/useTaxonomy.ts rename to packages/client/src/api/hooks/useTaxonomy.ts index 21cf220..588118b --- a/apps/web/src/hooks/useTaxonomy.ts +++ b/packages/client/src/api/hooks/useTaxonomy.ts @@ -1,5 +1,5 @@ import { getQueryKey } from "@trpc/react-query"; -import { api } from "../utils/trpc"; // Adjust path as necessary +import { api } from "../trpc"; // Adjust path as necessary import { useQueryClient } from "@tanstack/react-query"; export function useTaxonomy() { @@ -27,7 +27,7 @@ export function useTaxonomy() { queryClient.invalidateQueries({ queryKey }); }, }); - const batchDelete = api.taxonomy.batchDelete.useMutation({ + const deleteMany = api.taxonomy.deleteMany.useMutation({ onSuccess: () => { queryClient.invalidateQueries({ queryKey }) } @@ -42,6 +42,6 @@ export function useTaxonomy() { update, deleteItem, paginate, - batchDelete + deleteMany }; } diff --git a/packages/client/src/api/hooks/useTerm.ts b/packages/client/src/api/hooks/useTerm.ts new file mode 100755 index 0000000..ba9586e --- /dev/null +++ b/packages/client/src/api/hooks/useTerm.ts @@ -0,0 +1,71 @@ +import { getQueryKey } from "@trpc/react-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { DataNode, ObjectType, TermDto } from "@nicestack/common"; +import { api } from "../trpc"; +import { findQueryData } from "../utils"; +import { CrudOperation, emitDataChange } from "../../event"; + +export function useTerm() { + const queryClient = useQueryClient(); + const queryKey = getQueryKey(api.term); + const create = api.term.create.useMutation({ + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.TERM, result as any, CrudOperation.CREATED) + }, + }); + const upsertTags = api.term.upsertTags.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + + }, + }); + + + const update = api.term.update.useMutation({ + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + emitDataChange(ObjectType.TERM, result as any, CrudOperation.UPDATED) + }, + }); + + const softDeleteByIds = api.term.softDeleteByIds.useMutation({ + onSuccess: (result, variables) => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + const buildTree = ( + data: TermDto[], + parentId: string | null = null + ): DataNode[] => { + return data + .filter((term) => term.parentId === parentId) + .sort((a, b) => a.order - b.order) + .map((term) => { + const node: DataNode = { + title: term.name, + key: term.id, + value: term.id, + hasChildren: !term.hasChildren, + children: term.hasChildren + ? buildTree(data, term.id) + : undefined, + data: term, + }; + return node; + }); + }; + + const getTerm = (key: string) => { + return findQueryData(queryClient, api.term, key); + }; + return { + create, + + update, + softDeleteByIds, + getTerm, + upsertTags, + + }; +} diff --git a/packages/client/src/api/hooks/useVisitor.ts b/packages/client/src/api/hooks/useVisitor.ts new file mode 100644 index 0000000..53e4f0a --- /dev/null +++ b/packages/client/src/api/hooks/useVisitor.ts @@ -0,0 +1,131 @@ +import { api } from "../trpc"; +import { TroubleParams } from "../../singleton/DataHolder"; + +export function useVisitor() { + const utils = api.useUtils(); + const troubleParams = TroubleParams.getInstance(); + + const create = api.visitor.create.useMutation({ + onSuccess() { + utils.visitor.invalidate(); + utils.trouble.invalidate(); + }, + }); + /** + * 通用的乐观更新mutation工厂函数 + * @param updateFn 更新数据的具体逻辑函数 + * @returns 封装后的mutation配置对象 + */ + const createOptimisticMutation = ( + updateFn: (item: any, variables: any) => any + ) => ({ + // 在请求发送前执行本地数据预更新 + onMutate: async (variables: any) => { + const previousDataList: any[] = []; + // 动态生成参数列表,包括星标和其他参数 + + const paramsList = troubleParams.getItems(); + console.log(paramsList.length); + // 遍历所有参数列表,执行乐观更新 + for (const params of paramsList) { + // 取消可能的并发请求 + await utils.trouble.findManyWithCursor.cancel(); + // 获取并保存当前数据 + const previousData = + utils.trouble.findManyWithCursor.getInfiniteData({ + ...params, + }); + previousDataList.push(previousData); + // 执行乐观更新 + utils.trouble.findManyWithCursor.setInfiniteData( + { + ...params, + }, + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.id === variables?.troubleId + ? updateFn(item, variables) + : item + ), + })), + }; + } + ); + } + + return { previousDataList }; + }, + // 错误处理:数据回滚 + onError: (_err: any, _variables: any, context: any) => { + const paramsList = troubleParams.getItems(); + paramsList.forEach((params, index) => { + if (context?.previousDataList?.[index]) { + utils.trouble.findManyWithCursor.setInfiniteData( + { ...params }, + context.previousDataList[index] + ); + } + }); + }, + // 成功后的缓存失效 + onSuccess: (_: any, variables: any) => { + utils.visitor.invalidate(); + utils.trouble.findFirst.invalidate({ + where: { + id: (variables as any)?.troubleId, + }, + }); + }, + }); + // 定义具体的mutation + const read = api.visitor.create.useMutation( + createOptimisticMutation((item) => ({ + ...item, + views: (item.views || 0) + 1, + readed: true, + })) + ); + + const addStar = api.visitor.create.useMutation( + createOptimisticMutation((item) => ({ + ...item, + star: true, + })) + ); + + const deleteStar = api.visitor.deleteMany.useMutation( + createOptimisticMutation((item) => ({ + ...item, + star: false, + })) + ); + + const deleteMany = api.visitor.deleteMany.useMutation({ + onSuccess() { + utils.visitor.invalidate(); + }, + }); + + const createMany = api.visitor.createMany.useMutation({ + onSuccess() { + utils.visitor.invalidate(); + utils.message.invalidate(); + utils.post.invalidate(); + }, + }); + + return { + troubleParams, + create, + createMany, + deleteMany, + read, + addStar, + deleteStar, + }; +} diff --git a/packages/client/src/api/index.ts b/packages/client/src/api/index.ts new file mode 100644 index 0000000..496f94e --- /dev/null +++ b/packages/client/src/api/index.ts @@ -0,0 +1,3 @@ +export * from "./utils" +export * from "./hooks" +export * from "./trpc" \ No newline at end of file diff --git a/apps/web/src/utils/trpc.ts b/packages/client/src/api/trpc.ts old mode 100644 new mode 100755 similarity index 69% rename from apps/web/src/utils/trpc.ts rename to packages/client/src/api/trpc.ts index ebcc577..f414e9b --- a/apps/web/src/utils/trpc.ts +++ b/packages/client/src/api/trpc.ts @@ -1,4 +1,4 @@ import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@server/trpc/trpc.router'; -export const api = createTRPCReact(); \ No newline at end of file +export const api = createTRPCReact(); diff --git a/apps/web/src/utils/general.ts b/packages/client/src/api/utils.ts old mode 100755 new mode 100644 similarity index 88% rename from apps/web/src/utils/general.ts rename to packages/client/src/api/utils.ts index a27ba6a..056fe2d --- a/apps/web/src/utils/general.ts +++ b/packages/client/src/api/utils.ts @@ -1,15 +1,5 @@ import { QueryClient } from "@tanstack/react-query"; import { getQueryKey } from "@trpc/react-query"; -export const handleDownload = (url: string | undefined) => { - if (url) { - const link = document.createElement('a'); - link.href = url; - link.download = url.split('/').pop() || ''; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } -}; /** * 根据查询客户端缓存生成唯一数据列表的函数。 @@ -28,7 +18,8 @@ export function getCacheDataFromQuery(client: QueryClient, trpcQueryKey: any, .flatMap(cache => cache.slice(1)) .flat() .filter(item => item !== undefined) as T[]; - + // console.log('cacheData', cacheData) + // console.log('data', data) // 使用 Map 进行去重 const uniqueDataMap = new Map(); data.forEach((item: T) => { @@ -68,3 +59,4 @@ export function findQueryData(client: QueryClient, trpcQueryKey: any, key: st const uniqueData = getCacheDataFromQuery(client, trpcQueryKey, uniqueField); return findDataByKey(uniqueData, key, uniqueField); } + diff --git a/packages/client/src/event/index.ts b/packages/client/src/event/index.ts new file mode 100644 index 0000000..d4192fc --- /dev/null +++ b/packages/client/src/event/index.ts @@ -0,0 +1,149 @@ +/** + * 该模块提供了一个集中式的事件总线,用于处理不同类型对象的数据变更事件。 + * 它使用 `mitt` 库进行事件管理,并为不同类型的对象定义了特定的事件处理器。 + */ + +import mitt from 'mitt'; +import { DepartmentDto, ObjectType, RoleMapDto, StaffDto, TermDto, TroubleDto } from '@nicestack/common'; + +/** + * 枚举类型,表示可以对数据执行的CRUD操作。 + */ +export enum CrudOperation { + CREATED, // 创建操作 + UPDATED, // 更新操作 + DELETED // 删除操作 +} + +/** + * 类型定义,表示事件总线可以发出的事件类型。 + */ +type Events = { + dataChanged: { type: ObjectType, operation: CrudOperation, data: any } // 数据变更事件 +}; + +/** + * 事件总线实例,用于发出和监听数据变更事件。 + */ +export const EventBus = mitt(); + +/** + * 类型定义,表示一个用于发出数据变更事件的函数。 + * @template T - 要发出的数据的类型。 + */ +type EmitChangeFunction = (data: Partial, operation: CrudOperation) => void; + +/** + * 接口定义,表示不同类型对象的数据变更事件处理器。 + */ +interface EmitChangeHandlers { + [ObjectType.STAFF]: EmitChangeFunction; // 员工数据变更处理器 + [ObjectType.TROUBLE]: EmitChangeFunction; // 问题数据变更处理器 + [ObjectType.ROLE_MAP]: EmitChangeFunction; // 角色映射数据变更处理器 + [ObjectType.DEPARTMENT]: EmitChangeFunction; // 部门数据变更处理器 + [ObjectType.TERM]: EmitChangeFunction // 术语数据变更处理器 +} + +/** + * 对象,包含不同类型对象的数据变更事件处理器。 + */ +const emitChangeHandlers: EmitChangeHandlers = { + [ObjectType.STAFF]: (data, operation) => { + // 转换员工数据,包含额外字段 + const rowData = { + ...data, + officer_id: data.officerId, + phone_number: data.phoneNumber, + dept_name: data.department?.name, + domain_name: data.domain?.name + }; + + // 发出员工数据变更事件 + EventBus.emit("dataChanged", { + type: ObjectType.STAFF, + operation, + data: [rowData] + }); + }, + + [ObjectType.TROUBLE]: (data, operation) => { + // 转换问题数据,包含额外字段 + const rowData = { + ...data, + dept_name: data.department?.name, + possible_result: data.possibleResult, + }; + // 发出问题数据变更事件 + EventBus.emit("dataChanged", { + type: ObjectType.TROUBLE, + operation, + data: [rowData] + }); + }, + + [ObjectType.ROLE_MAP]: (data, operation) => { + // 转换角色映射数据,包含额外字段 + const rowData = { + staff_username: data.staff?.username, + staff_showname: data.staff?.showname, + staff_officer_id: data.staff?.officerId, + department_name: data.staff?.department?.name, + ...data, + }; + // 发出角色映射数据变更事件 + EventBus.emit("dataChanged", { + type: ObjectType.ROLE_MAP, + operation, + data: [rowData] + }); + }, + + [ObjectType.DEPARTMENT]: (data, operation) => { + // 转换部门数据,包含额外字段 + const rowData = { + is_domain: data.isDomain, + parent_id: data.parentId, + has_children: data.hasChildren, + ...data, + }; + // 发出部门数据变更事件 + EventBus.emit("dataChanged", { + type: ObjectType.DEPARTMENT, + operation, + data: [rowData] + }); + }, + [ObjectType.TERM]: (data, operation) => { + // 转换术语数据,包含额外字段 + const rowData = { + taxonomy_id: data.taxonomyId, + parent_id: data.parentId, + has_children: data.hasChildren, + ...data, + }; + // 发出术语数据变更事件 + EventBus.emit("dataChanged", { + type: ObjectType.TERM, + operation, + data: [rowData] + }); + } +}; + +/** + * 函数,用于发出特定对象类型的数据变更事件。 + * @param type - 发生变更的对象类型。 + * @param data - 与变更相关的数据。 + * @param operation - 执行的CRUD操作。 + */ +export function emitDataChange(type: ObjectType, data: any, operation: CrudOperation) { + // 获取指定对象类型的事件处理器 + const handler = emitChangeHandlers[type]; + if (handler) { + // 调用处理器发出数据变更事件 + handler(data, operation); + } else { + // 如果未找到指定对象类型的事件处理器,打印警告 + console.warn(`No emit handler for type: ${type}`); + } +} diff --git a/packages/client/src/hooks/index.ts b/packages/client/src/hooks/index.ts new file mode 100644 index 0000000..757afcb --- /dev/null +++ b/packages/client/src/hooks/index.ts @@ -0,0 +1,4 @@ +export * from "./useCheckBox" +export * from "./useStack" +export * from "./useTimeout" +export * from "./useAwaitState" \ No newline at end of file diff --git a/apps/web/src/hooks/useAwaitState.ts b/packages/client/src/hooks/useAwaitState.ts similarity index 100% rename from apps/web/src/hooks/useAwaitState.ts rename to packages/client/src/hooks/useAwaitState.ts diff --git a/packages/client/src/hooks/useCheckBox.ts b/packages/client/src/hooks/useCheckBox.ts new file mode 100755 index 0000000..77ad747 --- /dev/null +++ b/packages/client/src/hooks/useCheckBox.ts @@ -0,0 +1,156 @@ +/** + * 自定义钩子:用于管理复选框的选择状态 + * @template T - 复选框项的类型 + * @param {UseCheckBoxOptions} options - 配置选项 + * @returns {UseCheckBoxReturn} - 返回包含选择状态和相关操作的对象 + */ +import { useEffect, useMemo, useState } from "react"; + +/** + * 复选框配置选项类型 + * @template T - 复选框项的类型 + */ +type UseCheckBoxOptions = { + /** + * 选择模式,支持 "single"(单选)或 "multiple"(多选) + */ + mode?: "single" | "multiple"; + /** + * 最大选择数量,仅在多选模式下有效 + */ + maxSelection?: number; + /** + * 初始选中的项 + */ + initialSelected?: T[]; +}; + +/** + * 复选框钩子返回值类型 + * @template T - 复选框项的类型 + */ +type UseCheckBoxReturn = { + /** + * 当前选中的项 + */ + selected: T[]; + /** + * 设置选中项的函数 + */ + setSelected: React.Dispatch>; + /** + * 选择指定项的函数 + */ + select: (item: T) => void; + /** + * 取消选择指定项的函数 + */ + deselect: (item: T) => void; + /** + * 切换指定项选择状态的函数 + */ + toggle: (item: T) => void; + /** + * 清空所有选中项的函数 + */ + clear: () => void; + /** + * 判断指定项是否被选中的函数 + */ + isSelected: (item: T) => boolean; + /** + * 当前是否没有任何项被选中 + */ + isEmpty: boolean; +}; + +/** + * 自定义钩子:用于管理复选框的选择状态 + * @template T - 复选框项的类型 + * @param {UseCheckBoxOptions} options - 配置选项 + * @returns {UseCheckBoxReturn} - 返回包含选择状态和相关操作的对象 + */ +export function useCheckBox(options: UseCheckBoxOptions = {}): UseCheckBoxReturn { + // 使用 useState 初始化选中项,默认为空数组或传入的初始选中项 + const [selected, setSelected] = useState(options.initialSelected ?? []); + // 根据配置选项确定选择模式,默认为多选,如果 maxSelection 为 1 则为单选 + const mode = options.mode ?? (options.maxSelection === 1 ? "single" : "multiple"); + + /** + * 选择指定项的函数 + * @param {T} item - 要选择的项 + */ + const select = (item: T) => { + // 如果是单选模式,直接替换当前选中项 + if (mode === "single") { + setSelected([item]); + } + // 如果是多选模式且当前项未被选中,则添加到选中项列表中 + else if (!selected.includes(item)) { + // 如果未设置最大选择数量或当前选中项数量未达到最大值,则添加 + if (options.maxSelection === undefined || selected.length < options.maxSelection) { + setSelected((prev) => [...prev, item]); + } + } + }; + + /** + * 取消选择指定项的函数 + * @param {T} item - 要取消选择的项 + */ + const deselect = (item: T) => { + // 过滤掉当前项,更新选中项列表 + setSelected((prev) => prev.filter((i) => i !== item)); + }; + + /** + * 切换指定项选择状态的函数 + * @param {T} item - 要切换选择状态的项 + */ + const toggle = (item: T) => { + // 如果当前项已被选中,则取消选择,否则选择 + if (selected.includes(item)) { + deselect(item); + } else { + select(item); + } + }; + + /** + * 清空所有选中项的函数 + */ + const clear = () => { + // 将选中项列表设置为空数组 + setSelected([]); + }; + + /** + * 判断指定项是否被选中的函数 + * @param {T} item - 要判断的项 + * @returns {boolean} - 如果项被选中则返回 true,否则返回 false + */ + const isSelected = (item: T) => { + // 检查当前项是否在选中项列表中 + return selected.includes(item); + }; + + /** + * 判断当前是否没有任何项被选中 + */ + const isEmpty = useMemo(() => { + // 检查选中项列表是否为空 + return selected.length === 0; + }, [selected]); + + // 返回包含选中状态和相关操作的对象 + return { + selected, + select, + deselect, + toggle, + clear, + isSelected, + isEmpty, + setSelected, + }; +} diff --git a/packages/client/src/hooks/useStack.ts b/packages/client/src/hooks/useStack.ts new file mode 100755 index 0000000..d2374a0 --- /dev/null +++ b/packages/client/src/hooks/useStack.ts @@ -0,0 +1,128 @@ +/** + * 模块:useStack + * 功能:实现一个基于React的栈(Stack)数据结构Hook + * 作者:高级软件开发工程师 + * 版本:1.0.0 + * 使用场景:适用于需要在React组件中管理栈结构数据的场景,如撤销/重做操作、路由历史管理等 + */ + +import { Dispatch, SetStateAction, useMemo, useState } from "react"; + +/** + * 接口:StackHook + * 职责:定义栈Hook的返回对象结构 + * 核心功能:提供栈的基本操作接口,包括入栈、出栈、查看栈顶元素等 + */ +export interface StackHook { + stack: T[]; // 当前栈中的元素数组 + push: (item: T) => void; // 将元素压入栈顶 + pop: () => void; // 弹出栈顶元素 + popToItem: (item: T) => void; // 弹出栈中元素直到指定元素 + peek: () => T | undefined; // 查看栈顶元素 + isEmpty: boolean; // 判断栈是否为空 + clear: () => void; // 清空栈 + isOnlyDefaultItem: boolean; // 判断栈中是否仅包含默认元素 + setStack: Dispatch> // 直接设置栈内容 +} + +/** + * 函数:useStack + * 职责:创建一个栈Hook实例 + * 核心功能:管理栈状态并提供相关操作方法 + * 设计模式:基于React的Hook模式,封装栈操作逻辑 + * 使用示例: + * const { stack, push, pop } = useStack(); + * push(1); // 栈:[1] + * push(2); // 栈:[1, 2] + * pop(); // 栈:[1] + */ +export function useStack(defaultBottomItem?: T | null): StackHook { + // 初始化栈状态,支持设置默认底部元素 + const [stack, setStack] = useState( + !!defaultBottomItem ? [defaultBottomItem] : [] + ); + + /** + * 方法:push + * 功能:将元素压入栈顶 + * 输入参数:item - 要压入栈顶的元素 + * 算法复杂度:O(1) + */ + const push = (item: T) => setStack((prevStack) => [...prevStack, item]); + + /** + * 方法:pop + * 功能:弹出栈顶元素 + * 算法复杂度:O(1) + */ + const pop = () => + setStack((prevStack) => + prevStack.length > 0 ? prevStack.slice(0, -1) : prevStack + ); + + /** + * 方法:popToItem + * 功能:弹出栈中元素直到指定元素 + * 输入参数:item - 目标元素 + * 算法复杂度:O(n),n为栈中元素数量 + */ + const popToItem = (item: T) => { + setStack((prevStack) => { + const index = prevStack.lastIndexOf(item); + return index >= 0 && index < prevStack.length - 1 + ? prevStack.slice(0, index + 1) + : prevStack; + }); + }; + + /** + * 方法:peek + * 功能:查看栈顶元素 + * 返回值:栈顶元素,若栈为空则返回undefined + * 算法复杂度:O(1) + */ + const peek = (): T | undefined => stack[stack.length - 1]; + + /** + * 属性:isEmpty + * 功能:判断栈是否为空 + * 返回值:boolean,true表示栈为空 + * 算法复杂度:O(1) + */ + const isEmpty = useMemo(() => { + return stack.length === 0; + }, [stack, defaultBottomItem]); + + /** + * 属性:isOnlyDefaultItem + * 功能:判断栈中是否仅包含默认元素 + * 返回值:boolean,true表示栈中仅包含默认元素 + * 算法复杂度:O(1) + */ + const isOnlyDefaultItem = useMemo(() => { + if (defaultBottomItem !== undefined) { + return stack.length === 1 && stack[0] === defaultBottomItem; + } + return stack.length === 0; + }, [stack, defaultBottomItem]); + + /** + * 方法:clear + * 功能:清空栈 + * 算法复杂度:O(1) + */ + const clear = () => + setStack(!!defaultBottomItem ? [defaultBottomItem] : []); + + return { + stack, + setStack, + push, + pop, + popToItem, + peek, + isEmpty, + clear, + isOnlyDefaultItem, + }; +} diff --git a/packages/client/src/hooks/useTimeout.ts b/packages/client/src/hooks/useTimeout.ts new file mode 100644 index 0000000..4006556 --- /dev/null +++ b/packages/client/src/hooks/useTimeout.ts @@ -0,0 +1,78 @@ +// 引入 React,提供 Hooks 和类型支持 +import React from 'react'; + +/** + * @function useTimeout + * @description 自定义 React Hook,用于管理定时器操作的高级工具函数 + * + * @template CbParams 回调函数的泛型参数类型,提供灵活的类型支持 + * @param {(params?: CbParams) => void} cb 定时器触发后执行的回调函数 + * @param {number} [delayMs=0] 定时器延迟时间,默认为 0 毫秒 + * + * @returns {Object} 返回包含定时器控制方法的对象 + * - startTimer: 启动定时器方法 + * - clearTimer: 清除定时器方法 + * - isActive: 标识定时器是否处于活动状态 + * + * @technical-design + * - 使用 useRef 管理定时器引用,避免重复创建和内存泄漏 + * - 通过 useCallback 优化函数引用,减少不必要的重渲染 + * - 支持动态配置延迟时间和回调函数 + */ +function useTimeout(cb: (params?: CbParams) => void, delayMs = 0) { + // 创建持久化的定时器引用,生命周期跟随组件 + const ref = React.useRef(); + + /** + * @method clearTimer + * @description 清除当前活动的定时器 + * @complexity O(1) 常数级时间复杂度 + */ + const clearTimer = React.useCallback(() => { + // 检查定时器是否存在,避免重复清除 + if (ref.current) { + // 使用系统 clearTimeout 方法终止定时器 + clearTimeout(ref.current); + // 重置引用为 undefined,表示定时器已清除 + ref.current = undefined; + } + }, []); + + /** + * @method startTimer + * @description 启动新的定时器,并确保之前的定时器被清除 + * @complexity O(1) 常数级时间复杂度 + */ + const startTimer = React.useCallback(() => { + // 启动新定时器前先清除已存在的定时器,防止资源竞争 + clearTimer(); + + // 创建新的定时器,指定延迟和回调行为 + ref.current = setTimeout(() => { + // 执行传入的回调函数 + cb(); + + // 定时器执行完毕后,重置引用 + ref.current = undefined; + }, delayMs); + + }, [clearTimer, delayMs, cb]); + + /** + * @effect 组件卸载时自动清理定时器 + * @description 防止内存泄漏,确保组件销毁时定时器被正确清除 + */ + React.useEffect(() => () => clearTimer(), [clearTimer]); + + /** + * @returns 返回包含定时器控制方法的对象 + * 提供灵活的定时器管理接口 + */ + return { + startTimer, // 启动定时器方法 + clearTimer, // 清除定时器方法 + isActive: ref.current !== undefined // 判断定时器是否活跃的状态标识 + }; +} + +export { useTimeout }; \ No newline at end of file diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..21a4c55 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,8 @@ +export * from "./api" +export * from "./tools" +export * from "./presentation" +export * from "./io" +export * from "./providers" +export * from "./hooks" +export * from "./websocket" +export * from "./event" \ No newline at end of file diff --git a/packages/client/src/io/download.ts b/packages/client/src/io/download.ts new file mode 100644 index 0000000..9bc1df7 --- /dev/null +++ b/packages/client/src/io/download.ts @@ -0,0 +1,42 @@ +/** + * 处理文件下载功能 + * @param url - 要下载的文件URL,可能为undefined + * @throws {Error} 当URL无效或下载过程中发生错误时抛出异常 + */ +export const handleDownload = (url: string | undefined): void => { + if (!url) { + throw new Error("Invalid URL provided for download"); + } + const link = document.createElement("a"); + try { + link.href = url; + link.download = extractFileNameFromUrl(url); + link.style.display = "none"; // 避免影响页面布局 + document.body.appendChild(link); + link.click(); + } finally { + // 确保无论成功与否都清理DOM元素 + if (link) { + document.body.removeChild(link); + } + } +}; + +/** + * 从URL中提取文件名,若无法提取则返回随机文件名 + * @param url - 文件URL + * @returns 提取的文件名或随机文件名 + */ +const extractFileNameFromUrl = (url: string): string => { + const fileName = url.split("/").pop(); + return fileName ? fileName : generateRandomFileName(); +}; + +/** + * 生成随机文件名 + * @returns 随机文件名 + */ +const generateRandomFileName = (): string => { + const randomString = Math.random().toString(36).substring(2, 15); + return `file_${randomString}`; +}; \ No newline at end of file diff --git a/packages/client/src/io/index.ts b/packages/client/src/io/index.ts new file mode 100644 index 0000000..685fdc1 --- /dev/null +++ b/packages/client/src/io/index.ts @@ -0,0 +1 @@ +export * from "./download" diff --git a/packages/client/src/presentation/color.ts b/packages/client/src/presentation/color.ts new file mode 100644 index 0000000..e92fa8a --- /dev/null +++ b/packages/client/src/presentation/color.ts @@ -0,0 +1,13 @@ +import tinycolor from 'tinycolor2'; + +/** + * 淡化指定颜色的函数 + * @param color - 输入的颜色,可以是任意有效的 CSS 颜色格式 + * @param amount - 淡化的程度,范围从 0 到 100 + * @returns 淡化后的颜色字符串 + */ +export function lightenColor(color: string, amount: number): string { + const tinyColorInstance = tinycolor(color); + const lightenedColor = tinyColorInstance.lighten(amount); + return lightenedColor.toString(); +} \ No newline at end of file diff --git a/packages/client/src/presentation/index.ts b/packages/client/src/presentation/index.ts new file mode 100644 index 0000000..ad2e563 --- /dev/null +++ b/packages/client/src/presentation/index.ts @@ -0,0 +1 @@ +export * from "./color" \ No newline at end of file diff --git a/packages/client/src/providers/index.ts b/packages/client/src/providers/index.ts new file mode 100644 index 0000000..3deda80 --- /dev/null +++ b/packages/client/src/providers/index.ts @@ -0,0 +1 @@ +export { } \ No newline at end of file diff --git a/packages/client/src/singleton/DataHolder.ts b/packages/client/src/singleton/DataHolder.ts new file mode 100644 index 0000000..977b195 --- /dev/null +++ b/packages/client/src/singleton/DataHolder.ts @@ -0,0 +1,36 @@ +export class TroubleParams { + private static instance: TroubleParams; // 静态私有变量,用于存储单例实例 + private troubleParams: Array; // 私有数组属性,用于存储对象 + + private constructor() { + this.troubleParams = []; // 初始化空数组 + } + + public static getInstance(): TroubleParams { + if (!TroubleParams.instance) { + TroubleParams.instance = new TroubleParams(); + } + return TroubleParams.instance; + } + + public addItem(item: object): void { + // 代码意图解析: 向数组中添加一个对象,确保不会添加重复的对象。 + // 技术原理阐述: 在添加对象之前,使用 `some` 方法检查数组中是否已经存在相同的对象。如果不存在,则添加到数组中。 + // 数据结构解读: `some` 方法遍历数组,检查是否存在满足条件的元素。`JSON.stringify` 用于将对象转换为字符串进行比较。 + // 算法复杂度分析: `some` 方法的复杂度为 O(n),因为需要遍历数组中的每个元素。`JSON.stringify` 的复杂度取决于对象的大小,通常为 O(m),其中 m 是对象的属性数量。因此,总复杂度为 O(n * m)。 + // 可能的优化建议: 如果数组非常大,可以考虑使用哈希表(如 `Map` 或 `Set`)来存储对象的唯一标识符,以提高查找效率。 + + const isDuplicate = this.troubleParams.some( + (existingItem) => + JSON.stringify(existingItem) === JSON.stringify(item) + ); + + if (!isDuplicate) { + this.troubleParams.push(item); + } + } + + public getItems(): Array { + return [...this.troubleParams]; // 返回数组的副本,防止外部直接修改原数组 + } +} diff --git a/packages/client/src/tools/index.ts b/packages/client/src/tools/index.ts new file mode 100644 index 0000000..42a70bc --- /dev/null +++ b/packages/client/src/tools/index.ts @@ -0,0 +1,3 @@ +export * from "./level" +export * from "./objects" +export * from "./number" \ No newline at end of file diff --git a/packages/client/src/tools/level.ts b/packages/client/src/tools/level.ts new file mode 100755 index 0000000..304bac9 --- /dev/null +++ b/packages/client/src/tools/level.ts @@ -0,0 +1,136 @@ +/** + * 风险与任务评估工具模块 + * + * 本模块提供了一系列用于评估风险等级、任务紧急程度和综合问题级别的工具函数。 + * 主要用于项目管理、风险评估等场景,帮助用户量化分析各类风险因素。 + * + * 版本历史: + * - v1.0.0 初始版本,包含基础风险评估功能 + * - v1.1.0 新增任务紧急程度评估功能 + * - v1.2.0 添加综合问题级别评估功能 + */ + +import dayjs from "dayjs"; + +/** + * 计算风险等级 + * + * 根据概率和严重性的乘积来评估风险等级,采用线性分段评估方法。 + * + * @param probability 风险发生概率,范围0-100 + * @param severity 风险严重程度,范围0-100 + * @returns 风险等级,1-4级,4级为最高风险 + * + * 算法复杂度:O(1) + * 空间复杂度:O(1) + */ +export function getRiskLevel(probability: number, severity: number) { + // 计算风险值 + const riskValue = probability * severity; + + // 分段评估风险等级 + if (riskValue > 70) { + return 4; + } else if (riskValue > 42) { + return 3; + } else if (riskValue > 21) { + return 2; + } + return 1; +} + +/** + * 计算任务紧急程度评分 + * + * 根据截止日期与当前日期的差值来评估任务紧急程度。 + * 采用分段评分机制,距离截止日期越近,评分越高。 + * + * @param deadline 任务截止日期,支持字符串、Date对象或空值 + * @returns 紧急程度评分,0-100分,100分为最紧急 + * + * 算法复杂度:O(1) + * 空间复杂度:O(1) + */ +export function getDeadlineScore(deadline: string | Date | null | undefined) { + // 处理空值情况 + if (!deadline) { + return 0; + } + + // 计算距离截止日期的天数 + const deadlineDays = dayjs().diff(dayjs(deadline), "day"); + + // 初始化基础评分 + let deadlineScore = 25; + + // 根据天数范围调整评分 + if (deadlineDays > 365) { + deadlineScore = 100; + } else if (deadlineDays > 90) { + deadlineScore = 75; + } else if (deadlineDays > 30) { + deadlineScore = 50; + } + + return deadlineScore; +} + +/** + * 计算综合问题级别 + * + * 综合考虑概率、严重性、影响程度、成本和截止日期等因素, + * 采用加权平均算法评估问题级别。 + * + * @param probability 问题发生概率,范围0-100 + * @param severity 问题严重程度,范围0-100 + * @param impact 问题影响范围,范围0-100 + * @param cost 问题解决成本,范围0-100 + * @param deadline 问题解决截止日期 + * @returns 问题级别,0-4级,4级为最严重问题 + * + * 算法复杂度:O(1) + * 空间复杂度:O(1) + */ +export function getTroubleLevel( + probability: number, + severity: number, + impact: number, + cost: number, + deadline: string | Date +) { + // 计算距离截止日期的天数 + const deadlineDays = dayjs().diff(dayjs(deadline), "day"); + + // 初始化紧急程度评分 + let deadlineScore = 25; + + // 根据天数范围调整评分 + if (deadlineDays > 365) { + deadlineScore = 100; + } else if (deadlineDays > 90) { + deadlineScore = 75; + } else if (deadlineDays > 30) { + deadlineScore = 50; + } + + // 计算加权总分 + let total = + 0.257 * probability + + 0.325 * severity + + 0.269 * impact + + 0.084 * deadlineScore + + 0.065 * cost; + + // 根据总分评估问题级别 + if (total > 90) { + return 4; + } else if (total > 60) { + return 3; + } else if (total > 30) { + return 2; + } else if (probability * severity * impact * cost !== 1) { + return 1; + } else { + return 0; + } +} diff --git a/packages/client/src/tools/number.ts b/packages/client/src/tools/number.ts new file mode 100644 index 0000000..68fc50b --- /dev/null +++ b/packages/client/src/tools/number.ts @@ -0,0 +1,76 @@ +/** + * 数值边界处理工具模块 + * + * 版本历史: + * 1.0.0 - 初始版本,提供基础的数值边界处理功能 + * + * 使用场景: + * 本模块适用于需要对数值进行边界限制的场景,如: + * - 确保数值在指定范围内 + * - 防止数值溢出或下溢 + * - 数据校验和规范化处理 + */ + +/** + * 上界限制函数 + * + * 功能描述: + * 当输入数值大于最大值时,返回最大值;否则返回原数值 + * + * @param n - 待处理的数值 + * @param max - 允许的最大值 + * @returns 限制后的数值 + * + * 算法复杂度: + * 时间复杂度:O(1) + * 空间复杂度:O(1) + */ +export function upperBound(n: number, max: number) { + return n > max ? max : n; +} + +/** + * 下界限制函数 + * + * 功能描述: + * 当输入数值小于最小值时,返回最小值;否则返回原数值 + * + * @param n - 待处理的数值 + * @param min - 允许的最小值 + * @returns 限制后的数值 + * + * 算法复杂度: + * 时间复杂度:O(1) + * 空间复杂度:O(1) + */ +export function lowerBound(n: number, min: number) { + return n < min ? min : n; +} + +/** + * 双边界限制函数 + * + * 功能描述: + * 将数值限制在指定的最小值和最大值之间 + * + * 设计模式解析: + * 本函数采用组合模式,通过调用lowerBound和upperBound实现双重限制 + * + * @param n - 待处理的数值 + * @param min - 允许的最小值 + * @param max - 允许的最大值 + * @returns 限制后的数值 + * + * 使用示例: + * bound(5, 0, 10) // 返回5 + * bound(-1, 0, 10) // 返回0 + * bound(11, 0, 10) // 返回10 + * + * 算法复杂度: + * 时间复杂度:O(1) + * 空间复杂度:O(1) + */ +export function bound(n: number, min: number, max: number) { + // 先应用下界限制,再应用上界限制 + return upperBound(lowerBound(n, min), max); +} diff --git a/packages/client/src/tools/objects.ts b/packages/client/src/tools/objects.ts new file mode 100644 index 0000000..92527f3 --- /dev/null +++ b/packages/client/src/tools/objects.ts @@ -0,0 +1,21 @@ +/** + * 合并两个对象,仅当第二个对象的属性值不为null或undefined时进行覆盖 + * 采用函数式编程风格,避免副作用,提升代码可读性和可维护性 + * + * @param obj1 基础对象,其属性将被保留 + * @param obj2 覆盖对象,仅非空属性会覆盖基础对象的对应属性 + * @returns 合并后的新对象,原始对象不会被修改 + */ +export function mergeIfDefined( + obj1: Record, + obj2: Record +): Record { + // 使用reduce替代forEach,避免显式声明新对象,提升代码简洁性 + return Object.entries(obj2).reduce((acc, [key, value]) => { + // 使用nullish coalescing operator简化条件判断 + if (value != null) { + acc[key] = value; + } + return acc; + }, { ...obj1 }); // 使用对象展开运算符创建新对象,确保原始对象不被修改 +} diff --git a/packages/client/src/websocket/client.ts b/packages/client/src/websocket/client.ts new file mode 100644 index 0000000..14cfcc7 --- /dev/null +++ b/packages/client/src/websocket/client.ts @@ -0,0 +1,232 @@ +import { RetryConfig, WebSocketOptions, WebSocketResult, ReadyState, CloseCode } from './types'; +import { SocketMessage } from '@nicestack/common'; + +const DEFAULT_CONFIG: RetryConfig = { + initialRetryDelay: 1000, + maxRetryDelay: 30000, + maxRetryAttempts: 10, + jitter: 0.1 +}; + +const DEFAULT_OPTIONS: Partial = { + retryOnError: true, +}; + +export class WebSocketClient { + private ws: WebSocket | null = null; + private readyState: ReadyState = ReadyState.CLOSED; + private retryCount = 0; + private reconnectTimer?: NodeJS.Timeout; + private messageQueue: unknown[] = []; + private destroyed = false; + + private options: WebSocketOptions & Partial; + private config: RetryConfig; + + constructor(options: WebSocketOptions & Partial) { + this.options = { + ...DEFAULT_OPTIONS, + ...options, + }; + + this.config = { + ...DEFAULT_CONFIG, + ...options, + }; + + if (!this.options.manualConnect) { + this.connect(); + } + } + + private getWebSocketUrl(): string { + if (!this.options.url) throw new Error('WebSocket URL is required'); + const baseUrl = this.options.url; + const params = this.options.params || {}; + const queryString = Object.entries(params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + return queryString + ? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${queryString}` + : baseUrl; + } + + private getNextRetryDelay(): number { + const { initialRetryDelay, maxRetryDelay, jitter } = this.config; + const baseDelay = Math.min( + initialRetryDelay * Math.pow(2, this.retryCount), + maxRetryDelay + ); + const randomOffset = baseDelay * jitter * (Math.random() * 2 - 1); + return Math.max(0, Math.floor(baseDelay + randomOffset)); + } + + public send(message: unknown): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== ReadyState.OPEN) { + console.warn('[WebSocket] Cannot send message - connection not open'); + this.messageQueue.push(message); + reject(new Error('WebSocket is not connected or not open')); + return; + } + + try { + const messageStr = typeof message === 'string' ? message : JSON.stringify(message); + console.debug('[WebSocket] Sending message:', messageStr); + this.ws.send(messageStr); + resolve(); + } catch (error) { + console.error('[WebSocket] Failed to send message:', error); + reject(error); + } + }); + } + + private flushMessageQueue = async () => { + if (this.ws?.readyState === ReadyState.OPEN) { + const messages = [...this.messageQueue]; + this.messageQueue = []; + for (const message of messages) { + try { + await this.send(message); + } catch (error) { + this.messageQueue.push(message); + } + } + } + } + + private createWebSocket = async () => { + try { + console.log(`[WebSocket] Attempting to connect to ${this.getWebSocketUrl()}`); + const socket = new WebSocket(this.getWebSocketUrl(), this.options.protocols); + this.readyState = ReadyState.CONNECTING; + + socket.onopen = (event: Event) => { + console.log('[WebSocket] Connection established successfully'); + this.ws = socket; + this.readyState = ReadyState.OPEN; + this.retryCount = 0; + this.flushMessageQueue(); + this.options.onOpen?.(event); + }; + + socket.onclose = (event: CloseEvent) => { + console.log(`[WebSocket] Connection closed with code: ${event.code}, reason: ${event.reason}`); + this.readyState = ReadyState.CLOSED; + this.options.onClose?.(event); + + if (!this.destroyed && this.options.retryOnError && event.code !== CloseCode.NORMAL) { + console.log('[WebSocket] Abnormal closure, attempting to reconnect...'); + this.handleReconnect(); + } + }; + + socket.onerror = (event: Event) => { + console.error('[WebSocket] Error occurred:', event); + this.options.onError?.(event); + }; + + socket.onmessage = (event: MessageEvent>) => { + console.debug('[WebSocket] Message received:', event.data); + let data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + this.options.onMessage?.(data); + }; + + this.ws = socket; + return socket; + } catch (error) { + console.error('[WebSocket] Failed to create connection:', error); + throw error; + } + } + + private handleReconnect = async () => { + if (this.destroyed) { + console.log('[WebSocket] Instance destroyed, skipping reconnection'); + return; + } + + if (this.retryCount >= this.config.maxRetryAttempts) { + console.warn(`[WebSocket] Max retry attempts (${this.config.maxRetryAttempts}) reached`); + this.options.onMaxRetries?.(); + return; + } + + if (this.reconnectTimer) { + console.log('[WebSocket] Reconnection already in progress'); + return; + } + + const delay = this.getNextRetryDelay(); + console.log(`[WebSocket] Scheduling reconnection attempt ${this.retryCount + 1}/${this.config.maxRetryAttempts} in ${delay}ms`); + + this.reconnectTimer = setTimeout(async () => { + try { + this.retryCount++; + if (this.ws) { + console.log('[WebSocket] Closing existing connection before reconnect'); + this.ws.close(); + this.ws = null; + } + + await this.createWebSocket(); + console.log(`[WebSocket] Reconnection attempt ${this.retryCount + 1} successful`); + this.options.onReconnect?.(this.retryCount + 1); + } catch (error) { + console.error(`[WebSocket] Reconnection attempt ${this.retryCount + 1} failed:`, error); + await this.handleReconnect(); + } finally { + this.reconnectTimer = undefined; + } + }, delay); + } + + public connect = async () => { + if (this.ws || this.destroyed) return; + try { + await this.createWebSocket(); + } catch (error) { + if (this.options.retryOnError) { + await this.handleReconnect(); + } + } + } + + public reconnect = async () => { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + await this.handleReconnect(); + } + + public disconnect = () => { + this.destroyed = true; + if (this.reconnectTimer) { + console.log('[WebSocket] Clearing reconnect timer'); + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + if (this.ws) { + console.log('[WebSocket] Closing connection'); + this.ws.close(CloseCode.NORMAL); + this.ws = null; + } + this.retryCount = 0; + this.messageQueue = []; + } + + // Getters + public getWs(): WebSocket | null { + return this.ws; + } + + public getReadyState(): ReadyState { + return this.readyState; + } + + public getRetryCount(): number { + return this.retryCount; + } +} diff --git a/packages/client/src/websocket/index.ts b/packages/client/src/websocket/index.ts new file mode 100644 index 0000000..8d1cb3d --- /dev/null +++ b/packages/client/src/websocket/index.ts @@ -0,0 +1,2 @@ +export * from "./client" +export * from "./types" \ No newline at end of file diff --git a/packages/client/src/websocket/types.ts b/packages/client/src/websocket/types.ts new file mode 100644 index 0000000..6ccc317 --- /dev/null +++ b/packages/client/src/websocket/types.ts @@ -0,0 +1,46 @@ +import { Ref } from "react"; +import {SocketMessage} from "@nicestack/common" +export enum ReadyState { + CONNECTING = 0, + OPEN = 1, + CLOSING = 2, + CLOSED = 3, +} + +export enum CloseCode { + NORMAL = 1000, + ABNORMAL = 1006, + SERVICE_RESTART = 1012, + TRY_AGAIN_LATER = 1013, +} + +export interface RetryConfig { + initialRetryDelay: number; + maxRetryDelay: number; + maxRetryAttempts: number; + jitter: number; +} + +export interface WebSocketOptions { + url: string; + params?: Record; + protocols?: string | string[]; + manualConnect?: boolean; + retryOnError?: boolean; + onOpen?: (event: Event) => void; + onClose?: (event: CloseEvent) => void; + onMessage?: (event: SocketMessage) => void; + onError?: (event: Event) => void; + onReconnect?: (count: number) => void; + onMaxRetries?: () => void; +} + +export interface WebSocketResult { + ws: WebSocket | null; + readyState: ReadyState; + retryCountRef: Ref; + send: (message: unknown) => Promise; + connect: () => Promise; + disconnect: () => void; + reconnect: () => Promise; +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..3379233 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "allowJs": true, + "esModuleInterop": true, + "lib": [ + "dom", + "esnext" + ], + "jsx": "react-jsx", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "moduleResolution": "node", + "incremental": true, + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ], +} \ No newline at end of file diff --git a/packages/client/tsup.config.ts b/packages/client/tsup.config.ts new file mode 100644 index 0000000..eb4ea3e --- /dev/null +++ b/packages/client/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: false +}); diff --git a/packages/common/.env.example b/packages/common/.env.example old mode 100755 new mode 100644 index fc7d77e..0d37787 --- a/packages/common/.env.example +++ b/packages/common/.env.example @@ -1 +1 @@ -DATABASE_URL=postgresql://root:Letusdoit000@192.168.116.77:5432/app \ No newline at end of file +DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/defender_app?schema=public" \ No newline at end of file diff --git a/packages/common/package.json b/packages/common/package.json old mode 100755 new mode 100644 index cd20fdf..256a943 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,35 +1,35 @@ { "name": "@nicestack/common", "version": "1.0.0", - "description": "", - "main": "dist/cjs/index.js", - "module": "dist/esm/index.js", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "private": true, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "generate": "pnpm prisma generate", - "build:esm": "tsc -p tsconfig.esm.json", - "build:cjs": "tsc -p tsconfig.cjs.json", - "build": "pnpm run build:esm && pnpm run build:cjs", + "build": "pnpm generate && tsup", + "dev": "pnpm generate && tsup --watch ", "studio": "pnpm prisma studio", "db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init", - "watch:esm": "nodemon --watch src -e ts,tsx --exec 'pnpm run build:esm'", - "watch:cjs": "nodemon --watch src -e ts,tsx --exec 'pnpm run build:cjs'", - "watch:prisma": "nodemon --watch prisma/schema.prisma --exec 'pnpm run generate'", - "dev": "concurrently \"pnpm run watch:prisma\" \"pnpm run watch:esm\" \"pnpm run watch:cjs\" " + "postinstall": "pnpm generate" }, - "keywords": [], - "author": "", - "license": "ISC", "dependencies": { - "@prisma/client": "^5.16.2", - "prisma": "^5.16.2", - "zod": "^3.23.8" + "@prisma/client": "5.17.0", + "prisma": "5.17.0" + }, + "peerDependencies": { + "zod": "^3.23.8", + "yjs": "^13.6.20", + "lib0": "^0.2.98" }, "devDependencies": { "@types/node": "^20.3.1", - "concurrently": "^8.2.2", - "nodemon": "^3.1.4", "ts-node": "^10.9.1", - "typescript": "^5.5.3" + "typescript": "^5.5.4", + "zod-prisma-types": "^3.2.1", + "concurrently": "^8.0.0", + "tsup": "^8.3.5", + "rimraf": "^6.0.1" } } \ No newline at end of file diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma old mode 100755 new mode 100644 index 768412a..3abd5bb --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -2,161 +2,269 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" } +// generator zod { +// provider = "zod-prisma-types" +// output = "../src/generated" // (default) the directory where generated zod schemas will be saved +// createModelTypes = true +// createRelationValuesTypes = true +// writeNullishInModelTypes = true +// createPartialTypes = false +// useMultipleFiles = false +// useTypeAssertions = true +// } + datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") } model Taxonomy { - id String @id @default(uuid()) - name String @unique - deletedAt DateTime? - terms Term[] - order Int + id String @id @default(uuid()) + name String @unique + slug String @unique @map("slug") + deletedAt DateTime? @map("deleted_at") + createdAt DateTime @default(now()) @map("created_at") + terms Term[] + objectType String[] @map("object_type") + order Float? @map("order") - @@index([order, deletedAt]) -} - -model Relation { - id String @id @default(uuid()) - aId String - bId String - aType String - bType String - relationType String - createdAt DateTime? @default(now()) - - @@unique([aId, bId, aType, bType, relationType]) - @@map("relations") + @@index([order, deletedAt]) + @@map("taxonomy") } model Term { - id String @id @default(uuid()) - name String - taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id]) - taxonomyId String? - order Int - description String? - parentId String? - parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade) - children Term[] @relation("ChildParent") - ancestors TermAncestry[] @relation("DescendantToAncestor") - descendants TermAncestry[] @relation("AncestorToDescendant") + id String @id @default(uuid()) + name String + taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id]) + taxonomyId String? @map("taxonomy_id") + order Float? @map("order") + description String? + parentId String? @map("parent_id") + parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade) + children Term[] @relation("ChildParent") + ancestors TermAncestry[] @relation("DescendantToAncestor") + descendants TermAncestry[] @relation("AncestorToDescendant") + domainId String? @map("domain_id") + domain Department? @relation("TermDom", fields: [domainId], references: [id]) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + createdBy String? @map("created_by") + depts Department[] @relation("department_term") + hasChildren Boolean? @default(false) @map("has_children") - domainId String? - domain Department? @relation("TermDom", fields: [domainId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - createdBy String - createdStaff Staff? @relation(fields: [staffId], references: [id]) - staffId String? - - @@index([name]) // 对name字段建立索引,以加快基于name的查找速度 - @@index([parentId]) // 对parentId字段建立索引,以加快基于parentId的查找速度 - @@map("terms") + @@index([name]) // 对name字段建立索引,以加快基于name的查找速度 + @@index([parentId]) // 对parentId字段建立索引,以加快基于parentId的查找速度 + @@map("term") } model TermAncestry { - id String @id @default(uuid()) - ancestorId String - descendantId String - relDepth Int - ancestor Term @relation("AncestorToDescendant", fields: [ancestorId], references: [id], onDelete: Cascade) - descendant Term @relation("DescendantToAncestor", fields: [descendantId], references: [id], onDelete: Cascade) - createdAt DateTime? @default(now()) -} + id String @id @default(uuid()) + ancestorId String? @map("ancestor_id") + descendantId String @map("descendant_id") + relDepth Int @map("rel_depth") + ancestor Term? @relation("AncestorToDescendant", fields: [ancestorId], references: [id]) + descendant Term @relation("DescendantToAncestor", fields: [descendantId], references: [id]) -model Comment { - id String @id @default(uuid()) - style String - link String? - title String? - content String - attachments String[] @default([]) - createdAt DateTime? @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - createdBy String? - createdStaff Staff? @relation(fields: [createdBy], references: [id]) - - @@map("comments") + // 索引建议 + @@index([ancestorId]) // 针对祖先的查询 + @@index([descendantId]) // 针对后代的查询 + @@index([ancestorId, descendantId]) // 组合索引,用于查询特定的祖先-后代关系 + @@index([relDepth]) // 根据关系深度的查询 + @@map("term_ancestry") } model Staff { - id String @id @default(uuid()) - showname String? - username String @unique - password String - phoneNumber String? @unique - domainId String? - deptId String? - domain Department? @relation("DomainStaff", fields: [domainId], references: [id]) - department Department? @relation("DeptStaff", fields: [deptId], references: [id]) - registerToken String? - order Int - deletedAt DateTime? - system Boolean? @default(false) - comments Comment[] - terms Term[] - refreshTokens RefreshToken[] -} + id String @id @default(uuid()) + showname String? @map("showname") + username String @unique @map("username") + avatar String? @map("avatar") + password String? @map("password") + phoneNumber String? @unique @map("phone_number") -model RefreshToken { - id String @id @default(uuid()) - token String @unique - staffId String - staff Staff @relation(fields: [staffId], references: [id]) - createdAt DateTime @default(now()) + domainId String? @map("domain_id") + deptId String? @map("dept_id") - @@map("refreshTokens") + domain Department? @relation("DomainStaff", fields: [domainId], references: [id]) + department Department? @relation("DeptStaff", fields: [deptId], references: [id]) + registerToken String? @map("register_token") + order Float? + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + enabled Boolean? @default(true) + deletedAt DateTime? @map("deleted_at") + officerId String? @map("officer_id") + + + watchedPost Post[] @relation("post_watch_staff") + visits Visit[] + posts Post[] + sentMsgs Message[] @relation("message_sender") + receivedMsgs Message[] @relation("message_receiver") + + + + @@index([officerId]) + @@index([deptId]) + @@index([domainId]) + @@index([username]) + @@index([order]) + @@map("staff") } model Department { - id String @id @default(uuid()) - name String - order Int - ancestors DeptAncestry[] @relation("DescendantToAncestor") - descendants DeptAncestry[] @relation("AncestorToDescendant") - parentId String? @map("parentId") - parent Department? @relation("ChildParent", fields: [parentId], references: [id]) - children Department[] @relation("ChildParent") - domainTerms Term[] @relation("TermDom") - deletedAt DateTime? - isDomain Boolean? @default(false) - domainStaffs Staff[] @relation("DomainStaff") - deptStaffs Staff[] @relation("DeptStaff") + id String @id @default(uuid()) + name String + order Float? + ancestors DeptAncestry[] @relation("DescendantToAncestor") + descendants DeptAncestry[] @relation("AncestorToDescendant") + parentId String? @map("parent_id") + parent Department? @relation("ChildParent", fields: [parentId], references: [id]) + children Department[] @relation("ChildParent") + domainId String? @map("domain_id") + domainTerms Term[] @relation("TermDom") + deletedAt DateTime? @map("deleted_at") + isDomain Boolean? @default(false) @map("is_domain") + domainStaffs Staff[] @relation("DomainStaff") + deptStaffs Staff[] @relation("DeptStaff") + terms Term[] @relation("department_term") + + visits Visit[] @relation("visit_dept") + watchedPost Post[] @relation("post_watch_dept") + hasChildren Boolean? @default(false) @map("has_children") + + @@index([parentId]) + @@index([isDomain]) + @@index([name]) + @@index([order]) + @@map("department") } model DeptAncestry { - ancestorId String - descendantId String - relDepth Int - ancestor Department @relation("AncestorToDescendant", fields: [ancestorId], references: [id]) - descendant Department @relation("DescendantToAncestor", fields: [descendantId], references: [id]) + id String @id @default(uuid()) + ancestorId String? @map("ancestor_id") + descendantId String @map("descendant_id") + relDepth Int @map("rel_depth") + ancestor Department? @relation("AncestorToDescendant", fields: [ancestorId], references: [id]) + descendant Department @relation("DescendantToAncestor", fields: [descendantId], references: [id]) - @@id([descendantId, ancestorId]) - @@index([ancestorId]) // 对ancestorId字段建立索引,以加快基于ancestorId的查找速度 - @@index([descendantId]) // 对descendantId字段建立索引,以加快基于descendantId的查找速度 + // 索引建议 + @@index([ancestorId]) // 针对祖先的查询 + @@index([descendantId]) // 针对后代的查询 + @@index([ancestorId, descendantId]) // 组合索引,用于查询特定的祖先-后代关系 + @@index([relDepth]) // 根据关系深度的查询 + @@map("dept_ancestry") } model RoleMap { - id String @id @default(uuid()) - objectId String - roleId String - domainId String? - objectType String - role Role @relation(fields: [roleId], references: [id]) + id String @id @default(uuid()) + objectId String @map("object_id") + roleId String @map("role_id") + domainId String? @map("domain_id") + objectType String @map("object_type") + role Role @relation(fields: [roleId], references: [id]) + + @@index([domainId]) + @@index([objectId]) + @@map("rolemap") } model Role { - id String @id @default(uuid()) - name String @unique - permissions String[] @default([]) - roleMaps RoleMap[] - deletedAt DateTime? - system Boolean? @default(false) + id String @id @default(uuid()) + name String @unique @map("name") + permissions String[] @default([]) @map("permissions") + roleMaps RoleMap[] + system Boolean? @default(false) @map("system") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@map("role") +} + +model AppConfig { + id String @id @default(uuid()) + slug String @unique + title String? + description String? + meta Json? + + @@map("app_config") +} + +model Post { + id String @id @default(uuid()) + type String? + title String? + content String? + author Staff? @relation(fields: [authorId], references: [id]) + authorId String? + domainId String? + referenceId String? + attachments String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + visits Visit[] + watchStaffs Staff[] @relation("post_watch_staff") + watchDepts Department[] @relation("post_watch_dept") + + parentId String? + parent Post? @relation("PostChildren", fields: [parentId], references: [id]) + children Post[] @relation("PostChildren") + deletedAt DateTime? @map("deleted_at") + + // 复合索引 + @@index([type, domainId]) // 类型和域组合查询 + @@index([authorId, type]) // 作者和类型组合查询 + @@index([referenceId, type]) // 引用ID和类型组合查询 + @@index([parentId, type]) // 父级帖子和创建时间索引 + // 时间相关索引 + @@index([createdAt]) // 按创建时间倒序索引 + @@index([updatedAt]) // 按更新时间倒序索引 +} + +model Message { + id String @id @default(uuid()) + url String? + intent String? + option Json? + senderId String? @map("sender_id") + messageType String? + sender Staff? @relation(name: "message_sender", fields: [senderId], references: [id]) + title String? + content String? + receivers Staff[] @relation("message_receiver") + visits Visit[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @updatedAt @map("updated_at") + + @@index([messageType, createdAt]) + @@map("message") +} + +model Visit { + id String @id @default(uuid()) + postId String? @map("post_id") + troubleId String? @map("trouble_id") + messageId String? @map("message_id") + visitorId String @map("visitor_id") + visitor Staff @relation(fields: [visitorId], references: [id]) + deptId String? @map("dept_id") + department Department? @relation(name: "visit_dept", fields: [deptId], references: [id]) + createdAt DateTime? @default(now()) @map("created_at") + updatedAt DateTime? @updatedAt @map("updated_at") + sourceIP String? @map("source_ip") + visitType String? @map("visit_type") + post Post? @relation(fields: [postId], references: [id]) + message Message? @relation(fields: [messageId], references: [id]) + views Int @default(1) + + @@index([postId, visitType, visitorId]) + @@index([troubleId, visitType, visitorId]) + @@index([messageId, visitType, visitorId]) + @@map("visit") } diff --git a/packages/common/src/collaboration/index.ts b/packages/common/src/collaboration/index.ts new file mode 100644 index 0000000..d09bad4 --- /dev/null +++ b/packages/common/src/collaboration/index.ts @@ -0,0 +1,4 @@ +export * from "./y-awareness" +export * from "./y-sync" +export * from "./y-socket" +export * from "./types" \ No newline at end of file diff --git a/packages/common/src/collaboration/types.ts b/packages/common/src/collaboration/types.ts new file mode 100644 index 0000000..b22061f --- /dev/null +++ b/packages/common/src/collaboration/types.ts @@ -0,0 +1,7 @@ +export enum YMessageType { + Sync = 0, + Awareness = 1, + Auth = 2, + QueryAwareness = 3 +} + diff --git a/packages/common/src/collaboration/utils.ts b/packages/common/src/collaboration/utils.ts new file mode 100644 index 0000000..49ed64d --- /dev/null +++ b/packages/common/src/collaboration/utils.ts @@ -0,0 +1,3 @@ +export const isReactNative = (): boolean => { + return typeof navigator !== 'undefined' && navigator.product === 'ReactNative'; +} \ No newline at end of file diff --git a/packages/common/src/collaboration/y-auth.ts b/packages/common/src/collaboration/y-auth.ts new file mode 100644 index 0000000..2e821c5 --- /dev/null +++ b/packages/common/src/collaboration/y-auth.ts @@ -0,0 +1,29 @@ +import * as Y from 'yjs' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' + +export const messagePermissionDenied = 0 + +export const writePermissionDenied = ( + encoder: encoding.Encoder, + reason: string +): void => { + encoding.writeVarUint(encoder, messagePermissionDenied) + encoding.writeVarString(encoder, reason) +} + +export type PermissionDeniedHandler = ( + y: Y.Doc, + reason: string +) => void + +export const readAuthMessage = ( + decoder: decoding.Decoder, + y: Y.Doc, + permissionDeniedHandler: PermissionDeniedHandler +): void => { + switch (decoding.readVarUint(decoder)) { + case messagePermissionDenied: + permissionDeniedHandler(y, decoding.readVarString(decoder)) + } +} diff --git a/packages/common/src/collaboration/y-awareness.ts b/packages/common/src/collaboration/y-awareness.ts new file mode 100644 index 0000000..545db17 --- /dev/null +++ b/packages/common/src/collaboration/y-awareness.ts @@ -0,0 +1,544 @@ +/** + * 此文件实现了一个基于Yjs的分布式感知协议(Awareness Protocol) + * 用于在协同编辑场景中同步和共享非持久化的状态信息(如用户在线状态、光标位置等) + * + * @author + * @date 2023 + */ + +import * as encoding from 'lib0/encoding'; +import * as decoding from 'lib0/decoding'; +import * as time from 'lib0/time'; +import { Observable } from 'lib0/observable'; +import * as f from 'lib0/function'; +import * as Y from 'yjs'; // eslint-disable-line +import { YWsProvider } from './y-socket'; + +/** + * 客户端状态过期的超时时间,单位为毫秒 + * 如果一个客户端超过此时间未更新状态,则认为该客户端已离线 + */ +export const outdatedTimeout: number = 30000; + +/** + * MetaClientState 接口定义了客户端状态的元数据信息 + * 用于跟踪每个客户端状态的版本和最后更新时间 + * + * @interface + */ +export interface MetaClientState { + clock: number; // 单调递增的时钟值,用于状态版本控制 + lastUpdated: number; // 最后一次更新的Unix时间戳 +} + +/** + * StateMap类型定义了客户端ID到状态记录的映射关系 + * key为clientID,value为该客户端的状态对象 + */ +type StateMap = Map>; + + +/** + * Awareness类 - 用于管理协同编辑中的用户状态感知 + * 继承自Observable以提供事件机制 + */ +export class Awareness extends Observable { + /** Yjs文档实例,用于协同编辑 */ + doc: Y.Doc; + + /** 当前客户端的唯一标识 */ + clientID: number; + + /** 存储所有客户端的状态映射表 Map */ + states: StateMap; + + /** 存储所有客户端的元数据映射表 Map */ + meta: Map; + + /** 定时检查过期状态的定时器句柄 */ + private _checkInterval: ReturnType; + + /** + * 构造函数 + * @param doc Yjs文档实例 + */ + constructor(doc: Y.Doc) { + super(); + + /** + * 初始化实例属性 + */ + this.doc = doc; + this.clientID = doc.clientID; + this.states = new Map(); + this.meta = new Map(); + + /** + * 创建定时器,定期检查并清理过期的客户端状态 + * 间隔为过期时间的1/10 + */ + this._checkInterval = setInterval(() => { + const now = time.getUnixTime(); + + /** + * 如果本地状态接近过期(超过过期时间的一半), + * 则更新状态以保持活跃 + */ + if (this.getLocalState() !== null && (outdatedTimeout / 2 <= now - (this.meta.get(this.clientID)?.lastUpdated || 0))) { + this.setLocalState(this.getLocalState()); + } + + /** + * 检查并移除过期的远程客户端状态 + * 过期条件:非本地客户端 && 超过过期时间 && 存在状态 + */ + const remove: number[] = []; + this.meta.forEach((meta, clientid) => { + if (clientid !== this.clientID && outdatedTimeout <= now - meta.lastUpdated && this.states.has(clientid)) { + remove.push(clientid); + } + }); + if (remove.length > 0) { + removeAwarenessStates(this, remove, 'timeout'); + } + }, Math.floor(outdatedTimeout / 10)); + + /** + * 监听文档销毁事件,确保资源正确释放 + */ + doc.on('destroy', () => { + this.destroy(); + }); + + /** + * 初始化本地状态为空对象 + */ + this.setLocalState({}); + } + + /** + * 销毁实例,清理资源 + */ + override destroy() { + this.emit('destroy', [this]); + this.setLocalState(null); + super.destroy(); + clearInterval(this._checkInterval); + } + + /** + * 获取本地客户端状态 + * @returns 状态对象或null + */ + getLocalState(): Record | null { + return this.states.get(this.clientID) || null; + } + + /** + * 设置本地客户端状态 + * @param state 新的状态对象或null(表示删除状态) + */ + setLocalState(state: Record | null) { + const clientID = this.clientID; + const currLocalMeta = this.meta.get(clientID); + const clock = currLocalMeta === undefined ? 0 : currLocalMeta.clock + 1; + const prevState = this.states.get(clientID); + + /** + * 更新状态 + */ + if (state === null) { + this.states.delete(clientID); + } else { + this.states.set(clientID, state); + } + + /** + * 更新元数据(时钟值和最后更新时间) + */ + this.meta.set(clientID, { + clock, + lastUpdated: time.getUnixTime() + }); + + /** + * 跟踪状态变更类型: + * - added: 新增的状态 + * - updated: 更新的状态 + * - filteredUpdated: 实际发生变化的更新状态 + * - removed: 移除的状态 + */ + const added: number[] = []; + const updated: number[] = []; + const filteredUpdated: number[] = []; + const removed: number[] = []; + + if (state === null) { + removed.push(clientID); + } else if (prevState == null) { + if (state != null) { + added.push(clientID); + } + } else { + updated.push(clientID); + if (!f.equalityDeep(prevState, state)) { + filteredUpdated.push(clientID); + } + } + + /** + * 触发事件: + * - change: 仅当状态实际发生变化时触发 + * - update: 所有更新操作都会触发 + */ + if (added.length > 0 || filteredUpdated.length > 0 || removed.length > 0) { + this.emit('change', [{ added, updated: filteredUpdated, removed }, 'local']); + } + + this.emit('update', [{ added, updated, removed }, 'local']); + } + + /** + * 更新本地状态的指定字段 + * @param field 字段名 + * @param value 字段值 + */ + setLocalStateField(field: string, value: any) { + const state = this.getLocalState(); + if (state !== null) { + this.setLocalState({ + ...state, + [field]: value + }); + } + } + + /** + * 获取所有客户端的状态映射表 + * @returns StateMap + */ + getStates(): StateMap { + return this.states; + } +} + +/** + * 从Awareness中移除指定客户端的状态 + * @param awareness {Awareness} - Awareness实例 + * @param clients {number[]} - 需要移除状态的客户端ID数组 + * @param origin {any} - 状态变更的来源信息 + */ +export const removeAwarenessStates = (awareness: Awareness, clients: number[], origin: any) => { + + /** + * 记录实际被移除的客户端ID + * 用于后续触发事件通知 + */ + const removed: number[] = []; + + /** + * 遍历需要移除的客户端列表 + */ + for (let i = 0; i < clients.length; i++) { + const clientID = clients[i]; + + /** + * 检查该客户端是否存在状态 + * 只处理存在状态的客户端 + */ + if (awareness.states.has(clientID)) { + /** + * 从states中删除该客户端状态 + */ + awareness.states.delete(clientID); + + /** + * 特殊处理当前客户端 + * 如果移除的是当前客户端的状态: + * 1. 获取当前meta信息 + * 2. 更新clock值(+1)和最后更新时间 + */ + if (clientID === awareness.clientID) { + const curMeta = awareness.meta.get(clientID) as MetaClientState; + awareness.meta.set(clientID, { + clock: curMeta.clock + 1, + lastUpdated: time.getUnixTime() + }); + } + + /** + * 将实际移除的客户端ID加入removed数组 + */ + removed.push(clientID); + } + } + + /** + * 如果有状态被移除,触发事件通知 + * 包含两个事件: + * 1. change - 状态变更事件 + * 2. update - 状态更新事件 + * 事件数据包含: + * - added: 新增的状态(空数组) + * - updated: 更新的状态(空数组) + * - removed: 移除的状态 + * - origin: 变更来源 + */ + if (removed.length > 0) { + awareness.emit('change', [{ added: [], updated: [], removed }, origin]); + awareness.emit('update', [{ added: [], updated: [], removed }, origin]); + } +} + + + +/** + * 将Awareness状态编码为二进制更新数据 + * @param awareness {Awareness} - Awareness实例,包含状态管理相关功能 + * @param clients {number[]} - 需要编码的客户端ID数组 + * @param states {StateMap} - 状态映射表,默认使用awareness中的states + * @returns {Uint8Array} - 编码后的二进制数据 + */ +export const encodeAwarenessUpdate = (awareness: Awareness, clients: number[], states: StateMap = awareness.states): Uint8Array => { + + /** + * 获取需要编码的客户端数量 + * 用于预分配编码空间 + */ + const len = clients.length; + + /** + * 创建二进制编码器 + * 用于将状态数据编码为二进制格式 + */ + const encoder = encoding.createEncoder(); + + /** + * 写入客户端数量 + * 使用VarUint变长编码,节省空间 + */ + encoding.writeVarUint(encoder, len); + + /** + * 遍历每个客户端,编码其状态信息 + */ + for (let i = 0; i < len; i++) { + /** + * 获取当前客户端ID + */ + const clientID = clients[i]; + + /** + * 从states中获取该客户端的状态 + * 如果不存在则使用null + */ + const state = states.get(clientID) || null; + + /** + * 从awareness.meta中获取该客户端的时钟值 + * MetaClientState类型包含clock字段表示状态版本 + */ + const clock = (awareness.meta.get(clientID) as MetaClientState).clock; + + /** + * 将客户端信息写入编码器: + * 1. 写入clientID + * 2. 写入clock版本号 + * 3. 将状态序列化为JSON字符串写入 + */ + encoding.writeVarUint(encoder, clientID); + encoding.writeVarUint(encoder, clock); + encoding.writeVarString(encoder, JSON.stringify(state)); + } + + /** + * 将编码器中的数据转换为Uint8Array返回 + * 完成状态数据的二进制编码 + */ + return encoding.toUint8Array(encoder); +} + + +/** + * 修改Awareness更新数据的工具函数 + * @param update {Uint8Array} - 原始的awareness更新二进制数据 + * @param modify {Function} - 修改状态的回调函数 + * @returns {Uint8Array} - 修改后的二进制数据 + */ +export const modifyAwarenessUpdate = (update: Uint8Array, modify: (state: any) => any): Uint8Array => { + + /** + * 创建二进制解码器,用于读取原始update数据 + * decoding模块提供了二进制数据的解码能力 + */ + const decoder = decoding.createDecoder(update); + + /** + * 创建二进制编码器,用于写入修改后的数据 + * encoding模块提供了数据编码为二进制的能力 + */ + const encoder = encoding.createEncoder(); + + /** + * 读取update中包含的awareness状态数量 + * 使用VarUint变长编码,可以节省空间 + */ + const len = decoding.readVarUint(decoder); + + /** + * 将状态数量写入新的编码器 + */ + encoding.writeVarUint(encoder, len); + + /** + * 遍历处理每个awareness状态 + */ + for (let i = 0; i < len; i++) { + + /** + * 读取每个状态的元数据: + * - clientID: 客户端唯一标识 + * - clock: 状态版本时钟 + * - state: JSON格式的状态数据 + */ + const clientID = decoding.readVarUint(decoder); + const clock = decoding.readVarUint(decoder); + const state = JSON.parse(decoding.readVarString(decoder)); + + /** + * 使用modify回调函数处理状态 + * 可以对state进行任意修改 + */ + const modifiedState = modify(state); + + /** + * 将修改后的状态写回编码器: + * 1. 写入clientID + * 2. 写入clock + * 3. 将修改后的状态序列化为JSON字符串写入 + */ + encoding.writeVarUint(encoder, clientID); + encoding.writeVarUint(encoder, clock); + encoding.writeVarString(encoder, JSON.stringify(modifiedState)); + } + + /** + * 将编码器中的数据转换为Uint8Array返回 + * 完成二进制数据的重新编码 + */ + return encoding.toUint8Array(encoder); +} + + + +/** + * 应用Awareness状态更新的函数 + * @param awareness Awareness实例,用于管理用户状态 + * @param update 二进制格式的状态更新数据 + * @param origin 更新的来源信息 + */ +export const applyAwarenessUpdate = (awareness: Awareness, update: Uint8Array, origin: any) => { + /** + * 创建二进制解码器,用于解析update数据 + * Uint8Array是固定长度的8位无符号整数数组 + */ + const decoder = decoding.createDecoder(update); + + /** + * 获取当前Unix时间戳,用于记录状态更新时间 + */ + const timestamp = time.getUnixTime(); + + /** + * 定义数组跟踪不同类型的状态变更: + * added - 新增的客户端ID + * updated - 所有更新的客户端ID + * filteredUpdated - 状态实际发生变化的客户端ID + * removed - 移除的客户端ID + */ + const added: number[] = []; + const updated: number[] = []; + const filteredUpdated: number[] = []; + const removed: number[] = []; + + /** + * 读取需要更新的客户端数量 + * 使用变长整数编码(VarUint)提高传输效率 + */ + const len = decoding.readVarUint(decoder); + + /** + * 遍历处理每个客户端的状态更新 + */ + for (let i = 0; i < len; i++) { + /** + * 读取客户端ID、时钟值和状态数据 + * 状态数据使用JSON字符串传输,需要解析 + */ + const clientID = decoding.readVarUint(decoder); + let clock = decoding.readVarUint(decoder); + const state = JSON.parse(decoding.readVarString(decoder)); + + /** + * 获取客户端当前的元数据和状态 + * meta包含clock(时钟)和lastUpdated(最后更新时间) + */ + const clientMeta = awareness.meta.get(clientID); + const prevState = awareness.states.get(clientID); + const currClock = clientMeta === undefined ? 0 : clientMeta.clock; + + /** + * 时钟值更大或状态被删除时才应用更新 + * 使用逻辑时钟确保状态更新的顺序一致性 + */ + if (currClock < clock || (currClock === clock && state === null && awareness.states.has(clientID))) { + if (state === null) { + /** + * 处理状态删除 + * 本地状态特殊处理:增加时钟值而不删除状态 + */ + if (clientID === awareness.clientID && awareness.getLocalState() != null) { + clock++; + } else { + awareness.states.delete(clientID); + } + } else { + /** + * 更新客户端状态 + * 使用Map数据结构存储状态 + */ + awareness.states.set(clientID, state); + } + + /** + * 更新客户端元数据 + * 记录新的时钟值和更新时间戳 + */ + awareness.meta.set(clientID, { + clock, + lastUpdated: timestamp + }); + + /** + * 根据更新类型将clientID添加到相应的跟踪数组 + * 使用深度相等性比较检测状态是否实际发生变化 + */ + if (clientMeta === undefined && state !== null) { + added.push(clientID); + } else if (clientMeta !== undefined && state === null) { + removed.push(clientID); + } else if (state !== null) { + if (!f.equalityDeep(state, prevState)) { + filteredUpdated.push(clientID); + } + updated.push(clientID); + } + } + } + if (added.length > 0 || filteredUpdated.length > 0 || removed.length > 0) { + awareness.emit('change', [{ added, updated: filteredUpdated, removed }, origin]); + } + + if (!(origin instanceof YWsProvider)) + awareness.emit('update', [{ added, updated, removed }, origin]); +} diff --git a/packages/common/src/collaboration/y-handler.ts b/packages/common/src/collaboration/y-handler.ts new file mode 100644 index 0000000..218c2ae --- /dev/null +++ b/packages/common/src/collaboration/y-handler.ts @@ -0,0 +1,87 @@ +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import { YWsProvider } from './y-socket'; +import { YMessageType } from './types'; +import * as awarenessProtocol from './y-awareness' +import * as syncProtocol from './y-sync' +import * as authProtocol from './y-auth' +export type MessageHandler = (encoder: encoding.Encoder, decoder: decoding.Decoder, provider: YWsProvider, emitSynced: boolean, messageType: number) => void; + +export const messageHandlers: MessageHandler[] = [] + +messageHandlers[YMessageType.Sync] = ( + encoder, + decoder, + provider, + emitSynced, + _messageType +) => { + // console.log('Handling YMessageType.Sync') + encoding.writeVarUint(encoder, YMessageType.Sync) + const syncMessageType = syncProtocol.readSyncMessage( + decoder, + encoder, + provider.doc, + provider + ) + // console.debug(`Sync message type: ${syncMessageType}`) + if ( + emitSynced && syncMessageType === syncProtocol.messageYjsSyncStep2 && + !provider.synced + ) { + // console.log('Setting provider.synced to true') + provider.synced = true + } +} + +messageHandlers[YMessageType.QueryAwareness] = ( + encoder, + _decoder, + provider, + _emitSynced, + _messageType +) => { + console.log('Handling messageQueryAwareness') + encoding.writeVarUint(encoder, YMessageType.Awareness) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate( + provider.awareness, + Array.from(provider.awareness.getStates().keys()) + ) + ) + console.debug('Encoded awareness update for querying awareness state.') +} + +messageHandlers[YMessageType.Awareness] = ( + _encoder, + decoder, + provider, + _emitSynced, + _messageType +) => { + // console.log('Handling messageAwareness') + const awarenessUpdate = decoding.readVarUint8Array(decoder) + awarenessProtocol.applyAwarenessUpdate( + provider.awareness, + awarenessUpdate, + provider + ) +} + +messageHandlers[YMessageType.Auth] = ( + _encoder, + decoder, + provider, + _emitSynced, + _messageType +) => { + console.log('Handling messageAuth') + authProtocol.readAuthMessage( + decoder, + provider.doc, + (_ydoc, reason) => permissionDeniedHandler(provider, reason) + ) +} +const permissionDeniedHandler = (provider: YWsProvider, reason: string) => + console.warn(`Permission denied to access ${provider.url}.\n${reason}`) diff --git a/packages/common/src/collaboration/y-socket.ts b/packages/common/src/collaboration/y-socket.ts new file mode 100644 index 0000000..1a0bbb6 --- /dev/null +++ b/packages/common/src/collaboration/y-socket.ts @@ -0,0 +1,388 @@ +import * as Y from 'yjs' // eslint-disable-line +import * as bc from 'lib0/broadcastchannel' +import * as time from 'lib0/time' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import * as syncProtocol from './y-sync' +import * as awarenessProtocol from './y-awareness' +import { Observable } from 'lib0/observable' +import * as math from 'lib0/math' +import * as url from 'lib0/url' +import * as env from 'lib0/environment' +import { MessageHandler, messageHandlers } from './y-handler' +import { YMessageType } from './types' +import { isReactNative } from './utils' + +// const messageReconnectTimeout = awarenessProtocol.outdatedTimeout +const readMessage = (provider: YWsProvider, buf: Uint8Array, emitSynced: boolean): encoding.Encoder => { + const decoder = decoding.createDecoder(buf) + const encoder = encoding.createEncoder() + const messageType = decoding.readVarUint(decoder) + const messageHandler = provider.messageHandlers[messageType] + if (messageHandler) { + messageHandler(encoder, decoder, provider, emitSynced, messageType) + } else { + console.error('Unable to compute message') + } + return encoder +} + +const setupSocket = (provider: YWsProvider) => { + if (provider.shouldConnect && provider.socket === null) { + const websocketUrl = provider.url.replace(/^http/, 'ws') + // console.log(`Setting up WebSocket connection to ${websocketUrl}`) + const socket = new WebSocket(websocketUrl) + socket.binaryType = 'arraybuffer' + provider.socket = socket + provider.wsconnecting = true + provider.wsconnected = false + provider.synced = false + + socket.onmessage = (event) => { + provider.wsLastMessageReceived = time.getUnixTime() + const encoder = readMessage(provider, new Uint8Array(event.data), true) + if (encoding.length(encoder) > 1) { + socket.send(encoding.toUint8Array(encoder)) + } + } + + + socket.onerror = (error) => { + console.log('WebSocket connection error:', error) + provider.emit('connection-error', [error, provider]) + } + + socket.onclose = (event) => { + console.log('WebSocket closed', event) + provider.emit('connection-close', [event.reason, provider]) + provider.socket = null + provider.wsconnecting = false + + if (provider.wsconnected) { + provider.wsconnected = false + provider.synced = false + awarenessProtocol.removeAwarenessStates( + provider.awareness, + Array.from(provider.awareness.getStates().keys()).filter((client) => + client !== provider.doc.clientID + ), + provider + ) + provider.emit('status', [{ + status: 'disconnected' + }]) + } else { + provider.wsUnsuccessfulReconnects++ + } + + // 重连逻辑 + setTimeout( + () => setupSocket(provider), + math.min( + math.pow(2, provider.wsUnsuccessfulReconnects) * 100, + provider.maxBackoffTime + ) + ) + } + socket.onopen = () => { + console.log('\x1b[32m%s\x1b[0m', 'WebSocket connected') + provider.wsLastMessageReceived = time.getUnixTime() + provider.wsconnecting = false + provider.wsconnected = true + provider.wsUnsuccessfulReconnects = 0 + provider.emit('status', [{ + status: 'connected' + }]) + + // 发送初始同步数据 + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, YMessageType.Sync) + syncProtocol.writeSyncStep1(encoder, provider.doc) + socket.send(encoding.toUint8Array(encoder)) + + // 发送awareness状态 + if (provider.awareness.getLocalState() !== null) { + const encoderAwarenessState = encoding.createEncoder() + encoding.writeVarUint(encoderAwarenessState, YMessageType.Awareness) + encoding.writeVarUint8Array( + encoderAwarenessState, + awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [ + provider.doc.clientID + ]) + ) + socket.send(encoding.toUint8Array(encoderAwarenessState)) + } + } + provider.emit('status', [{ + status: 'connecting' + }]) + } +} +const broadcastMessage = (provider: YWsProvider, buf: ArrayBuffer) => { + // console.log(`Broadcasting message: ${new Uint8Array(buf)}`) + if (provider.wsconnected && provider.socket) { + provider.socket.send(buf) + // console.log(`Document update detected, send to server: ${buf}`) + } + if (!isReactNative() && provider.bcconnected) { + bc.publish(provider.bcChannel, buf, provider) + } +} +export class YWsProvider extends Observable { + public serverUrl: string; + public bcChannel: string; + public maxBackoffTime: number; + public params: Record; + public protocols: string[]; + public doc: Y.Doc; + public roomId: string; + public socket: WebSocket | null; + public awareness: awarenessProtocol.Awareness; + public wsconnected: boolean; + public wsconnecting: boolean; + public bcconnected: boolean; + public disableBc: boolean; + public wsUnsuccessfulReconnects: number; + public messageHandlers: MessageHandler[]; + private _synced: boolean; + public wsLastMessageReceived: number; + public shouldConnect: boolean; + private _resyncInterval: ReturnType; + // private _checkInterval: ReturnType; + private _bcSubscriber: (data: ArrayBuffer, origin: any) => void; + private _updateHandler: (update: Uint8Array, origin: any) => void; + private _awarenessUpdateHandler: (changed: { added: any; updated: any; removed: any }, _origin: any) => void; + private _exitHandler: () => void; + + constructor( + serverUrl: string, + roomId: string, + doc: Y.Doc, + { + connect = true, + awareness = new awarenessProtocol.Awareness(doc), + params = {}, + protocols = [], + resyncInterval = -1, + maxBackoffTime = 2500, + disableBc = false + }: { + connect?: boolean, + awareness?: awarenessProtocol.Awareness, + params?: Record, + protocols?: string[], + resyncInterval?: number, + maxBackoffTime?: number, + disableBc?: boolean + } = {} + ) { + super() + while (serverUrl[serverUrl.length - 1] === '/') { + serverUrl = serverUrl.slice(0, serverUrl.length - 1) + } + this.serverUrl = serverUrl + this.params = { ...params, roomId } + this.bcChannel = serverUrl + '/' + roomId + this.maxBackoffTime = maxBackoffTime + this.protocols = protocols + this.doc = doc + this.socket = null + this.awareness = awareness + this.roomId = roomId + this.wsconnected = false + this.wsconnecting = false + this.bcconnected = false + this.disableBc = isReactNative() ? true : disableBc + this.wsUnsuccessfulReconnects = 0 + this.messageHandlers = messageHandlers.slice() + this._synced = false + this.wsLastMessageReceived = 0 + this.shouldConnect = connect + this._resyncInterval = 0 as unknown as ReturnType; + if (resyncInterval > 0) { + this._resyncInterval = setInterval(() => { + if (this.socket && this.socket.OPEN) { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, YMessageType.Sync) + syncProtocol.writeSyncStep1(encoder, doc) + console.log(`Resyncing data on interval: ${encoding.toUint8Array(encoder)}`) + this.socket.send(encoding.toUint8Array(encoder)) + } + }, resyncInterval) + } + this._bcSubscriber = (data: ArrayBuffer, origin: any) => { + if (origin !== this) { + + const encoder = readMessage(this, new Uint8Array(data), false) + if (encoding.length(encoder) > 1) { + // console.log(`Broadcasting response on BroadcastChannel: ${encoding.toUint8Array(encoder)}`) + bc.publish(this.bcChannel, encoding.toUint8Array(encoder), this) + } + } + } + this._updateHandler = (update: Uint8Array, origin: any) => { + + if (origin !== this) { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, YMessageType.Sync) + syncProtocol.writeUpdate(encoder, update) + + broadcastMessage(this, encoding.toUint8Array(encoder)) + } + } + this.doc.on('update', this._updateHandler) + this._awarenessUpdateHandler = ({ added, updated, removed }: { added: any; updated: any; removed: any }, _origin: any) => { + const changedClients = added.concat(updated).concat(removed) + console.log(`update awareness from ${_origin}`, changedClients) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, YMessageType.Awareness) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients) + ) + // console.log(`Awareness update detected, broadcasting message: ${encoding.toUint8Array(encoder)}`) + broadcastMessage(this, encoding.toUint8Array(encoder)) + } + this._exitHandler = () => { + awarenessProtocol.removeAwarenessStates( + this.awareness, + [doc.clientID], + 'app closed' + ) + console.log('App closed, removing awareness states.') + } + if (env.isNode && typeof process !== 'undefined') { + process.on('exit', this._exitHandler) + } + awareness.on('update', this._awarenessUpdateHandler) + // this._checkInterval = setInterval(() => { + // if ( + // this.wsconnected && + // messageReconnectTimeout < time.getUnixTime() - this.wsLastMessageReceived + // ) { + // if (this.socket) { + // console.log('No message received for a while, disconnecting socket') + // this.socket.close() + // } + // } + // }, messageReconnectTimeout / 10) + if (connect) { + this.connect() + } + } + get url(): string { + const encodedParams = url.encodeQueryParams(this.params) + return this.serverUrl + + (encodedParams.length === 0 ? '' : '?' + encodedParams) + } + get synced(): boolean { + return this._synced + } + set synced(state: boolean) { + if (this._synced !== state) { + this._synced = state + this.emit('synced', [state]) + this.emit('sync', [state]) + // console.log(`Synced state changed: ${state}`) + } + } + + destroy() { + if (this._resyncInterval) { + clearInterval(this._resyncInterval) + } + // if (this._checkInterval) { + // clearInterval(this._checkInterval) + // } + this.disconnect() + if (env.isNode && typeof process !== 'undefined') { + process.off('exit', this._exitHandler) + } + this.awareness.off('update', this._awarenessUpdateHandler) + this.doc.off('update', this._updateHandler) + console.log('Destroying provider and clearing intervals/subscriptions.') + super.destroy() + } + + connectBc() { + if (isReactNative() || this.disableBc) { + return + } + if (!this.bcconnected) { + bc.subscribe(this.bcChannel, this._bcSubscriber) + this.bcconnected = true + // console.log(`Subscribed to BroadcastChannel: ${this.bcChannel}`) + } + const encoderSync = encoding.createEncoder() + encoding.writeVarUint(encoderSync, YMessageType.Sync) + syncProtocol.writeSyncStep1(encoderSync, this.doc) + // console.log(`Connecting BroadcastChannel with syncStep1: ${encoding.toUint8Array(encoderSync)}`) + bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync), this) + const encoderState = encoding.createEncoder() + encoding.writeVarUint(encoderState, YMessageType.Sync) + syncProtocol.writeSyncStep2(encoderState, this.doc) + // console.log(`Connecting BroadcastChannel with syncStep2: ${encoding.toUint8Array(encoderState)}`) + bc.publish(this.bcChannel, encoding.toUint8Array(encoderState), this) + const encoderAwarenessQuery = encoding.createEncoder() + encoding.writeVarUint(encoderAwarenessQuery, YMessageType.QueryAwareness) + // console.log(`Connecting BroadcastChannel with awareness query: ${encoding.toUint8Array(encoderAwarenessQuery)}`) + bc.publish( + this.bcChannel, + encoding.toUint8Array(encoderAwarenessQuery), + this + ) + const encoderAwarenessState = encoding.createEncoder() + encoding.writeVarUint(encoderAwarenessState, YMessageType.Awareness) + encoding.writeVarUint8Array( + encoderAwarenessState, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ + this.doc.clientID + ]) + ) + // console.log(`Connecting BroadcastChannel with awareness state: ${encoding.toUint8Array(encoderAwarenessState)}`) + bc.publish( + this.bcChannel, + encoding.toUint8Array(encoderAwarenessState), + this + ) + } + + disconnectBc() { + if (isReactNative()) { + return + } + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, YMessageType.Awareness) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ + this.doc.clientID + ], new Map()) + ) + // console.log(`Disconnecting BroadcastChannel, clearing awareness state: ${encoding.toUint8Array(encoder)}`) + broadcastMessage(this, encoding.toUint8Array(encoder)) + if (this.bcconnected) { + bc.unsubscribe(this.bcChannel, this._bcSubscriber) + this.bcconnected = false + // console.log(`Unsubscribed from BroadcastChannel: ${this.bcChannel}`) + } + } + + disconnect() { + this.shouldConnect = false + this.disconnectBc() + if (this.socket) { + console.log('Disconnecting socket') + this.socket.close() + } + } + + connect() { + this.shouldConnect = true + if (!this.wsconnected && this.socket === null) { + + setupSocket(this) + this.connectBc() + } + } +} diff --git a/packages/common/src/collaboration/y-sync.ts b/packages/common/src/collaboration/y-sync.ts new file mode 100644 index 0000000..6f5dd1a --- /dev/null +++ b/packages/common/src/collaboration/y-sync.ts @@ -0,0 +1,189 @@ +/** + * @module sync-protocol + * 同步协议模块 + */ + +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import * as Y from 'yjs' + +/** + * StateMap 类型定义 + */ +export type StateMap = Map + +/** + * Yjs 核心定义了两种消息类型: + * • YjsSyncStep1: 包含发送客户端的状态集。当接收到时,客户端应该回复 YjsSyncStep2。 + * • YjsSyncStep2: 包含所有缺失的结构和完整的删除集。当接收到时,客户端可以确保它已经 + * 收到了来自远程客户端的所有信息。 + * + * 在点对点网络中,你可能想要引入 SyncDone 消息类型。双方都应该用 SyncStep1 初始化连接。 + * 当客户端收到 SyncStep2 时,它应该回复 SyncDone。当本地客户端同时收到 SyncStep2 和 + * SyncDone 时,它可以确保已经与远程客户端同步。 + * + * 在客户端-服务器模型中,你需要采用不同的处理方式:客户端应该用 SyncStep1 初始化连接。 + * 当服务器收到 SyncStep1 时,它应该立即回复 SyncStep2,紧接着发送 SyncStep1。当客户端 + * 收到 SyncStep1 时回复 SyncStep2。可选地,服务器在收到 SyncStep2 后可以发送 SyncDone, + * 这样客户端就知道同步已完成。这种更复杂的同步模型有两个原因:1. 这个协议可以很容易地 + * 在 http 和 websockets 之上实现。2. 服务器应该只回复请求,而不是发起请求。因此客户端 + * 必须发起同步。 + * + * 消息的构造: + * [messageType : varUint, message definition..] + * + * 注意:消息不包含房间名称的信息。这必须由上层协议处理! + * + * stringify[messageType] 将消息定义字符串化(messageType 已经从缓冲区读取) + */ + +export const messageYjsSyncStep1: number = 0 +export const messageYjsSyncStep2: number = 1 +export const messageYjsUpdate: number = 2 + +/** + * 创建同步步骤1消息 + * 用于发起同步请求,包含本地文档状态向量 + * + * @param encoder - 编码器对象 + * @param doc - 当前文档实例 + */ +export const writeSyncStep1 = (encoder: encoding.Encoder, doc: Y.Doc): void => { + // 写入消息类型标识 + encoding.writeVarUint(encoder, messageYjsSyncStep1) + // 获取并编码当前文档的状态向量 + // 状态向量记录了每个客户端最新的更新序号 + const sv = Y.encodeStateVector(doc) + // 将状态向量写入为变长字节数组 + encoding.writeVarUint8Array(encoder, sv) +} + +/** + * 创建同步步骤2消息 + * 用于响应同步请求,发送增量更新数据 + * + * @param encoder - 编码器对象 + * @param doc - 当前文档实例 + * @param encodedStateVector - 对方的状态向量(可选) + */ +export const writeSyncStep2 = ( + encoder: encoding.Encoder, + doc: Y.Doc, + encodedStateVector?: Uint8Array +): void => { + // 写入消息类型标识 + encoding.writeVarUint(encoder, messageYjsSyncStep2) + + // 根据对方状态向量编码增量更新 + // 只发送对方缺少的更新内容 + encoding.writeVarUint8Array(encoder, Y.encodeStateAsUpdate(doc, encodedStateVector)) +} + +/** + * 处理同步步骤1消息 + * 读取对方状态向量并回复步骤2消息 + * + * @param decoder - 解码器对象 + * @param encoder - 编码器对象 + * @param doc - 当前文档实例 + */ +export const readSyncStep1 = ( + decoder: decoding.Decoder, + encoder: encoding.Encoder, + doc: Y.Doc +): void => + // 读取状态向量并直接调用writeSyncStep2回复 + writeSyncStep2(encoder, doc, decoding.readVarUint8Array(decoder)) + +/** + * 处理同步步骤2消息 + * 将收到的增量更新应用到本地文档 + * + * @param decoder - 解码器对象 + * @param doc - 当前文档实例 + * @param transactionOrigin - 事务来源信息 + */ +export const readSyncStep2 = ( + decoder: decoding.Decoder, + doc: Y.Doc, + transactionOrigin: any +): void => { + try { + // 读取并应用增量更新 + // transactionOrigin用于标识更新来源 + Y.applyUpdate(doc, decoding.readVarUint8Array(decoder), transactionOrigin) + } catch (error) { + // 错误处理 - 记录日志但不中断程序 + console.error('Caught error while handling a Yjs update', error) + } +} + + +/** + * 写入更新消息 + */ +export const writeUpdate = (encoder: encoding.Encoder, update: Uint8Array): void => { + encoding.writeVarUint(encoder, messageYjsUpdate) + encoding.writeVarUint8Array(encoder, update) +} + +/** + * 读取并将 Structs 和 DeleteStore 应用到 y 实例 + */ +export const readUpdate = readSyncStep2 + +/** + * 处理同步消息的函数 + * 基于 Yjs 协议规范实现文档同步的消息处理 + * + * @param decoder - 用于解码二进制消息的解码器对象 + * @param encoder - 用于编码响应消息的编码器对象 + * @param doc - Y.Doc 实例,代表当前文档 + * @param transactionOrigin - 事务来源信息 + * @returns 返回处理的消息类型 + */ +export const readSyncMessage = ( + decoder: decoding.Decoder, + encoder: encoding.Encoder, + doc: Y.Doc, + transactionOrigin: any +): number => { + + /** + * 读取消息类型 + * 使用变长整数编码(VarUint)提高传输效率 + * VarUint 编码根据数值大小使用1-8字节不等 + */ + const messageType = decoding.readVarUint(decoder) + + /** + * 根据消息类型分发处理 + * 实现三阶段同步协议: + * 1. Step1 - 客户端发送状态向量,请求同步 + * 2. Step2 - 服务端响应增量更新 + * 3. Update - 实时更新推送 + */ + switch (messageType) { + case messageYjsSyncStep1: + // 处理同步第一步 - 读取状态向量并编码响应 + readSyncStep1(decoder, encoder, doc) + break + case messageYjsSyncStep2: + // 处理同步第二步 - 合并增量更新 + readSyncStep2(decoder, doc, transactionOrigin) + break + case messageYjsUpdate: + // 处理实时更新 - 直接应用更新 + readUpdate(decoder, doc, transactionOrigin) + break + default: + // 未知消息类型则抛出异常 + throw new Error('Unknown message type') + } + + /** + * 返回消息类型供调用方使用 + * 可用于追踪消息处理流程或条件判断 + */ + return messageType +} diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts old mode 100644 new mode 100755 index 6282219..7f200e3 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -1,55 +1,83 @@ -import { RolePerms } from "./enum"; +import { Prisma } from "@prisma/client"; +import { + AppConfigSlug, + RiskState, + RolePerms, + TaxonomySlug, + TroubleParamsKey, + TroubleState, + TroubleType, + VisitType, +} from "./enum"; +import { troubleUnDetailSelect } from "./select"; -export const InitRoles: { name: string, permissions: string[], system?: boolean }[] = [ - { - name: "基层", - permissions: [ - RolePerms.CREATE_TROUBLE, - RolePerms.CREATE_WORKPROGRESS, - ] - }, - { - name: "机关", - permissions: [ - RolePerms.CREATE_TROUBLE, - RolePerms.CREATE_WORKPROGRESS, - ], - }, - { - name: "领导", - permissions: [ - RolePerms.READ_DOM_TROUBLE, - RolePerms.CREATE_INSTRUCTION, - ], - }, - { - name: "域管理员", - permissions: Object.keys(RolePerms).filter( - (perm) => - ![ - RolePerms.READ_ANY_CHART, - RolePerms.READ_ANY_TROUBLE, - RolePerms.READ_ANY_TERM, - RolePerms.PROCESS_ANY_ASSESSMENT, - RolePerms.PROCESS_ANY_TROUBLE, - RolePerms.EDIT_ROOT_OPTION, - RolePerms.EDIT_ANY_TERM, - RolePerms.EDIT_ANY_TROUBLE, - RolePerms.EDIT_ANY_ASSESSMENT, - RolePerms.DELETE_ANY_TROUBLE, - RolePerms.DELETE_ANY_TERM, - RolePerms.DELETE_ANY_ASSESSMENT, - ].includes(perm as any) - ) as RolePerms[], - }, - { - name: "根管理员", - permissions: Object.keys(RolePerms) as RolePerms[], - }, +export const InitRoles: { + name: string; + permissions: string[]; + system?: boolean; +}[] = [ + { + name: "基层", + permissions: [ + RolePerms.CREATE_TROUBLE, + RolePerms.CREATE_WORKPROGRESS, + RolePerms.READ_DOM_TERM, + RolePerms.READ_AUDIT_TROUBLE, + ], + }, + { + name: "机关", + permissions: [ + RolePerms.CREATE_TROUBLE, + RolePerms.CREATE_WORKPROGRESS, + RolePerms.CREATE_ALERT, + RolePerms.READ_DOM_TERM, + RolePerms.MANAGE_DOM_TROUBLE, + RolePerms.READ_AUDIT_TROUBLE, + ], + }, + { + name: "领导", + permissions: [ + RolePerms.READ_DOM_TERM, + RolePerms.READ_DOM_TROUBLE, + RolePerms.CREATE_INSTRUCTION, + ], + }, + { + name: "域管理员", + permissions: Object.keys(RolePerms).filter( + (perm) => + ![ + RolePerms.READ_ANY_CHART, + RolePerms.READ_ANY_TROUBLE, + RolePerms.READ_ANY_TERM, + ].includes(perm as any) + ) as RolePerms[], + }, + { + name: "根管理员", + permissions: Object.keys(RolePerms) as RolePerms[], + }, +]; +export const InitTaxonomies: { name: string; slug: string }[] = [ + { + name: "分类", + slug: TaxonomySlug.CATEGORY, + }, + { + name: "研判单元", + slug: TaxonomySlug.UNIT, + }, + { + name: "标签", + slug: TaxonomySlug.TAG, + }, +]; +export const InitAppConfigs: Prisma.AppConfigCreateInput[] = [ + { + title: "基本设置", + slug: AppConfigSlug.BASE_SETTING, + description: "", + }, ]; -export const InitTaxonomies: { name: string }[] = [{ - name: '分类' -}, -{ - name: '研判单元' -}] \ No newline at end of file diff --git a/packages/common/src/db.ts b/packages/common/src/db.ts index 374f916..edcdac0 100755 --- a/packages/common/src/db.ts +++ b/packages/common/src/db.ts @@ -1,16 +1,18 @@ import { PrismaClient } from "@prisma/client"; - let prisma: PrismaClient | null = null; - const createPrismaClient = () => { return new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], }); }; - export const db = (() => { - if (!prisma) { - prisma = createPrismaClient(); + if (typeof window === 'undefined') { + if (!prisma) { + prisma = createPrismaClient(); + } + return prisma; + } else { + // Optional: You can throw an error or return null to indicate that this should not be used on the client side. + return null } - return prisma; })(); diff --git a/packages/common/src/enum.ts b/packages/common/src/enum.ts old mode 100644 new mode 100755 index 723d9d0..9aaa1f8 --- a/packages/common/src/enum.ts +++ b/packages/common/src/enum.ts @@ -1,16 +1,68 @@ +import { troubleUnDetailSelect } from "./select"; + +export enum SocketMsgType { + NOTIFY, +} +export enum PostType { + TROUBLE_PROGRESS = "trouble_progress", + TROUBLE_INSTRUCTION = "trouble_instrcution", + POST = "post", + POST_COMMENT = "post_comment", +} +export enum TaxonomySlug { + CATEGORY = "category", + UNIT = "unit", + TAG = "tag", +} +export enum VisitType { + STAR = "star", + READED = "read", +} +export enum TroubleState { + AUDITING = 0, + PROCESSING = 1, + CANCEL_REQUEST = 2, + CANCELED = 3, +} +export enum RiskState { + AUDITING = 0, + CONTROLLING = 4, + RELEASED = 5, +} + +export enum QuadrantType { + URG_IMPORTANT = "super", + URGENT = "urgent", + IMPORTANT = "imporant", + TRIVIAL = "trival", +} +export enum TroubleType { + RISK = "安全风险", + TROUBLE = "问题隐患", + ALERT = "风险预警", +} +export enum AssessmentStatus { + ASSESSING = "评估中", + COMPLETED = "已完成", +} +export enum TermType { + RISK_UNIT = "RISK_UNIT", + RISK_INDICATOR = "RISK_INDICATOR", + RISK_CATEGORY = "RISK_CATEGORY", +} export enum ObjectType { - DEPARTMENT = "DEPARTMENT", - STAFF = "STAFF", - COMMENT = "COMMENT", - TERM = "TERM", + DEPARTMENT = "department", + STAFF = "staff", + COMMENT = "comment", + TERM = "term", + TROUBLE = "trouble", + APP_CONFIG = "app_config", + ROLE = "role", + ROLE_MAP = "rolemap", + MESSAGE = "message", + POST = "post", + VISIT = "visit", } -export enum RelationType { - WATCH = "WATCH", - READED = "READED", - MESSAGE = "MESSAGE", -} - - export enum RolePerms { // Create Permissions 创建权限 CREATE_ALERT = "CREATE_ALERT", // 创建警报 @@ -19,10 +71,10 @@ export enum RolePerms { CREATE_WORKPROGRESS = "CREATE_WORKPROGRESS", // 创建工作进度 CREATE_ASSESSMENT = "CREATE_ASSESSMENT", // 创建评估 CREATE_TERM = "CREATE_TERM", // 创建术语 - // Read Permissions 读取权限 READ_ANY_TROUBLE = "READ_ANY_TROUBLE", // 读取任何问题 READ_DOM_TROUBLE = "READ_DOM_TROUBLE", // 读取领域问题 + READ_AUDIT_TROUBLE = "READ_AUDIT_TROUBLE", READ_ANY_CHART = "READ_ANY_CHART", // 读取任何图表 READ_DOM_CHART = "READ_DOM_CHART", // 读取领域图表 READ_ANY_ASSESSMENT = "READ_ANY_ASSESSMENT", // 读取任何评估 @@ -30,30 +82,132 @@ export enum RolePerms { READ_ANY_TERM = "READ_ANY_TERM", // 读取任何术语 READ_DOM_TERM = "READ_DOM_TERM", // 读取领域术语 - // Edit Permissions 编辑权限 - EDIT_DOM_TROUBLE = "EDIT_DOM_TROUBLE", // 编辑领域问题 - EDIT_ANY_TROUBLE = "EDIT_ANY_TROUBLE", // 编辑任何问题 - EDIT_DOM_ROLE = "EDIT_DOM_ROLE", // 编辑领域角色 - EDIT_ROOT_OPTION = "EDIT_ROOT_OPTION", // 编辑根选项 - EDIT_DOM_ASSESSMENT = "EDIT_DOM_ASSESSMENT", // 编辑领域评估 - EDIT_ANY_ASSESSMENT = "EDIT_ANY_ASSESSMENT", // 编辑任何评估 - EDIT_DOM_TERM = "EDIT_DOM_TERM", // 编辑领域术语 - EDIT_ANY_TERM = "EDIT_ANY_TERM", // 编辑任何术语 + READ_ANY_POST = "READ_ANY_POST", // 读取任何问题 + READ_DOM_POST = "READ_DOM_POST", // 读取领域问题 - // Delete Permissions 删除权限 - DELETE_DOM_TROUBLE = "DELETE_DOM_TROUBLE", // 删除领域问题 - DELETE_ANY_TROUBLE = "DELETE_ANY_TROUBLE", // 删除任何问题 - DELETE_DOM_ASSESSMENT = "DELETE_DOM_ASSESSMENT", // 删除领域评估 - DELETE_ANY_ASSESSMENT = "DELETE_ANY_ASSESSMENT", // 删除任何评估 - DELETE_DOM_TERM = "DELETE_DOM_TERM", // 删除领域术语 - DELETE_ANY_TERM = "DELETE_ANY_TERM", // 删除任何术语 + MANAGE_ANY_POST = "MANAGE_ANY_POST", + MANAGE_DOM_POST = "MANAGE_DOM_POST", + MANAGE_ANY_TROUBLE = "MANAGE_ANY_TROUBLE", + MANAGE_DOM_TROUBLE = "MANAGE_DOM_TROUBLE", - // Process Permissions 处理权限 - PROCESS_DOM_TROUBLE = "PROCESS_DOM_TROUBLE", // 处理领域问题 - PROCESS_ANY_TROUBLE = "PROCESS_ANY_TROUBLE", // 处理任何问题 - PROCESS_DOM_ASSESSMENT = "PROCESS_DOM_ASSESSMENT", // 处理领域评估 - PROCESS_ANY_ASSESSMENT = "PROCESS_ANY_ASSESSMENT", // 处理任何评估 + MANAGE_DOM_TERM = "MANAGE_DOM_TERM", + MANAGE_ANY_TERM = "MANAGE_ANY_TERM", + MANAGE_BASE_SETTING = "MANAGE_BASE_SETTING", + // Staff and Department Permissions + MANAGE_ANY_STAFF = "MANAGE_ANY_STAFF", + MANAGE_DOM_STAFF = "MANAGE_DOM_STAFF", + MANAGE_ANY_DEPT = "MANAGE_ANY_DEPT", + MANAGE_DOM_DEPT = "MANAGE_DOM_DEPT", + // Role Permissions + MANAGE_ANY_ROLE = "MANAGE_ANY_ROLE", + MANAGE_DOM_ROLE = "MANAGE_DOM_ROLE", +} +export enum RemindType { + BOTH = "both", + CHECK = "check", + DUTY = "duty", +} - // Audit Permissions 审核权限 - AUDIT_TROUBLE = "AUDIT_TROUBLE", // 审核问题 -} \ No newline at end of file +export const LevelColor = { + 1: "#BBDDFF", + 2: "#FFE6B3", + 3: "#FFC2C2", + 4: "#FFC2C2", +} as const; + +export enum AppConfigSlug { + BASE_SETTING = "base_setting", +} +export const TroubleStateMap = { + [TroubleState.AUDITING]: "待审核", + [TroubleState.PROCESSING]: "处理中", + [TroubleState.CANCEL_REQUEST]: "待销帐", + [TroubleState.CANCELED]: "已销帐", +}; +export const RiskStateMap = { + [RiskState.AUDITING]: "待审核", + [RiskState.CONTROLLING]: "管控中", + [RiskState.RELEASED]: "已解除", +}; +export const TroubleTypeStateMap = new Map([ + [`${TroubleType.TROUBLE}_${TroubleState.AUDITING}`, "待审核"], + [`${TroubleType.TROUBLE}_${TroubleState.PROCESSING}`, `处理中`], + [`${TroubleType.TROUBLE}_${TroubleState.CANCEL_REQUEST}`, `待销帐`], + [`${TroubleType.TROUBLE}_${TroubleState.CANCELED}`, `已销帐`], + + [`${TroubleType.RISK}_${RiskState.AUDITING}`, "待审核"], + [`${TroubleType.RISK}_${RiskState.CONTROLLING}`, `管控中`], + [`${TroubleType.RISK}_${RiskState.RELEASED}`, `已解除`], + + [`${TroubleType.ALERT}_${RiskState.AUDITING}`, "待审核"], + [`${TroubleType.ALERT}_${RiskState.CONTROLLING}`, `管控中`], + [`${TroubleType.ALERT}_${RiskState.RELEASED}`, `已解除`], +]); +export const TroubleLevelMap = new Map([ + [`${TroubleType.TROUBLE}_0`, "全部级别"], + [`${TroubleType.TROUBLE}_1`, `四级隐患`], + [`${TroubleType.TROUBLE}_2`, `三级隐患`], + [`${TroubleType.TROUBLE}_3`, `二级隐患`], + [`${TroubleType.TROUBLE}_4`, `一级隐患`], + + [`${TroubleType.RISK}_0`, "全部级别"], + [`${TroubleType.RISK}_1`, `一般风险`], + [`${TroubleType.RISK}_2`, `较大风险`], + [`${TroubleType.RISK}_3`, `重大风险`], + [`${TroubleType.RISK}_4`, `特大风险`], + + [`${TroubleType.ALERT}_0`, "全部预警"], + [`${TroubleType.ALERT}_1`, `蓝色预警`], + [`${TroubleType.ALERT}_2`, `黄色预警`], + [`${TroubleType.ALERT}_3`, `橙色预警`], + [`${TroubleType.ALERT}_4`, `红色预警`], +]); +export function GetTroubleLevel( + type: string | undefined, + level: number | undefined +): string | undefined { + return TroubleLevelMap.get(`${type || "ELSE"}_${level}`) || "暂未评级"; +} +export function GetTroubleState( + type: string | undefined, + state: number | undefined +): string | undefined { + return TroubleTypeStateMap.get(`${type || "ELSE"}_${state}`) || "无状态"; +} +export enum SendMessageType { + TO_DUTY = "to_duty", + TO_CHECK = "to_check", + TO_REQUEST_DELAY = "to_request_delay", + TO_DELAY = "to_delay", + TO_REQUEST_CANCEL = "to_request_cancel", + INSTRUCTION = "instrcution", + PROGRESS = "progress", +} + +export enum DraftType { + TROUBLE = "trouble_darft", + POST = "post_darft", +} +export enum ForwardType { + TROUBLE = "trouble", + POST = "post", +} +export enum ToWhoType { + DOMAIN = "本域可见", + DEPT = "本单位可见", + SELF = "仅自己可见", + CUSTOM = "自定义", +} +// 定义枚举来存储查询键 +export enum TroubleParamsKey { + RISK_AUDITING = "RISK_AUDITING", + RISK_CONTROLLING = "RISK_CONTROLLING", + RISK_RELEASED = "RISK_RELEASED", + TROUBLE_AUDITING = "TROUBLE_AUDITING", + TROUBLE_PROCESSING = "TROUBLE_PROCESSING", + TROUBLE_CANCEL_REQUEST = "TROUBLE_CANCEL_REQUEST", + TROUBLE_CANCELED = "TROUBLE_CANCELED", + STAR = "STAR", + DUTY = "DUTY", + CHECK = "CHECK", +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 188949a..20204f6 100755 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,8 +1,10 @@ -export * from "@prisma/client" -export * from "zod" -export * from "./db" export * from "./schema" export * from "./enum" -export * from "./type" +export * from "./types" export * from "./utils" -export * from "./constants" \ No newline at end of file +export * from "./constants" +export * from "./select" +export * from "./collaboration" +export * from "./db" +// export * from "./generated" +export * from "@prisma/client" \ No newline at end of file diff --git a/packages/common/src/schema.ts b/packages/common/src/schema.ts index 8969397..23ca8df 100755 --- a/packages/common/src/schema.ts +++ b/packages/common/src/schema.ts @@ -1,266 +1,410 @@ -import { z } from "zod" -import { ObjectType } from "./enum"; +import { z } from "zod"; +import { ObjectType, RiskState, TroubleState } from "./enum"; export const AuthSchema = { - signInRequset: z.object({ - username: z.string(), - password: z.string(), - phoneNumber: z.string().nullish() - }), - signUpRequest: z.object({ - username: z.string(), - password: z.string(), - phoneNumber: z.string().nullish() - }), - changePassword: z.object({ - username: z.string(), - phoneNumber: z.string().nullish(), - oldPassword: z.string(), - newPassword: z.string(), - }), - refreshTokenRequest: z.object({ - refreshToken: z.string(), - }), - logoutRequest: z.object({ - refreshToken: z.string(), - }) + signUpRequest: z.object({ + username: z.string(), + password: z.string(), + deptId: z.string().nullish(), + officerId: z.string().nullish(), + showname: z.string().nullish(), + phoneNumber: z.string().nullish(), + }), + refreshTokenRequest: z.object({ + refreshToken: z.string(), + sessionId: z.string(), + }), + logoutRequest: z.object({ + refreshToken: z.string(), + sessionId: z.string(), + }), + signInRequset: z.object({ + username: z.string().nullish(), + password: z.string().nullish(), + phoneNumber: z.string().nullish(), + }), + changePassword: z.object({ + phoneNumber: z.string().nullish(), + newPassword: z.string(), + renewPassword: z.string(), + username: z.string().nullish(), + }), + refreshToken: z.object({ + refreshToken: z.string(), + }), + logout: z.object({ + refreshToken: z.string(), + }), +}; +const SortModel = z.object({ + colId: z.string(), + sort: z.enum(["asc", "desc"]), +}); +export const UpdateOrderSchema = z.object({ + id: z.string(), + overId: z.string(), +}); +export const RowRequestSchema = z.object({ + startRow: z.number().nullish(), + endRow: z.number().nullish(), + rowGroupCols: z.array( + z.object({ + id: z.string(), + displayName: z.string(), + field: z.string(), + }) + ), + valueCols: z.array( + z.object({ + id: z.string().nullish(), + displayName: z.string().nullish(), + aggFunc: z.string().nullish(), + field: z.string().nullish(), + }) + ), + pivotCols: z.array(z.any()).nullish(), + pivotMode: z.boolean().nullish(), + groupKeys: z.array(z.any()).nullish(), + filterModel: z.any().nullish(), + sortModel: z.array(SortModel).nullish(), + includeDeleted: z.boolean().nullish() +}); +export const StaffMethodSchema = { + create: z.object({ + showname: z.string().nullish(), + username: z.string(), + phoneNumber: z.string().nullish(), + domainId: z.string().nullish(), + password: z.string().nullish(), + deptId: z.string().nullish(), + officerId: z.string().nullish(), + emitChange: z.boolean().nullish(), + hashedPassword: z.string().nullish(), + }), + update: z.object({ + id: z.string(), + showname: z.string().nullish(), + username: z.string().nullish(), + domainId: z.string().nullish(), + deptId: z.string().nullish(), + phoneNumber: z.string().nullish(), + order: z.number().nullish(), + registerToken: z.string().nullish(), + password: z.string().nullish(), + officerId: z.string().nullish(), + }), + delete: z.object({ + id: z.string(), + }), + deleteMany: z.object({ + ids: z.array(z.string()), + }), + findByDept: z.object({ + deptId: z.string(), + domainId: z.string().nullish(), + }), + findMany: z.object({ + keyword: z.string().nullish(), + domainId: z.string().nullish(), + deptId: z.string().nullish(), + limit: z.number().nullish(), + ids: z.array(z.string()).nullish(), + }), + findUnique: z.object({ + phoneNumber: z.string().nullish(), + id: z.string().nullish(), + }), + paginate: z.object({ + page: z.number(), + pageSize: z.number(), + domainId: z.string().nullish(), + deptId: z.string().nullish(), + ids: z.array(z.string()).nullish(), + }), + getRows: RowRequestSchema.extend({ + domainId: z.string().nullish(), + }), }; -export const StaffSchema = { - create: z.object({ - username: z.string(), - password: z.string(), - domainId: z.string().nullish(), - phoneNumber: z.string().nullish() - }), - update: z.object({ - id: z.string(), - name: z.string().nullish(), - password: z.string().nullish(), - domainId: z.string().nullish(), - deptId: z.string().nullish(), - phoneNumber: z.string().nullish(), - order: z.number().nullish(), - registerToken: z.string().nullish(), - }), - delete: z.object({ - id: z.string(), - }), - batchDelete: z.object({ - ids: z.array(z.string()), - }), - findByDept: z.object({ - deptId: z.string(), - domainId: z.string().nullish(), - }), - findMany: z.object({ - keyword: z.string().nullish(), - domainId: z.string().nullish(), - ids: z.array(z.string()).nullish(), - }), - findUnique: z.object({ - id: z.string().nullish(), - }), - paginate: z.object({ - page: z.number(), - pageSize: z.number(), - domainId: z.string().nullish(), - deptId: z.string().nullish(), - ids: z.array(z.string()).nullish(), - }), +export const DepartmentMethodSchema = { + create: z.object({ + name: z.string(), + termIds: z.array(z.string()).nullish(), + parentId: z.string().nullish(), + isDomain: z.boolean().nullish(), + }), + getRows: RowRequestSchema.extend({ + parentId: z.string().nullish(), + }), + update: z.object({ + id: z.string(), + name: z.string().nullish(), + termIds: z.array(z.string()).nullish(), + parentId: z.string().nullish(), + deletedAt: z.date().nullish(), + order: z.number().nullish(), + isDomain: z.boolean().nullish(), + }), + delete: z.object({ + id: z.string(), + }), + findMany: z.object({ + keyword: z.string().nullish(), + ids: z.array(z.string()).nullish(), + limit: z.number().nullish(), + domain: z.boolean().nullish(), + }), + findById: z.object({ + id: z.string(), + }), + paginate: z.object({ + page: z.number(), + pageSize: z.number(), + ids: z.array(z.string()).nullish(), + }), + getSimpleTree: z.object({ + deptIds: z.array(z.string().nullish()).nullish(), + parentId: z.string().nullish(), + domain: z.boolean().nullish(), + rootId: z.string().nullish(), + }), }; -export const DepartmentSchema = { - create: z.object({ - name: z.string(), - parentId: z.string().nullish(), - isDomain: z.boolean().nullish(), - }), - update: z.object({ - id: z.string(), - name: z.string().nullish(), - parentId: z.string().nullish(), - deletedAt: z.date().nullish(), - order: z.number().nullish(), - isDomain: z.boolean().nullish(), - }), - delete: z.object({ - id: z.string(), - }), - findMany: z.object({ - keyword: z.string().nullish(), - ids: z.array(z.string()).nullish(), - }), - paginate: z.object({ - page: z.number(), - pageSize: z.number(), - ids: z.array(z.string()).nullish(), - }), -}; -export const RoleMapSchema = { - create: z.object({ - objectId: z.string(), - roleId: z.string(), - domainId: z.string(), - objectType: z.nativeEnum(ObjectType), - }), - update: z.object({ - id: z.string(), - objectId: z.string().nullish(), - roleId: z.string().nullish(), - domainId: z.string().nullish(), - objectType: z.nativeEnum(ObjectType).nullish(), - }), - createManyRoles: z.object({ - objectId: z.string(), - roleIds: z.array(z.string()), - domainId: z.string(), - objectType: z.nativeEnum(ObjectType), - }), - createManyObjects: z.object({ - objectIds: z.array(z.string()), - roleId: z.string(), - domainId: z.string().nullish(), - objectType: z.nativeEnum(ObjectType), - }), - batchDelete: z.object({ - ids: z.array(z.string()), - }), - paginate: z.object({ - page: z.number().min(1), - pageSize: z.number().min(1), - domainId: z.string().nullish(), - roleId: z.string().nullish(), - }), - deleteWithObject: z.object({ - objectId: z.string(), - }), - getRoleMapDetail: z.object({ - roleId: z.string(), - domainId: z.string().nullish(), - }), - getPermsForObject: z.object({ - domainId: z.string(), - staffId: z.string(), - deptId: z.string(), - }), +export const TransformMethodSchema = { + importTrouble: z.object({ + base64: z.string(), + domainId: z.string().nullish(), + }), + importStaffs: z.object({ + base64: z.string(), + domainId: z.string().nullish(), + }), + importTerms: z.object({ + base64: z.string(), + domainId: z.string().nullish(), + taxonomyId: z.string().nullish(), + parentId: z.string().nullish(), + }), + importDepts: z.object({ + base64: z.string(), + domainId: z.string().nullish(), + parentId: z.string().nullish(), + }), + exportTroubles: z.object({ + termIdFilters: z.map(z.string(), z.array(z.string())).nullish(), + deptIds: z.array(z.string()).nullish(), + search: z.string().nullish(), + type: z.string().nullish(), + levels: z.array(z.number()).nullish(), + createStartDate: z.string().nullish(), + createEndDate: z.string().nullish(), + domainId: z.string().nullish(), + states: z + .array( + z.union([z.nativeEnum(TroubleState), z.nativeEnum(RiskState)]) + ) + .nullish(), + }), }; -export const RoleSchema = { - create: z.object({ - name: z.string(), - permissions: z.array(z.string()).nullish(), - }), - update: z.object({ - id: z.string(), - name: z.string().nullish(), - permissions: z.array(z.string()).nullish(), - }), - batchDelete: z.object({ - ids: z.array(z.string()), - }), - paginate: z.object({ - page: z.number().nullish(), - pageSize: z.number().nullish(), - }), - findMany: z.object({ - keyword: z.string().nullish(), - }), +export const TermMethodSchema = { + getRows: RowRequestSchema.extend({ + parentId: z.string().nullish(), + domainId: z.string().nullish(), + taxonomyId: z.string().nullish(), + }), + create: z.object({ + name: z.string(), + description: z.string().nullish(), + domainId: z.string().nullish(), + // slug: z.string().min(1), // Assuming slug cannot be empty + parentId: z.string().nullish(), // Optional field + taxonomyId: z.string(), // Optional field + watchStaffIds: z.array(z.string()).nullish(), + watchDeptIds: z.array(z.string()).nullish(), + }), + update: z.object({ + id: z.string(), + description: z.string().nullish(), + parentId: z.string().nullish(), + domainId: z.string().nullish(), + name: z.string().nullish(), + // slug: z.string().nullish(), + taxonomyId: z.string().nullish(), + order: z.number().nullish(), + watchStaffIds: z.array(z.string()).nullish(), + watchDeptIds: z.array(z.string()).nullish(), + }), + delete: z.object({ + id: z.string(), + }), + paginate: z.object({ + page: z.number().min(1), + pageSize: z.number().min(1), + }), + deleteMany: z.object({ + ids: z.array(z.string()), + }), + findManyWithCursor: z.object({ + cursor: z.string().nullish(), + search: z.string().nullish(), + limit: z.number().min(1).max(100).nullish(), + taxonomyId: z.string().nullish(), + taxonomySlug: z.string().nullish(), + id: z.string().nullish(), + initialIds: z.array(z.string()).nullish(), + }), + getChildren: z.object({ + parentId: z.string().nullish(), + domainId: z.string().nullish(), + taxonomyId: z.string().nullish(), + cursor: z.string().nullish(), + limit: z.number().min(1).max(100).nullish(), + }), + getSimpleTree: z.object({ + termIds: z.array(z.string().nullish()).nullish(), + parentId: z.string().nullish(), + taxonomyId: z.string().nullish(), + }), + findMany: z.object({ + keyword: z.string().nullish(), + ids: z.array(z.string()).nullish(), + taxonomyId: z.string().nullish(), + taxonomySlug: z.string().nullish(), + limit: z.number().nullish(), + }), + getTreeData: z.object({ + taxonomyId: z.string().nullish(), + taxonomySlug: z.string().nullish(), + domainId: z.string().nullish(), + }), }; -export const TaxonomySchema = { - create: z.object({ - name: z.string(), - // slug: z.string().min(1), // Assuming slug cannot be empty - }), - delete: z.object({ - id: z.string(), - }), - findByName: z.object({ - name: z.string(), - }), - findById: z.object({ - id: z.string(), - }), - batchDelete: z.object({ - ids: z.array(z.string()), - }), - update: z.object({ - id: z.string(), - name: z.string().nullish(), - // slug: z.string().nullish(), - order: z.number().nullish(), - }), - paginate: z.object({ - page: z.number().min(1), - pageSize: z.number().min(1), - }), -}; -export const TermSchema = { - create: z.object({ - name: z.string(), - description: z.string().nullish(), - domainId: z.string().nullish(), - // slug: z.string().min(1), // Assuming slug cannot be empty - parentId: z.string().nullish(), // Optional field - taxonomyId: z.string(), // Optional field - watchStaffIds: z.array(z.string()).nullish(), - watchDeptIds: z.array(z.string()).nullish(), - }), - update: z.object({ - id: z.string(), - description: z.string().nullish(), - parentId: z.string().nullish(), - domainId: z.string().nullish(), - name: z.string().nullish(), - // slug: z.string().nullish(), - taxonomyId: z.string().nullish(), - order: z.number().nullish(), - watchStaffIds: z.array(z.string()).nullish(), - watchDeptIds: z.array(z.string()).nullish(), - }), - delete: z.object({ - id: z.string(), - }), - paginate: z.object({ - page: z.number().min(1), - pageSize: z.number().min(1), - }), - batchDelete: z.object({ - ids: z.array(z.string()), - }), - cursorList: z.object({ - cursor: z.string().nullish(), - search: z.string().nullish(), - limit: z.number().min(1).max(100).nullish(), - taxonomyId: z.string(), - id: z.string(), - }), - getChildren: z.object({ - parentId: z.string().nullish(), - domainId: z.string().nullish(), - taxonomyId: z.string().nullish(), - cursor: z.string().nullish(), - limit: z.number().min(1).max(100).nullish(), - }), - findMany: z.object({ - keyword: z.string().nullish(), - ids: z.array(z.string()).nullish(), - taxonomyId: z.string().nullish(), - }), -}; -export const TransformSchema = { - importStaffs: z.object({ - base64: z.string(), - domainId: z.string().nullish(), - }), - importTerms: z.object({ - base64: z.string(), - domainId: z.string().nullish(), - taxonomyId: z.string().nullish(), - parentId: z.string().nullish(), - }), - importDepts: z.object({ - base64: z.string(), - domainId: z.string().nullish(), - parentId: z.string().nullish(), - }), +export const RoleMapMethodSchema = { + create: z.object({ + objectId: z.string(), + roleId: z.string(), + domainId: z.string(), + objectType: z.nativeEnum(ObjectType), + }), + update: z.object({ + id: z.string(), + objectId: z.string().nullish(), + roleId: z.string().nullish(), + domainId: z.string().nullish(), + objectType: z.nativeEnum(ObjectType).nullish(), + }), + setRolesForObject: z.object({ + objectId: z.string(), + roleIds: z.array(z.string()), + domainId: z.string(), + objectType: z.nativeEnum(ObjectType), + }), + setRoleForObjects: z.object({ + objectIds: z.array(z.string()), + roleId: z.string(), + domainId: z.string().nullish(), + objectType: z.nativeEnum(ObjectType), + }), + deleteMany: z.object({ + ids: z.array(z.string()), + }), + paginate: z.object({ + page: z.number().min(1), + pageSize: z.number().min(1), + domainId: z.string().nullish(), + roleId: z.string().nullish(), + }), + deleteWithObject: z.object({ + objectId: z.string(), + }), + getRoleMapDetail: z.object({ + roleId: z.string(), + domainId: z.string().nullish(), + }), + getPermsForObject: z.object({ + domainId: z.string(), + staffId: z.string(), + deptId: z.string(), + }), + getRows: RowRequestSchema.extend({ + roleId: z.string().nullish(), + domainId: z.string().nullish(), + }), + getStaffsNotMap: z.object({ + domainId: z.string().nullish(), + roleId: z.string().nullish(), + }), }; +export const RoleMethodSchema = { + create: z.object({ + name: z.string(), + permissions: z.array(z.string()).nullish(), + }), + update: z.object({ + id: z.string(), + name: z.string().nullish(), + permissions: z.array(z.string()).nullish(), + }), + deleteMany: z.object({ + ids: z.array(z.string()), + }), + paginate: z.object({ + page: z.number().nullish(), + pageSize: z.number().nullish(), + }), + findMany: z.object({ + keyword: z.string().nullish(), + }), +}; +export const TaxonomyMethodSchema = { + create: z.object({ + name: z.string(), + slug: z.string(), + objectType: z.array(z.nativeEnum(ObjectType)).nullish(), + }), + delete: z.object({ + id: z.string(), + }), + findByName: z.object({ + name: z.string(), + }), + findBySlug: z.object({ + slug: z.string(), + }), + findById: z.object({ + id: z.string(), + }), + deleteMany: z.object({ + ids: z.array(z.string()), + }), + update: z.object({ + id: z.string(), + name: z.string().nullish(), + slug: z.string().nullish(), + order: z.number().nullish(), + objectType: z.array(z.nativeEnum(ObjectType)).nullish(), + }), + paginate: z.object({ + page: z.number().min(1), + pageSize: z.number().min(1), + }), + getAll: z.object({ + type: z.nativeEnum(ObjectType).nullish(), + }), +}; + +export const BaseCursorSchema = z.object({ + cursor: z.string().nullish(), + limit: z.number().min(-1).max(100).nullish(), + keyword: z.string().nullish(), + states: z.array(z.number()).nullish(), + termIds: z.array(z.string()).nullish(), + termIdFilters: z.map(z.string(), z.array(z.string())).nullish(), + selectedIds: z.array(z.string()).nullish(), + initialIds: z.array(z.string()).nullish(), + excludeIds: z.array(z.string()).nullish(), + createStartDate: z.date().nullish(), + createEndDate: z.date().nullish(), + deptId: z.string().nullish(), +}); + + diff --git a/packages/common/src/select.ts b/packages/common/src/select.ts new file mode 100644 index 0000000..e182521 --- /dev/null +++ b/packages/common/src/select.ts @@ -0,0 +1,69 @@ +import { Prisma } from "@prisma/client"; + +export const postDetailSelect: Prisma.PostSelect = { + id: true, + type: true, + title: true, + content: true, + attachments: true, + referenceId: true, + watchDepts: true, + watchStaffs: true, + updatedAt: true, + author: { + select: { + id: true, + showname: true, + avatar: true, + department: { + select: { + id: true, + name: true, + }, + }, + domain: { + select: { + id: true, + name: true, + }, + }, + }, + }, +}; +export const postUnDetailSelect: Prisma.PostSelect = { + id: true, + type: true, + title: true, + content: true, + attachments: true, + updatedAt: true, + referenceId: true, + author: { + select: { + id: true, + showname: true, + avatar: true, + department: { + select: { + id: true, + name: true, + }, + }, + domain: { + select: { + id: true, + name: true, + }, + }, + }, + }, +}; +export const messageDetailSelect: Prisma.MessageSelect = { + id: true, + sender: true, + content: true, + title: true, + url: true, + option: true, + intent: true, +}; diff --git a/packages/common/src/type.ts b/packages/common/src/type.ts deleted file mode 100644 index 9f0d95a..0000000 --- a/packages/common/src/type.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Department, Staff, Term } from "@prisma/client"; - -export interface DataNode { - title: any; - key: string; - hasChildren?: boolean; - children?: DataNode[]; - value: string; - data?: any; - isLeaf?: boolean; -} -export type StaffDto = Staff & { - domain?: Department; - department?: Department; -}; -export type UserProfile = Staff & { - permissions: string[]; - department?: Department; - domain?: Department; -} - -export interface JwtPayload { - sub: string; - username: string; -} -export interface GenPerms { - instruction?: boolean; - createProgress?: boolean; - requestCancel?: boolean; - acceptCancel?: boolean; - - conclude?: boolean; - createRisk?: boolean; - editIndicator?: boolean; - editMethod?: boolean; - editOrg?: boolean; - - edit?: boolean; - delete?: boolean; - read?: boolean; -} -export type TermDto = Term & { - permissions: GenPerms; - children: TermDto[]; - hasChildren: boolean; -}; -export type DepartmentDto = Department & { - parent: DepartmentDto; - children: DepartmentDto[]; - hasChildren: boolean; - staffs: StaffDto[]; -}; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts new file mode 100755 index 0000000..4d76e48 --- /dev/null +++ b/packages/common/src/types.ts @@ -0,0 +1,180 @@ +import type { + Staff, + Department, + Term, + Message, + Post, + Trouble, + RoleMap, +} from "@prisma/client"; +import { SocketMsgType, RolePerms } from "./enum"; +import { RowRequestSchema } from "./schema"; +import { z } from "zod"; +// import { MessageWithRelations, PostWithRelations, TroubleWithRelations } from "./generated"; +export interface SocketMessage { + type: SocketMsgType; + payload?: T; +} + +export interface DataNode { + title: any; + key: string; + hasChildren?: boolean; + children?: DataNode[]; + value: string; + data?: any; + isLeaf?: boolean; +} + +export interface JwtPayload { + sub: string; + username: string; +} +export type AppLocalSettings = { + urgent?: number; + important?: number; + exploreTime?: Date; +}; +export type StaffDto = Staff & { + domain?: Department; + department?: Department; +}; +export interface AuthDto { + token: string; + staff: StaffDto; + refreshToken: string; + perms: string[]; +} +export type UserProfile = Staff & { + permissions: RolePerms[]; + deptIds: string[]; + parentDeptIds: string[]; + domain: Department; + department: Department; +}; +export interface ObjectWithId { + id: string; // Ensure the row contains at least an 'id' field + [key: string]: any; // Allow additional fields as needed +} + +export interface DataNode { + title: any; + key: string; + value: string; + data?: any; + order?: string; + id?: string; +} +export interface TreeDataNode extends DataNode { + hasChildren?: boolean; + children?: TreeDataNode[]; + isLeaf?: boolean; + pId?: string; +} +export interface DeptSimpleTreeNode extends TreeDataNode { + hasStaff?: boolean; +} +export type StaffRowModel = { + avatar: string; + dept_name: string; + officer_id: string; + phone_number: string; + showname: string; + username: string; +}; +export interface TokenPayload { + id: string; + phoneNumber: string; + name: string; +} +export interface ResPerm { + instruction?: boolean; + createProgress?: boolean; + requestCancel?: boolean; + acceptCancel?: boolean; + + conclude?: boolean; + createRisk?: boolean; + editIndicator?: boolean; + editMethod?: boolean; + editOrg?: boolean; + + edit?: boolean; + delete?: boolean; + read?: boolean; +} + +export type MessageDto = Message & { + readed: boolean; + receivers: Staff[]; + sender: Staff; +}; +export type PostComment = { + id: string; + type: string; + title: string; + content: string; + authorId: string; + domainId: string; + referenceId: string; + attachments: string[]; + createdAt: Date; + updatedAt: Date; + parentId: string; + author: { + id: string; + showname: string; + username: string; + avatar: string; + }; +}; +export type PostDto = Post & { + readed: boolean; + readedCount: number; + author: StaffDto; + limitedComments: PostComment[]; + commentsCount: number; + perms?: { + delete: boolean; + // edit: boolean; + }; + watchDepts: Department[]; + watchStaffs: Staff[]; +}; + +export type TermDto = Term & { + permissions: ResPerm; + children: TermDto[]; + hasChildren: boolean; +}; +export type DepartmentDto = Department & { + parent: DepartmentDto; + children: DepartmentDto[]; + hasChildren: boolean; + staffs: StaffDto[]; + terms: TermDto[] +}; +export type RoleMapDto = RoleMap & { + staff: StaffDto +} +export interface BaseSetting { + // termAvatars?: Record; //termId - url + appConfig?: { + splashScreen?: string; + devDept?: string; + // fakeError?: { + // title?: string | null; + // content?: string | null; + // time?: string | null; + // }; + }; +} +export type RowModelResult = { + rowData: any[]; + rowCount: number; +}; +export type RowModelRequest = z.infer; +export interface ChangedRows { + rows: any[]; + op: "add" | "update" | "remove"; +} diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts old mode 100644 new mode 100755 index 74ce84b..ee1e16d --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,106 +1,325 @@ import { Staff } from "@prisma/client"; -import { DataNode } from "./type"; +import { TermDto, TreeDataNode } from "./types"; export function findNodeByKey( - nodes: DataNode[], - targetKey: string -): DataNode | null { - let result: DataNode | null = null; - - for (const node of nodes) { - if (node.key === targetKey) { - return node; - } - - if (node.children && node.children.length > 0) { - result = findNodeByKey(node.children, targetKey); - if (result) { - return result; - } - } + nodes: TreeDataNode[], + targetKey: string +): TreeDataNode | null { + let result: TreeDataNode | null = null; + for (const node of nodes) { + if (node.key === targetKey) { + return node; } - - return result; + if (node.children && node.children.length > 0) { + result = findNodeByKey(node.children, targetKey); + if (result) { + return result; + } + } + } + return result; } +export const interpolateColor = (percent: number) => { + let r, g; + if (percent < 0.5) { + // Transition from red to yellow + r = 255; + g = Math.floor(2 * percent * 255); + } else { + // Transition from yellow to green + r = Math.floor(2 * (1 - percent) * 255); + g = 255; + } + return `rgba(${r}, ${g}, 0, 0.4)`; // No blue component needed +}; export function findStaffById( - nodes: DataNode[], - staffId: string + nodes: TreeDataNode[], + staffId: string ): Staff | null { - for (const node of nodes) { - // 在当前节点的staffs数组中查找 - const foundStaff = node?.data?.staffs.find( - (staff: Staff) => staff.id === staffId - ); - if (foundStaff) { - return foundStaff; - } - - // 如果当前节点的staffs数组中没有找到,则递归在子节点中查找 - if (node.children) { - const foundInChildren = findStaffById(node.children, staffId); - if (foundInChildren) { - return foundInChildren; - } - } + for (const node of nodes) { + // 在当前节点的staffs数组中查找 + const foundStaff = node?.data?.staffs.find( + (staff: Staff) => staff.id === staffId + ); + if (foundStaff) { + return foundStaff; } + // 如果当前节点的staffs数组中没有找到,则递归在子节点中查找 + if (node.children) { + const foundInChildren = findStaffById(node.children, staffId); + if (foundInChildren) { + return foundInChildren; + } + } + } + // 如果在所有节点及其子节点中都没有找到,返回null + return null; +} +export function getRandomTimeInterval(year: number): { startDate: Date; endDate: Date } { + // Helper function to generate a random date within the given year + function getRandomDate(year: number): Date { + const start = new Date(year, 0, 1).getTime(); + const end = new Date(year + 1, 0, 1).getTime(); + return new Date(start + Math.random() * (end - start)); + } - // 如果在所有节点及其子节点中都没有找到,返回null - return null; + let startDate = getRandomDate(year); + let endDate = getRandomDate(year); + + // Ensure the startDate is before endDate + if (startDate > endDate) { + [startDate, endDate] = [endDate, startDate]; + } + + return { startDate, endDate }; } interface MappingConfig { - titleField?: string; - keyField?: string; - valueField?: string; - hasChildrenField?: string; // Optional, in case the structure has nested items - childrenField?: string; + titleField?: string; + keyField?: string; + valueField?: string; + hasChildrenField?: string; // Optional, in case the structure has nested items + childrenField?: string; +} +export function stringToColor(str: string): string { + let hash = 0; + + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + + let color = '#'; + for (let i = 0; i < 3; i++) { + let value = (hash >> (i * 8)) & 0xFF; + + // Adjusting the value to avoid dark, gray or too light colors + if (value < 100) { + value += 100; // Avoids too dark colors + } + if (value > 200) { + value -= 55; // Avoids too light colors + } + + // Ensure the color is not gray by adjusting R, G, B individually + value = Math.floor((value + 255) / 2); + + color += ('00' + value.toString(16)).slice(-2); + } + + return color; } -export function mapToDataNodes( - inputArray: any[], - config: MappingConfig = {} -): DataNode[] { - const { - titleField = "title", - keyField = "key", - valueField = "value", - hasChildrenField = "hasChildren", - childrenField = "children" - } = config; - return inputArray.map((item) => { - const hasChildren = item[hasChildrenField] || false; - const children = item[childrenField] - return { - title: item[titleField] || "", - key: item[keyField] || "", - value: item[valueField] || null, - data: item, - children: children - ? mapToDataNodes(children, { titleField, keyField, valueField, hasChildrenField, childrenField }) - : undefined, - hasChildren - }; - }); +export function mapToTreeDataNodes( + inputArray: any[], + config: MappingConfig = {} +): TreeDataNode[] { + const { + titleField = "title", + keyField = "key", + valueField = "value", + hasChildrenField = "hasChildren", + childrenField = "children" + } = config; + + return inputArray.map((item) => { + const hasChildren = item[hasChildrenField] || false; + const children = item[childrenField] + return { + title: item[titleField] || "", + key: item[keyField] || "", + value: item[valueField] || null, + data: item, + children: children + ? mapToTreeDataNodes(children, { titleField, keyField, valueField, hasChildrenField, childrenField }) + : undefined, + hasChildren + }; + }); } +export function arraysAreEqual(arr1: T[] = [], arr2: T[] = []): boolean { + if (arr1.length !== arr2.length) { + return false; + } + return arr1.every((element, index) => element === arr2[index]); +} + +export function mergeAndDeduplicate(...arrays: T[][]): T[] { + const set = new Set(arrays.flat()); + return Array.from(set); +} + +export function arraysIntersect(array1: T[], array2: T[]): boolean { + const set = new Set(array1); + for (const item of array2) { + if (set.has(item)) { + return true; + } + } + return false; +} +export function calculatePercentage(part: number, whole: number) { + if (whole === 0) { + return 0; + } + const percentage = (part / whole) * 100; + return percentage; +} +export function getRandomIntInRange(min: number, max: number): number { + if (min > max) { + [min, max] = [max, min]; + } + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function getRandomBooleanNormalDist(mean: number = 0, stdDev: number = 1): boolean { + // Generate two uniformly distributed values in the range (0, 1] + const u1 = Math.random(); + const u2 = Math.random(); + + // Apply the Box-Muller transform to get two independent standard normal random variables + const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); + const z1 = Math.sqrt(-2.0 * Math.log(u1)) * Math.sin(2.0 * Math.PI * u2); + + // Scale and shift to match the desired mean and standard deviation + const result0 = z0 * stdDev + mean; + const result1 = z1 * stdDev + mean; + + // Use one of the results to determine the boolean value + return result0 >= 0; +} +export function decimalToPercentage(decimal: number): number { + if (typeof decimal !== 'number') { + throw new Error("Input must be a number."); + } + + return Math.round(decimal * 100); +} +export function deduplicateObjectArray(array: T[], key: K): T[] { + const seen = new Set(); + return array.filter(item => { + const val = item[key]; + if (seen.has(val)) { + return false; + } + seen.add(val); + return true; + }); +} +type Primitive = string | number | boolean | null | undefined; +export function removeEmptyOrZeroValues>(obj: T): Partial { + const result: Partial = {}; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const value = obj[key]; + if (value !== null && value !== undefined && value !== '' && value !== 0) { + result[key] = value; + } + } + } + + return result +} + +export function truncateString(content: string, maxLength: number) { + return content.length > maxLength ? `${content.slice(0, maxLength)}...` : content; +}; +export function getRandomElement(arr: T[]): T { + const randomIndex = Math.floor(Math.random() * arr.length); + return arr[randomIndex]; +} +export function getRandomElements(arr: T[], count?: number): T[] { + // If count is not provided, choose a random count between 1 and arr.length + const effectiveCount = count ?? Math.floor(Math.random() * arr.length) + 1; + const result: T[] = []; + const usedIndices: Set = new Set(); + + while (result.length < effectiveCount && usedIndices.size < arr.length) { + const randomIndex = Math.floor(Math.random() * arr.length); + + if (!usedIndices.has(randomIndex)) { + result.push(arr[randomIndex]); + usedIndices.add(randomIndex); + } + } + + return result; +} + +export function getRandomChineseName(): string { + const surnames = ["王", "李", "张", "刘", "陈", "杨", "赵", "黄", "周", "吴"]; + const givenNames = [ + "伟", "芳", "娜", "敏", "静", "丽", "强", "磊", "军", "洋", + "勇", "艳", "杰", "娟", "涛", "明", "霞", "秀英", "鹏" + ]; + + const randomSurnameIndex = Math.floor(Math.random() * surnames.length); + const randomGivenNameIndex1 = Math.floor(Math.random() * givenNames.length); + const randomGivenNameIndex2 = Math.floor(Math.random() * givenNames.length); + + const surname = surnames[randomSurnameIndex]; + const givenName = givenNames[randomGivenNameIndex1] + givenNames[randomGivenNameIndex2]; + + + return surname + givenName; +} +export function getRandomAvatarUrl(): string { + const randomString = Math.random().toString(36).substring(7); + return `https://robohash.org/${randomString}.png`; +} + +export const getUniqueItems = (items: T[], key?: keyof T) => { + if (key === undefined) { + // 当未提供键时,假设是纯数组进行去重 + return Array.from(new Set(items)); + } else { + // 提供了键时,对对象数组进行去重 + return Array.from( + new Map(items.map(item => [item[key] as unknown || null, item])).values() + ); + } +}; +export type Extractor = (array: T[], key: K) => T[K][]; + + + /** - * 合并两个数组并去重。 - * - * 该函数将两个输入数组的元素合并为一个数组, - * 并确保结果数组中没有重复的元素。元素的顺序根据它们首次出现的顺序保留。 - * - * @template T - 输入数组中元素的类型。 - * @param {T[]} array1 - 要合并的第一个数组。 - * @param {T[]} array2 - 要合并的第二个数组。 - * @returns {T[]} 包含来自两个输入数组的唯一元素的新数组。 - * - * @example - * const array1 = [1, 2, 3, 4]; - * const array2 = [3, 4, 5, 6]; - * const result = mergeAndDeduplicate(array1, array2); - * console.log(result); // 输出: [1, 2, 3, 4, 5, 6] + * 从对象数组中提取指定属性值组成新数组 + * @template T 源对象类型 + * @template K 属性键类型,必须是T的键名 + * @param {T[]} array 源对象数组 + * @param {K} key 要提取的属性键名 + * @returns {T[K][]} 提取出的属性值数组 */ -export function mergeAndDeduplicate(array1: T[], array2: T[]): T[] { - const set = new Set([...array1, ...array2]); - return Array.from(set); -} +export const pluckProperty = (array: T[], key: K): T[K][] => { + // 使用map方法遍历数组,返回每个对象指定属性的值 + return array.map(item => item[key]); +}; +/** + * 将对象数组转换为以指定属性值为键的对象数组 + * @template T 源对象类型 + * @template K 属性键类型,必须是T的键名 + * @param {T[]} array 源对象数组 + * @param {K} key 要作为键的属性名 + * @returns {Array>} 转换后的对象数组 + */ +export const mapPropertiesToObjects = (array: T[], key: K): Array> => { + // 使用map方法遍历数组,为每个对象创建一个新对象 + // 新对象以指定属性值为键,原对象为值 + return array.map(item => ({ [item[key] as string]: item })); +}; + +/** + * 将数组映射为包含指定属性的对象数组 + * @template T 数组元素类型 + * @param {T[]} array 源数组 + * @param {string} key 要添加的属性名 + * @returns {Array>} 映射后的对象数组 + */ +export const mapArrayToObjectArray = ( + array: T[], + key: string = 'id' +): Array> => { + return array.map(item => ({ [key]: item })); +}; \ No newline at end of file diff --git a/packages/common/tsconfig.cjs.json b/packages/common/tsconfig.cjs.json deleted file mode 100755 index 01dae2d..0000000 --- a/packages/common/tsconfig.cjs.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "outDir": "./dist/cjs", - "target": "ES2020", - "module": "CommonJS", - "declaration": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "moduleResolution": "node", - "resolveJsonModule": true - }, - "include": [ - "src" - ], - "exclude": [ - "prisma", - "node_modules", - "dist" - ] -} \ No newline at end of file diff --git a/packages/common/tsconfig.esm.json b/packages/common/tsconfig.esm.json deleted file mode 100755 index 14bbf84..0000000 --- a/packages/common/tsconfig.esm.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "outDir": "./dist/esm", - "target": "ES2020", - "module": "ESNext", - "declaration": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "moduleResolution": "node", - // Include JSON files as modules. - "resolveJsonModule": true, - }, - "include": [ - "src" - ], - "exclude": [ - "prisma", - "node_modules", - "dist" - ] -} \ No newline at end of file diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json new file mode 100644 index 0000000..2dcd86b --- /dev/null +++ b/packages/common/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "lib": [ + "es2022" + ], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", + "removeComments": true, + "skipLibCheck": true, + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": false, + "noFallthroughCasesInSwitch": false, + "noUncheckedIndexedAccess": false, + "noImplicitOverride": false, + "noPropertyAccessFromIndexSignature": false, + "emitDeclarationOnly": true, + "outDir": "dist", + "incremental": true, + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/__tests__" + ] +} \ No newline at end of file diff --git a/packages/common/tsup.config.ts b/packages/common/tsup.config.ts new file mode 100644 index 0000000..1eacf7a --- /dev/null +++ b/packages/common/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + splitting: false, + sourcemap: true, + clean: false, + dts: true +}); diff --git a/tsconfig.json b/tsconfig.base.json similarity index 87% rename from tsconfig.json rename to tsconfig.base.json index d2c16d7..2effa74 100644 --- a/tsconfig.json +++ b/tsconfig.base.json @@ -6,12 +6,15 @@ "incremental": true, "skipLibCheck": true, "strictNullChecks": true, - "noImplicitAny": false, "strictBindCallApply": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": false, // 关闭未使用局部变量的警告 "noUnusedParameters": false, // 关闭未使用函数参数的警告 + "strict": true, + "removeComments": true, + "noImplicitAny": false, + "strictPropertyInitialization": false, "paths": { "@server/*": [ "./apps/server/src/*" @@ -19,9 +22,6 @@ "@web/*": [ "apps/web/*" ], - "@admin/*": [ - "apps/admin/*" - ] } } } \ No newline at end of file