Compare commits
9 Commits
1349317f88
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e146af1af6 | |||
| 1e514e6631 | |||
| b459607d31 | |||
| 796ffcfe00 | |||
| 37f9faf2a4 | |||
| 7f15051f18 | |||
| 9d32874e1e | |||
| fab2b34a03 | |||
| 5024477b74 |
@@ -45,9 +45,11 @@ RUN apt-get update && apt-get install -y \
|
|||||||
telnet \
|
telnet \
|
||||||
redis-tools \
|
redis-tools \
|
||||||
iputils-ping \
|
iputils-ping \
|
||||||
|
dnsutils \
|
||||||
potrace \
|
potrace \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
zsh \
|
zsh \
|
||||||
|
vim \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 安装 Python 3.12
|
# 安装 Python 3.12
|
||||||
@@ -94,8 +96,11 @@ RUN curl -fsSL https://code-server.dev/install.sh | sh -s -- --version=${CODE_SE
|
|||||||
|
|
||||||
# 安装 npm 全局包
|
# 安装 npm 全局包
|
||||||
RUN npm install -g \
|
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
|
RUN mkdir -p /workspace /root/.local/share/code-server/User
|
||||||
@@ -112,7 +117,8 @@ RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \
|
|||||||
&& code-server --install-extension dbaeumer.vscode-eslint \
|
&& code-server --install-extension dbaeumer.vscode-eslint \
|
||||||
&& code-server --install-extension prisma.prisma \
|
&& code-server --install-extension prisma.prisma \
|
||||||
&& code-server --install-extension ecmel.vscode-html-css \
|
&& code-server --install-extension ecmel.vscode-html-css \
|
||||||
&& code-server --install-extension cweijan.vscode-redis-client
|
&& code-server --install-extension cweijan.vscode-redis-client \
|
||||||
|
&& code-server --install-extension anthropic.claude-code
|
||||||
|
|
||||||
# 配置 code-server (密码将在启动时设置)
|
# 配置 code-server (密码将在启动时设置)
|
||||||
RUN mkdir -p /root/.config/code-server && \
|
RUN mkdir -p /root/.config/code-server && \
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ services:
|
|||||||
- code-server-config:/root/.config/code-server
|
- code-server-config:/root/.config/code-server
|
||||||
# Code Server 数据(插件、用户设置、扩展数据)
|
# Code Server 数据(插件、用户设置、扩展数据)
|
||||||
- code-server-data:/root/.local/share/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:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ PKUAI_API_BASE=
|
|||||||
|
|
||||||
|
|
||||||
# 仅在开发环境加载(写在.env.development中)
|
# 仅在开发环境加载(写在.env.development中)
|
||||||
|
PORT=
|
||||||
NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT=
|
NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT=
|
||||||
|
NEXT_PUBLIC_DEV_TERMINAL_URL=
|
||||||
DEV_TERMINAL=
|
DEV_TERMINAL=
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
/.next-prod/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
|
|||||||
69
CLAUDE.md
69
CLAUDE.md
@@ -3,13 +3,14 @@
|
|||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## 项目说明
|
## 项目说明
|
||||||
本项目模板(Hair Keeper v1.1.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
本项目模板(Hair Keeper v1.2.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||||
|
|
||||||
Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||||
|
|
||||||
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
|
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
|
||||||
|
|
||||||
## 主要依赖库
|
## 主要依赖库
|
||||||
|
本项目使用pnpm作为包管理器
|
||||||
- 基础:next + react + trpc + prisma
|
- 基础:next + react + trpc + prisma
|
||||||
- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
|
- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
|
||||||
- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
|
- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
|
||||||
@@ -49,43 +50,43 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
|||||||
|
|
||||||
## 重要目录和文件
|
## 重要目录和文件
|
||||||
### 前端
|
### 前端
|
||||||
- `components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
- `src/components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
||||||
- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
- `src/components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
||||||
- `components/ai-elements/`:ai对话相关的组件
|
- `src/components/ai-elements/`:ai对话相关的组件
|
||||||
- `components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
- `src/components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
||||||
- `components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
- `src/components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
||||||
- `components/icons/`:项目的自定义图标可以写在这个文件夹
|
- `src/components/icons/`:项目的自定义图标可以写在这个文件夹
|
||||||
- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
- `src/components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
||||||
- `components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
- `src/components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
||||||
- `app/(main)/`:开发者在这里自行实现的所有业务的页面
|
- `src/app/(main)/`:开发者在这里自行实现的所有业务的页面
|
||||||
- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
- `src/app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
||||||
- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
- `src/app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
||||||
- `app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
- `src/app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
||||||
- `hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
- `src/hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
||||||
- `lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
- `src/lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
||||||
- `lib/stores/`:通过zustand管理的全局的状态
|
- `src/lib/stores/`:通过zustand管理的全局的状态
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
- `server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
- `src/server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
||||||
- `server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
- `src/server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
||||||
- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
- `src/server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
||||||
- `server/routers/jobs.ts`:tRPC任务进度订阅路由
|
- `src/server/routers/jobs.ts`:tRPC任务进度订阅路由
|
||||||
- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
- `src/server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
||||||
- `server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
- `src/server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
||||||
- `server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
- `src/server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
||||||
- `server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
- `src/server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
||||||
- `server/agents`:LLM的对接和使用
|
- `src/server/agents`:LLM的对接和使用
|
||||||
- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
||||||
- `server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
||||||
- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
||||||
- `api/dev/`:开发模式下的辅助功能需要的api
|
- `src/api/dev/`:开发模式下的辅助功能需要的api
|
||||||
|
|
||||||
### 其他
|
### 其他
|
||||||
- `constants/`:项目全局常量管理
|
- `src/constants/`:项目全局常量管理
|
||||||
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
- `src/constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
||||||
- `lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
- `src/lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
||||||
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
- `src/lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
||||||
- `lib/format.ts`:数据格式化工具函数库
|
- `src/lib/format.ts`:数据格式化工具函数库
|
||||||
|
|
||||||
## 非标准命令
|
## 非标准命令
|
||||||
- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互
|
- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -1,11 +1,12 @@
|
|||||||
## 项目说明
|
## 项目说明
|
||||||
本项目模板(Hair Keeper v1.1.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
本项目模板(Hair Keeper v1.2.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||||
|
|
||||||
Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||||
|
|
||||||
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
|
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
|
||||||
|
|
||||||
## 主要依赖库
|
## 主要依赖库
|
||||||
|
本项目使用pnpm作为包管理器
|
||||||
- 基础:next + react + trpc + prisma
|
- 基础:next + react + trpc + prisma
|
||||||
- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
|
- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
|
||||||
- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
|
- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
|
||||||
@@ -45,44 +46,43 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
|||||||
|
|
||||||
## 重要目录和文件
|
## 重要目录和文件
|
||||||
### 前端
|
### 前端
|
||||||
- `components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
- `src/components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
||||||
- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
- `src/components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
||||||
- `components/ai-elements/`:ai对话相关的组件
|
- `src/components/ai-elements/`:ai对话相关的组件
|
||||||
- `components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
- `src/components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
||||||
- `components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
- `src/components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
||||||
- `components/icons/`:项目的自定义图标可以写在这个文件夹
|
- `src/components/icons/`:项目的自定义图标可以写在这个文件夹
|
||||||
- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
- `src/components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
||||||
- `components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
- `src/components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
||||||
- `app/(main)/`:开发者在这里自行实现的所有业务的页面
|
- `src/app/(main)/`:开发者在这里自行实现的所有业务的页面
|
||||||
- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
- `src/app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
||||||
- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
- `src/app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
||||||
- `app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
- `src/app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
||||||
- `hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
- `src/hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
||||||
- `lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
- `src/lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
||||||
- `lib/stores/`:通过zustand管理的全局的状态
|
- `src/lib/stores/`:通过zustand管理的全局的状态
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
- `server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
- `src/server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
||||||
- `server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
- `src/server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
||||||
- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
- `src/server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
||||||
- `server/routers/jobs.ts`:tRPC任务进度订阅路由
|
- `src/server/routers/jobs.ts`:tRPC任务进度订阅路由
|
||||||
- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
- `src/server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
||||||
- `server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
- `src/server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
||||||
- `server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
- `src/server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
||||||
- `server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
- `src/server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
||||||
- `server/agents`:LLM的对接和使用
|
- `src/server/agents`:LLM的对接和使用
|
||||||
- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
||||||
- `server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
||||||
- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
||||||
- `api/dev/`:开发模式下的辅助功能需要的api
|
- `src/api/dev/`:开发模式下的辅助功能需要的api
|
||||||
|
|
||||||
### 其他
|
### 其他
|
||||||
- `constants/`:项目全局常量管理
|
- `src/constants/`:项目全局常量管理
|
||||||
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
- `src/constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
||||||
- `constants/menu.ts`: 菜单管理工具库,提供菜单项定义、查询、权限过滤等功能
|
- `src/lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
||||||
- `lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
- `src/lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
||||||
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
- `src/lib/format.ts`:数据格式化工具函数库
|
||||||
- `lib/format.ts`:数据格式化工具函数库
|
|
||||||
|
|
||||||
## 非标准命令
|
## 非标准命令
|
||||||
- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互
|
- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
distDir: process.env.NODE_ENV === "production" ? ".next-prod" : '.next', // 开发和生产环境输出到不同的目录,这样可以同时运行开发服务器和生产服务器
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hair-keeper",
|
"name": "hair-keeper",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000 --turbo",
|
"dev": "next dev -p 3000 --turbo",
|
||||||
"dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M",
|
"dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start",
|
||||||
"lint": "next lint && tsc --noEmit",
|
"lint": "next lint && tsc --noEmit",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"build:analyze": "ANALYZE=true next build"
|
"build:analyze": "ANALYZE=true next build"
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"motion": "^12.23.22",
|
"motion": "^12.23.22",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "~15.4.8",
|
"next": "~15.4.10",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nuqs": "^2.6.0",
|
"nuqs": "^2.6.0",
|
||||||
|
|||||||
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
Normal file → Executable file
451
quickstart.sh
Normal file → Executable file
@@ -1,2 +1,451 @@
|
|||||||
#!/bin/bash
|
#!/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
|
||||||
|
|||||||
4
simple_deploy.sh
Normal file → Executable file
4
simple_deploy.sh
Normal file → Executable file
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 此脚本用来一键部署到生产服务器
|
# 此脚本用来一键部署到生产服务器
|
||||||
# 需事先配置好ssh免密登录,目标服务器需要安装好node、pnpm、tsx、pm2来运行容器
|
# 需事先配置好ssh免密登录,目标服务器需要安装好node、pnpm、tsx、pm2来运行程序
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
@@ -20,7 +20,7 @@ TEMP_DIR=$(mktemp -d)
|
|||||||
trap "rm -rf $TEMP_DIR" EXIT
|
trap "rm -rf $TEMP_DIR" EXIT
|
||||||
|
|
||||||
# 复制必要文件
|
# 复制必要文件
|
||||||
cp -r .next $TEMP_DIR/
|
cp -r .next-prod $TEMP_DIR/
|
||||||
cp -r public $TEMP_DIR/
|
cp -r public $TEMP_DIR/
|
||||||
cp -r prisma $TEMP_DIR/
|
cp -r prisma $TEMP_DIR/
|
||||||
cp package.json $TEMP_DIR/
|
cp package.json $TEMP_DIR/
|
||||||
|
|||||||
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 { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { trpc } from '@/lib/trpc'
|
||||||
@@ -73,7 +73,7 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
|||||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
|
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
|
||||||
const [commitType, setCommitType] = React.useState<'normal' | 'amend' | null>(null)
|
const [commitType, setCommitType] = React.useState<'normal' | 'amend' | null>(null)
|
||||||
const [confirmAction, setConfirmAction] = React.useState<{
|
const [confirmAction, setConfirmAction] = React.useState<{
|
||||||
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset'
|
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset' | 'push'
|
||||||
commitId?: string
|
commitId?: string
|
||||||
message?: string
|
message?: string
|
||||||
title?: 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) => {
|
const handleBranchChange = (branchName: string | null) => {
|
||||||
if (!branchName) return
|
if (!branchName) return
|
||||||
@@ -306,9 +317,21 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
|||||||
resetToCommitMutation.mutate({ commitId: confirmAction.commitId })
|
resetToCommitMutation.mutate({ commitId: confirmAction.commitId })
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'push':
|
||||||
|
pushToRemoteMutation.mutate({ branchName: selectedBranch })
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理推送远程仓库
|
||||||
|
const handlePushToRemote = () => {
|
||||||
|
setConfirmAction({
|
||||||
|
type: 'push',
|
||||||
|
title: '推送到远程仓库',
|
||||||
|
description: `确定要将分支 "${selectedBranch}" 的最新提交推送到远程仓库吗?`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 手动刷新所有数据
|
// 手动刷新所有数据
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
refetchBranches()
|
refetchBranches()
|
||||||
@@ -357,7 +380,7 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右半部分:Commit按钮和刷新按钮 */}
|
{/* 右半部分:Commit按钮、推送按钮和刷新按钮 */}
|
||||||
<div className="flex items-center gap-2 flex-1 justify-end">
|
<div className="flex items-center gap-2 flex-1 justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowCommitDialog(true)}
|
onClick={() => setShowCommitDialog(true)}
|
||||||
@@ -367,6 +390,16 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
|||||||
<GitCommit className="mr-2 h-4 w-4" />
|
<GitCommit className="mr-2 h-4 w-4" />
|
||||||
提交更改
|
提交更改
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
|
|||||||
@@ -126,8 +126,7 @@ export function DevTools() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const port = process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'
|
window.open(process.env.NEXT_PUBLIC_DEV_TERMINAL_URL || `http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`, '_blank')
|
||||||
window.open(`http://localhost:${port}`, '_blank')
|
|
||||||
}}
|
}}
|
||||||
disabled={!terminalLoaded}
|
disabled={!terminalLoaded}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
@@ -194,7 +193,7 @@ export function DevTools() {
|
|||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
{terminalLoaded ? (
|
{terminalLoaded ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={`http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`}
|
src={process.env.NEXT_PUBLIC_DEV_TERMINAL_URL || `http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`}
|
||||||
className="w-full h-full border-0 rounded-md bg-black"
|
className="w-full h-full border-0 rounded-md bg-black"
|
||||||
title="开发终端"
|
title="开发终端"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export function RoleManagementDialog() {
|
|||||||
options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
|
options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
|
||||||
value={editingRole.permissionIds.map(String)}
|
value={editingRole.permissionIds.map(String)}
|
||||||
onChange={handlePermissionChange}
|
onChange={handlePermissionChange}
|
||||||
multiple={{ enable: true, limit: 1 }}
|
multiple={{ enable: true }}
|
||||||
>
|
>
|
||||||
<SelectPopover>
|
<SelectPopover>
|
||||||
<SelectTrigger placeholder="选择权限">
|
<SelectTrigger placeholder="选择权限">
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export function FormDialog({
|
|||||||
if (formRef.current) {
|
if (formRef.current) {
|
||||||
// 查找第一个可聚焦的输入元素
|
// 查找第一个可聚焦的输入元素
|
||||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
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) {
|
if (firstInput) {
|
||||||
firstInput.focus()
|
firstInput.focus()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'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 { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
@@ -19,20 +19,43 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
||||||
import { useIsMobile } from '@/hooks/use-mobile'
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
import { cn } from '@/lib/utils'
|
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
|
// MultiStepFormDialog Context
|
||||||
export interface MultiStepFormDialogContextValue {
|
export interface MultiStepFormDialogContextValue {
|
||||||
onCancel: () => void
|
form: UseFormReturn<any>
|
||||||
onPrevious: () => void
|
close: () => void
|
||||||
onNext: () => void
|
steps: StepConfig[]
|
||||||
isSubmitting: boolean
|
currentStep: number
|
||||||
isValidating: boolean
|
totalSteps: number
|
||||||
submitButtonText: string
|
|
||||||
isFirstStep: boolean
|
isFirstStep: boolean
|
||||||
isLastStep: boolean
|
isLastStep: boolean
|
||||||
|
goToStep: (step: number) => void
|
||||||
|
goNext: () => void
|
||||||
|
goPrev: () => void
|
||||||
|
isLoading?: boolean
|
||||||
|
canGoNext: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
|
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
|
||||||
@@ -45,91 +68,343 @@ export function useMultiStepFormDialogContext() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
// 字段配置类型定义
|
// 步骤指示器组件
|
||||||
export interface FormFieldConfig {
|
export interface StepIndicatorProps {
|
||||||
name: string
|
className?: string
|
||||||
label: string
|
showLabels?: boolean
|
||||||
required?: boolean
|
clickable?: boolean
|
||||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
|
|
||||||
className?: string // 允许为单个字段指定自定义样式
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 步骤配置类型定义
|
export function StepIndicator({ className, showLabels = false, clickable = false }: StepIndicatorProps) {
|
||||||
export interface StepConfig {
|
const { steps, currentStep, goToStep, isLoading } = useMultiStepFormDialogContext()
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
fields: FormFieldConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 多步骤表单操作按钮栏组件
|
|
||||||
export function MultiStepFormActionBar() {
|
|
||||||
const {
|
|
||||||
onCancel,
|
|
||||||
onPrevious,
|
|
||||||
onNext,
|
|
||||||
isSubmitting,
|
|
||||||
isValidating,
|
|
||||||
submitButtonText,
|
|
||||||
isFirstStep,
|
|
||||||
isLastStep
|
|
||||||
} = useMultiStepFormDialogContext()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between pt-6 border-t">
|
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||||
<div className="flex space-x-2">
|
{steps.map((step, index) => {
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
const isCompleted = index < currentStep
|
||||||
取消
|
const isCurrent = index === currentStep
|
||||||
</Button>
|
const isClickable = clickable && !isLoading && index <= currentStep
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
return (
|
||||||
{!isFirstStep && (
|
<React.Fragment key={step.name}>
|
||||||
<Button
|
{index > 0 && (
|
||||||
type="button"
|
<div
|
||||||
variant="outline"
|
className={cn(
|
||||||
onClick={onPrevious}
|
"h-0.5 w-8 transition-colors",
|
||||||
disabled={isSubmitting}
|
index <= currentStep ? "bg-primary" : "bg-muted"
|
||||||
>
|
)}
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
/>
|
||||||
上一步
|
)}
|
||||||
</Button>
|
<button
|
||||||
)}
|
type="button"
|
||||||
{!isLastStep && (
|
disabled={!isClickable}
|
||||||
<Button
|
onClick={() => isClickable && goToStep(index)}
|
||||||
type="button"
|
className={cn(
|
||||||
onClick={onNext}
|
"flex items-center gap-2 transition-colors",
|
||||||
disabled={isSubmitting || isValidating}
|
isClickable && "cursor-pointer hover:opacity-80",
|
||||||
>
|
!isClickable && "cursor-default"
|
||||||
{isValidating ? '验证中...' : '下一步'}
|
)}
|
||||||
<ChevronRight className="w-4 h-4 ml-1" />
|
>
|
||||||
</Button>
|
<div
|
||||||
)}
|
className={cn(
|
||||||
<Button
|
"flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
|
||||||
type="submit"
|
isCompleted && "border-primary bg-primary text-primary-foreground",
|
||||||
disabled={isSubmitting}
|
isCurrent && "border-primary text-primary",
|
||||||
className={!isLastStep ? 'hidden' : ''}
|
!isCompleted && !isCurrent && "border-muted text-muted-foreground"
|
||||||
>
|
)}
|
||||||
{isSubmitting ? `${submitButtonText}中...` : submitButtonText}
|
>
|
||||||
</Button>
|
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
|
||||||
</div>
|
</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>
|
</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 {
|
export interface MultiStepFormDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
form: UseFormReturn<any>
|
form: UseFormReturn<any>
|
||||||
onSubmit: (data: any) => Promise<void> | void
|
|
||||||
steps: StepConfig[]
|
steps: StepConfig[]
|
||||||
contentClassName?: string
|
|
||||||
gridClassName?: string
|
|
||||||
|
|
||||||
/* action */
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isSubmitting: boolean
|
className?: string
|
||||||
submitButtonText: string
|
formClassName?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
isLoading?: boolean
|
||||||
|
initialStep?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultiStepFormDialog({
|
export function MultiStepFormDialog({
|
||||||
@@ -137,30 +412,33 @@ export function MultiStepFormDialog({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
|
||||||
steps,
|
steps,
|
||||||
contentClassName = 'max-w-4xl',
|
|
||||||
gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4',
|
|
||||||
onClose,
|
onClose,
|
||||||
isSubmitting,
|
className = 'max-w-lg',
|
||||||
submitButtonText,
|
formClassName,
|
||||||
|
children,
|
||||||
|
isLoading = false,
|
||||||
|
initialStep = 0,
|
||||||
}: MultiStepFormDialogProps) {
|
}: MultiStepFormDialogProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
|
||||||
const [isValidating, setIsValidating] = useState(false)
|
|
||||||
const formRef = useRef<HTMLFormElement>(null)
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
const [currentStep, setCurrentStep] = useState(initialStep)
|
||||||
|
|
||||||
// 当对话框打开或步骤改变时,自动聚焦到第一个表单输入控件
|
// 重置步骤当对话框关闭或重新打开时
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
setCurrentStep(initialStep)
|
||||||
|
}
|
||||||
|
}, [isOpen, initialStep])
|
||||||
|
|
||||||
|
// 当对话框打开或步骤变化时,自动聚焦到第一个表单输入控件
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !isLoading) {
|
||||||
(document.activeElement as HTMLElement)?.blur()
|
(document.activeElement as HTMLElement)?.blur()
|
||||||
// 使用 setTimeout 确保 DOM 已完全渲染
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (formRef.current) {
|
if (formRef.current) {
|
||||||
// 查找第一个可聚焦的输入元素
|
|
||||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
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) {
|
if (firstInput) {
|
||||||
firstInput.focus()
|
firstInput.focus()
|
||||||
@@ -170,138 +448,78 @@ export function MultiStepFormDialog({
|
|||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [isOpen, currentStep])
|
}, [isOpen, isLoading, currentStep])
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
const close = () => {
|
||||||
await onSubmit(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setCurrentStep(0)
|
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNext = async () => {
|
const totalSteps = steps.length
|
||||||
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 isFirstStep = currentStep === 0
|
const isFirstStep = currentStep === 0
|
||||||
const isLastStep = currentStep === steps.length - 1
|
const isLastStep = currentStep === totalSteps - 1
|
||||||
const currentStepConfig = steps[currentStep]
|
|
||||||
|
|
||||||
// 步骤指示器组件
|
const goToStep = (step: number) => {
|
||||||
const stepIndicator = (
|
if (step >= 0 && step < totalSteps) {
|
||||||
<div className="flex items-center justify-between mb-6">
|
setCurrentStep(step)
|
||||||
{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 ${
|
const goNext = () => {
|
||||||
index <= currentStep
|
if (!isLastStep) {
|
||||||
? 'bg-primary text-primary-foreground'
|
setCurrentStep((prev) => prev + 1)
|
||||||
: 'bg-muted text-muted-foreground'
|
}
|
||||||
}`}
|
}
|
||||||
>
|
|
||||||
{index + 1}
|
const goPrev = () => {
|
||||||
</div>
|
if (!isFirstStep) {
|
||||||
<div className="ml-2 text-sm">
|
setCurrentStep((prev) => prev - 1)
|
||||||
<div className={index <= currentStep ? 'text-primary font-medium' : 'text-muted-foreground'}>
|
}
|
||||||
{step.title}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
// 验证当前步骤的字段
|
||||||
{index < steps.length - 1 && (
|
const canGoNext = async (): Promise<boolean> => {
|
||||||
<div className={`w-12 h-0.5 mx-4 ${index < currentStep ? 'bg-primary' : 'bg-muted'}`} />
|
const currentStepConfig = steps[currentStep]
|
||||||
)}
|
if (!currentStepConfig) return true
|
||||||
</div>
|
|
||||||
))}
|
const fieldNames = currentStepConfig.fields.map((f) => f.name)
|
||||||
</div>
|
const result = await form.trigger(fieldNames as any)
|
||||||
)
|
return result
|
||||||
|
}
|
||||||
// Context 值
|
|
||||||
const contextValue: MultiStepFormDialogContextValue = {
|
const contextValue: MultiStepFormDialogContextValue = {
|
||||||
onCancel: handleClose,
|
form,
|
||||||
onPrevious: handlePrevious,
|
close,
|
||||||
onNext: handleNext,
|
steps,
|
||||||
isSubmitting,
|
currentStep,
|
||||||
isValidating,
|
totalSteps,
|
||||||
submitButtonText,
|
isFirstStep,
|
||||||
isFirstStep,
|
isLastStep,
|
||||||
isLastStep
|
goToStep,
|
||||||
|
goNext,
|
||||||
|
goPrev,
|
||||||
|
isLoading,
|
||||||
|
canGoNext,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表单内容组件
|
|
||||||
const formContent = (
|
const formContent = (
|
||||||
<MultiStepFormDialogContext.Provider value={contextValue}>
|
<MultiStepFormDialogContext.Provider value={contextValue}>
|
||||||
<div className="space-y-4">
|
<Form {...form}>
|
||||||
<Form {...form}>
|
<form ref={formRef} className={cn("space-y-6", formClassName)}>
|
||||||
<form ref={formRef} onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
{children}
|
||||||
{/* 当前步骤标题和描述 */}
|
</form>
|
||||||
<div className="border-b pb-4">
|
</Form>
|
||||||
<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>
|
|
||||||
</MultiStepFormDialogContext.Provider>
|
</MultiStepFormDialogContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
||||||
// 根据设备类型渲染不同的组件
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={isOpen} onOpenChange={handleClose}>
|
<Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||||
<DrawerContent>
|
<DrawerContent className={className}>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>{title}</DrawerTitle>
|
<DrawerTitle>{title}</DrawerTitle>
|
||||||
<DrawerDescription>{description}</DrawerDescription>
|
<DrawerDescription>{description}</DrawerDescription>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
||||||
{stepIndicator}
|
|
||||||
{formContent}
|
{formContent}
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
@@ -310,14 +528,13 @@ export function MultiStepFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||||
<DialogContent className={contentClassName}>
|
<DialogContent className={className} showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{stepIndicator}
|
<div className="overflow-y-auto max-h-[70vh]">
|
||||||
<div className='overflow-y-auto max-h-[70vh]'>
|
|
||||||
{formContent}
|
{formContent}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
*/
|
*/
|
||||||
export const SITE_NAME = 'Hair Keeper'
|
export const SITE_NAME = 'Hair Keeper'
|
||||||
export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑'
|
export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑'
|
||||||
export const SITE_VERSION = 'v1.1.0'
|
export const SITE_VERSION = 'v1.2.0'
|
||||||
@@ -12,6 +12,33 @@ export const minioClient = new Client({
|
|||||||
|
|
||||||
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
|
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户端访问的基础 URL
|
||||||
|
* 优先使用 MINIO_SERVER_URL(公网地址),否则使用内部地址
|
||||||
|
*/
|
||||||
|
function getClientBaseUrl(): string {
|
||||||
|
if (process.env.MINIO_SERVER_URL) {
|
||||||
|
return process.env.MINIO_SERVER_URL;
|
||||||
|
}
|
||||||
|
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
||||||
|
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
|
||||||
|
const port = process.env.MINIO_API_PORT || '9000';
|
||||||
|
return `${protocol}://${endpoint}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 替换预签名 URL 中的内部地址为客户端可访问的地址
|
||||||
|
*/
|
||||||
|
function replaceUrlBase(originalUrl: string): string {
|
||||||
|
const clientBase = getClientBaseUrl();
|
||||||
|
const url = new URL(originalUrl);
|
||||||
|
const clientUrl = new URL(clientBase);
|
||||||
|
url.protocol = clientUrl.protocol;
|
||||||
|
url.hostname = clientUrl.hostname;
|
||||||
|
url.port = clientUrl.port; // 空字符串表示使用协议默认端口
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
// 桶初始化标志
|
// 桶初始化标志
|
||||||
let bucketInitialized = false;
|
let bucketInitialized = false;
|
||||||
|
|
||||||
@@ -150,7 +177,7 @@ export async function generatePresignedPostPolicy(
|
|||||||
const presignedData = await minioClient.presignedPostPolicy(policy);
|
const presignedData = await minioClient.presignedPostPolicy(policy);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
postURL: presignedData.postURL,
|
postURL: replaceUrlBase(presignedData.postURL),
|
||||||
formData: presignedData.formData,
|
formData: presignedData.formData,
|
||||||
objectName,
|
objectName,
|
||||||
};
|
};
|
||||||
@@ -237,7 +264,7 @@ export async function generatePresignedGetObject(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url,
|
url: replaceUrlBase(url),
|
||||||
expiresIn: expirySeconds,
|
expiresIn: expirySeconds,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -343,9 +370,5 @@ export async function getObjectMetadata(objectName: string) {
|
|||||||
* @returns 公开访问 URL
|
* @returns 公开访问 URL
|
||||||
*/
|
*/
|
||||||
export function getPublicUrl(objectName: string): string {
|
export function getPublicUrl(objectName: string): string {
|
||||||
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
return `${getClientBaseUrl()}/${BUCKET_NAME}/${objectName}`;
|
||||||
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
|
|
||||||
const port = process.env.MINIO_API_PORT || '9000';
|
|
||||||
|
|
||||||
return `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${objectName}`;
|
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
resetToCommit,
|
resetToCommit,
|
||||||
getCurrentBranch,
|
getCurrentBranch,
|
||||||
hasUncommittedChanges,
|
hasUncommittedChanges,
|
||||||
|
pushToRemote,
|
||||||
} from '@/server/utils/git-helper'
|
} 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
|
export type DevPanelRouter = typeof devPanelRouter
|
||||||
@@ -46,7 +46,8 @@ export function startTerminalService() {
|
|||||||
'-p', port,
|
'-p', port,
|
||||||
'-t', 'titleFixed=开发终端',
|
'-t', 'titleFixed=开发终端',
|
||||||
'-t', 'fontSize=14',
|
'-t', 'fontSize=14',
|
||||||
'-i', '127.0.0.1',
|
'-c', `super_admin:${process.env.SUPER_ADMIN_PASSWORD}`,
|
||||||
|
'-i', process.env.SUPER_ADMIN_PASSWORD ? '0.0.0.0' : "127.0.0.1",
|
||||||
'--writable',
|
'--writable',
|
||||||
'tmux', 'new', '-A',
|
'tmux', 'new', '-A',
|
||||||
'-s', process.env.DEV_TERMINAL || 'nextdev',
|
'-s', process.env.DEV_TERMINAL || 'nextdev',
|
||||||
|
|||||||
@@ -632,3 +632,19 @@ export async function hasUncommittedChanges(): Promise<boolean> {
|
|||||||
return false
|
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