Compare commits
14 Commits
5020bd1532
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2157b2bef8 | |||
| 1e514e6631 | |||
| b459607d31 | |||
| 796ffcfe00 | |||
| 37f9faf2a4 | |||
| 7f15051f18 | |||
| 9d32874e1e | |||
| fab2b34a03 | |||
| 5024477b74 | |||
| 1349317f88 | |||
| c1fda9bb7e | |||
| 91d39b3145 | |||
| 2a80a44972 | |||
| 7f3190a223 |
12
.claude/settings.json
Normal file
12
.claude/settings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(./.env.example)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.development)",
|
||||
"Read(./.env.production)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,12 @@ RUN apt-get update && apt-get install -y \
|
||||
cmake \
|
||||
telnet \
|
||||
redis-tools \
|
||||
iputils-ping \
|
||||
dnsutils \
|
||||
potrace \
|
||||
imagemagick \
|
||||
zsh \
|
||||
vim \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 Python 3.12
|
||||
@@ -93,8 +96,11 @@ RUN curl -fsSL https://code-server.dev/install.sh | sh -s -- --version=${CODE_SE
|
||||
|
||||
# 安装 npm 全局包
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code \
|
||||
@musistudio/claude-code-router
|
||||
@musistudio/claude-code-router \
|
||||
pm2
|
||||
|
||||
# 安装 claude code
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
# 创建工作目录
|
||||
RUN mkdir -p /workspace /root/.local/share/code-server/User
|
||||
@@ -108,11 +114,11 @@ RUN mkdir -p /var/run/sshd && \
|
||||
RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \
|
||||
&& code-server --install-extension bierner.markdown-mermaid \
|
||||
&& code-server --install-extension ms-python.python \
|
||||
&& code-server --install-extension rooveterinaryinc.roo-cline \
|
||||
&& code-server --install-extension dbaeumer.vscode-eslint \
|
||||
&& code-server --install-extension prisma.prisma \
|
||||
&& code-server --install-extension ecmel.vscode-html-css \
|
||||
&& code-server --install-extension cweijan.vscode-redis-client
|
||||
&& code-server --install-extension cweijan.vscode-redis-client \
|
||||
&& code-server --install-extension anthropic.claude-code
|
||||
|
||||
# 配置 code-server (密码将在启动时设置)
|
||||
RUN mkdir -p /root/.config/code-server && \
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- **cmake**: 构建工具
|
||||
- **telnet**: 网络调试
|
||||
- **redis-tools**: Redis 命令行工具
|
||||
- **ping**: 网络连通性测试
|
||||
- **potrace**: 位图转矢量图
|
||||
- **imagemagick**: 图像处理工具
|
||||
- **uv**: 快速 Python 包管理器
|
||||
|
||||
@@ -11,14 +11,18 @@ services:
|
||||
- "7681:7681" # ttyd (Web Terminal)
|
||||
- "3000:3000" # Next.js Dev Server
|
||||
volumes:
|
||||
# 项目代码映射(使用 cached 模式提高性能)
|
||||
# 项目代码映射(如果映射到Windows/Mac宿主机的本地目录,使用 cached 模式提高性能)
|
||||
- ../:/workspace:cached
|
||||
# node_modules 使用命名卷以提高性能
|
||||
- node_modules:/workspace/node_modules
|
||||
# pnpm store 缓存
|
||||
- pnpm_store:/root/.local/share/pnpm/store
|
||||
# Git 配置(可选,如果需要保留 Git 配置)
|
||||
- ~/.gitconfig:/root/.gitconfig:ro
|
||||
# Code Server 配置(包含 config.yaml 和密码)
|
||||
- code-server-config:/root/.config/code-server
|
||||
# Code Server 数据(插件、用户设置、扩展数据)
|
||||
- code-server-data:/root/.local/share/code-server
|
||||
# Claude Code 配置和保存数据
|
||||
- claude:/root/.claude
|
||||
# Claude Code Router 配置和保存数据
|
||||
- claude-code-router:/root/.claude-code-router
|
||||
# SSH 配置
|
||||
- ssh:/root/.ssh
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
@@ -26,6 +26,7 @@ POSTGRESQL_PASSWORD=
|
||||
POSTGRESQL_PORT=
|
||||
DATABASE_URL=
|
||||
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=
|
||||
REDIS_PASSWORD=
|
||||
|
||||
@@ -40,10 +41,12 @@ MINIO_BUCKET=
|
||||
|
||||
# 应用相关
|
||||
SUPER_ADMIN_PASSWORD=
|
||||
USER_DEFAULT_PASSWORD=
|
||||
## 数据库批次操作默认并发数
|
||||
DB_PARALLEL_LIMIT =
|
||||
|
||||
# NextAuth.js Configuration
|
||||
NEXTAUTH_SECRET=
|
||||
NEXTAUTH_URL=
|
||||
|
||||
PKUAI_API_KEY=
|
||||
PKUAI_API_BASE=
|
||||
@@ -52,7 +55,9 @@ PKUAI_API_BASE=
|
||||
|
||||
|
||||
# 仅在开发环境加载(写在.env.development中)
|
||||
PORT=
|
||||
NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT=
|
||||
NEXT_PUBLIC_DEV_TERMINAL_URL=
|
||||
DEV_TERMINAL=
|
||||
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-prod/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
@@ -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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"prisma.pinToPrisma6": true
|
||||
}
|
||||
94
CLAUDE.md
Normal file
94
CLAUDE.md
Normal 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.2.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||
|
||||
Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
|
||||
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
|
||||
|
||||
## 主要依赖库
|
||||
本项目使用pnpm作为包管理器
|
||||
- 基础:next + react + trpc + prisma
|
||||
- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
|
||||
- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
|
||||
- 用户交互增强:motion(动画) + framer-motion(动画) + use-gesture/react(手势)
|
||||
- Headless UI:react-hook-form + tanstack/react-table + headless-tree/react
|
||||
- 数据和存储:pg(PostgreSQL) + ioredis + minio
|
||||
- 后台任务及消息队列:bullmq
|
||||
- AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件)
|
||||
- 辅助性库:lodash + zod + date-fns + nanoid + zustand + p-limit
|
||||
- 其他:next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard
|
||||
|
||||
## 项目约定
|
||||
### 前端
|
||||
- 可使用`pnpx shadcn@latest add`添加UI控件(会添加到components/ui中)
|
||||
- 表单输入组件提供value、onChange和disabled三种属性的支持,可方便的集成到react-hook-form中
|
||||
- z-index约定:常规对话框及其遮罩z-50,表单控件的Popover为z-60,全屏预览及遮罩z-70
|
||||
- tailwindcss v4支持直接编写如 w-[10px] 这样的任意值,非必要不写style,支持样式类合并`import { cn } from "@/lib/utils"`
|
||||
- 用`import { useCallbackRef } from "@/hooks/use-callback-ref"`这个钩子构建引用不变但逻辑总是最新的函数,解决闭包陷阱
|
||||
|
||||
### 后端
|
||||
- tRPC接口报错时可使用TRPCError,例如throw new TRPCError({ code: 'NOT_FOUND', message: '' })
|
||||
- server/trpc.ts中导出了createTRPCRouter(本质是t.router)用来创建路由,预定义了publicProcedure用于创建无权限限制、也不需要登录的api
|
||||
- server/trpc.ts中预定义了permissionRequiredProcedure,用来创建限制特定权限访问的api,例如permissionRequiredProcedure(Permissions.USER_MANAGE);空字符串表示无权限要求,但是需要用户登录;约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问,该权限不在Permissions中定义,只有超级管理员才能绕过授权限制访问所有接口,因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。
|
||||
- 数据库批次操作时,使用`const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))`控制最大并发数
|
||||
|
||||
### 数据和存储
|
||||
- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`,可以从中导入定义的数据表的ts类型,例如`import type { User } from '@prisma/client'`
|
||||
- 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient
|
||||
- 时间字段统一使用 `@db.Timestamptz` 类型
|
||||
- 前后端参数传递尽量使用扁平结构而非嵌套结构
|
||||
- 文件的上传和下载采用“客户端直传”架构(基于MinIO),服务器端只负责授权和生成预签名URL
|
||||
|
||||
### 开发模式
|
||||
为了方便开发,本项目模板内置了在开发模式下可用的大量页面和功能
|
||||
- 仅在开发阶段使用的页面、布局、api等会被NextJS识别并处理的文件,以dev.tsx、dev.ts、dev.jsx、dev.js为后缀,不会被打包到生产环境
|
||||
- 仅在开发阶段使用的数据模型以Dev开头,对应的数据表以dev_开头
|
||||
|
||||
## 重要目录和文件
|
||||
### 前端
|
||||
- `src/components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
||||
- `src/components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
||||
- `src/components/ai-elements/`:ai对话相关的组件
|
||||
- `src/components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
||||
- `src/components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
||||
- `src/components/icons/`:项目的自定义图标可以写在这个文件夹
|
||||
- `src/components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
||||
- `src/components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
||||
- `src/app/(main)/`:开发者在这里自行实现的所有业务的页面
|
||||
- `src/app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
||||
- `src/app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
||||
- `src/app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
||||
- `src/hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
||||
- `src/lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
||||
- `src/lib/stores/`:通过zustand管理的全局的状态
|
||||
|
||||
### 后端
|
||||
- `src/server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
||||
- `src/server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
||||
- `src/server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
||||
- `src/server/routers/jobs.ts`:tRPC任务进度订阅路由
|
||||
- `src/server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
||||
- `src/server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
||||
- `src/server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
||||
- `src/server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
||||
- `src/server/agents`:LLM的对接和使用
|
||||
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
||||
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
||||
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
||||
- `src/api/dev/`:开发模式下的辅助功能需要的api
|
||||
|
||||
### 其他
|
||||
- `src/constants/`:项目全局常量管理
|
||||
- `src/constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
||||
- `src/lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
||||
- `src/lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
||||
- `src/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进行分析
|
||||
77
README.md
77
README.md
@@ -1,11 +1,12 @@
|
||||
## 项目说明
|
||||
本项目模板(Hair Keeper v1.0.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||
本项目模板(Hair Keeper v1.2.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||
|
||||
Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
|
||||
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
|
||||
|
||||
## 主要依赖库
|
||||
本项目使用pnpm作为包管理器
|
||||
- 基础:next + react + trpc + prisma
|
||||
- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
|
||||
- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
|
||||
@@ -14,7 +15,7 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
- 数据和存储:pg(PostgreSQL) + ioredis + minio
|
||||
- 后台任务及消息队列:bullmq
|
||||
- AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件)
|
||||
- 辅助性库:lodash + zod + date-fns + nanoid
|
||||
- 辅助性库:lodash + zod + date-fns + nanoid + zustand + p-limit
|
||||
- 其他:next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard
|
||||
|
||||
## 项目约定
|
||||
@@ -27,11 +28,12 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
|
||||
### 后端
|
||||
- tRPC接口报错时可使用TRPCError,例如throw new TRPCError({ code: 'NOT_FOUND', message: '' })
|
||||
- server/trpc.ts中预定义了publicProcedure用于创建无权限限制、也不需要登录的api
|
||||
- server/trpc.ts中预定义了publicProcedurepermissionRequiredProcedure,用来创建限制特定权限访问的路由,例如permissionRequiredProcedure(Permissions.USER_MANAGE);空字符串表示无权限要求,但是需要用户登录;约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问,该权限不在Permissions中定义,只有超级管理员才能绕过授权限制访问所有接口,因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。
|
||||
- server/trpc.ts中导出了createTRPCRouter(本质是t.router)用来创建路由,预定义了publicProcedure用于创建无权限限制、也不需要登录的api
|
||||
- server/trpc.ts中预定义了permissionRequiredProcedure,用来创建限制特定权限访问的api,例如permissionRequiredProcedure(Permissions.USER_MANAGE);空字符串表示无权限要求,但是需要用户登录;约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问,该权限不在Permissions中定义,只有超级管理员才能绕过授权限制访问所有接口,因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。
|
||||
- 数据库批次操作时,使用`const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))`控制最大并发数
|
||||
|
||||
### 数据和存储
|
||||
- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`
|
||||
- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`,可以从中导入定义的数据表的ts类型,例如`import type { User } from '@prisma/client'`
|
||||
- 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient
|
||||
- 时间字段统一使用 `@db.Timestamptz` 类型
|
||||
- 前后端参数传递尽量使用扁平结构而非嵌套结构
|
||||
@@ -44,42 +46,43 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
|
||||
## 重要目录和文件
|
||||
### 前端
|
||||
- `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通信
|
||||
- `src/components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
||||
- `src/components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
||||
- `src/components/ai-elements/`:ai对话相关的组件
|
||||
- `src/components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
||||
- `src/components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
||||
- `src/components/icons/`:项目的自定义图标可以写在这个文件夹
|
||||
- `src/components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
||||
- `src/components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
||||
- `src/app/(main)/`:开发者在这里自行实现的所有业务的页面
|
||||
- `src/app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
||||
- `src/app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
||||
- `src/app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
||||
- `src/hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
||||
- `src/lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
||||
- `src/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
|
||||
- `src/server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
||||
- `src/server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
||||
- `src/server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
||||
- `src/server/routers/jobs.ts`:tRPC任务进度订阅路由
|
||||
- `src/server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
||||
- `src/server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
||||
- `src/server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
||||
- `src/server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
||||
- `src/server/agents`:LLM的对接和使用
|
||||
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
||||
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
||||
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
||||
- `src/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`:数据格式化工具函数库
|
||||
- `src/constants/`:项目全局常量管理
|
||||
- `src/constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
||||
- `src/lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
||||
- `src/lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
||||
- `src/lib/format.ts`:数据格式化工具函数库
|
||||
|
||||
## 非标准命令
|
||||
- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
name: hair-keeper
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: bitnami/postgresql:17 # https://hub.docker.com/r/bitnami/postgresql
|
||||
container_name: hair-keeper-dev-pg
|
||||
restart: always
|
||||
ports:
|
||||
- "${POSTGRESQL_PORT}:5432"
|
||||
@@ -10,21 +11,19 @@ services:
|
||||
POSTGRESQL_PASSWORD: ${POSTGRESQL_PASSWORD}
|
||||
POSTGRESQL_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
|
||||
volumes:
|
||||
- hair_keeper_postgresql_data:/bitnami/postgresql
|
||||
- postgresql_data:/bitnami/postgresql
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine # https://hub.docker.com/_/redis
|
||||
container_name: hair-keeper-dev-redis
|
||||
ports:
|
||||
- "${REDIS_PORT}:6379"
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
|
||||
volumes:
|
||||
- hair_keeper_redis_data:/data
|
||||
- redis_data:/data
|
||||
restart: always
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
|
||||
container_name: hair-keeper-dev-minio
|
||||
ports:
|
||||
- "${MINIO_API_PORT}:9000" # API端口
|
||||
- "${MINIO_CONSOLE_PORT}:9001" # Console端口
|
||||
@@ -33,11 +32,11 @@ services:
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
MINIO_SERVER_URL: ${MINIO_SERVER_URL}
|
||||
volumes:
|
||||
- hair_keeper_minio_data:/data
|
||||
- minio_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
hair_keeper_postgresql_data:
|
||||
hair_keeper_redis_data:
|
||||
hair_keeper_minio_data:
|
||||
postgresql_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
|
||||
distDir: process.env.NODE_ENV === "production" ? ".next-prod" : '.next', // 开发和生产环境输出到不同的目录,这样可以同时运行开发服务器和生产服务器
|
||||
};
|
||||
|
||||
|
||||
|
||||
13
package.json
13
package.json
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "hair-keeper",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000 --turbo",
|
||||
"dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "eslint",
|
||||
"start": "next start",
|
||||
"lint": "next lint && tsc --noEmit",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"build:analyze": "ANALYZE=true next build"
|
||||
},
|
||||
@@ -74,10 +74,11 @@
|
||||
"minio": "^8.0.6",
|
||||
"motion": "^12.23.22",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "~15.4.0",
|
||||
"next": "~15.4.10",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.6.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"pg": "^8.16.3",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"prisma": "^6.15.0",
|
||||
@@ -95,7 +96,8 @@
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.9"
|
||||
"zod": "^4.1.9",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -107,7 +109,6 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-live": "^4.1.8",
|
||||
"shadcn": "^3.5.0",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -19,6 +19,7 @@ model User {
|
||||
name String?
|
||||
status String? // 在校/减离/NULL
|
||||
deptCode String? @map("dept_code") // 所属院系代码(外键)
|
||||
currentManagedDept String? @map("current_managed_dept") @db.VarChar(5) // 当前正在管理的院系代码,用于支持院系管理员类型的角色
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
password String
|
||||
@@ -29,6 +30,7 @@ model User {
|
||||
dept Dept? @relation(fields: [deptCode], references: [code])
|
||||
roles Role[] // 多对多关联角色
|
||||
selectionLogs SelectionLog[] // 选择日志
|
||||
deptAdmins DeptAdmin[] // 作为院系管理员的信息
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
@@ -41,10 +43,31 @@ model Dept {
|
||||
|
||||
// 关联
|
||||
users User[]
|
||||
deptAdmins DeptAdmin[] // 院系管理员
|
||||
|
||||
@@map("dept")
|
||||
}
|
||||
|
||||
// 院系管理员表
|
||||
model DeptAdmin {
|
||||
id Int @id @default(autoincrement())
|
||||
uid String @db.VarChar(30) // 管理员用户ID(外键)
|
||||
deptCode String @map("dept_code") @db.VarChar(5) // 院系代码(外键)
|
||||
adminEmail String? @map("admin_email") @db.VarChar(100) // 管理员邮箱
|
||||
adminLinePhone String? @map("admin_line_phone") @db.VarChar(100) // 管理员座机
|
||||
adminMobilePhone String? @map("admin_mobile_phone") @db.VarChar(100) // 管理员手机
|
||||
note String? @db.VarChar(1000) // 备注
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// 关联
|
||||
user User @relation(fields: [uid], references: [id])
|
||||
dept Dept @relation(fields: [deptCode], references: [code])
|
||||
|
||||
@@unique([uid, deptCode], name: "uidx_uid_dept_code")
|
||||
@@map("dept_admin")
|
||||
}
|
||||
|
||||
// 角色表
|
||||
model Role {
|
||||
id Int @id @default(autoincrement())
|
||||
@@ -68,7 +91,7 @@ model Permission {
|
||||
model SelectionLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String // 关联到用户
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// 用于标识是哪个的选项,用.进行分隔,例如"user.filter.dept"
|
||||
context String
|
||||
|
||||
148
prisma/seed.ts
148
prisma/seed.ts
@@ -3,9 +3,13 @@ import bcrypt from 'bcryptjs'
|
||||
import { Permissions, ALL_PERMISSIONS } from '../src/constants/permissions'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
// 从环境变量获取并发限制,默认为16
|
||||
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
|
||||
|
||||
// 解析 JSON 文件并导入院系数据
|
||||
async function importDepartments() {
|
||||
const jsonPath = path.join(__dirname, 'init_data', '院系.json')
|
||||
@@ -16,7 +20,7 @@ async function importDepartments() {
|
||||
|
||||
await Promise.all(
|
||||
departments.map((dept: any) => {
|
||||
return prisma.dept.upsert({
|
||||
return dbParallelLimit(() => prisma.dept.upsert({
|
||||
where: { code: dept.id },
|
||||
update: {
|
||||
name: dept.name,
|
||||
@@ -27,7 +31,7 @@ async function importDepartments() {
|
||||
name: dept.name,
|
||||
fullName: dept.full_name,
|
||||
},
|
||||
})
|
||||
}))
|
||||
})
|
||||
)
|
||||
console.log('院系数据导入完成')
|
||||
@@ -37,13 +41,15 @@ async function main() {
|
||||
console.log('开始数据库初始化...')
|
||||
|
||||
// 插入权限
|
||||
for (const permName of ALL_PERMISSIONS) {
|
||||
await prisma.permission.upsert({
|
||||
where: { name: permName },
|
||||
update: {},
|
||||
create: { name: permName },
|
||||
await Promise.all(
|
||||
ALL_PERMISSIONS.map((permName) => {
|
||||
return dbParallelLimit(() => prisma.permission.upsert({
|
||||
where: { name: permName },
|
||||
update: {},
|
||||
create: { name: permName },
|
||||
}))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 角色与权限映射
|
||||
const rolePermissionsMap: Record<string, string[]> = {
|
||||
@@ -51,18 +57,20 @@ async function main() {
|
||||
}
|
||||
|
||||
// 插入角色
|
||||
for (const [roleName, perms] of Object.entries(rolePermissionsMap)) {
|
||||
await prisma.role.upsert({
|
||||
where: { name: roleName },
|
||||
update: {},
|
||||
create: {
|
||||
name: roleName,
|
||||
permissions: {
|
||||
connect: perms.map((name) => ({ name })),
|
||||
await Promise.all(
|
||||
Object.entries(rolePermissionsMap).map(([roleName, perms]) => {
|
||||
return dbParallelLimit(() => prisma.role.upsert({
|
||||
where: { name: roleName },
|
||||
update: {},
|
||||
create: {
|
||||
name: roleName,
|
||||
permissions: {
|
||||
connect: perms.map((name) => ({ name })),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await importDepartments()
|
||||
|
||||
@@ -74,33 +82,37 @@ async function main() {
|
||||
{ id: 'unknown', name: '未知用户', status: '在校', deptCode: '00001', roleNames: [] },
|
||||
]
|
||||
|
||||
for (const u of usersToCreate) {
|
||||
const password = await bcrypt.hash(u.password ?? '123456', 12)
|
||||
await prisma.user.upsert({
|
||||
where: { id: u.id },
|
||||
update: {
|
||||
name: u.name,
|
||||
status: u.status,
|
||||
deptCode: u.deptCode,
|
||||
password,
|
||||
isSuperAdmin: u.isSuperAdmin ?? false,
|
||||
roles: {
|
||||
set: u.roleNames.map((name) => ({ name })),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
status: u.status,
|
||||
deptCode: u.deptCode,
|
||||
password,
|
||||
isSuperAdmin: u.isSuperAdmin ?? false,
|
||||
roles: {
|
||||
connect: u.roleNames.map((name) => ({ name })),
|
||||
},
|
||||
},
|
||||
await Promise.all(
|
||||
usersToCreate.map((u) => {
|
||||
return dbParallelLimit(async () => {
|
||||
const password = await bcrypt.hash(u.password || process.env.USER_DEFAULT_PASSWORD || 'jeep4ahxahx7ee7U', 12)
|
||||
await prisma.user.upsert({
|
||||
where: { id: u.id },
|
||||
update: {
|
||||
name: u.name,
|
||||
status: u.status,
|
||||
deptCode: u.deptCode,
|
||||
password,
|
||||
isSuperAdmin: u.isSuperAdmin ?? false,
|
||||
roles: {
|
||||
set: u.roleNames.map((name) => ({ name })),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
status: u.status,
|
||||
deptCode: u.deptCode,
|
||||
password,
|
||||
isSuperAdmin: u.isSuperAdmin ?? false,
|
||||
roles: {
|
||||
connect: u.roleNames.map((name) => ({ name })),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 插入文件类型(仅开发环境)
|
||||
const fileTypes = [
|
||||
@@ -125,18 +137,19 @@ async function main() {
|
||||
{ id: 'OTHER', name: '其他', description: '无法归入以上分类的文件' },
|
||||
]
|
||||
|
||||
for (let index = 0; index < fileTypes.length; index++) {
|
||||
const fileType = fileTypes[index]
|
||||
await prisma.devFileType.upsert({
|
||||
where: { id: fileType.id },
|
||||
update: {
|
||||
name: fileType.name,
|
||||
description: fileType.description,
|
||||
order: (index + 1) * 10,
|
||||
},
|
||||
create: {...fileType, order: (index + 1) * 10},
|
||||
await Promise.all(
|
||||
fileTypes.map((fileType, index) => {
|
||||
return dbParallelLimit(() => prisma.devFileType.upsert({
|
||||
where: { id: fileType.id },
|
||||
update: {
|
||||
name: fileType.name,
|
||||
description: fileType.description,
|
||||
order: (index + 1) * 10,
|
||||
},
|
||||
create: {...fileType, order: (index + 1) * 10},
|
||||
}))
|
||||
})
|
||||
}
|
||||
)
|
||||
console.log('文件类型数据初始化完成')
|
||||
|
||||
// 插入依赖包类型(仅开发环境)
|
||||
@@ -152,18 +165,19 @@ async function main() {
|
||||
{ id: 'NODEJS_CORE', name: 'Node.js核心', description: 'Node.js运行时内置的模块,用于底层操作如文件系统、路径处理等,例如fs、path、child_process、util、module、os、http、crypto、events、stream、process、net、url、assert。' },
|
||||
]
|
||||
|
||||
for (let index = 0; index < pkgTypes.length; index++) {
|
||||
const pkgType = pkgTypes[index]
|
||||
await prisma.devPkgType.upsert({
|
||||
where: { id: pkgType.id },
|
||||
update: {
|
||||
name: pkgType.name,
|
||||
description: pkgType.description,
|
||||
order: (index + 1) * 10,
|
||||
},
|
||||
create: {...pkgType, order: (index + 1) * 10},
|
||||
await Promise.all(
|
||||
pkgTypes.map((pkgType, index) => {
|
||||
return dbParallelLimit(() => prisma.devPkgType.upsert({
|
||||
where: { id: pkgType.id },
|
||||
update: {
|
||||
name: pkgType.name,
|
||||
description: pkgType.description,
|
||||
order: (index + 1) * 10,
|
||||
},
|
||||
create: {...pkgType, order: (index + 1) * 10},
|
||||
}))
|
||||
})
|
||||
}
|
||||
)
|
||||
console.log('依赖包类型数据初始化完成')
|
||||
|
||||
console.log('数据库初始化完成')
|
||||
@@ -177,4 +191,4 @@ main()
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
})
|
||||
|
||||
44
quickstart.md
Normal file
44
quickstart.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Hair Keeper开发容器使用帮助
|
||||
## AI编程代理用法
|
||||
命令行中:
|
||||
|
||||
* `ccr code` 打开一个新对话
|
||||
* `ccr code --resume` 回到之前某个对话
|
||||
|
||||
对话中:
|
||||
|
||||
* `ESC` 连按2次可以查看和回溯到之前的某一轮对话
|
||||
* `alt+tab` 切换模型是否打开思维链,默认是开启的,请关闭,因为思考模式很慢,只在极度复杂的任务时才打开
|
||||
* `\` 如果您的提示词过长,一行输入不下,可以在行末输入这个字符来换行
|
||||
* `@` 如果您需要引用项目中的文件,可以用这个符号
|
||||
* `/ide` 输入这个命令,AI会和您的IDE进行联动,这样您在IDE中选中的代码会被自动发送给AI,AI在修改代码时也会在IDE中打开代码预览。
|
||||
|
||||
其他:
|
||||
|
||||
* `CLAUDE.md` 文件中的内容每次对话都会发送给AI,如果您有什么要强调的,可以写在这里面,您也可以输入`/init` 让AI自动扫描项目并编写CLAUDE.md,一般这里面写的都是项目的约定、编码习惯和开发目标。
|
||||
|
||||
## 项目与开发环境
|
||||
##### 常见命令:
|
||||
* `pnpm run dev` 打开开发服务器
|
||||
* `pnpm run lint` 检查代码中是否存在明显错误,建议每次AI进行了一次大修改,先用这个命令排查错误,有错误就粘贴给AI让它解决
|
||||
* `pnpm run build` 构建和打包项目,耗时很长,如果AI尝试执行这个命令,阻止它并告诉它只需要执行`pnpm run lint` 排查错误
|
||||
* `pnpm prisma migrate dev --name add_some_tables` 如果您对`schema.prisma` 进行了修改并希望修改能同步到数据库,执行这条命令,`add_some_tables` 请替换成能够描述您实际修改的标识符
|
||||
|
||||
##### 访问容器内部服务(SSH转发):
|
||||
如果您是通过浏览器访问云开发容器,有时候可能需要访问云开发容器内部的本地服务,您可以通过SSH转发来实现。
|
||||
|
||||
例如我们可以通过这种方法来使用Prisma Studio(prisma提供的数据库管理工具):
|
||||
|
||||
```bash
|
||||
pnpm prisma studio --port 5555 # 在容器中执行
|
||||
ssh -N -L 5555:127.0.0.1:5555 root@cloud.liuyh.com -p <Hair Keeper容器的SSH服务映射的外部端口> # 在本地主机中执行,然后输入您开发环节的密码
|
||||
```
|
||||
然后您可以在本地输入 [http://localhost:5555/](http://localhost:5555/) 访问容器内部运行的Prisma Studio。
|
||||
|
||||
虽然您访问的是本地地址,但是Prisma Studio服务是运行在远程服务器的容器中的,SSH则是连接两者的桥梁。
|
||||
|
||||
## 代码仓库快速入门
|
||||
* `git push origin main` 推送本地代码到远程仓库,推送完成后您可以访问这个链接查看您的代码,也就是说您的代码在服务器上有了个备份,避免意外删除或丢失,通过远程仓库,您还可以与其他人合作开发一个项目,git能够解决代码的版本问题和不同成员修改的合并问题。
|
||||
|
||||
* `git add -A && git commit -m "修改了xxx文件、新增了xxx功能"` 在您对代码进行了一定的修改后,可以执行这条命令,相当于一个存档点,也便于您后续查看开发历史。在您进行了几次修改,准备结束今天的工作时,您可以执行`git push origin main` 将代码同步到远程仓库,避免代码丢失。
|
||||
|
||||
451
quickstart.sh
Executable file
451
quickstart.sh
Executable file
@@ -0,0 +1,451 @@
|
||||
#!/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-opus-4-6",
|
||||
"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-6",
|
||||
"background": "pku-anthropic,claude-haiku-4-5-20251001",
|
||||
"think": "pku-anthropic,claude-opus-4-6",
|
||||
"longContext": "pku-anthropic,claude-opus-4-6",
|
||||
"longContextThreshold": 80000,
|
||||
"webSearch": "",
|
||||
"image": "pku-anthropic,claude-opus-4-6"
|
||||
},
|
||||
"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
Executable file
69
simple_deploy.sh
Executable 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}"
|
||||
28
simple_deploy_local.sh
Executable file
28
simple_deploy_local.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# 此脚本用来直接在当前目录下一键部署生产服务器
|
||||
# 需要安装好node、pnpm、pm2来运行程序
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
PROJECT_NAME="hair-keeper" # 可自由修改,默认使用本项目模板的名称
|
||||
PORT="8000"
|
||||
|
||||
pnpm run lint
|
||||
echo "🔨 开始构建项目..."
|
||||
pnpm run build
|
||||
|
||||
echo "🗄️ 运行数据库迁移..."
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
|
||||
echo "🔄 使用PM2重启服务..."
|
||||
pm2 describe hair-keeper > /dev/null 2>&1 && pm2 delete hair-keeper
|
||||
pm2 start pnpm --name $PROJECT_NAME -- start -p ${PORT}
|
||||
# 保存当前 PM2 进程列表的快照,使其在系统重启后能自动恢复
|
||||
pm2 save
|
||||
|
||||
echo "✅ 部署完成!服务运行在端口 ${PORT}"
|
||||
echo "📊 查看服务状态: pm2 status"
|
||||
echo "📝 查看日志: pm2 logs $PROJECT_NAME"
|
||||
echo "❌ 关闭服务: pm2 delete $PROJECT_NAME"
|
||||
echo "🎉 部署成功!"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit } from 'lucide-react'
|
||||
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
@@ -73,7 +73,7 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
|
||||
const [commitType, setCommitType] = React.useState<'normal' | 'amend' | null>(null)
|
||||
const [confirmAction, setConfirmAction] = React.useState<{
|
||||
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset'
|
||||
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset' | 'push'
|
||||
commitId?: string
|
||||
message?: string
|
||||
title?: string
|
||||
@@ -191,6 +191,17 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
},
|
||||
})
|
||||
|
||||
// 推送远程仓库mutation
|
||||
const pushToRemoteMutation = trpc.devPanel!.pushToRemote.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// 处理分支选择(仅用于查看历史,不切换实际分支)
|
||||
const handleBranchChange = (branchName: string | null) => {
|
||||
if (!branchName) return
|
||||
@@ -306,9 +317,21 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
resetToCommitMutation.mutate({ commitId: confirmAction.commitId })
|
||||
}
|
||||
break
|
||||
case 'push':
|
||||
pushToRemoteMutation.mutate({ branchName: selectedBranch })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 处理推送远程仓库
|
||||
const handlePushToRemote = () => {
|
||||
setConfirmAction({
|
||||
type: 'push',
|
||||
title: '推送到远程仓库',
|
||||
description: `确定要将分支 "${selectedBranch}" 的最新提交推送到远程仓库吗?`,
|
||||
})
|
||||
}
|
||||
|
||||
// 手动刷新所有数据
|
||||
const handleRefresh = () => {
|
||||
refetchBranches()
|
||||
@@ -357,7 +380,7 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右半部分:Commit按钮和刷新按钮 */}
|
||||
{/* 右半部分:Commit按钮、推送按钮和刷新按钮 */}
|
||||
<div className="flex items-center gap-2 flex-1 justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCommitDialog(true)}
|
||||
@@ -367,6 +390,16 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
<GitCommit className="mr-2 h-4 w-4" />
|
||||
提交更改
|
||||
</Button>
|
||||
{selectedBranch && !selectedBranch.startsWith('origin/') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePushToRemote}
|
||||
disabled={pushToRemoteMutation.isPending}
|
||||
title="推送到远程仓库"
|
||||
>
|
||||
<Upload className={cn("h-4 w-4", pushToRemoteMutation.isPending && "animate-pulse")} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
|
||||
@@ -126,8 +126,7 @@ export function DevTools() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const port = process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'
|
||||
window.open(`http://localhost:${port}`, '_blank')
|
||||
window.open(process.env.NEXT_PUBLIC_DEV_TERMINAL_URL || `http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`, '_blank')
|
||||
}}
|
||||
disabled={!terminalLoaded}
|
||||
className="gap-1.5"
|
||||
@@ -194,7 +193,7 @@ export function DevTools() {
|
||||
<div className="w-full h-full">
|
||||
{terminalLoaded ? (
|
||||
<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"
|
||||
title="开发终端"
|
||||
/>
|
||||
|
||||
247
src/app/(main)/users/dept-admin/columns.tsx
Normal file
247
src/app/(main)/users/dept-admin/columns.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
143
src/app/(main)/users/dept-admin/page.tsx
Normal file
143
src/app/(main)/users/dept-admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
src/app/(main)/users/layout.tsx
Normal file
13
src/app/(main)/users/layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
export default function UsersLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -207,7 +207,7 @@ export function RoleManagementDialog() {
|
||||
options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
|
||||
value={editingRole.permissionIds.map(String)}
|
||||
onChange={handlePermissionChange}
|
||||
multiple={{ enable: true, limit: 1 }}
|
||||
multiple={{ enable: true }}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="选择权限">
|
||||
@@ -218,6 +218,7 @@ export function UserUpdateDialog({ userId, isOpen, onClose, onUserUpdated }: Use
|
||||
form={updateForm}
|
||||
fields={formFields}
|
||||
onClose={handleClose}
|
||||
isLoading={isLoadingUser}
|
||||
>
|
||||
<FormGridContent />
|
||||
<FormActionBar>
|
||||
@@ -29,7 +29,7 @@ export function WelcomeDialog({ open, onOpenChange }: WelcomeDialogProps) {
|
||||
<DialogTitle className="text-2xl">欢迎您,开发者</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="pt-4 text-base">
|
||||
{/* 内容暂时没想好,先不实现 */}
|
||||
感谢您选择 Hair Keeper 作为开发底座,祝您开发顺利!
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
|
||||
@@ -32,12 +32,14 @@ import {
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// FormDialog Context
|
||||
export interface FormDialogContextValue {
|
||||
form: UseFormReturn<any>
|
||||
close: () => void
|
||||
fields: FormFieldConfig[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const FormDialogContext = createContext<FormDialogContextValue | null>(null)
|
||||
@@ -77,7 +79,7 @@ export function FormCancelAction({ children = '取消', variant = 'outline', onC
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant={variant} onClick={handleClick} {...props}>
|
||||
<Button type="button" variant={variant} onClick={handleClick} disabled={props.disabled} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
@@ -99,7 +101,7 @@ export function FormResetAction({
|
||||
confirmDescription = '确定要重置表单吗?表单将回到打开时的状态。',
|
||||
...props
|
||||
}: FormResetActionProps) {
|
||||
const { form } = useFormDialogContext()
|
||||
const { form, isLoading } = useFormDialogContext()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
const handleConfirm = () => {
|
||||
@@ -113,7 +115,7 @@ export function FormResetAction({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant={variant} onClick={() => setShowConfirm(true)} {...props}>
|
||||
<Button type="button" variant={variant} onClick={() => setShowConfirm(true)} disabled={isLoading || props.disabled} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -151,14 +153,14 @@ export function FormSubmitAction({
|
||||
variant = 'default',
|
||||
...props
|
||||
}: FormSubmitActionProps) {
|
||||
const { form } = useFormDialogContext()
|
||||
const { form, isLoading } = useFormDialogContext()
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || disabled}
|
||||
disabled={isSubmitting || disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -186,7 +188,21 @@ export interface 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 (
|
||||
<div className={cn("p-1", className)}>
|
||||
@@ -223,6 +239,7 @@ export interface FormDialogProps {
|
||||
className?: string // 允许自定义对话框内容样式,可控制宽度
|
||||
formClassName?: string // 允许自定义表格样式
|
||||
children: React.ReactNode // 操作按钮区域内容
|
||||
isLoading?: boolean // 是否正在加载数据
|
||||
}
|
||||
|
||||
export function FormDialog({
|
||||
@@ -235,13 +252,14 @@ export function FormDialog({
|
||||
className = 'max-w-md',
|
||||
formClassName,
|
||||
children,
|
||||
isLoading = false,
|
||||
}: FormDialogProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
// 当对话框打开时,自动聚焦到第一个表单输入控件
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isOpen && !isLoading) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
// 使用 setTimeout 确保 DOM 已完全渲染
|
||||
@@ -249,7 +267,7 @@ export function FormDialog({
|
||||
if (formRef.current) {
|
||||
// 查找第一个可聚焦的输入元素
|
||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
|
||||
)
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
@@ -259,7 +277,7 @@ export function FormDialog({
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen])
|
||||
}, [isOpen, isLoading])
|
||||
|
||||
const close = () => {
|
||||
onClose()
|
||||
@@ -269,7 +287,8 @@ export function FormDialog({
|
||||
const contextValue: FormDialogContextValue = {
|
||||
form,
|
||||
close,
|
||||
fields
|
||||
fields,
|
||||
isLoading
|
||||
}
|
||||
|
||||
// 表单内容组件,在 Dialog 和 Drawer 中复用
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
@@ -19,20 +19,43 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2, ChevronLeft, ChevronRight, Check } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// 字段配置类型定义
|
||||
export interface StepFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// 步骤配置类型定义
|
||||
export interface StepConfig {
|
||||
name: string
|
||||
title: string
|
||||
description?: string
|
||||
fields: StepFieldConfig[]
|
||||
className?: string // 步骤内容区域的网格样式
|
||||
}
|
||||
|
||||
// MultiStepFormDialog Context
|
||||
export interface MultiStepFormDialogContextValue {
|
||||
onCancel: () => void
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
isSubmitting: boolean
|
||||
isValidating: boolean
|
||||
submitButtonText: string
|
||||
form: UseFormReturn<any>
|
||||
close: () => void
|
||||
steps: StepConfig[]
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
goToStep: (step: number) => void
|
||||
goNext: () => void
|
||||
goPrev: () => void
|
||||
isLoading?: boolean
|
||||
canGoNext: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
|
||||
@@ -45,91 +68,343 @@ export function useMultiStepFormDialogContext() {
|
||||
return context
|
||||
}
|
||||
|
||||
// 字段配置类型定义
|
||||
export interface FormFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
|
||||
className?: string // 允许为单个字段指定自定义样式
|
||||
// 步骤指示器组件
|
||||
export interface StepIndicatorProps {
|
||||
className?: string
|
||||
showLabels?: boolean
|
||||
clickable?: boolean
|
||||
}
|
||||
|
||||
// 步骤配置类型定义
|
||||
export interface StepConfig {
|
||||
title: string
|
||||
description?: string
|
||||
fields: FormFieldConfig[]
|
||||
}
|
||||
|
||||
// 多步骤表单操作按钮栏组件
|
||||
export function MultiStepFormActionBar() {
|
||||
const {
|
||||
onCancel,
|
||||
onPrevious,
|
||||
onNext,
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
submitButtonText,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
} = useMultiStepFormDialogContext()
|
||||
export function StepIndicator({ className, showLabels = false, clickable = false }: StepIndicatorProps) {
|
||||
const { steps, currentStep, goToStep, isLoading } = useMultiStepFormDialogContext()
|
||||
|
||||
return (
|
||||
<div className="flex justify-between pt-6 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{!isFirstStep && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{!isLastStep && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={isSubmitting || isValidating}
|
||||
>
|
||||
{isValidating ? '验证中...' : '下一步'}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={!isLastStep ? 'hidden' : ''}
|
||||
>
|
||||
{isSubmitting ? `${submitButtonText}中...` : submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep
|
||||
const isCurrent = index === currentStep
|
||||
const isClickable = clickable && !isLoading && index <= currentStep
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.name}>
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-0.5 w-8 transition-colors",
|
||||
index <= currentStep ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isClickable}
|
||||
onClick={() => isClickable && goToStep(index)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 transition-colors",
|
||||
isClickable && "cursor-pointer hover:opacity-80",
|
||||
!isClickable && "cursor-default"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
|
||||
isCompleted && "border-primary bg-primary text-primary-foreground",
|
||||
isCurrent && "border-primary text-primary",
|
||||
!isCompleted && !isCurrent && "border-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
|
||||
</div>
|
||||
{showLabels && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium hidden sm:inline",
|
||||
isCurrent && "text-primary",
|
||||
!isCurrent && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 步骤内容组件
|
||||
export interface StepContentProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepContent({ className = 'grid grid-cols-1 gap-4' }: StepContentProps) {
|
||||
const { form, steps, currentStep, isLoading } = useMultiStepFormDialogContext()
|
||||
const currentStepConfig = steps[currentStep]
|
||||
|
||||
if (!currentStepConfig) return null
|
||||
|
||||
// 如果正在加载,显示骨架屏
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("p-1", className, currentStepConfig.className)}>
|
||||
{currentStepConfig.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 (
|
||||
<div className={cn("p-1", className, currentStepConfig.className)}>
|
||||
{currentStepConfig.fields.map((fieldConfig) => (
|
||||
<FormField
|
||||
key={fieldConfig.name}
|
||||
control={form.control}
|
||||
name={fieldConfig.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={fieldConfig.className || ''}>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{fieldConfig.render({ field })}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 步骤标题组件(显示当前步骤的标题和描述)
|
||||
export interface StepHeaderProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepHeader({ className }: StepHeaderProps) {
|
||||
const { steps, currentStep } = useMultiStepFormDialogContext()
|
||||
const currentStepConfig = steps[currentStep]
|
||||
|
||||
if (!currentStepConfig) return null
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<h4 className="font-medium">{currentStepConfig.title}</h4>
|
||||
{currentStepConfig.description && (
|
||||
<p className="text-sm text-muted-foreground">{currentStepConfig.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 上一步按钮组件
|
||||
export interface PrevStepActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onPrev?: () => void
|
||||
}
|
||||
|
||||
export function PrevStepAction({
|
||||
children,
|
||||
variant = 'outline',
|
||||
onPrev,
|
||||
...props
|
||||
}: PrevStepActionProps) {
|
||||
const { goPrev, isFirstStep, isLoading } = useMultiStepFormDialogContext()
|
||||
|
||||
const handleClick = () => {
|
||||
if (onPrev) {
|
||||
onPrev()
|
||||
} else {
|
||||
goPrev()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isFirstStep || isLoading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
上一步
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 下一步按钮组件
|
||||
export interface NextStepActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onNext?: () => Promise<boolean> | boolean
|
||||
validateCurrentStep?: boolean
|
||||
}
|
||||
|
||||
export function NextStepAction({
|
||||
children,
|
||||
variant = 'default',
|
||||
onNext,
|
||||
validateCurrentStep = true,
|
||||
...props
|
||||
}: NextStepActionProps) {
|
||||
const { goNext, isLastStep, isLoading, canGoNext } = useMultiStepFormDialogContext()
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsValidating(true)
|
||||
try {
|
||||
if (validateCurrentStep) {
|
||||
const canProceed = await canGoNext()
|
||||
if (!canProceed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (onNext) {
|
||||
const shouldProceed = await onNext()
|
||||
if (!shouldProceed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
goNext()
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLastStep) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading || isValidating || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
下一步
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
{isValidating && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 取消按钮组件
|
||||
export interface StepCancelActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function StepCancelAction({ children = '取消', variant = 'outline', onCancel, ...props }: StepCancelActionProps) {
|
||||
const { close } = useMultiStepFormDialogContext()
|
||||
|
||||
const handleClick = () => {
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant={variant} onClick={handleClick} disabled={props.disabled} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 提交按钮组件(仅在最后一步显示)
|
||||
export interface StepSubmitActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
onSubmit: (data: any) => Promise<void> | void
|
||||
children?: React.ReactNode
|
||||
disabled?: boolean
|
||||
isSubmitting?: boolean
|
||||
showSpinningLoader?: boolean
|
||||
showOnlyOnLastStep?: boolean
|
||||
}
|
||||
|
||||
export function StepSubmitAction({
|
||||
onSubmit,
|
||||
children = '提交',
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
showSpinningLoader = true,
|
||||
showOnlyOnLastStep = true,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: StepSubmitActionProps) {
|
||||
const { form, isLastStep, isLoading } = useMultiStepFormDialogContext()
|
||||
|
||||
if (showOnlyOnLastStep && !isLastStep) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{isSubmitting && showSpinningLoader && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 操作按钮栏组件
|
||||
export interface StepActionBarProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepActionBar({ children, className }: StepActionBarProps) {
|
||||
return (
|
||||
<div className={cn("flex justify-between", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 左侧操作区
|
||||
export function StepActionBarLeft({ children, className }: { children?: React.ReactNode; className?: string }) {
|
||||
return <div className={cn("flex gap-2", className)}>{children}</div>
|
||||
}
|
||||
|
||||
// 右侧操作区
|
||||
export function StepActionBarRight({ children, className }: { children?: React.ReactNode; className?: string }) {
|
||||
return <div className={cn("flex gap-2", className)}>{children}</div>
|
||||
}
|
||||
|
||||
// 主对话框组件
|
||||
export interface MultiStepFormDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
description: string
|
||||
form: UseFormReturn<any>
|
||||
onSubmit: (data: any) => Promise<void> | void
|
||||
steps: StepConfig[]
|
||||
contentClassName?: string
|
||||
gridClassName?: string
|
||||
|
||||
/* action */
|
||||
onClose: () => void
|
||||
isSubmitting: boolean
|
||||
submitButtonText: string
|
||||
className?: string
|
||||
formClassName?: string
|
||||
children: React.ReactNode
|
||||
isLoading?: boolean
|
||||
initialStep?: number
|
||||
}
|
||||
|
||||
export function MultiStepFormDialog({
|
||||
@@ -137,30 +412,33 @@ export function MultiStepFormDialog({
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
onSubmit,
|
||||
steps,
|
||||
contentClassName = 'max-w-4xl',
|
||||
gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4',
|
||||
onClose,
|
||||
isSubmitting,
|
||||
submitButtonText,
|
||||
className = 'max-w-lg',
|
||||
formClassName,
|
||||
children,
|
||||
isLoading = false,
|
||||
initialStep = 0,
|
||||
}: MultiStepFormDialogProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [currentStep, setCurrentStep] = useState(initialStep)
|
||||
|
||||
// 当对话框打开或步骤改变时,自动聚焦到第一个表单输入控件
|
||||
// 重置步骤当对话框关闭或重新打开时
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
setCurrentStep(initialStep)
|
||||
}
|
||||
}, [isOpen, initialStep])
|
||||
|
||||
// 当对话框打开或步骤变化时,自动聚焦到第一个表单输入控件
|
||||
useEffect(() => {
|
||||
if (isOpen && !isLoading) {
|
||||
(document.activeElement as HTMLElement)?.blur()
|
||||
// 使用 setTimeout 确保 DOM 已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
// 查找第一个可聚焦的输入元素
|
||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
|
||||
)
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
@@ -170,138 +448,78 @@ export function MultiStepFormDialog({
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen, currentStep])
|
||||
}, [isOpen, isLoading, currentStep])
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await onSubmit(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0)
|
||||
const close = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setIsValidating(true)
|
||||
try {
|
||||
// 验证当前步骤的字段,只有验证通过才能跳转到下一步
|
||||
const currentStepFields = currentStepConfig.fields.map(field => field.name)
|
||||
const isValid = await form.trigger(currentStepFields)
|
||||
if (isValid) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const totalSteps = steps.length
|
||||
const isFirstStep = currentStep === 0
|
||||
const isLastStep = currentStep === steps.length - 1
|
||||
const currentStepConfig = steps[currentStep]
|
||||
const isLastStep = currentStep === totalSteps - 1
|
||||
|
||||
// 步骤指示器组件
|
||||
const stepIndicator = (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
index <= currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="ml-2 text-sm">
|
||||
<div className={index <= currentStep ? 'text-primary font-medium' : 'text-muted-foreground'}>
|
||||
{step.title}
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`w-12 h-0.5 mx-4 ${index < currentStep ? 'bg-primary' : 'bg-muted'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Context 值
|
||||
const contextValue: MultiStepFormDialogContextValue = {
|
||||
onCancel: handleClose,
|
||||
onPrevious: handlePrevious,
|
||||
onNext: handleNext,
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
submitButtonText,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
const goToStep = (step: number) => {
|
||||
if (step >= 0 && step < totalSteps) {
|
||||
setCurrentStep(step)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (!isLastStep) {
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goPrev = () => {
|
||||
if (!isFirstStep) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证当前步骤的字段
|
||||
const canGoNext = async (): Promise<boolean> => {
|
||||
const currentStepConfig = steps[currentStep]
|
||||
if (!currentStepConfig) return true
|
||||
|
||||
const fieldNames = currentStepConfig.fields.map((f) => f.name)
|
||||
const result = await form.trigger(fieldNames as any)
|
||||
return result
|
||||
}
|
||||
|
||||
const contextValue: MultiStepFormDialogContextValue = {
|
||||
form,
|
||||
close,
|
||||
steps,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
goToStep,
|
||||
goNext,
|
||||
goPrev,
|
||||
isLoading,
|
||||
canGoNext,
|
||||
}
|
||||
|
||||
// 表单内容组件
|
||||
const formContent = (
|
||||
<MultiStepFormDialogContext.Provider value={contextValue}>
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
<form ref={formRef} onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{/* 当前步骤标题和描述 */}
|
||||
<div className="border-b pb-4">
|
||||
<h3 className="text-lg font-medium">{currentStepConfig.title}</h3>
|
||||
{currentStepConfig.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{currentStepConfig.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前步骤的字段 */}
|
||||
<div className={cn("p-1", gridClassName)}>
|
||||
{currentStepConfig.fields.map((fieldConfig) => (
|
||||
<FormField
|
||||
key={fieldConfig.name}
|
||||
control={form.control}
|
||||
name={fieldConfig.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={fieldConfig.className || ''}>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{fieldConfig.render({ field })}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<MultiStepFormActionBar />
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form ref={formRef} className={cn("space-y-6", formClassName)}>
|
||||
{children}
|
||||
</form>
|
||||
</Form>
|
||||
</MultiStepFormDialogContext.Provider>
|
||||
)
|
||||
|
||||
// 根据设备类型渲染不同的组件
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={handleClose}>
|
||||
<DrawerContent>
|
||||
<Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||
<DrawerContent className={className}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
||||
{stepIndicator}
|
||||
{formContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
@@ -310,17 +528,16 @@ export function MultiStepFormDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className={contentClassName}>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||
<DialogContent className={className} showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{stepIndicator}
|
||||
<div className='overflow-y-auto max-h-[70vh]'>
|
||||
<div className="overflow-y-auto max-h-[70vh]">
|
||||
{formContent}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function AppSidebar({ menuItems, ...props }: AppSidebarProps) {
|
||||
// 等待侧边栏关闭动画完成后再导航
|
||||
setTimeout(() => {
|
||||
router.push(href)
|
||||
}, 300)
|
||||
}, 350)
|
||||
}
|
||||
}, [isMobile, setOpenMobile, router])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { User, LogOut, KeyRound } from 'lucide-react'
|
||||
import { DevPanel } from '@/app/(main)/dev/panel'
|
||||
import { ChangePasswordDialog } from '@/components/layout/change-password-dialog'
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
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 { getMenuTitle } from '@/constants/menu'
|
||||
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 {
|
||||
user?: AppUser
|
||||
@@ -38,6 +42,47 @@ export function Header({ user }: HeaderProps) {
|
||||
|
||||
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 = () => {
|
||||
startTransition(() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
@@ -105,6 +150,52 @@ export function Header({ user }: HeaderProps) {
|
||||
<DropdownMenuItem disabled>
|
||||
{user.isSuperAdmin ? '超级管理员' : (Array.isArray(user.roles) ? user.roles.join('、') : user.roles)}
|
||||
</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 />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 cursor-pointer"
|
||||
@@ -131,4 +222,4 @@ export function Header({ user }: HeaderProps) {
|
||||
/>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
FileBarChart,
|
||||
Globe,
|
||||
Menu,
|
||||
LucideIcon
|
||||
LucideIcon,
|
||||
User,
|
||||
Building
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,8 @@ export const menuIconMap: Record<string, LucideIcon> = {
|
||||
'ClipboardCheck': ClipboardCheck,
|
||||
'FileBarChart': FileBarChart,
|
||||
'Globe': Globe,
|
||||
'User': User,
|
||||
'Building': Building
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -220,6 +220,20 @@ export const menuItems: MenuItem[] = [
|
||||
href: '/users',
|
||||
icon: 'UserCog',
|
||||
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: '系统设置',
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
*/
|
||||
export const SITE_NAME = 'Hair Keeper'
|
||||
export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑'
|
||||
export const SITE_VERSION = 'v1.0.0'
|
||||
export const SITE_VERSION = 'v1.2.0'
|
||||
18
src/lib/stores/userStore.ts
Normal file
18
src/lib/stores/userStore.ts
Normal 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 }),
|
||||
}))
|
||||
@@ -12,6 +12,33 @@ export const minioClient = new Client({
|
||||
|
||||
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;
|
||||
|
||||
@@ -150,7 +177,7 @@ export async function generatePresignedPostPolicy(
|
||||
const presignedData = await minioClient.presignedPostPolicy(policy);
|
||||
|
||||
return {
|
||||
postURL: presignedData.postURL,
|
||||
postURL: replaceUrlBase(presignedData.postURL),
|
||||
formData: presignedData.formData,
|
||||
objectName,
|
||||
};
|
||||
@@ -237,7 +264,7 @@ export async function generatePresignedGetObject(
|
||||
);
|
||||
|
||||
return {
|
||||
url,
|
||||
url: replaceUrlBase(url),
|
||||
expiresIn: expirySeconds,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -343,9 +370,5 @@ export async function getObjectMetadata(objectName: string) {
|
||||
* @returns 公开访问 URL
|
||||
*/
|
||||
export function getPublicUrl(objectName: string): string {
|
||||
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}/${BUCKET_NAME}/${objectName}`;
|
||||
return `${getClientBaseUrl()}/${BUCKET_NAME}/${objectName}`;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function getRedisClient() {
|
||||
const redisPassword = process.env.REDIS_PASSWORD
|
||||
|
||||
redisClient = new Redis({
|
||||
host: 'localhost',
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(redisPort, 10),
|
||||
password: redisPassword,
|
||||
maxRetriesPerRequest: null, // BullMQ 推荐设置
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTRPCRouter } from '@/server/trpc'
|
||||
import { usersRouter } from './users'
|
||||
import { deptAdminRouter } from './dept-admin'
|
||||
import { selectionRouter } from './selection'
|
||||
import { uploadRouter } from './upload'
|
||||
import { globalRouter } from './global'
|
||||
@@ -14,6 +15,7 @@ import { commonRouter } from './common'
|
||||
export const appRouter = createTRPCRouter({
|
||||
common: commonRouter,
|
||||
users: usersRouter,
|
||||
deptAdmin: deptAdminRouter,
|
||||
selection: selectionRouter,
|
||||
upload: uploadRouter,
|
||||
global: globalRouter,
|
||||
@@ -27,4 +29,4 @@ export const appRouter = createTRPCRouter({
|
||||
} : {})
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// 通用接口,与特定业务关联性不强,需要在不同的地方反复使用
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { inferProcedureOutput } from '@trpc/server';
|
||||
|
||||
export const commonRouter = createTRPCRouter({
|
||||
getDepts: permissionRequiredProcedure('').query(({ ctx }) =>
|
||||
ctx.db.dept.findMany({ orderBy: { code: 'asc' } })
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
export type CommonRouter = typeof commonRouter;
|
||||
export type Dept = inferProcedureOutput<CommonRouter['getDepts']>[number]
|
||||
|
||||
206
src/server/routers/dept-admin.ts
Normal file
206
src/server/routers/dept-admin.ts
Normal 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]
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resetToCommit,
|
||||
getCurrentBranch,
|
||||
hasUncommittedChanges,
|
||||
pushToRemote,
|
||||
} from '@/server/utils/git-helper'
|
||||
|
||||
|
||||
@@ -224,6 +225,25 @@ export const devPanelRouter = createTRPCRouter({
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 推送分支到远程仓库
|
||||
*/
|
||||
pushToRemote: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
branchName: z.string().min(1, '分支名称不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await pushToRemote(input.branchName)
|
||||
return { success: true, message: `已推送分支 ${input.branchName} 到远程仓库` }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `推送失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevPanelRouter = typeof devPanelRouter
|
||||
@@ -225,26 +225,7 @@ export function createDownloadProcedure(config: DownloadConfig) {
|
||||
* 提供文件上传相关的接口,使用工厂函数创建特定业务的上传接口
|
||||
*/
|
||||
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 创建的接口调用参数
|
||||
|
||||
@@ -6,6 +6,10 @@ import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
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({
|
||||
list: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
@@ -186,14 +190,16 @@ export const usersRouter = createTRPCRouter({
|
||||
|
||||
await Promise.all(
|
||||
batch.map(user =>
|
||||
ctx.db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
roles: action === 'grant'
|
||||
? { connect: { id: roleId } }
|
||||
: { disconnect: { id: roleId } }
|
||||
}
|
||||
})
|
||||
dbParallelLimit(() =>
|
||||
ctx.db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
roles: action === 'grant'
|
||||
? { 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 }) => {
|
||||
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 } })
|
||||
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 } })
|
||||
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 = {
|
||||
name: name?.trim() || '',
|
||||
@@ -372,6 +388,102 @@ export const usersRouter = createTRPCRouter({
|
||||
))
|
||||
).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 }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ export function startTerminalService() {
|
||||
'-p', port,
|
||||
'-t', 'titleFixed=开发终端',
|
||||
'-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',
|
||||
'tmux', 'new', '-A',
|
||||
'-s', process.env.DEV_TERMINAL || 'nextdev',
|
||||
|
||||
@@ -632,3 +632,19 @@ export async function hasUncommittedChanges(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送当前分支到远程仓库
|
||||
* @param branchName 分支名称
|
||||
*/
|
||||
export async function pushToRemote(branchName: string): Promise<void> {
|
||||
try {
|
||||
await execAsync(`git push origin "${branchName}"`, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('推送到远程仓库失败:', error)
|
||||
throw new Error(`无法推送到远程仓库: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user