Compare commits

1 Commits
main ... main

Author SHA1 Message Date
5020bd1532 feat: Hair Keeper v1.1.0 版本更新
本次更新包含以下主要改进:

## 新功能
- 添加quickstart.sh脚本帮助用户快速使用模板项目
- 添加simple_deploy.sh便于部署
- 新增院系管理功能(DeptAdmin),支持增删改查院系管理员信息
- 用户可以在header中切换管理的院系
- 添加zustand全局状态管理
- 添加DEFAULT_USER_PASSWORD环境变量,作为创建用户时的默认密码
- 添加p-limit库和DB_PARALLEL_LIMIT环境变量控制数据库批次操作并发数

## 安全修复
- 修复Next.js CVE-2025-66478漏洞
- 限制只有超级管理员才能创建超级管理员用户

## 开发环境优化
- 开发终端兼容云端环境
- MinIO客户端直传兼容云端环境
- 开发容器增加vim和Claude Code插件
- 编程代理改用Claude
- docker-compose.yml添加全局name属性

## Bug修复与代码优化
- 删除用户时级联删除SelectionLog
- 手机端关闭侧边栏后刷新页面延迟调整(300ms=>350ms)
- instrumentation.ts移至src内部以适配生产环境
- 删除部分引发类型错误的无用代码
- 优化quickstart.sh远程仓库推送相关配置

## 文件变更
- 新增49个文件,修改多个配置和源代码文件
- 重构用户管理模块目录结构

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 16:58:55 +08:00
49 changed files with 2209 additions and 290 deletions

12
.claude/settings.json Normal file
View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Read(./.env.example)"
],
"deny": [
"Read(./.env)",
"Read(./.env.development)",
"Read(./.env.production)"
]
}
}

View File

@@ -44,9 +44,11 @@ RUN apt-get update && apt-get install -y \
cmake \ cmake \
telnet \ telnet \
redis-tools \ redis-tools \
iputils-ping \
potrace \ potrace \
imagemagick \ imagemagick \
zsh \ zsh \
vim \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 安装 Python 3.12 # 安装 Python 3.12
@@ -108,11 +110,11 @@ RUN mkdir -p /var/run/sshd && \
RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \ RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \
&& code-server --install-extension bierner.markdown-mermaid \ && code-server --install-extension bierner.markdown-mermaid \
&& code-server --install-extension ms-python.python \ && code-server --install-extension ms-python.python \
&& code-server --install-extension rooveterinaryinc.roo-cline \
&& code-server --install-extension dbaeumer.vscode-eslint \ && code-server --install-extension dbaeumer.vscode-eslint \
&& code-server --install-extension prisma.prisma \ && code-server --install-extension prisma.prisma \
&& code-server --install-extension ecmel.vscode-html-css \ && code-server --install-extension ecmel.vscode-html-css \
&& code-server --install-extension cweijan.vscode-redis-client && code-server --install-extension cweijan.vscode-redis-client \
&& code-server --install-extension anthropic.claude-code
# 配置 code-server (密码将在启动时设置) # 配置 code-server (密码将在启动时设置)
RUN mkdir -p /root/.config/code-server && \ RUN mkdir -p /root/.config/code-server && \

View File

@@ -29,6 +29,7 @@
- **cmake**: 构建工具 - **cmake**: 构建工具
- **telnet**: 网络调试 - **telnet**: 网络调试
- **redis-tools**: Redis 命令行工具 - **redis-tools**: Redis 命令行工具
- **ping**: 网络连通性测试
- **potrace**: 位图转矢量图 - **potrace**: 位图转矢量图
- **imagemagick**: 图像处理工具 - **imagemagick**: 图像处理工具
- **uv**: 快速 Python 包管理器 - **uv**: 快速 Python 包管理器

View File

@@ -11,14 +11,18 @@ services:
- "7681:7681" # ttyd (Web Terminal) - "7681:7681" # ttyd (Web Terminal)
- "3000:3000" # Next.js Dev Server - "3000:3000" # Next.js Dev Server
volumes: volumes:
# 项目代码映射(使用 cached 模式提高性能) # 项目代码映射(如果映射到Windows/Mac宿主机的本地目录使用 cached 模式提高性能)
- ../:/workspace:cached - ../:/workspace:cached
# node_modules 使用命名卷以提高性能 # Code Server 配置(包含 config.yaml 和密码)
- node_modules:/workspace/node_modules - code-server-config:/root/.config/code-server
# pnpm store 缓存 # Code Server 数据(插件、用户设置、扩展数据)
- pnpm_store:/root/.local/share/pnpm/store - code-server-data:/root/.local/share/code-server
# Git 配置(可选,如果需要保留 Git 配置) # Claude Code 配置和保存数据
- ~/.gitconfig:/root/.gitconfig:ro - claude:/root/.claude
# Claude Code Router 配置和保存数据
- claude-code-router:/root/.claude-code-router
# SSH 配置
- ssh:/root/.ssh
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- TZ=Asia/Shanghai - TZ=Asia/Shanghai

View File

@@ -26,6 +26,7 @@ POSTGRESQL_PASSWORD=
POSTGRESQL_PORT= POSTGRESQL_PORT=
DATABASE_URL= DATABASE_URL=
REDIS_HOST=
REDIS_PORT= REDIS_PORT=
REDIS_PASSWORD= REDIS_PASSWORD=
@@ -40,10 +41,12 @@ MINIO_BUCKET=
# 应用相关 # 应用相关
SUPER_ADMIN_PASSWORD= SUPER_ADMIN_PASSWORD=
USER_DEFAULT_PASSWORD=
## 数据库批次操作默认并发数
DB_PARALLEL_LIMIT =
# NextAuth.js Configuration # NextAuth.js Configuration
NEXTAUTH_SECRET= NEXTAUTH_SECRET=
NEXTAUTH_URL=
PKUAI_API_KEY= PKUAI_API_KEY=
PKUAI_API_BASE= PKUAI_API_BASE=
@@ -52,7 +55,9 @@ PKUAI_API_BASE=
# 仅在开发环境加载(写在.env.development中 # 仅在开发环境加载(写在.env.development中
PORT=
NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT= NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT=
NEXT_PUBLIC_DEV_TERMINAL_URL=
DEV_TERMINAL= DEV_TERMINAL=

View File

@@ -1,12 +0,0 @@
{
"mcpServers":{
"ai-elements": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://registry.ai-sdk.dev/api/mcp"
]
}
}
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"prisma.pinToPrisma6": true
}

94
CLAUDE.md Normal file
View File

@@ -0,0 +1,94 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目说明
本项目模板Hair Keeper v1.1.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
## 主要依赖库
本项目使用pnpm作为包管理器
- 基础next + react + trpc + prisma
- UI基础框架tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
- 图表等高级UIrecharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
- 用户交互增强motion(动画) + framer-motion(动画) + use-gesture/react(手势)
- Headless UIreact-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进行分析

View File

@@ -1,5 +1,5 @@
## 项目说明 ## 项目说明
本项目模板Hair Keeper v1.0.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。 本项目模板Hair Keeper v1.1.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。 Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
@@ -14,7 +14,7 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
- 数据和存储pg(PostgreSQL) + ioredis + minio - 数据和存储pg(PostgreSQL) + ioredis + minio
- 后台任务及消息队列bullmq - 后台任务及消息队列bullmq
- AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件) - 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 - 其他next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard
## 项目约定 ## 项目约定
@@ -27,11 +27,12 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
### 后端 ### 后端
- tRPC接口报错时可使用TRPCError例如throw new TRPCError({ code: 'NOT_FOUND', message: '' }) - tRPC接口报错时可使用TRPCError例如throw new TRPCError({ code: 'NOT_FOUND', message: '' })
- server/trpc.ts中预定义了publicProcedure用于创建无权限限制、也不需要登录的api - server/trpc.ts中导出了createTRPCRouter本质是t.router用来创建路由预定义了publicProcedure用于创建无权限限制、也不需要登录的api
- server/trpc.ts中预定义了publicProcedurepermissionRequiredProcedure用来创建限制特定权限访问的路由例如permissionRequiredProcedure(Permissions.USER_MANAGE)空字符串表示无权限要求但是需要用户登录约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问该权限不在Permissions中定义只有超级管理员才能绕过授权限制访问所有接口因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。 - 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 - 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient
- 时间字段统一使用 `@db.Timestamptz` 类型 - 时间字段统一使用 `@db.Timestamptz` 类型
- 前后端参数传递尽量使用扁平结构而非嵌套结构 - 前后端参数传递尽量使用扁平结构而非嵌套结构
@@ -58,6 +59,7 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
- `app/(main)/users/`用户管理模块提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现 - `app/(main)/users/`用户管理模块提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
- `hooks/`可复用React Hooks库部分复杂的组件也通过hook实现Headless UI逻辑与样式分离组件中可复用的逻辑都可以放在这 - `hooks/`可复用React Hooks库部分复杂的组件也通过hook实现Headless UI逻辑与样式分离组件中可复用的逻辑都可以放在这
- `lib/trpc.ts`创建并导出tRPC React客户端实例用于前端与后端API通信 - `lib/trpc.ts`创建并导出tRPC React客户端实例用于前端与后端API通信
- `lib/stores/`通过zustand管理的全局的状态
### 后端 ### 后端
- `server/routers/`项目trpc api定义文件开发者主要在这里定义和实现业务的后端API - `server/routers/`项目trpc api定义文件开发者主要在这里定义和实现业务的后端API
@@ -77,6 +79,7 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
### 其他 ### 其他
- `constants/`:项目全局常量管理 - `constants/`:项目全局常量管理
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)" - `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)"
- `constants/menu.ts`: 菜单管理工具库,提供菜单项定义、查询、权限过滤等功能
- `lib/schema/`集中管理数据验证schema定义前后端统一的数据结构和验证规则前端对默认值等其他要求写在表单组件中后端对默认值等其他要求写在接口文件中使用z.input而不是z.infer来获取Schema的输入类型 - `lib/schema/`集中管理数据验证schema定义前后端统一的数据结构和验证规则前端对默认值等其他要求写在表单组件中后端对默认值等其他要求写在接口文件中使用z.input而不是z.infer来获取Schema的输入类型
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序 - `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
- `lib/format.ts`:数据格式化工具函数库 - `lib/format.ts`:数据格式化工具函数库

View File

@@ -1,7 +1,8 @@
name: hair-keeper
services: services:
postgresql: postgresql:
image: bitnami/postgresql:17 # https://hub.docker.com/r/bitnami/postgresql image: bitnami/postgresql:17 # https://hub.docker.com/r/bitnami/postgresql
container_name: hair-keeper-dev-pg
restart: always restart: always
ports: ports:
- "${POSTGRESQL_PORT}:5432" - "${POSTGRESQL_PORT}:5432"
@@ -10,21 +11,19 @@ services:
POSTGRESQL_PASSWORD: ${POSTGRESQL_PASSWORD} POSTGRESQL_PASSWORD: ${POSTGRESQL_PASSWORD}
POSTGRESQL_INITDB_ARGS: "--encoding=UTF-8 --locale=C" POSTGRESQL_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
volumes: volumes:
- hair_keeper_postgresql_data:/bitnami/postgresql - postgresql_data:/bitnami/postgresql
redis: redis:
image: redis:8-alpine # https://hub.docker.com/_/redis image: redis:8-alpine # https://hub.docker.com/_/redis
container_name: hair-keeper-dev-redis
ports: ports:
- "${REDIS_PORT}:6379" - "${REDIS_PORT}:6379"
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes: volumes:
- hair_keeper_redis_data:/data - redis_data:/data
restart: always restart: always
minio: minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z image: minio/minio:RELEASE.2025-07-23T15-54-02Z
container_name: hair-keeper-dev-minio
ports: ports:
- "${MINIO_API_PORT}:9000" # API端口 - "${MINIO_API_PORT}:9000" # API端口
- "${MINIO_CONSOLE_PORT}:9001" # Console端口 - "${MINIO_CONSOLE_PORT}:9001" # Console端口
@@ -33,11 +32,11 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_SERVER_URL: ${MINIO_SERVER_URL} MINIO_SERVER_URL: ${MINIO_SERVER_URL}
volumes: volumes:
- hair_keeper_minio_data:/data - minio_data:/data
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
restart: always restart: always
volumes: volumes:
hair_keeper_postgresql_data: postgresql_data:
hair_keeper_redis_data: redis_data:
hair_keeper_minio_data: minio_data:

View File

@@ -1,13 +1,13 @@
{ {
"name": "hair-keeper", "name": "hair-keeper",
"version": "0.1.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000 --turbo", "dev": "next dev -p 3000 --turbo",
"dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M", "dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M",
"build": "next build", "build": "next build",
"start": "next start -p 3000", "start": "next start -p 3000",
"lint": "eslint", "lint": "next lint && tsc --noEmit",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"build:analyze": "ANALYZE=true next build" "build:analyze": "ANALYZE=true next build"
}, },
@@ -74,10 +74,11 @@
"minio": "^8.0.6", "minio": "^8.0.6",
"motion": "^12.23.22", "motion": "^12.23.22",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next": "~15.4.0", "next": "~15.4.8",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.6.0", "nuqs": "^2.6.0",
"p-limit": "^7.2.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"prisma": "^6.15.0", "prisma": "^6.15.0",
@@ -95,7 +96,8 @@
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.1.9" "zod": "^4.1.9",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@@ -107,7 +109,6 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.2", "eslint-config-next": "15.5.2",
"react-icons": "^5.5.0",
"react-live": "^4.1.8", "react-live": "^4.1.8",
"shadcn": "^3.5.0", "shadcn": "^3.5.0",
"tailwindcss": "^4", "tailwindcss": "^4",

View File

@@ -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;

View File

@@ -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;

View File

@@ -19,6 +19,7 @@ model User {
name String? name String?
status String? // 在校/减离/NULL status String? // 在校/减离/NULL
deptCode String? @map("dept_code") // 所属院系代码(外键) deptCode String? @map("dept_code") // 所属院系代码(外键)
currentManagedDept String? @map("current_managed_dept") @db.VarChar(5) // 当前正在管理的院系代码,用于支持院系管理员类型的角色
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
password String password String
@@ -29,6 +30,7 @@ model User {
dept Dept? @relation(fields: [deptCode], references: [code]) dept Dept? @relation(fields: [deptCode], references: [code])
roles Role[] // 多对多关联角色 roles Role[] // 多对多关联角色
selectionLogs SelectionLog[] // 选择日志 selectionLogs SelectionLog[] // 选择日志
deptAdmins DeptAdmin[] // 作为院系管理员的信息
@@map("user") @@map("user")
} }
@@ -41,10 +43,31 @@ model Dept {
// 关联 // 关联
users User[] users User[]
deptAdmins DeptAdmin[] // 院系管理员
@@map("dept") @@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 { model Role {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@@ -68,7 +91,7 @@ model Permission {
model SelectionLog { model SelectionLog {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String // 关联到用户 userId String // 关联到用户
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// 用于标识是哪个的选项,用.进行分隔,例如"user.filter.dept" // 用于标识是哪个的选项,用.进行分隔,例如"user.filter.dept"
context String context String

View File

@@ -3,9 +3,13 @@ import bcrypt from 'bcryptjs'
import { Permissions, ALL_PERMISSIONS } from '../src/constants/permissions' import { Permissions, ALL_PERMISSIONS } from '../src/constants/permissions'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import pLimit from 'p-limit'
const prisma = new PrismaClient() const prisma = new PrismaClient()
// 从环境变量获取并发限制默认为16
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
// 解析 JSON 文件并导入院系数据 // 解析 JSON 文件并导入院系数据
async function importDepartments() { async function importDepartments() {
const jsonPath = path.join(__dirname, 'init_data', '院系.json') const jsonPath = path.join(__dirname, 'init_data', '院系.json')
@@ -16,7 +20,7 @@ async function importDepartments() {
await Promise.all( await Promise.all(
departments.map((dept: any) => { departments.map((dept: any) => {
return prisma.dept.upsert({ return dbParallelLimit(() => prisma.dept.upsert({
where: { code: dept.id }, where: { code: dept.id },
update: { update: {
name: dept.name, name: dept.name,
@@ -27,7 +31,7 @@ async function importDepartments() {
name: dept.name, name: dept.name,
fullName: dept.full_name, fullName: dept.full_name,
}, },
}) }))
}) })
) )
console.log('院系数据导入完成') console.log('院系数据导入完成')
@@ -37,13 +41,15 @@ async function main() {
console.log('开始数据库初始化...') console.log('开始数据库初始化...')
// 插入权限 // 插入权限
for (const permName of ALL_PERMISSIONS) { await Promise.all(
await prisma.permission.upsert({ ALL_PERMISSIONS.map((permName) => {
where: { name: permName }, return dbParallelLimit(() => prisma.permission.upsert({
update: {}, where: { name: permName },
create: { name: permName }, update: {},
create: { name: permName },
}))
}) })
} )
// 角色与权限映射 // 角色与权限映射
const rolePermissionsMap: Record<string, string[]> = { const rolePermissionsMap: Record<string, string[]> = {
@@ -51,18 +57,20 @@ async function main() {
} }
// 插入角色 // 插入角色
for (const [roleName, perms] of Object.entries(rolePermissionsMap)) { await Promise.all(
await prisma.role.upsert({ Object.entries(rolePermissionsMap).map(([roleName, perms]) => {
where: { name: roleName }, return dbParallelLimit(() => prisma.role.upsert({
update: {}, where: { name: roleName },
create: { update: {},
name: roleName, create: {
permissions: { name: roleName,
connect: perms.map((name) => ({ name })), permissions: {
connect: perms.map((name) => ({ name })),
},
}, },
}, }))
}) })
} )
await importDepartments() await importDepartments()
@@ -74,33 +82,37 @@ async function main() {
{ id: 'unknown', name: '未知用户', status: '在校', deptCode: '00001', roleNames: [] }, { id: 'unknown', name: '未知用户', status: '在校', deptCode: '00001', roleNames: [] },
] ]
for (const u of usersToCreate) { await Promise.all(
const password = await bcrypt.hash(u.password ?? '123456', 12) usersToCreate.map((u) => {
await prisma.user.upsert({ return dbParallelLimit(async () => {
where: { id: u.id }, const password = await bcrypt.hash(u.password || process.env.USER_DEFAULT_PASSWORD || 'jeep4ahxahx7ee7U', 12)
update: { await prisma.user.upsert({
name: u.name, where: { id: u.id },
status: u.status, update: {
deptCode: u.deptCode, name: u.name,
password, status: u.status,
isSuperAdmin: u.isSuperAdmin ?? false, deptCode: u.deptCode,
roles: { password,
set: u.roleNames.map((name) => ({ name })), isSuperAdmin: u.isSuperAdmin ?? false,
}, roles: {
}, set: u.roleNames.map((name) => ({ name })),
create: { },
id: u.id, },
name: u.name, create: {
status: u.status, id: u.id,
deptCode: u.deptCode, name: u.name,
password, status: u.status,
isSuperAdmin: u.isSuperAdmin ?? false, deptCode: u.deptCode,
roles: { password,
connect: u.roleNames.map((name) => ({ name })), isSuperAdmin: u.isSuperAdmin ?? false,
}, roles: {
}, connect: u.roleNames.map((name) => ({ name })),
},
},
})
})
}) })
} )
// 插入文件类型(仅开发环境) // 插入文件类型(仅开发环境)
const fileTypes = [ const fileTypes = [
@@ -125,18 +137,19 @@ async function main() {
{ id: 'OTHER', name: '其他', description: '无法归入以上分类的文件' }, { id: 'OTHER', name: '其他', description: '无法归入以上分类的文件' },
] ]
for (let index = 0; index < fileTypes.length; index++) { await Promise.all(
const fileType = fileTypes[index] fileTypes.map((fileType, index) => {
await prisma.devFileType.upsert({ return dbParallelLimit(() => prisma.devFileType.upsert({
where: { id: fileType.id }, where: { id: fileType.id },
update: { update: {
name: fileType.name, name: fileType.name,
description: fileType.description, description: fileType.description,
order: (index + 1) * 10, order: (index + 1) * 10,
}, },
create: {...fileType, order: (index + 1) * 10}, create: {...fileType, order: (index + 1) * 10},
}))
}) })
} )
console.log('文件类型数据初始化完成') 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。' }, { 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++) { await Promise.all(
const pkgType = pkgTypes[index] pkgTypes.map((pkgType, index) => {
await prisma.devPkgType.upsert({ return dbParallelLimit(() => prisma.devPkgType.upsert({
where: { id: pkgType.id }, where: { id: pkgType.id },
update: { update: {
name: pkgType.name, name: pkgType.name,
description: pkgType.description, description: pkgType.description,
order: (index + 1) * 10, order: (index + 1) * 10,
}, },
create: {...pkgType, order: (index + 1) * 10}, create: {...pkgType, order: (index + 1) * 10},
}))
}) })
} )
console.log('依赖包类型数据初始化完成') console.log('依赖包类型数据初始化完成')
console.log('数据库初始化完成') console.log('数据库初始化完成')
@@ -177,4 +191,4 @@ main()
console.error(e) console.error(e)
await prisma.$disconnect() await prisma.$disconnect()
process.exit(1) process.exit(1)
}) })

450
quickstart.sh Executable file
View File

@@ -0,0 +1,450 @@
#!/bin/bash
# Hair Keeper 模板项目快速配置脚本
# 用于帮助用户快速配置环境变量和开发工具
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印带颜色的信息
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_header() {
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
}
# 读取用户输入,支持默认值
read_input() {
local prompt="$1"
local default="$2"
local allow_empty="$3"
local result
if [ -n "$default" ]; then
echo -n "$prompt [默认: $default]: " >&2
read result
result="${result:-$default}"
else
if [ "$allow_empty" = "true" ]; then
echo -n "$prompt (可为空): " >&2
read result
else
while [ -z "$result" ]; do
echo -n "$prompt: " >&2
read result
if [ -z "$result" ]; then
print_warning "此项不能为空,请重新输入"
fi
done
fi
fi
echo "$result"
}
# 读取密码输入(隐藏输入)
read_password() {
local prompt="$1"
local allow_empty="$2"
local result
if [ "$allow_empty" = "true" ]; then
echo -n "$prompt (可为空): " >&2
read -s result
echo "" >&2
else
while [ -z "$result" ]; do
echo -n "$prompt: " >&2
read -s result
echo "" >&2
if [ -z "$result" ]; then
print_warning "此项不能为空,请重新输入"
fi
done
fi
echo "$result"
}
# 生成随机密码
generate_password() {
local length="$1"
if command -v pwgen &> /dev/null; then
pwgen -s "$length" 1
else
# 如果没有 pwgen使用 openssl 或 /dev/urandom
if command -v openssl &> /dev/null; then
openssl rand -base64 "$length" | tr -dc 'a-zA-Z0-9' | head -c "$length"
else
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c "$length"
fi
fi
}
# 选择菜单
select_option() {
local prompt="$1"
shift
local options=("$@")
local choice
echo "$prompt" >&2
for i in "${!options[@]}"; do
echo " $((i+1)). ${options[$i]}" >&2
done
while true; do
echo -n "请输入选项编号: " >&2
read choice
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#options[@]}" ]; then
echo "$choice"
return
fi
print_warning "无效选项,请重新输入"
done
}
# 主函数
main() {
print_header "Hair Keeper 模板项目快速配置"
echo "本脚本将帮助您快速配置项目环境变量和开发工具"
echo ""
# ==================== 环境变量配置 ====================
print_header "环境变量配置"
# --- PostgreSQL 配置 ---
print_info "配置 PostgreSQL 数据库..."
echo ""
POSTGRESQL_USERNAME=$(read_input "PostgreSQL 用户名" "postgres")
POSTGRESQL_PASSWORD=$(read_password "PostgreSQL 密码" "true")
POSTGRESQL_PORT=$(read_input "PostgreSQL 端口" "5432")
echo ""
db_url_choice=$(select_option "DATABASE_URL 配置方式:" "自动构造 PostgreSQL URL" "手动输入完整 URL")
if [ "$db_url_choice" = "1" ]; then
POSTGRESQL_HOSTNAME=$(read_input "PostgreSQL 主机名" "postgresql")
POSTGRESQL_DBNAME=$(read_input "PostgreSQL 数据库名" "postgres")
POSTGRESQL_SCHEMA=$(read_input "PostgreSQL 模式名" "public")
if [ -n "$POSTGRESQL_PASSWORD" ]; then
DATABASE_URL="postgresql://${POSTGRESQL_USERNAME}:${POSTGRESQL_PASSWORD}@${POSTGRESQL_HOSTNAME}:${POSTGRESQL_PORT}/${POSTGRESQL_DBNAME}?schema=${POSTGRESQL_SCHEMA}"
else
DATABASE_URL="postgresql://${POSTGRESQL_USERNAME}@${POSTGRESQL_HOSTNAME}:${POSTGRESQL_PORT}/${POSTGRESQL_DBNAME}?schema=${POSTGRESQL_SCHEMA}"
fi
else
DATABASE_URL=$(read_input "DATABASE_URL" "")
fi
echo ""
print_success "PostgreSQL 配置完成"
echo ""
# --- Redis 配置 ---
print_info "配置 Redis..."
echo ""
REDIS_HOST=$(read_input "Redis 主机名" "redis")
REDIS_PORT=$(read_input "Redis 端口" "6379")
REDIS_PASSWORD=$(read_password "Redis 密码" "true")
echo ""
print_success "Redis 配置完成"
echo ""
# --- MinIO 配置 ---
print_info "配置 MinIO 对象存储..."
echo ""
MINIO_ENDPOINT=$(read_input "MinIO 端点地址" "minio")
MINIO_API_PORT=$(read_input "MinIO API 端口" "9000")
MINIO_CONSOLE_PORT=$(read_input "MinIO 控制台端口" "9001")
MINIO_USE_SSL=$(read_input "MinIO 是否使用 SSL (true/false)" "false")
MINIO_ROOT_USER=$(read_input "MinIO 用户名" "admin")
MINIO_ROOT_PASSWORD=$(read_password "MinIO 密码" "true")
MINIO_SERVER_URL=$(read_input "MinIO 服务器 URL (用于生成公开访问链接)" "" "true")
MINIO_BUCKET=$(read_input "MinIO 存储桶名称" "app-files")
echo ""
print_success "MinIO 配置完成"
echo ""
# --- 应用配置 ---
print_info "配置应用参数..."
echo ""
SUPER_ADMIN_PASSWORD=$(generate_password 16)
USER_DEFAULT_PASSWORD=$(generate_password 16)
NEXTAUTH_SECRET=$(generate_password 32)
print_info "已自动生成以下密码:"
echo " SUPER_ADMIN_PASSWORD: $SUPER_ADMIN_PASSWORD"
echo " USER_DEFAULT_PASSWORD: $USER_DEFAULT_PASSWORD"
echo " NEXTAUTH_SECRET: $NEXTAUTH_SECRET"
echo ""
DB_PARALLEL_LIMIT=$(read_input "数据库批次操作默认并发数" "32")
echo ""
print_success "应用参数配置完成"
echo ""
# --- AI API 配置 ---
print_info "配置 AI API..."
echo ""
PKUAI_API_KEY=$(read_password "PKU AI API Key" "true")
PKUAI_API_BASE=$(read_input "PKU AI API Base URL" "https://chat.noc.pku.edu.cn/")
echo ""
print_success "AI API 配置完成"
echo ""
# --- 开发环境配置 ---
print_info "配置开发环境参数..."
echo ""
DEV_PORT=$(read_input "开发服务器端口" "3000")
DEV_TERMINAL_PORT=$(read_input "开发终端默认端口" "7681")
DEV_TERMINAL_URL=$(read_input "开发终端 URL" "" "true")
DEV_TERMINAL=$(read_input "开发终端 tmux session 名称" "nextdev")
echo ""
print_success "开发环境配置完成"
echo ""
# ==================== 写入配置文件 ====================
print_header "写入配置文件"
# 写入 .env 文件
print_info "写入 .env 文件..."
cat > .env << EOF
# ==================== 容器相关 ====================
POSTGRESQL_USERNAME=${POSTGRESQL_USERNAME}
POSTGRESQL_PASSWORD=${POSTGRESQL_PASSWORD}
POSTGRESQL_PORT=${POSTGRESQL_PORT}
DATABASE_URL="${DATABASE_URL}"
REDIS_HOST=${REDIS_HOST}
REDIS_PORT=${REDIS_PORT}
REDIS_PASSWORD=${REDIS_PASSWORD}
MINIO_ENDPOINT=${MINIO_ENDPOINT}
MINIO_API_PORT=${MINIO_API_PORT}
MINIO_CONSOLE_PORT=${MINIO_CONSOLE_PORT}
MINIO_USE_SSL=${MINIO_USE_SSL}
MINIO_ROOT_USER=${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
MINIO_SERVER_URL=${MINIO_SERVER_URL}
MINIO_BUCKET=${MINIO_BUCKET}
# ==================== 应用相关 ====================
SUPER_ADMIN_PASSWORD=${SUPER_ADMIN_PASSWORD}
USER_DEFAULT_PASSWORD=${USER_DEFAULT_PASSWORD}
## 数据库批次操作默认并发数
DB_PARALLEL_LIMIT=${DB_PARALLEL_LIMIT}
# ==================== NextAuth.js Configuration ====================
NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
# ==================== AI API ====================
PKUAI_API_KEY=${PKUAI_API_KEY}
PKUAI_API_BASE=${PKUAI_API_BASE}
EOF
print_success ".env 文件已创建"
# 写入 .env.development 文件
print_info "写入 .env.development 文件..."
cat > .env.development << EOF
# 仅在开发环境加载
PORT=${DEV_PORT}
NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT=${DEV_TERMINAL_PORT}
NEXT_PUBLIC_DEV_TERMINAL_URL=${DEV_TERMINAL_URL}
DEV_TERMINAL=${DEV_TERMINAL}
EOF
print_success ".env.development 文件已创建"
# 写入 .env.production 文件
print_info "写入 .env.production 文件..."
cat > .env.production << EOF
# 仅在生产环境加载
EOF
print_success ".env.production 文件已创建"
# ==================== Claude Code 编程代理配置 ====================
print_header "Claude Code 编程代理配置"
# 重命名 CLAUDE.md
if [ -f "CLAUDE.md" ]; then
print_info "重命名 CLAUDE.md 为 TEMPLATE_README.md..."
mv CLAUDE.md TEMPLATE_README.md
print_success "CLAUDE.md 已重命名为 TEMPLATE_README.md"
fi
# 创建新的 CLAUDE.md
print_info "创建新的 CLAUDE.md..."
cat > CLAUDE.md << 'EOF'
# CLAUDE.md
本文件为 AI 代理(如 Claude提供在本代码库中工作的指导说明。
## 项目说明
本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md目前尚未实现业务功能
EOF
print_success "新的 CLAUDE.md 已创建"
# 清空 README.md
print_info "清空 README.md..."
> README.md
print_success "README.md 已清空"
# 创建 Claude Code Router 配置
print_info "配置 Claude Code Router..."
mkdir -p ~/.claude-code-router
cat > ~/.claude-code-router/config.json << EOF
{
"LOG": false,
"LOG_LEVEL": "debug",
"CLAUDE_PATH": "",
"HOST": "127.0.0.1",
"PORT": 3456,
"APIKEY": "",
"API_TIMEOUT_MS": "600000",
"PROXY_URL": "",
"transformers": [],
"Providers": [
{
"name": "pku-anthropic",
"api_base_url": "${PKUAI_API_BASE}api/anthropic/v1/messages",
"api_key": "${PKUAI_API_KEY}",
"models": [
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101",
"claude-haiku-4-5-20251001"
],
"transformer": {
"use": [
"Anthropic"
]
}
}
],
"StatusLine": {
"enabled": false,
"currentStyle": "default",
"default": {
"modules": []
},
"powerline": {
"modules": []
}
},
"Router": {
"default": "pku-anthropic,claude-opus-4-5-20251101",
"background": "pku-anthropic,claude-haiku-4-5-20251001",
"think": "pku-anthropic,claude-opus-4-5-20251101",
"longContext": "pku-anthropic,claude-opus-4-5-20251101",
"longContextThreshold": 80000,
"webSearch": "",
"image": "pku-anthropic,claude-opus-4-5-20251101"
},
"CUSTOM_ROUTER_PATH": ""
}
EOF
print_success "Claude Code Router 配置已写入 ~/.claude-code-router/config.json"
# ==================== Git 版本控制初始化 ====================
print_header "Git 版本控制初始化"
# 删除模板项目的 git 仓库
if [ -d ".git" ]; then
print_info "删除模板项目的 git 仓库..."
rm -rf .git
print_success "已删除 .git 目录"
fi
# 获取用户的 git 配置信息
print_info "配置 Git 用户信息..."
echo ""
GIT_USER_EMAIL=$(read_input "Git 用户邮箱" "")
GIT_USER_NAME=$(read_input "Git 用户名" "")
echo ""
git init
git config init.defaultBranch main
print_info "设置 Git 用户配置..."
git config user.email "$GIT_USER_EMAIL"
git config user.name "$GIT_USER_NAME"
print_success "Git 用户配置已设置"
# 初始化新的 git 仓库
print_info "初始化 Git 仓库..."
git add .
git commit -m "init"
git branch -M main
print_success "Git 仓库已初始化并完成首次提交 (主分支: main)"
# 配置远程仓库
echo ""
print_info "配置远程仓库 (格式: https://用户名:密码@gitea.example.com/用户名/仓库名.git)"
GIT_REMOTE_URL=$(read_input "远程仓库链接" "" "true")
if [ -n "$GIT_REMOTE_URL" ]; then
git remote add origin "$GIT_REMOTE_URL"
print_success "远程仓库已配置: $GIT_REMOTE_URL"
else
print_info "跳过远程仓库配置"
fi
# ==================== 完成提示 ====================
print_header "配置完成!"
echo -e "${GREEN}所有配置已完成!${NC}"
echo ""
echo "Claude Code 编程代理使用说明:"
echo -e " ${CYAN}ccr code${NC} - 打开 Claude Code 编程代理,可通过对话完成编程任务"
echo -e " ${CYAN}ccr ui${NC} - 配置 Claude Code 编程代理对接的大模型"
echo -e " ${CYAN}ccr code --resume${NC} - 查看历史对话"
echo ""
echo "接下来您可以进行以下操作:"
echo -e " ${CYAN}1. pnpm install${NC} - 安装依赖包"
echo -e " ${CYAN}2. pnpm prisma migrate dev${NC} - 迁移数据库并初始化 Prisma 客户端"
echo -e " ${CYAN}3. pnpm run db:seed${NC} - 初始化数据库数据"
echo -e " ${CYAN}4. pnpm run dev${NC} - 运行开发服务器"
echo -e " ${CYAN}5. ccr code${NC} - 打开编程代理后,先输入 ${YELLOW}/init <我的项目的主要功能是...>${NC} 初始化"
echo " 然后再开始使用 AI 完成编程任务"
echo ""
print_success "祝您开发愉快!"
}
# 运行主函数
main

69
simple_deploy.sh Normal file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# 此脚本用来一键部署到生产服务器
# 需事先配置好ssh免密登录目标服务器需要安装好node、pnpm、tsx、pm2来运行容器
set -e
# 配置
REMOTE_USER=""
REMOTE_HOST=""
REMOTE_PORT=""
PROJECT_NAME=""
REMOTE_DIR="~/$PROJECT_NAME"
pnpm run lint
echo "🔨 开始构建项目..."
pnpm run build
echo "📦 准备部署文件..."
# 创建临时目录
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
# 复制必要文件
cp -r .next $TEMP_DIR/
cp -r public $TEMP_DIR/
cp -r prisma $TEMP_DIR/
cp package.json $TEMP_DIR/
cp pnpm-lock.yaml $TEMP_DIR/
cp next.config.ts $TEMP_DIR/
echo "🚀 上传文件到服务器..."
ssh ${REMOTE_USER}@${REMOTE_HOST} "mkdir -p ${REMOTE_DIR}"
rsync -avz --delete \
--exclude=node_modules \
--exclude=.git \
$TEMP_DIR/ ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/
# 上传环境变量文件
echo "📝 上传环境变量文件..."
[ -f .env ] && scp .env ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/.env
[ -f .env.production ] && scp .env.production ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/.env.production
echo "⚙️ 在服务器上配置和启动服务..."
ssh ${REMOTE_USER}@${REMOTE_HOST} << ENDSSH
cd ${REMOTE_DIR}
# 安装生产依赖包括prisma用于数据库迁移和pm2用于进程管理
echo "📥 安装依赖..."
pnpm install --prod
# 运行数据库迁移
echo "🗄️ 运行数据库迁移..."
npx prisma migrate deploy
npx prisma generate
# 使用PM2管理Next.js服务
echo "🔄 使用PM2重启服务..."
/home/user/.local/share/pnpm/pm2 delete $PROJECT_NAME || true
/home/user/.local/share/pnpm/pm2 start pnpm --name $PROJECT_NAME -- start -p ${REMOTE_PORT}
# 保存当前 PM2 进程列表的快照,使其在系统重启后能自动恢复
/home/user/.local/share/pnpm/pm2 save
echo "✅ 部署完成!服务运行在端口 ${REMOTE_PORT}"
echo "📊 查看服务状态: pm2 status"
echo "📝 查看日志: pm2 logs $PROJECT_NAME"
echo "❌ 关闭服务: pm2 delete $PROJECT_NAME"
ENDSSH
echo "🎉 部署成功!"
echo "访问地址: http://${REMOTE_HOST}:${REMOTE_PORT}"

View File

@@ -126,8 +126,7 @@ export function DevTools() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const port = process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681' window.open(process.env.NEXT_PUBLIC_DEV_TERMINAL_URL || `http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`, '_blank')
window.open(`http://localhost:${port}`, '_blank')
}} }}
disabled={!terminalLoaded} disabled={!terminalLoaded}
className="gap-1.5" className="gap-1.5"
@@ -194,7 +193,7 @@ export function DevTools() {
<div className="w-full h-full"> <div className="w-full h-full">
{terminalLoaded ? ( {terminalLoaded ? (
<iframe <iframe
src={`http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`} src={process.env.NEXT_PUBLIC_DEV_TERMINAL_URL || `http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`}
className="w-full h-full border-0 rounded-md bg-black" className="w-full h-full border-0 rounded-md bg-black"
title="开发终端" title="开发终端"
/> />

View File

@@ -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<DeptAdmin>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
size: 32,
enableSorting: false,
enableHiding: false,
},
{
id: 'id',
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="ID" />
),
cell: ({ row }) => <div className="font-medium">{row.original.id}</div>,
meta: {
label: 'ID',
},
},
{
id: 'uid',
accessorKey: 'uid',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="用户ID" />
),
cell: ({ row }) => <div className="font-medium">{row.original.uid}</div>,
enableColumnFilter: true,
meta: {
label: '用户ID',
filter: {
placeholder: '请输入用户ID',
variant: 'text',
}
},
},
{
id: 'userName',
accessorKey: 'user.name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="姓名" />
),
cell: ({ row }) => <div>{row.original.user?.name || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '姓名',
filter: {
placeholder: '请输入姓名',
variant: 'text',
}
},
},
{
id: 'deptCode',
accessorKey: 'deptCode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="院系" />
),
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.dept?.name || '-'}</div>
<div className="text-xs text-muted-foreground">{row.original.deptCode}</div>
</div>
),
enableColumnFilter: true,
meta: {
label: '院系',
filter: {
variant: 'multiSelect',
options: options.depts?.map(dept => ({
id: dept.code,
name: dept.fullName,
})) || [],
}
},
},
{
id: 'adminEmail',
accessorKey: 'adminEmail',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="邮箱" />
),
cell: ({ row }) => <div>{row.original.adminEmail || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '邮箱',
filter: {
placeholder: '请输入邮箱',
variant: 'text',
}
},
},
{
id: 'adminLinePhone',
accessorKey: 'adminLinePhone',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="座机" />
),
cell: ({ row }) => <div>{row.original.adminLinePhone || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '座机',
filter: {
placeholder: '请输入座机',
variant: 'text',
}
},
},
{
id: 'adminMobilePhone',
accessorKey: 'adminMobilePhone',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="手机" />
),
cell: ({ row }) => <div>{row.original.adminMobilePhone || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '手机',
filter: {
placeholder: '请输入手机',
variant: 'text',
}
},
},
{
id: 'note',
accessorKey: 'note',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="备注" />
),
cell: ({ row }) => (
<div className="max-w-[200px] truncate" title={row.original.note || ''}>
{row.original.note || '-'}
</div>
),
enableSorting: false,
meta: {
label: '备注',
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="创建时间" />
),
cell: ({ row }) => {
return <div>{formatDate(row.original.createdAt) || '-'}</div>
},
meta: {
label: '创建时间',
}
},
{
id: 'updatedAt',
accessorKey: 'updatedAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="更新时间" />
),
cell: ({ row }) => {
return <div>{formatDate(row.original.updatedAt) || '-'}</div>
},
meta: {
label: '更新时间',
}
},
{
id: 'actions',
cell: ({ row }) => {
const deptAdmin = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 md:w-9">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => actions.onEdit(deptAdmin.id)}>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => actions.onDelete(deptAdmin.id)}
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
size: 32,
enableSorting: false,
enableHiding: false,
},
]

View File

@@ -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<typeof createDeptAdminSchema>
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<CreateDeptAdminInput>({
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 }) => (
<Input {...field} placeholder="请输入用户ID职工号" />
),
},
{
name: 'deptCode',
label: '院系',
required: true,
render: ({ field }) => (
<AdvancedSelect
{...field}
options={sortedDeptOptions}
onChange={(value) => { 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))
}}
>
<SelectPopover>
<SelectTrigger placeholder="请选择院系">
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索院系名称/代码" />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
),
},
{
name: 'adminEmail',
label: '邮箱',
render: ({ field }) => (
<Input {...field} type="email" placeholder="请输入邮箱" />
),
},
{
name: 'adminLinePhone',
label: '座机',
render: ({ field }) => (
<Input {...field} placeholder="请输入座机号码" />
),
},
{
name: 'adminMobilePhone',
label: '手机',
render: ({ field }) => (
<Input {...field} placeholder="请输入手机号码" />
),
},
{
name: 'note',
label: '备注',
render: ({ field }) => (
<Textarea {...field} placeholder="请输入备注信息" className="min-h-[80px]" />
),
},
], [sortedDeptOptions, logDeptSelection])
const handleSubmit = async (data: CreateDeptAdminInput) => {
createDeptAdminMutation.mutate(data)
}
const handleClose = () => {
setIsCreateDialogOpen(false)
}
return (
<>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
</Dialog>
<FormDialog
isOpen={isCreateDialogOpen}
title="创建院系管理员"
description="请填写院系管理员信息"
form={createForm}
fields={formFields}
onClose={handleClose}
>
<FormGridContent />
<FormActionBar>
<FormCancelAction />
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={createDeptAdminMutation.isPending}></FormSubmitAction>
</FormActionBar>
</FormDialog>
</>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import React from 'react'
import { trpc } from '@/lib/trpc'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
interface DeptAdminDeleteDialogProps {
deptAdminId: number | null
isOpen: boolean
onClose: () => void
onDeptAdminDeleted: () => void
}
export function DeptAdminDeleteDialog({
deptAdminId,
isOpen,
onClose,
onDeptAdminDeleted,
}: DeptAdminDeleteDialogProps) {
// 获取院系管理员详情
const { data: deptAdmin } = trpc.deptAdmin.getById.useQuery(
{ id: deptAdminId! },
{ enabled: !!deptAdminId && isOpen }
)
// 删除院系管理员 mutation
const deleteDeptAdminMutation = trpc.deptAdmin.delete.useMutation({
onSuccess: () => {
onClose()
toast.success('院系管理员删除成功')
onDeptAdminDeleted()
},
onError: (error) => {
toast.error(error.message || '删除院系管理员失败')
},
})
const handleConfirm = () => {
if (deptAdminId) {
deleteDeptAdminMutation.mutate({ id: deptAdminId })
}
}
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deptAdmin ? (
<>
<strong>{deptAdmin.user?.name || deptAdmin.uid}</strong> <strong>{deptAdmin.dept?.name || deptAdmin.deptCode}</strong>
<br />
</>
) : (
'确定要删除该院系管理员吗?此操作无法撤销。'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteDeptAdminMutation.isPending}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={deleteDeptAdminMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteDeptAdminMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import React, { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { trpc } from '@/lib/trpc'
import { Input } from '@/components/ui/input'
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 updateDeptAdminSchema = z.object({
id: z.number(),
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 UpdateDeptAdminInput = z.input<typeof updateDeptAdminSchema>
interface DeptAdminUpdateDialogProps {
deptAdminId: number | null
isOpen: boolean
onClose: () => void
onDeptAdminUpdated: () => void
}
export function DeptAdminUpdateDialog({
deptAdminId,
isOpen,
onClose,
onDeptAdminUpdated,
}: DeptAdminUpdateDialogProps) {
// react-hook-form 管理更新表单
const updateForm = useForm<UpdateDeptAdminInput>({
resolver: zodResolver(updateDeptAdminSchema),
defaultValues: {
id: 0,
uid: '',
deptCode: '',
adminEmail: '',
adminLinePhone: '',
adminMobilePhone: '',
note: '',
},
})
// 获取院系列表
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.update.dept',
scope: 'personal',
})
// 获取院系管理员详情
const { data: deptAdmin, isLoading } = trpc.deptAdmin.getById.useQuery(
{ id: deptAdminId! },
{ enabled: !!deptAdminId && isOpen }
)
// 更新院系管理员 mutation
const updateDeptAdminMutation = trpc.deptAdmin.update.useMutation({
onSuccess: () => {
onClose()
toast.success('院系管理员更新成功')
onDeptAdminUpdated()
},
onError: (error) => {
toast.error(error.message || '更新院系管理员失败')
},
})
// 当获取到院系管理员数据时,填充表单
useEffect(() => {
if (deptAdmin) {
updateForm.reset({
id: deptAdmin.id,
uid: deptAdmin.uid,
deptCode: deptAdmin.deptCode,
adminEmail: deptAdmin.adminEmail || '',
adminLinePhone: deptAdmin.adminLinePhone || '',
adminMobilePhone: deptAdmin.adminMobilePhone || '',
note: deptAdmin.note || '',
})
}
}, [deptAdmin, updateForm])
// 定义字段配置
const formFields: FormFieldConfig[] = React.useMemo(() => [
{
name: 'uid',
label: '用户ID',
required: true,
render: ({ field }) => (
<Input {...field} placeholder="请输入用户ID职工号" />
),
},
{
name: 'deptCode',
label: '院系',
required: true,
render: ({ field }) => (
<AdvancedSelect
{...field}
options={sortedDeptOptions}
onChange={(value) => { 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))
}}
>
<SelectPopover>
<SelectTrigger placeholder="请选择院系">
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索院系名称/代码" />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
),
},
{
name: 'adminEmail',
label: '邮箱',
render: ({ field }) => (
<Input {...field} type="email" placeholder="请输入邮箱" />
),
},
{
name: 'adminLinePhone',
label: '座机',
render: ({ field }) => (
<Input {...field} placeholder="请输入座机号码" />
),
},
{
name: 'adminMobilePhone',
label: '手机',
render: ({ field }) => (
<Input {...field} placeholder="请输入手机号码" />
),
},
{
name: 'note',
label: '备注',
render: ({ field }) => (
<Textarea {...field} placeholder="请输入备注信息" className="min-h-[80px]" />
),
},
], [sortedDeptOptions, logDeptSelection])
const handleSubmit = async (data: UpdateDeptAdminInput) => {
updateDeptAdminMutation.mutate(data)
}
return (
<FormDialog
isOpen={isOpen}
title="编辑院系管理员"
description="修改院系管理员信息"
form={updateForm}
fields={formFields}
onClose={onClose}
isLoading={isLoading}
>
<FormGridContent />
<FormActionBar>
<FormCancelAction />
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={updateDeptAdminMutation.isPending}></FormSubmitAction>
</FormActionBar>
</FormDialog>
)
}

View File

@@ -0,0 +1,143 @@
'use client'
import React, { useState, useCallback, useMemo, Suspense } from 'react'
import { trpc } from '@/lib/trpc'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DeptAdminCreateDialog } from './components/DeptAdminCreateDialog'
import { DeptAdminUpdateDialog } from './components/DeptAdminUpdateDialog'
import { DeptAdminDeleteDialog } from './components/DeptAdminDeleteDialog'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableToolbar } from '@/components/data-table/toolbar'
import { createDeptAdminColumns, type DeptAdminColumnsOptions } from './columns'
import type { DeptAdmin } from '@/server/routers/dept-admin'
import { useDataTable } from '@/hooks/use-data-table'
import { keepPreviousData } from '@tanstack/react-query'
import { DataTableSortList } from '@/components/data-table/sort-list'
import { toast } from 'sonner'
import { DataTableSkeleton } from '@/components/data-table/table-skeleton'
interface DeptAdminPageDataTableProps {
onEdit: (id: number) => void
onDelete: (id: number) => void
}
function DeptAdminPageDataTable({ onEdit, onDelete }: DeptAdminPageDataTableProps) {
// 获取部门列表用于过滤器选项
const { data: depts } = trpc.common.getDepts.useQuery()
// 创建表格列定义选项
const columnsOptions: DeptAdminColumnsOptions = useMemo(() => ({
depts: depts || [],
}), [depts])
// 创建表格列定义
const columns = useMemo(() => createDeptAdminColumns({
onEdit,
onDelete,
}, columnsOptions), [onEdit, onDelete, columnsOptions])
// 使用 useDataTable hook传入 queryFn
const { table, queryResult } = useDataTable<DeptAdmin>({
columns,
initialState: {
pagination: { pageIndex: 1, pageSize: 10 },
columnPinning: { left: ["select"], right: ["actions"] },
columnVisibility: { id: false }
},
getRowId: (row) => row.id.toString(),
queryFn: useCallback((params) => {
const result = trpc.deptAdmin.list.useQuery(params, {
placeholderData: keepPreviousData,
})
if (result.error) {
toast.error("获取院系管理员数据失败:" + result.error.toString().substring(0, 100))
}
return result
}, []),
})
return (
<DataTable table={table} isLoading={queryResult.isLoading}>
<DataTableToolbar table={table}>
<DataTableSortList table={table} />
</DataTableToolbar>
</DataTable>
)
}
export default function DeptAdminPage() {
// 更新院系管理员对话框状态
const [updateDeptAdminId, setUpdateDeptAdminId] = useState<number | null>(null)
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
// 删除院系管理员对话框状态
const [deleteDeptAdminId, setDeleteDeptAdminId] = useState<number | null>(null)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
// 用于刷新数据的 utils
const utils = trpc.useUtils()
// 处理编辑院系管理员
const handleEditDeptAdmin = useCallback((id: number) => {
setUpdateDeptAdminId(id)
setIsUpdateDialogOpen(true)
}, [])
// 关闭更新对话框
const handleCloseUpdateDialog = useCallback(() => {
setIsUpdateDialogOpen(false)
setUpdateDeptAdminId(null)
}, [])
// 处理删除院系管理员
const handleDeleteDeptAdmin = useCallback((id: number) => {
setDeleteDeptAdminId(id)
setIsDeleteDialogOpen(true)
}, [])
// 关闭删除对话框
const handleCloseDeleteDialog = useCallback(() => {
setIsDeleteDialogOpen(false)
setDeleteDeptAdminId(null)
}, [])
// 刷新院系管理员列表
const handleRefreshDeptAdmins = useCallback(() => {
utils.deptAdmin.list.invalidate()
}, [utils])
return (
<div className="space-y-6">
{/* 院系管理员列表和创建按钮 */}
<Card>
<CardHeader className="flex items-center justify-between">
<CardTitle className="text-lg"></CardTitle>
<div className="flex items-center gap-2">
<DeptAdminCreateDialog onDeptAdminCreated={handleRefreshDeptAdmins} />
</div>
</CardHeader>
<CardContent>
<Suspense fallback={<DataTableSkeleton columnCount={10} rowCount={10} />}>
<DeptAdminPageDataTable onEdit={handleEditDeptAdmin} onDelete={handleDeleteDeptAdmin} />
</Suspense>
</CardContent>
</Card>
{/* 更新院系管理员对话框 */}
<DeptAdminUpdateDialog
deptAdminId={updateDeptAdminId}
isOpen={isUpdateDialogOpen}
onClose={handleCloseUpdateDialog}
onDeptAdminUpdated={handleRefreshDeptAdmins}
/>
{/* 删除院系管理员对话框 */}
<DeptAdminDeleteDialog
deptAdminId={deleteDeptAdminId}
isOpen={isDeleteDialogOpen}
onClose={handleCloseDeleteDialog}
onDeptAdminDeleted={handleRefreshDeptAdmins}
/>
</div>
)
}

View File

@@ -0,0 +1,13 @@
"use client";
export default function UsersLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
</>
);
}

View File

@@ -207,7 +207,7 @@ export function RoleManagementDialog() {
options={permissions.map(p => ({ ...p, id: p.id.toString() }))} options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
value={editingRole.permissionIds.map(String)} value={editingRole.permissionIds.map(String)}
onChange={handlePermissionChange} onChange={handlePermissionChange}
multiple={{ enable: true, limit: 1 }} multiple={{ enable: true }}
> >
<SelectPopover> <SelectPopover>
<SelectTrigger placeholder="选择权限"> <SelectTrigger placeholder="选择权限">

View File

@@ -218,6 +218,7 @@ export function UserUpdateDialog({ userId, isOpen, onClose, onUserUpdated }: Use
form={updateForm} form={updateForm}
fields={formFields} fields={formFields}
onClose={handleClose} onClose={handleClose}
isLoading={isLoadingUser}
> >
<FormGridContent /> <FormGridContent />
<FormActionBar> <FormActionBar>

View File

@@ -29,7 +29,7 @@ export function WelcomeDialog({ open, onOpenChange }: WelcomeDialogProps) {
<DialogTitle className="text-2xl"></DialogTitle> <DialogTitle className="text-2xl"></DialogTitle>
</div> </div>
<DialogDescription className="pt-4 text-base"> <DialogDescription className="pt-4 text-base">
{/* 内容暂时没想好,先不实现 */} Hair Keeper
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">

View File

@@ -32,12 +32,14 @@ import {
import { useIsMobile } from '@/hooks/use-mobile' import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
// FormDialog Context // FormDialog Context
export interface FormDialogContextValue { export interface FormDialogContextValue {
form: UseFormReturn<any> form: UseFormReturn<any>
close: () => void close: () => void
fields: FormFieldConfig[] fields: FormFieldConfig[]
isLoading?: boolean
} }
const FormDialogContext = createContext<FormDialogContextValue | null>(null) const FormDialogContext = createContext<FormDialogContextValue | null>(null)
@@ -77,7 +79,7 @@ export function FormCancelAction({ children = '取消', variant = 'outline', onC
} }
return ( return (
<Button type="button" variant={variant} onClick={handleClick} {...props}> <Button type="button" variant={variant} onClick={handleClick} disabled={props.disabled} {...props}>
{children} {children}
</Button> </Button>
) )
@@ -99,7 +101,7 @@ export function FormResetAction({
confirmDescription = '确定要重置表单吗?表单将回到打开时的状态。', confirmDescription = '确定要重置表单吗?表单将回到打开时的状态。',
...props ...props
}: FormResetActionProps) { }: FormResetActionProps) {
const { form } = useFormDialogContext() const { form, isLoading } = useFormDialogContext()
const [showConfirm, setShowConfirm] = useState(false) const [showConfirm, setShowConfirm] = useState(false)
const handleConfirm = () => { const handleConfirm = () => {
@@ -113,7 +115,7 @@ export function FormResetAction({
return ( return (
<> <>
<Button type="button" variant={variant} onClick={() => setShowConfirm(true)} {...props}> <Button type="button" variant={variant} onClick={() => setShowConfirm(true)} disabled={isLoading || props.disabled} {...props}>
{children} {children}
</Button> </Button>
@@ -151,14 +153,14 @@ export function FormSubmitAction({
variant = 'default', variant = 'default',
...props ...props
}: FormSubmitActionProps) { }: FormSubmitActionProps) {
const { form } = useFormDialogContext() const { form, isLoading } = useFormDialogContext()
return ( return (
<Button <Button
type="button" type="button"
variant={variant} variant={variant}
onClick={form.handleSubmit(onSubmit)} onClick={form.handleSubmit(onSubmit)}
disabled={isSubmitting || disabled} disabled={isSubmitting || disabled || isLoading}
{...props} {...props}
> >
{children} {children}
@@ -186,7 +188,21 @@ export interface FormGridContentProps {
} }
export function FormGridContent({ className = 'grid grid-cols-1 gap-4' }: FormGridContentProps) { export function FormGridContent({ className = 'grid grid-cols-1 gap-4' }: FormGridContentProps) {
const { form, fields } = useFormDialogContext() const { form, fields, isLoading } = useFormDialogContext()
// 如果正在加载,显示骨架屏
if (isLoading) {
return (
<div className={cn("p-1", className)}>
{fields.map((fieldConfig) => (
<div key={fieldConfig.name} className={cn("space-y-2", fieldConfig.className || '')}>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
)
}
return ( return (
<div className={cn("p-1", className)}> <div className={cn("p-1", className)}>
@@ -223,6 +239,7 @@ export interface FormDialogProps {
className?: string // 允许自定义对话框内容样式,可控制宽度 className?: string // 允许自定义对话框内容样式,可控制宽度
formClassName?: string // 允许自定义表格样式 formClassName?: string // 允许自定义表格样式
children: React.ReactNode // 操作按钮区域内容 children: React.ReactNode // 操作按钮区域内容
isLoading?: boolean // 是否正在加载数据
} }
export function FormDialog({ export function FormDialog({
@@ -235,13 +252,14 @@ export function FormDialog({
className = 'max-w-md', className = 'max-w-md',
formClassName, formClassName,
children, children,
isLoading = false,
}: FormDialogProps) { }: FormDialogProps) {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
// 当对话框打开时,自动聚焦到第一个表单输入控件 // 当对话框打开时,自动聚焦到第一个表单输入控件
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen && !isLoading) {
// 使当前拥有焦点的元素通常是用来触发打开这个drawer的控件失去焦点不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上 // 使当前拥有焦点的元素通常是用来触发打开这个drawer的控件失去焦点不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
(document.activeElement as HTMLElement)?.blur(); (document.activeElement as HTMLElement)?.blur();
// 使用 setTimeout 确保 DOM 已完全渲染 // 使用 setTimeout 确保 DOM 已完全渲染
@@ -259,7 +277,7 @@ export function FormDialog({
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [isOpen]) }, [isOpen, isLoading])
const close = () => { const close = () => {
onClose() onClose()
@@ -269,7 +287,8 @@ export function FormDialog({
const contextValue: FormDialogContextValue = { const contextValue: FormDialogContextValue = {
form, form,
close, close,
fields fields,
isLoading
} }
// 表单内容组件,在 Dialog 和 Drawer 中复用 // 表单内容组件,在 Dialog 和 Drawer 中复用

View File

@@ -1,122 +0,0 @@
"use client";
import type { Column } from "@tanstack/react-table";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ExtendedColumnFilter } from "@/types/data-table";
interface DataTableRangeFilterProps<TData> extends React.ComponentProps<"div"> {
filter: ExtendedColumnFilter<TData>;
column: Column<TData>;
inputId: string;
onFilterUpdate: (
filterId: string,
updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>,
) => void;
}
export function DataTableRangeFilter<TData>({
filter,
column,
inputId,
onFilterUpdate,
className,
...props
}: DataTableRangeFilterProps<TData>) {
const meta = column.columnDef.meta;
const [min, max] = React.useMemo(() => {
const range = column.columnDef.meta?.filter?.range;
if (range) return range;
const values = column.getFacetedMinMaxValues();
if (!values) return [0, 100];
return [values[0], values[1]];
}, [column]);
const formatValue = React.useCallback(
(value: string | number | undefined) => {
if (value === undefined || value === "") return "";
const numValue = Number(value);
return Number.isNaN(numValue)
? ""
: numValue.toLocaleString(undefined, {
maximumFractionDigits: 0,
});
},
[],
);
const value = React.useMemo(() => {
if (Array.isArray(filter.value)) return filter.value.map(formatValue);
return [formatValue(filter.value), ""];
}, [filter.value, formatValue]);
const onRangeValueChange = React.useCallback(
(value: string, isMin?: boolean) => {
const numValue = Number(value);
const currentValues = Array.isArray(filter.value)
? filter.value
: ["", ""];
const otherValue = isMin
? (currentValues[1] ?? "")
: (currentValues[0] ?? "");
if (
value === "" ||
(!Number.isNaN(numValue) &&
(isMin
? numValue >= min && numValue <= (Number(otherValue) || max)
: numValue <= max && numValue >= (Number(otherValue) || min)))
) {
onFilterUpdate(filter.filterId, {
value: isMin ? [value, otherValue] : [otherValue, value],
});
}
},
[filter.filterId, filter.value, min, max, onFilterUpdate],
);
return (
<div
data-slot="range"
className={cn("flex w-full items-center gap-2", className)}
{...props}
>
<Input
id={`${inputId}-min`}
type="number"
aria-label={`${meta?.label} minimum value`}
aria-valuemin={min}
aria-valuemax={max}
data-slot="range-min"
inputMode="numeric"
placeholder={min.toString()}
min={min}
max={max}
className="h-8 w-full rounded"
defaultValue={value[0]}
onChange={(event) => onRangeValueChange(event.target.value, true)}
/>
<span className="sr-only shrink-0 text-muted-foreground">to</span>
<Input
id={`${inputId}-max`}
type="number"
aria-label={`${meta?.label} maximum value`}
aria-valuemin={min}
aria-valuemax={max}
data-slot="range-max"
inputMode="numeric"
placeholder={max.toString()}
min={min}
max={max}
className="h-8 w-full rounded"
defaultValue={value[1]}
onChange={(event) => onRangeValueChange(event.target.value)}
/>
</div>
);
}

View File

@@ -45,7 +45,7 @@ export function AppSidebar({ menuItems, ...props }: AppSidebarProps) {
// 等待侧边栏关闭动画完成后再导航 // 等待侧边栏关闭动画完成后再导航
setTimeout(() => { setTimeout(() => {
router.push(href) router.push(href)
}, 300) }, 350)
} }
}, [isMobile, setOpenMobile, router]) }, [isMobile, setOpenMobile, router])

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { User, LogOut, KeyRound } from 'lucide-react' import { User, LogOut, KeyRound } from 'lucide-react'
import { DevPanel } from '@/app/(main)/dev/panel' import { DevPanel } from '@/app/(main)/dev/panel'
import { ChangePasswordDialog } from '@/components/layout/change-password-dialog' import { ChangePasswordDialog } from '@/components/layout/change-password-dialog'
@@ -14,7 +14,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar' import { SidebarTrigger } from '@/components/ui/sidebar'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
@@ -23,6 +22,11 @@ import { useTheme } from 'next-themes'
import { ThemeToggleButton, useThemeTransition } from '@/components/common/theme-toggle-button' import { ThemeToggleButton, useThemeTransition } from '@/components/common/theme-toggle-button'
import { getMenuTitle } from '@/constants/menu' import { getMenuTitle } from '@/constants/menu'
import type { User as AppUser } from '@/types/user' import type { User as AppUser } from '@/types/user'
import { useUserStore } from '@/lib/stores/userStore'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc'
import { AdvancedSelect, SelectContent, SelectedName, SelectInput, SelectItemList, SelectPopover, SelectTrigger } from '@/components/common/advanced-select'
import type { Dept } from '@prisma/client'
interface HeaderProps { interface HeaderProps {
user?: AppUser user?: AppUser
@@ -38,6 +42,47 @@ export function Header({ user }: HeaderProps) {
const pageTitle = getMenuTitle(pathname, 2) // 只匹配到第二级菜单 const pageTitle = getMenuTitle(pathname, 2) // 只匹配到第二级菜单
// 从zustand store获取当前管理的院系信息
const { setCurrentManagedDept } = useUserStore()
// 获取可管理的院系列表(包含当前管理的院系信息)
const { data: managedDeptsData } = trpc.users.getManagedDepts.useQuery()
// 本地状态管理
const [currentManagedDeptCode, setCurrentManagedDeptCode] = useState<string | null>(null)
const [managedDepts, setManagedDepts] = useState<Array<Dept>>([])
// 初始化数据
useEffect(() => {
if (managedDeptsData && managedDeptsData.currentDept !== undefined) {
setCurrentManagedDeptCode(managedDeptsData.currentDept)
setManagedDepts(managedDeptsData.depts)
// 更新store中的当前管理院系信息
const deptInfo = managedDeptsData.depts.find(dept => dept.code === managedDeptsData.currentDept) || null
setCurrentManagedDept(deptInfo)
}
}, [managedDeptsData, setCurrentManagedDept])
// 切换管理院系
const switchManagedDeptMutation = trpc.users.switchManagedDept.useMutation({
onSuccess: (data) => {
toast.success('切换管理院系成功')
// 更新本地状态
setCurrentManagedDeptCode(data.deptCode)
// 更新store中的当前管理院系信息
setCurrentManagedDept(managedDepts.find(dept => dept.code === data.deptCode) || null)
},
onError: (error) => {
toast.error(error.message || '切换管理院系失败')
},
})
// 处理院系切换
const handleDeptChange = (deptCode: string | null) => {
switchManagedDeptMutation.mutate({ deptCode })
}
const handleThemeToggle = () => { const handleThemeToggle = () => {
startTransition(() => { startTransition(() => {
setTheme(theme === 'dark' ? 'light' : 'dark') setTheme(theme === 'dark' ? 'light' : 'dark')
@@ -105,6 +150,52 @@ export function Header({ user }: HeaderProps) {
<DropdownMenuItem disabled> <DropdownMenuItem disabled>
{user.isSuperAdmin ? '超级管理员' : (Array.isArray(user.roles) ? user.roles.join('、') : user.roles)} {user.isSuperAdmin ? '超级管理员' : (Array.isArray(user.roles) ? user.roles.join('、') : user.roles)}
</DropdownMenuItem> </DropdownMenuItem>
{/* 管理院系 - 根据可管理院系数量决定显示方式 */}
{managedDepts.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel></DropdownMenuLabel>
{managedDepts.length === 1 ? (
// 只有一个可管理院系时,直接显示院系名称
<DropdownMenuItem disabled>
{managedDepts[0].fullName}
</DropdownMenuItem>
) : (
// 多个可管理院系时,显示下拉选择器
<div className="px-2 py-1.5">
<AdvancedSelect
value={currentManagedDeptCode}
onChange={handleDeptChange}
options={managedDepts.map(dept => ({
id: dept.code,
name: dept.fullName,
shortName: dept.name
}))}
disabled={switchManagedDeptMutation.isPending}
filterFunction={(option, searchValue) => {
const search = searchValue.toLowerCase()
return option.id.includes(search) ||
option.name.toLowerCase().includes(search) ||
(option.shortName && option.shortName.toLowerCase().includes(search))
}}
>
<SelectPopover>
<SelectTrigger
placeholder="请选择管理院系"
className="h-9"
>
<SelectedName />
</SelectTrigger>
<SelectContent>
{managedDepts.length > 5 && <SelectInput placeholder="搜索院系名称/代码" />}
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
</div>
)}
</>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-red-600 cursor-pointer" className="text-red-600 cursor-pointer"
@@ -131,4 +222,4 @@ export function Header({ user }: HeaderProps) {
/> />
</header> </header>
) )
} }

View File

@@ -27,7 +27,9 @@ import {
FileBarChart, FileBarChart,
Globe, Globe,
Menu, Menu,
LucideIcon LucideIcon,
User,
Building
} from 'lucide-react' } from 'lucide-react'
/** /**
@@ -62,6 +64,8 @@ export const menuIconMap: Record<string, LucideIcon> = {
'ClipboardCheck': ClipboardCheck, 'ClipboardCheck': ClipboardCheck,
'FileBarChart': FileBarChart, 'FileBarChart': FileBarChart,
'Globe': Globe, 'Globe': Globe,
'User': User,
'Building': Building
} }
/** /**

View File

@@ -220,6 +220,20 @@ export const menuItems: MenuItem[] = [
href: '/users', href: '/users',
icon: 'UserCog', icon: 'UserCog',
permission: Permissions.USER_MANAGE, permission: Permissions.USER_MANAGE,
children: [
{
title: '用户与授权',
href: '/users/user-info',
icon: 'UserCog',
permission: Permissions.USER_MANAGE,
},
{
title: '院系管理员',
href: '/users/dept-admin',
icon: 'Building',
permission: Permissions.USER_MANAGE,
}
]
}, },
{ {
title: '系统设置', title: '系统设置',

View File

@@ -3,4 +3,4 @@
*/ */
export const SITE_NAME = 'Hair Keeper' export const SITE_NAME = 'Hair Keeper'
export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑' export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑'
export const SITE_VERSION = 'v1.0.0' export const SITE_VERSION = 'v1.1.0'

View File

@@ -0,0 +1,18 @@
import { create } from 'zustand'
import type { Dept } from '@prisma/client'
/**
* 用户状态管理Store
* 用于管理用户相关的全局状态,如当前管理的院系
*/
interface UserStore {
// 当前管理的院系
currentManagedDept: Dept | null
// 设置当前管理的院系
setCurrentManagedDept: (deptInfo: Dept | null) => void
}
export const useUserStore = create<UserStore>((set) => ({
currentManagedDept: null,
setCurrentManagedDept: (deptInfo) => set({ currentManagedDept: deptInfo }),
}))

View File

@@ -12,6 +12,33 @@ export const minioClient = new Client({
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files'; export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
/**
* 获取客户端访问的基础 URL
* 优先使用 MINIO_SERVER_URL公网地址否则使用内部地址
*/
function getClientBaseUrl(): string {
if (process.env.MINIO_SERVER_URL) {
return process.env.MINIO_SERVER_URL;
}
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_API_PORT || '9000';
return `${protocol}://${endpoint}:${port}`;
}
/**
* 替换预签名 URL 中的内部地址为客户端可访问的地址
*/
function replaceUrlBase(originalUrl: string): string {
const clientBase = getClientBaseUrl();
const url = new URL(originalUrl);
const clientUrl = new URL(clientBase);
url.protocol = clientUrl.protocol;
url.hostname = clientUrl.hostname;
url.port = clientUrl.port; // 空字符串表示使用协议默认端口
return url.toString();
}
// 桶初始化标志 // 桶初始化标志
let bucketInitialized = false; let bucketInitialized = false;
@@ -150,7 +177,7 @@ export async function generatePresignedPostPolicy(
const presignedData = await minioClient.presignedPostPolicy(policy); const presignedData = await minioClient.presignedPostPolicy(policy);
return { return {
postURL: presignedData.postURL, postURL: replaceUrlBase(presignedData.postURL),
formData: presignedData.formData, formData: presignedData.formData,
objectName, objectName,
}; };
@@ -237,7 +264,7 @@ export async function generatePresignedGetObject(
); );
return { return {
url, url: replaceUrlBase(url),
expiresIn: expirySeconds, expiresIn: expirySeconds,
}; };
} catch (error) { } catch (error) {
@@ -343,9 +370,5 @@ export async function getObjectMetadata(objectName: string) {
* @returns 公开访问 URL * @returns 公开访问 URL
*/ */
export function getPublicUrl(objectName: string): string { export function getPublicUrl(objectName: string): string {
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http'; return `${getClientBaseUrl()}/${BUCKET_NAME}/${objectName}`;
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_API_PORT || '9000';
return `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${objectName}`;
} }

View File

@@ -13,7 +13,7 @@ export function getRedisClient() {
const redisPassword = process.env.REDIS_PASSWORD const redisPassword = process.env.REDIS_PASSWORD
redisClient = new Redis({ redisClient = new Redis({
host: 'localhost', host: process.env.REDIS_HOST || 'localhost',
port: parseInt(redisPort, 10), port: parseInt(redisPort, 10),
password: redisPassword, password: redisPassword,
maxRetriesPerRequest: null, // BullMQ 推荐设置 maxRetriesPerRequest: null, // BullMQ 推荐设置

View File

@@ -1,5 +1,6 @@
import { createTRPCRouter } from '@/server/trpc' import { createTRPCRouter } from '@/server/trpc'
import { usersRouter } from './users' import { usersRouter } from './users'
import { deptAdminRouter } from './dept-admin'
import { selectionRouter } from './selection' import { selectionRouter } from './selection'
import { uploadRouter } from './upload' import { uploadRouter } from './upload'
import { globalRouter } from './global' import { globalRouter } from './global'
@@ -14,6 +15,7 @@ import { commonRouter } from './common'
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
common: commonRouter, common: commonRouter,
users: usersRouter, users: usersRouter,
deptAdmin: deptAdminRouter,
selection: selectionRouter, selection: selectionRouter,
upload: uploadRouter, upload: uploadRouter,
global: globalRouter, global: globalRouter,
@@ -27,4 +29,4 @@ export const appRouter = createTRPCRouter({
} : {}) } : {})
}) })
export type AppRouter = typeof appRouter export type AppRouter = typeof appRouter

View File

@@ -1,8 +1,12 @@
// 通用接口,与特定业务关联性不强,需要在不同的地方反复使用 // 通用接口,与特定业务关联性不强,需要在不同的地方反复使用
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc' import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
import { inferProcedureOutput } from '@trpc/server';
export const commonRouter = createTRPCRouter({ export const commonRouter = createTRPCRouter({
getDepts: permissionRequiredProcedure('').query(({ ctx }) => getDepts: permissionRequiredProcedure('').query(({ ctx }) =>
ctx.db.dept.findMany({ orderBy: { code: 'asc' } }) ctx.db.dept.findMany({ orderBy: { code: 'asc' } })
), ),
}) })
export type CommonRouter = typeof commonRouter;
export type Dept = inferProcedureOutput<CommonRouter['getDepts']>[number]

View File

@@ -0,0 +1,206 @@
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
import { Permissions } from '@/constants/permissions'
import { dataTableQueryParamsSchema } from '@/lib/schema/data-table'
import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
import { z } from 'zod'
import { inferProcedureOutput, TRPCError } from '@trpc/server'
// 创建院系管理员的 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(),
})
// 更新院系管理员的 schema
const updateDeptAdminSchema = z.object({
id: z.number(),
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(),
})
export const deptAdminRouter = createTRPCRouter({
list: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(dataTableQueryParamsSchema)
.query(async ({ ctx, input }) => {
const { where, orderBy, skip, take } = transformDataTableQueryParams(input, {
model: 'DeptAdmin',
columns: {
id: { field: 'id', variant: 'number', sortable: true },
uid: { field: 'uid', variant: 'text', sortable: true },
userName: { field: 'user.name', variant: 'text', sortable: true },
deptCode: { field: 'deptCode', variant: 'multiSelect', sortable: true },
adminEmail: { field: 'adminEmail', variant: 'text', sortable: true },
adminLinePhone: { field: 'adminLinePhone', variant: 'text', sortable: true },
adminMobilePhone: { field: 'adminMobilePhone', variant: 'text', sortable: true },
createdAt: { field: 'createdAt', sortable: true },
updatedAt: { field: 'updatedAt', sortable: true },
},
})
const [data, total] = await Promise.all([
ctx.db.deptAdmin.findMany({
where,
orderBy: orderBy.some(item => 'id' in item) ? orderBy : [...orderBy, { id: 'asc' }],
skip,
take,
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
}),
ctx.db.deptAdmin.count({ where }),
])
return { data, total }
}),
create: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(createDeptAdminSchema)
.mutation(async ({ ctx, input }) => {
const { uid, deptCode, adminEmail, adminLinePhone, adminMobilePhone, note } = input
// 检查用户是否存在
const user = await ctx.db.user.findUnique({ where: { id: uid } })
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
}
// 检查院系是否存在
const dept = await ctx.db.dept.findUnique({ where: { code: deptCode } })
if (!dept) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系不存在' })
}
// 检查是否已存在相同的用户-院系组合
const existing = await ctx.db.deptAdmin.findUnique({
where: {
uidx_uid_dept_code: {
uid,
deptCode,
},
},
})
if (existing) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '该用户已是该院系的管理员' })
}
return ctx.db.deptAdmin.create({
data: {
uid,
deptCode,
adminEmail: adminEmail?.trim() || null,
adminLinePhone: adminLinePhone?.trim() || null,
adminMobilePhone: adminMobilePhone?.trim() || null,
note: note?.trim() || null,
},
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
})
}),
update: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(updateDeptAdminSchema)
.mutation(async ({ ctx, input }) => {
const { id, uid, deptCode, adminEmail, adminLinePhone, adminMobilePhone, note } = input
// 检查院系管理员是否存在
const existing = await ctx.db.deptAdmin.findUnique({ where: { id } })
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
}
// 检查用户是否存在
const user = await ctx.db.user.findUnique({ where: { id: uid } })
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
}
// 检查院系是否存在
const dept = await ctx.db.dept.findUnique({ where: { code: deptCode } })
if (!dept) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系不存在' })
}
// 如果修改了 uid 或 deptCode检查新的组合是否已存在
if (uid !== existing.uid || deptCode !== existing.deptCode) {
const duplicate = await ctx.db.deptAdmin.findUnique({
where: {
uidx_uid_dept_code: {
uid,
deptCode,
},
},
})
if (duplicate) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '该用户已是该院系的管理员' })
}
}
return ctx.db.deptAdmin.update({
where: { id },
data: {
uid,
deptCode,
adminEmail: adminEmail?.trim() || null,
adminLinePhone: adminLinePhone?.trim() || null,
adminMobilePhone: adminMobilePhone?.trim() || null,
note: note?.trim() || null,
},
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
})
}),
getById: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(z.object({ id: z.number() }))
.query(async ({ ctx, input }) => {
const deptAdmin = await ctx.db.deptAdmin.findUnique({
where: { id: input.id },
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
})
if (!deptAdmin) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
}
return deptAdmin
}),
delete: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const { id } = input
// 检查院系管理员是否存在
const existing = await ctx.db.deptAdmin.findUnique({ where: { id } })
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
}
return ctx.db.deptAdmin.delete({
where: { id },
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
})
}),
})
export type DeptAdminRouter = typeof deptAdminRouter
export type DeptAdmin = inferProcedureOutput<DeptAdminRouter['list']>['data'][number]

View File

@@ -225,26 +225,7 @@ export function createDownloadProcedure(config: DownloadConfig) {
* 提供文件上传相关的接口,使用工厂函数创建特定业务的上传接口 * 提供文件上传相关的接口,使用工厂函数创建特定业务的上传接口
*/ */
export const uploadRouter = createTRPCRouter({ export const uploadRouter = createTRPCRouter({
// 供给照片批量上传接口
supplyPhotos: createBatchUploadProcedure({
category: 'transfer/supply',
maxSize: 1 * 1024 * 1024, // 1MB
allowedContentType: 'image/*',
expirySeconds: 3600,
permission: Permissions.TRANSFER_SUPPLY_CREATE,
}),
supplyPdfs: createBatchUploadProcedure({
category: 'transfer/supply',
maxSize: 1 * 1024 * 1024, // 1MB
allowedContentType: 'application/pdf',
expirySeconds: 3600,
permission: Permissions.TRANSFER_SUPPLY_CREATE,
}),
// 已上传的供给照片下载
downloadSupplyPhotos: createDownloadProcedure({
expirySeconds: 3600,
permission: Permissions.TRANSFER_SUPPLY_CREATE,
})
}); });
export type SingleUploadProcedureInput = inferProcedureInput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口调用参数 export type SingleUploadProcedureInput = inferProcedureInput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口调用参数

View File

@@ -6,6 +6,10 @@ import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { z } from 'zod' import { z } from 'zod'
import { inferProcedureOutput, TRPCError } from '@trpc/server' import { inferProcedureOutput, TRPCError } from '@trpc/server'
import pLimit from 'p-limit'
// 从环境变量获取并发限制默认为16
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
export const usersRouter = createTRPCRouter({ export const usersRouter = createTRPCRouter({
list: permissionRequiredProcedure(Permissions.USER_MANAGE) list: permissionRequiredProcedure(Permissions.USER_MANAGE)
@@ -186,14 +190,16 @@ export const usersRouter = createTRPCRouter({
await Promise.all( await Promise.all(
batch.map(user => batch.map(user =>
ctx.db.user.update({ dbParallelLimit(() =>
where: { id: user.id }, ctx.db.user.update({
data: { where: { id: user.id },
roles: action === 'grant' data: {
? { connect: { id: roleId } } roles: action === 'grant'
: { disconnect: { id: roleId } } ? { connect: { id: roleId } }
} : { disconnect: { id: roleId } }
}) }
})
)
) )
) )
@@ -206,6 +212,11 @@ export const usersRouter = createTRPCRouter({
create: permissionRequiredProcedure(Permissions.USER_MANAGE).input(createUserSchema).mutation(async ({ ctx, input }) => { create: permissionRequiredProcedure(Permissions.USER_MANAGE).input(createUserSchema).mutation(async ({ ctx, input }) => {
const { id, name, status, deptCode, password, roleIds, isSuperAdmin } = input const { id, name, status, deptCode, password, roleIds, isSuperAdmin } = input
// 检查是否尝试创建超级管理员,只有超级管理员才能创建超级管理员
if (isSuperAdmin && !ctx.session?.user?.isSuperAdmin) {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有超级管理员才能创建超级管理员用户' })
}
const existingUser = await ctx.db.user.findUnique({ where: { id } }) const existingUser = await ctx.db.user.findUnique({ where: { id } })
if (existingUser) throw new TRPCError({ code: 'BAD_REQUEST', message: '用户ID已存在' }) if (existingUser) throw new TRPCError({ code: 'BAD_REQUEST', message: '用户ID已存在' })
@@ -235,6 +246,11 @@ export const usersRouter = createTRPCRouter({
const existingUser = await ctx.db.user.findUnique({ where: { id } }) const existingUser = await ctx.db.user.findUnique({ where: { id } })
if (!existingUser) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' }) if (!existingUser) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
// 检查是否尝试修改 isSuperAdmin 字段,只有超级管理员才能操作
if (isSuperAdmin !== existingUser.isSuperAdmin && !ctx.session?.user?.isSuperAdmin) {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有超级管理员才能修改超级管理员权限' })
}
// 准备更新数据 // 准备更新数据
const updateData: any = { const updateData: any = {
name: name?.trim() || '', name: name?.trim() || '',
@@ -372,6 +388,102 @@ export const usersRouter = createTRPCRouter({
)) ))
).sort((a, b) => a.id - b.id), ).sort((a, b) => a.id - b.id),
} }
}),
// 获取当前用户可管理的院系列表和正在管理的院系
getManagedDepts: permissionRequiredProcedure('')
.query(async ({ ctx }) => {
const userId = ctx.session!.user.id
// 获取用户当前信息
const currentUser = await ctx.db.user.findUnique({
where: { id: userId },
select: { currentManagedDept: true }
})
let depts: Array<{ code: string; name: string; fullName: string }>
// 超级管理员可以管理所有院系
if (ctx.session?.user?.isSuperAdmin) {
depts = await ctx.db.dept.findMany({
orderBy: { code: 'asc' }
})
} else {
// 普通用户只能管理自己被授权的院系
const deptAdmins = await ctx.db.deptAdmin.findMany({
where: { uid: userId },
include: { dept: true },
orderBy: { deptCode: 'asc' }
})
depts = deptAdmins.map(da => da.dept)
}
// 如果用户当前没有管理院系,但有可管理的院系,自动设置为第一个
let currentDept = currentUser?.currentManagedDept
if (!currentDept && depts.length > 0) {
currentDept = depts[0].code
await ctx.db.user.update({
where: { id: userId },
data: { currentManagedDept: currentDept }
})
}
return {
currentDept,
depts
}
}),
// 切换当前管理的院系
switchManagedDept: permissionRequiredProcedure('')
.input(z.object({
deptCode: z.string().nullable()
}))
.mutation(async ({ ctx, input }) => {
const { deptCode } = input
// 如果要切换到某个院系,需要验证权限
if (deptCode) {
// 超级管理员可以切换到任意院系
if (!ctx.session?.user?.isSuperAdmin) {
// 普通用户需要验证是否有该院系的管理权限
const deptAdmin = await ctx.db.deptAdmin.findUnique({
where: {
uidx_uid_dept_code: {
uid: ctx.session!.user.id,
deptCode: deptCode
}
}
})
if (!deptAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '您没有该院系的管理权限'
})
}
}
// 验证院系是否存在
const dept = await ctx.db.dept.findUnique({
where: { code: deptCode }
})
if (!dept) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '院系不存在'
})
}
}
// 更新用户的当前管理院系
await ctx.db.user.update({
where: { id: ctx.session!.user.id },
data: { currentManagedDept: deptCode }
})
return { success: true, deptCode }
}) })
}) })

View File

@@ -46,7 +46,8 @@ export function startTerminalService() {
'-p', port, '-p', port,
'-t', 'titleFixed=开发终端', '-t', 'titleFixed=开发终端',
'-t', 'fontSize=14', '-t', 'fontSize=14',
'-i', '127.0.0.1', '-c', `super_admin:${process.env.SUPER_ADMIN_PASSWORD}`,
'-i', process.env.SUPER_ADMIN_PASSWORD ? '0.0.0.0' : "127.0.0.1",
'--writable', '--writable',
'tmux', 'new', '-A', 'tmux', 'new', '-A',
'-s', process.env.DEV_TERMINAL || 'nextdev', '-s', process.env.DEV_TERMINAL || 'nextdev',