From 2a80a449720992047affd4b9912eeb662b230a82 Mon Sep 17 00:00:00 2001 From: liuyh Date: Tue, 18 Nov 2025 20:07:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0DEFAULT=5FUSER=5FPASS?= =?UTF-8?q?WORD=EF=BC=8C=E4=BD=9C=E4=B8=BA=E5=88=9B=E5=BB=BA=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=97=B6=E7=9A=84=E9=BB=98=E8=AE=A4=E5=AF=86=E7=A0=81?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0p-limit=E5=BA=93=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0DB=5FPARALLEL=5FLIMIT=20=3D=2032=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E4=BD=9C=E4=B8=BA=E2=80=9C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E6=89=B9=E6=AC=A1=E6=93=8D=E4=BD=9C=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=95=B0=E2=80=9D=20=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=89=8D=E8=83=BD=E5=88=9B=E5=BB=BA=E8=B6=85=E7=BA=A7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E7=94=A8=E6=88=B7=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=97=B6=E5=8F=AF=E4=BB=A5=E7=BA=A7=E8=81=94?= =?UTF-8?q?=E5=88=A0=E9=99=A4SelectionLog=20=E6=B7=BB=E5=8A=A0zustand?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=20?= =?UTF-8?q?=E4=B8=80=E5=AF=B9=E5=A4=9A=E7=9A=84=E9=99=A2=E7=B3=BB=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=20=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5=E9=99=A2=E7=B3=BB=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E4=BF=A1=E6=81=AF=E3=80=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E5=9C=A8header=E4=B8=AD=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=9A=84=E9=99=A2=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .clineignore | 53 ++++ .clinerules/TEMPLATE_README.md | 89 +++++++ .env.example | 3 + README.md | 10 +- package.json | 4 +- .../migration.sql | 5 + .../migration.sql | 26 ++ prisma/schema.prisma | 25 +- prisma/seed.ts | 148 ++++++----- src/app/(main)/users/dept-admin/columns.tsx | 247 ++++++++++++++++++ .../components/DeptAdminCreateDialog.tsx | 186 +++++++++++++ .../components/DeptAdminDeleteDialog.tsx | 84 ++++++ .../components/DeptAdminUpdateDialog.tsx | 194 ++++++++++++++ src/app/(main)/users/dept-admin/page.tsx | 143 ++++++++++ src/app/(main)/users/layout.tsx | 13 + .../(main)/users/{ => user-info}/columns.tsx | 0 .../components/BatchAuthorizationDialog.tsx | 0 .../components/RoleManagementDialog.tsx | 0 .../components/UserCreateDialog.tsx | 0 .../components/UserDeleteDialog.tsx | 0 .../components/UserUpdateDialog.tsx | 1 + src/app/(main)/users/{ => user-info}/page.tsx | 0 src/components/common/form-dialog.tsx | 37 ++- src/components/layout/header.tsx | 97 ++++++- src/constants/menu-icons.ts | 6 +- src/constants/menu.ts | 14 + src/lib/stores/userStore.ts | 18 ++ src/server/routers/_app.ts | 4 +- src/server/routers/common.ts | 6 +- src/server/routers/dept-admin.ts | 206 +++++++++++++++ src/server/routers/users.ts | 128 ++++++++- 31 files changed, 1651 insertions(+), 96 deletions(-) create mode 100644 .clineignore create mode 100644 .clinerules/TEMPLATE_README.md create mode 100644 prisma/migrations/20251118103418_add_cascade_delete_to_selection_log/migration.sql create mode 100644 prisma/migrations/20251118105141_add_dept_admin_and_managed_dept_switch/migration.sql create mode 100644 src/app/(main)/users/dept-admin/columns.tsx create mode 100644 src/app/(main)/users/dept-admin/components/DeptAdminCreateDialog.tsx create mode 100644 src/app/(main)/users/dept-admin/components/DeptAdminDeleteDialog.tsx create mode 100644 src/app/(main)/users/dept-admin/components/DeptAdminUpdateDialog.tsx create mode 100644 src/app/(main)/users/dept-admin/page.tsx create mode 100644 src/app/(main)/users/layout.tsx rename src/app/(main)/users/{ => user-info}/columns.tsx (100%) rename src/app/(main)/users/{ => user-info}/components/BatchAuthorizationDialog.tsx (100%) rename src/app/(main)/users/{ => user-info}/components/RoleManagementDialog.tsx (100%) rename src/app/(main)/users/{ => user-info}/components/UserCreateDialog.tsx (100%) rename src/app/(main)/users/{ => user-info}/components/UserDeleteDialog.tsx (100%) rename src/app/(main)/users/{ => user-info}/components/UserUpdateDialog.tsx (99%) rename src/app/(main)/users/{ => user-info}/page.tsx (100%) create mode 100644 src/lib/stores/userStore.ts create mode 100644 src/server/routers/dept-admin.ts diff --git a/.clineignore b/.clineignore new file mode 100644 index 0000000..dbf0606 --- /dev/null +++ b/.clineignore @@ -0,0 +1,53 @@ +# 这里的文件会被Cline忽略,一般从 .gitignore 直接复制就行,一些需要保密不便给大模型看的文件也可以写在这 + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env*.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/postgresql +tasks.md +/prisma/zod + +# pnpm +.pnpm-store/ + +# lock 一般的项目需要用git管理,但这个是模板项目就不管理 +package-lock.json +pnpm-lock.yaml diff --git a/.clinerules/TEMPLATE_README.md b/.clinerules/TEMPLATE_README.md new file mode 100644 index 0000000..7f37af1 --- /dev/null +++ b/.clinerules/TEMPLATE_README.md @@ -0,0 +1,89 @@ +## 项目说明 +本项目模板(Hair Keeper v1.0.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。 + +Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。 + +开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。 + +## 主要依赖库 +- 基础:next + react + trpc + prisma +- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast) +- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable +- 用户交互增强:motion(动画) + framer-motion(动画) + use-gesture/react(手势) +- Headless UI:react-hook-form + tanstack/react-table + headless-tree/react +- 数据和存储:pg(PostgreSQL) + ioredis + minio +- 后台任务及消息队列:bullmq +- AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件) +- 辅助性库:lodash + zod + date-fns + nanoid + zustand + p-limit +- 其他:next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard + +## 项目约定 +### 前端 +- 可使用`pnpx shadcn@latest add`添加UI控件(会添加到components/ui中) +- 表单输入组件提供value、onChange和disabled三种属性的支持,可方便的集成到react-hook-form中 +- z-index约定:常规对话框及其遮罩z-50,表单控件的Popover为z-60,全屏预览及遮罩z-70 +- tailwindcss v4支持直接编写如 w-[10px] 这样的任意值,非必要不写style,支持样式类合并`import { cn } from "@/lib/utils"` +- 用`import { useCallbackRef } from "@/hooks/use-callback-ref"`这个钩子构建引用不变但逻辑总是最新的函数,解决闭包陷阱 + +### 后端 +- tRPC接口报错时可使用TRPCError,例如throw new TRPCError({ code: 'NOT_FOUND', message: '' }) +- server/trpc.ts中导出了createTRPCRouter(本质是t.router)用来创建路由,预定义了publicProcedure用于创建无权限限制、也不需要登录的api +- server/trpc.ts中预定义了permissionRequiredProcedure,用来创建限制特定权限访问的api,例如permissionRequiredProcedure(Permissions.USER_MANAGE);空字符串表示无权限要求,但是需要用户登录;约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问,该权限不在Permissions中定义,只有超级管理员才能绕过授权限制访问所有接口,因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。 +- 数据库批次操作时,使用`const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))`控制最大并发数 + +### 数据和存储 +- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`,可以从中导入定义的数据表的ts类型,例如`import type { User } from '@prisma/client'` +- 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient +- 时间字段统一使用 `@db.Timestamptz` 类型 +- 前后端参数传递尽量使用扁平结构而非嵌套结构 +- 文件的上传和下载采用“客户端直传”架构(基于MinIO),服务器端只负责授权和生成预签名URL + +### 开发模式 +为了方便开发,本项目模板内置了在开发模式下可用的大量页面和功能 +- 仅在开发阶段使用的页面、布局、api等会被NextJS识别并处理的文件,以dev.tsx、dev.ts、dev.jsx、dev.js为后缀,不会被打包到生产环境 +- 仅在开发阶段使用的数据模型以Dev开头,对应的数据表以dev_开头 + +## 重要目录和文件 +### 前端 +- `components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件 +- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用 +- `components/ai-elements/`:ai对话相关的组件 +- `components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件 +- `components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能 +- `components/icons/`:项目的自定义图标可以写在这个文件夹 +- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器 +- `components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库 +- `app/(main)/`:开发者在这里自行实现的所有业务的页面 +- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考 +- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现 +- `app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现 +- `hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这 +- `lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信 +- `lib/stores/`:通过zustand管理的全局的状态 + +### 后端 +- `server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API +- `server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册 +- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由 +- `server/routers/jobs.ts`:tRPC任务进度订阅路由 +- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验 +- `server/routers/global.ts`:系统全局和特定业务关联不大的一些api +- `server/routers/dev/`:开发模式下的辅助功能需要的trpc api +- `server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中 +- `server/agents`:LLM的对接和使用 +- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务 +- `server/service/dev/`:开发模式下的辅助功能需要的后台服务 +- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持 +- `api/dev/`:开发模式下的辅助功能需要的api + +### 其他 +- `constants/`:项目全局常量管理 +- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)") +- `lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型 +- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序 +- `lib/format.ts`:数据格式化工具函数库 + +## 非标准命令 +- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互 +- `pnpm run db:seed`:数据库种子数据,`prisma/init_data/`存放种子初始化数据 +- `pnpm run build:analyze`:打包项目并使用next/bundle-analyzer进行分析 diff --git a/.env.example b/.env.example index 9baef90..b975358 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,9 @@ MINIO_BUCKET= # 应用相关 SUPER_ADMIN_PASSWORD= +USER_DEFAULT_PASSWORD= +## 数据库批次操作默认并发数 +DB_PARALLEL_LIMIT = # NextAuth.js Configuration NEXTAUTH_SECRET= diff --git a/README.md b/README.md index 9c61bbb..7f37af1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。 - 数据和存储:pg(PostgreSQL) + ioredis + minio - 后台任务及消息队列:bullmq - AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件) -- 辅助性库:lodash + zod + date-fns + nanoid +- 辅助性库:lodash + zod + date-fns + nanoid + zustand + p-limit - 其他:next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard ## 项目约定 @@ -27,11 +27,12 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。 ### 后端 - tRPC接口报错时可使用TRPCError,例如throw new TRPCError({ code: 'NOT_FOUND', message: '' }) -- server/trpc.ts中预定义了publicProcedure用于创建无权限限制、也不需要登录的api -- server/trpc.ts中预定义了publicProcedurepermissionRequiredProcedure,用来创建限制特定权限访问的路由,例如permissionRequiredProcedure(Permissions.USER_MANAGE);空字符串表示无权限要求,但是需要用户登录;约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问,该权限不在Permissions中定义,只有超级管理员才能绕过授权限制访问所有接口,因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。 +- server/trpc.ts中导出了createTRPCRouter(本质是t.router)用来创建路由,预定义了publicProcedure用于创建无权限限制、也不需要登录的api +- server/trpc.ts中预定义了permissionRequiredProcedure,用来创建限制特定权限访问的api,例如permissionRequiredProcedure(Permissions.USER_MANAGE);空字符串表示无权限要求,但是需要用户登录;约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问,该权限不在Permissions中定义,只有超级管理员才能绕过授权限制访问所有接口,因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。 +- 数据库批次操作时,使用`const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))`控制最大并发数 ### 数据和存储 -- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client` +- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`,可以从中导入定义的数据表的ts类型,例如`import type { User } from '@prisma/client'` - 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient - 时间字段统一使用 `@db.Timestamptz` 类型 - 前后端参数传递尽量使用扁平结构而非嵌套结构 @@ -58,6 +59,7 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。 - `app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现 - `hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这 - `lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信 +- `lib/stores/`:通过zustand管理的全局的状态 ### 后端 - `server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API diff --git a/package.json b/package.json index c544a63..27de831 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "next-auth": "^4.24.11", "next-themes": "^0.4.6", "nuqs": "^2.6.0", + "p-limit": "^7.2.0", "pg": "^8.16.3", "prism-react-renderer": "^2.4.1", "prisma": "^6.15.0", @@ -95,7 +96,8 @@ "tailwind-merge": "^3.3.1", "use-stick-to-bottom": "^1.1.1", "vaul": "^1.1.2", - "zod": "^4.1.9" + "zod": "^4.1.9", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/prisma/migrations/20251118103418_add_cascade_delete_to_selection_log/migration.sql b/prisma/migrations/20251118103418_add_cascade_delete_to_selection_log/migration.sql new file mode 100644 index 0000000..0e8419e --- /dev/null +++ b/prisma/migrations/20251118103418_add_cascade_delete_to_selection_log/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "selection_log" DROP CONSTRAINT "selection_log_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "selection_log" ADD CONSTRAINT "selection_log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251118105141_add_dept_admin_and_managed_dept_switch/migration.sql b/prisma/migrations/20251118105141_add_dept_admin_and_managed_dept_switch/migration.sql new file mode 100644 index 0000000..fb7fafd --- /dev/null +++ b/prisma/migrations/20251118105141_add_dept_admin_and_managed_dept_switch/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "current_managed_dept" VARCHAR(5); + +-- CreateTable +CREATE TABLE "dept_admin" ( + "id" SERIAL NOT NULL, + "uid" VARCHAR(30) NOT NULL, + "dept_code" VARCHAR(5) NOT NULL, + "admin_email" VARCHAR(100), + "admin_line_phone" VARCHAR(100), + "admin_mobile_phone" VARCHAR(100), + "note" VARCHAR(1000), + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL, + + CONSTRAINT "dept_admin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "dept_admin_uid_dept_code_key" ON "dept_admin"("uid", "dept_code"); + +-- AddForeignKey +ALTER TABLE "dept_admin" ADD CONSTRAINT "dept_admin_uid_fkey" FOREIGN KEY ("uid") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dept_admin" ADD CONSTRAINT "dept_admin_dept_code_fkey" FOREIGN KEY ("dept_code") REFERENCES "dept"("code") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ed525a..71c2229 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ model User { name String? status String? // 在校/减离/NULL deptCode String? @map("dept_code") // 所属院系代码(外键) + currentManagedDept String? @map("current_managed_dept") @db.VarChar(5) // 当前正在管理的院系代码,用于支持院系管理员类型的角色 createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz password String @@ -29,6 +30,7 @@ model User { dept Dept? @relation(fields: [deptCode], references: [code]) roles Role[] // 多对多关联角色 selectionLogs SelectionLog[] // 选择日志 + deptAdmins DeptAdmin[] // 作为院系管理员的信息 @@map("user") } @@ -41,10 +43,31 @@ model Dept { // 关联 users User[] + deptAdmins DeptAdmin[] // 院系管理员 @@map("dept") } +// 院系管理员表 +model DeptAdmin { + id Int @id @default(autoincrement()) + uid String @db.VarChar(30) // 管理员用户ID(外键) + deptCode String @map("dept_code") @db.VarChar(5) // 院系代码(外键) + adminEmail String? @map("admin_email") @db.VarChar(100) // 管理员邮箱 + adminLinePhone String? @map("admin_line_phone") @db.VarChar(100) // 管理员座机 + adminMobilePhone String? @map("admin_mobile_phone") @db.VarChar(100) // 管理员手机 + note String? @db.VarChar(1000) // 备注 + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + // 关联 + user User @relation(fields: [uid], references: [id]) + dept Dept @relation(fields: [deptCode], references: [code]) + + @@unique([uid, deptCode], name: "uidx_uid_dept_code") + @@map("dept_admin") +} + // 角色表 model Role { id Int @id @default(autoincrement()) @@ -68,7 +91,7 @@ model Permission { model SelectionLog { id Int @id @default(autoincrement()) userId String // 关联到用户 - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) // 用于标识是哪个的选项,用.进行分隔,例如"user.filter.dept" context String diff --git a/prisma/seed.ts b/prisma/seed.ts index 3901899..07faba3 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -3,9 +3,13 @@ import bcrypt from 'bcryptjs' import { Permissions, ALL_PERMISSIONS } from '../src/constants/permissions' import fs from 'fs' import path from 'path' +import pLimit from 'p-limit' const prisma = new PrismaClient() +// 从环境变量获取并发限制,默认为16 +const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10)) + // 解析 JSON 文件并导入院系数据 async function importDepartments() { const jsonPath = path.join(__dirname, 'init_data', '院系.json') @@ -16,7 +20,7 @@ async function importDepartments() { await Promise.all( departments.map((dept: any) => { - return prisma.dept.upsert({ + return dbParallelLimit(() => prisma.dept.upsert({ where: { code: dept.id }, update: { name: dept.name, @@ -27,7 +31,7 @@ async function importDepartments() { name: dept.name, fullName: dept.full_name, }, - }) + })) }) ) console.log('院系数据导入完成') @@ -37,13 +41,15 @@ async function main() { console.log('开始数据库初始化...') // 插入权限 - for (const permName of ALL_PERMISSIONS) { - await prisma.permission.upsert({ - where: { name: permName }, - update: {}, - create: { name: permName }, + await Promise.all( + ALL_PERMISSIONS.map((permName) => { + return dbParallelLimit(() => prisma.permission.upsert({ + where: { name: permName }, + update: {}, + create: { name: permName }, + })) }) - } + ) // 角色与权限映射 const rolePermissionsMap: Record = { @@ -51,18 +57,20 @@ async function main() { } // 插入角色 - for (const [roleName, perms] of Object.entries(rolePermissionsMap)) { - await prisma.role.upsert({ - where: { name: roleName }, - update: {}, - create: { - name: roleName, - permissions: { - connect: perms.map((name) => ({ name })), + await Promise.all( + Object.entries(rolePermissionsMap).map(([roleName, perms]) => { + return dbParallelLimit(() => prisma.role.upsert({ + where: { name: roleName }, + update: {}, + create: { + name: roleName, + permissions: { + connect: perms.map((name) => ({ name })), + }, }, - }, + })) }) - } + ) await importDepartments() @@ -74,33 +82,37 @@ async function main() { { id: 'unknown', name: '未知用户', status: '在校', deptCode: '00001', roleNames: [] }, ] - for (const u of usersToCreate) { - const password = await bcrypt.hash(u.password ?? '123456', 12) - await prisma.user.upsert({ - where: { id: u.id }, - update: { - name: u.name, - status: u.status, - deptCode: u.deptCode, - password, - isSuperAdmin: u.isSuperAdmin ?? false, - roles: { - set: u.roleNames.map((name) => ({ name })), - }, - }, - create: { - id: u.id, - name: u.name, - status: u.status, - deptCode: u.deptCode, - password, - isSuperAdmin: u.isSuperAdmin ?? false, - roles: { - connect: u.roleNames.map((name) => ({ name })), - }, - }, + await Promise.all( + usersToCreate.map((u) => { + return dbParallelLimit(async () => { + const password = await bcrypt.hash(u.password || process.env.USER_DEFAULT_PASSWORD || 'jeep4ahxahx7ee7U', 12) + await prisma.user.upsert({ + where: { id: u.id }, + update: { + name: u.name, + status: u.status, + deptCode: u.deptCode, + password, + isSuperAdmin: u.isSuperAdmin ?? false, + roles: { + set: u.roleNames.map((name) => ({ name })), + }, + }, + create: { + id: u.id, + name: u.name, + status: u.status, + deptCode: u.deptCode, + password, + isSuperAdmin: u.isSuperAdmin ?? false, + roles: { + connect: u.roleNames.map((name) => ({ name })), + }, + }, + }) + }) }) - } + ) // 插入文件类型(仅开发环境) const fileTypes = [ @@ -125,18 +137,19 @@ async function main() { { id: 'OTHER', name: '其他', description: '无法归入以上分类的文件' }, ] - for (let index = 0; index < fileTypes.length; index++) { - const fileType = fileTypes[index] - await prisma.devFileType.upsert({ - where: { id: fileType.id }, - update: { - name: fileType.name, - description: fileType.description, - order: (index + 1) * 10, - }, - create: {...fileType, order: (index + 1) * 10}, + await Promise.all( + fileTypes.map((fileType, index) => { + return dbParallelLimit(() => prisma.devFileType.upsert({ + where: { id: fileType.id }, + update: { + name: fileType.name, + description: fileType.description, + order: (index + 1) * 10, + }, + create: {...fileType, order: (index + 1) * 10}, + })) }) - } + ) console.log('文件类型数据初始化完成') // 插入依赖包类型(仅开发环境) @@ -152,18 +165,19 @@ async function main() { { id: 'NODEJS_CORE', name: 'Node.js核心', description: 'Node.js运行时内置的模块,用于底层操作如文件系统、路径处理等,例如fs、path、child_process、util、module、os、http、crypto、events、stream、process、net、url、assert。' }, ] - for (let index = 0; index < pkgTypes.length; index++) { - const pkgType = pkgTypes[index] - await prisma.devPkgType.upsert({ - where: { id: pkgType.id }, - update: { - name: pkgType.name, - description: pkgType.description, - order: (index + 1) * 10, - }, - create: {...pkgType, order: (index + 1) * 10}, + await Promise.all( + pkgTypes.map((pkgType, index) => { + return dbParallelLimit(() => prisma.devPkgType.upsert({ + where: { id: pkgType.id }, + update: { + name: pkgType.name, + description: pkgType.description, + order: (index + 1) * 10, + }, + create: {...pkgType, order: (index + 1) * 10}, + })) }) - } + ) console.log('依赖包类型数据初始化完成') console.log('数据库初始化完成') @@ -177,4 +191,4 @@ main() console.error(e) await prisma.$disconnect() process.exit(1) - }) \ No newline at end of file + }) diff --git a/src/app/(main)/users/dept-admin/columns.tsx b/src/app/(main)/users/dept-admin/columns.tsx new file mode 100644 index 0000000..4c8efb8 --- /dev/null +++ b/src/app/(main)/users/dept-admin/columns.tsx @@ -0,0 +1,247 @@ +'use client' + +import { ColumnDef } from '@tanstack/react-table' +import { Button } from '@/components/ui/button' +import { Edit, Trash2, MoreHorizontal } from 'lucide-react' +import { formatDate } from '@/lib/format' +import { Checkbox } from '@/components/ui/checkbox' +import { DataTableColumnHeader } from '@/components/data-table/column-header' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import type { DeptAdmin } from '@/server/routers/dept-admin' + +// 操作回调类型 +export type DeptAdminActions = { + onEdit: (id: number) => void + onDelete: (id: number) => void +} + +// 列定义选项类型 +export type DeptAdminColumnsOptions = { + depts?: Array<{ code: string; name: string; fullName: string }> +} + +// 创建院系管理员表格列定义 +export const createDeptAdminColumns = ( + actions: DeptAdminActions, + options: DeptAdminColumnsOptions = {} +): ColumnDef[] => [ + { + id: "select", + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 32, + enableSorting: false, + enableHiding: false, + }, + { + id: 'id', + accessorKey: 'id', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.id}
, + meta: { + label: 'ID', + }, + }, + { + id: 'uid', + accessorKey: 'uid', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.uid}
, + enableColumnFilter: true, + meta: { + label: '用户ID', + filter: { + placeholder: '请输入用户ID', + variant: 'text', + } + }, + }, + { + id: 'userName', + accessorKey: 'user.name', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.user?.name || '-'}
, + enableColumnFilter: true, + meta: { + label: '姓名', + filter: { + placeholder: '请输入姓名', + variant: 'text', + } + }, + }, + { + id: 'deptCode', + accessorKey: 'deptCode', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+
{row.original.dept?.name || '-'}
+
{row.original.deptCode}
+
+ ), + enableColumnFilter: true, + meta: { + label: '院系', + filter: { + variant: 'multiSelect', + options: options.depts?.map(dept => ({ + id: dept.code, + name: dept.fullName, + })) || [], + } + }, + }, + { + id: 'adminEmail', + accessorKey: 'adminEmail', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.adminEmail || '-'}
, + enableColumnFilter: true, + meta: { + label: '邮箱', + filter: { + placeholder: '请输入邮箱', + variant: 'text', + } + }, + }, + { + id: 'adminLinePhone', + accessorKey: 'adminLinePhone', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.adminLinePhone || '-'}
, + enableColumnFilter: true, + meta: { + label: '座机', + filter: { + placeholder: '请输入座机', + variant: 'text', + } + }, + }, + { + id: 'adminMobilePhone', + accessorKey: 'adminMobilePhone', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.adminMobilePhone || '-'}
, + enableColumnFilter: true, + meta: { + label: '手机', + filter: { + placeholder: '请输入手机', + variant: 'text', + } + }, + }, + { + id: 'note', + accessorKey: 'note', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.note || '-'} +
+ ), + enableSorting: false, + meta: { + label: '备注', + }, + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return
{formatDate(row.original.createdAt) || '-'}
+ }, + meta: { + label: '创建时间', + } + }, + { + id: 'updatedAt', + accessorKey: 'updatedAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return
{formatDate(row.original.updatedAt) || '-'}
+ }, + meta: { + label: '更新时间', + } + }, + { + id: 'actions', + cell: ({ row }) => { + const deptAdmin = row.original + return ( + + + + + + actions.onEdit(deptAdmin.id)}> + + 编辑 + + actions.onDelete(deptAdmin.id)} + > + + 删除 + + + + ) + }, + size: 32, + enableSorting: false, + enableHiding: false, + }, +] diff --git a/src/app/(main)/users/dept-admin/components/DeptAdminCreateDialog.tsx b/src/app/(main)/users/dept-admin/components/DeptAdminCreateDialog.tsx new file mode 100644 index 0000000..6373392 --- /dev/null +++ b/src/app/(main)/users/dept-admin/components/DeptAdminCreateDialog.tsx @@ -0,0 +1,186 @@ +'use client' + +import React, { useState } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { trpc } from '@/lib/trpc' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Dialog, DialogTrigger } from '@/components/ui/dialog' +import { Plus } from 'lucide-react' +import { toast } from 'sonner' +import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog' +import { + AdvancedSelect, + SelectPopover, + SelectTrigger, + SelectContent, + SelectInput, + SelectItemList, + SelectedName +} from '@/components/common/advanced-select' +import { useSmartSelectOptions } from '@/hooks/use-smart-select-options' +import { Textarea } from '@/components/ui/textarea' + +// 创建院系管理员的 schema +const createDeptAdminSchema = z.object({ + uid: z.string().min(1, '用户ID不能为空'), + deptCode: z.string().min(1, '院系代码不能为空'), + adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')), + adminLinePhone: z.string().optional(), + adminMobilePhone: z.string().optional(), + note: z.string().optional(), +}) + +type CreateDeptAdminInput = z.input + +const createDeptAdminDefaultValues: CreateDeptAdminInput = { + uid: '', + deptCode: '', + adminEmail: '', + adminLinePhone: '', + adminMobilePhone: '', + note: '', +} + +interface DeptAdminCreateDialogProps { + onDeptAdminCreated: () => void +} + +export function DeptAdminCreateDialog({ onDeptAdminCreated }: DeptAdminCreateDialogProps) { + // 表单 dialog 控制 + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + + // react-hook-form 管理创建表单 + const createForm = useForm({ + resolver: zodResolver(createDeptAdminSchema), + defaultValues: createDeptAdminDefaultValues, + }) + + // 获取院系列表 + const { data: depts } = trpc.common.getDepts.useQuery() + + const deptOptions = depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || [] + const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({ + options: deptOptions, + context: 'deptAdmin.create.dept', + scope: 'personal', + }) + + // 创建院系管理员 mutation + const createDeptAdminMutation = trpc.deptAdmin.create.useMutation({ + onSuccess: () => { + setIsCreateDialogOpen(false) + createForm.reset(createDeptAdminDefaultValues) + toast.success('院系管理员创建成功') + onDeptAdminCreated() + }, + onError: (error) => { + toast.error(error.message || '创建院系管理员失败') + }, + }) + + // 定义字段配置 + const formFields: FormFieldConfig[] = React.useMemo(() => [ + { + name: 'uid', + label: '用户ID', + required: true, + render: ({ field }) => ( + + ), + }, + { + name: 'deptCode', + label: '院系', + required: true, + render: ({ field }) => ( + { logDeptSelection(value); field.onChange(value) }} + filterFunction={(option, searchValue) => { + const search = searchValue.toLowerCase() + return option.id.includes(search) || option.name.toLowerCase().includes(search) || + (option.shortName && option.shortName.toLowerCase().includes(search)) + }} + > + + + + + + + + + + + ), + }, + { + name: 'adminEmail', + label: '邮箱', + render: ({ field }) => ( + + ), + }, + { + name: 'adminLinePhone', + label: '座机', + render: ({ field }) => ( + + ), + }, + { + name: 'adminMobilePhone', + label: '手机', + render: ({ field }) => ( + + ), + }, + { + name: 'note', + label: '备注', + render: ({ field }) => ( +