Compare commits

..

13 Commits

Author SHA1 Message Date
9a3b697072 Hair Keeper v1.2.0版本更新
修复性:
* nextjs升级到\~15.4.10

新功能:
* 支持在开发容器内快速部署(默认绑定8000端口)
* 开发者面板页面git工具支持推送远程仓库
* 新增多步表单控件
* 新增开发容器工具:dnsutils
* 新增Hair Keeper开发容器使用帮助
2026-02-04 15:55:32 +08:00
b459607d31 feat: 新增多步表单控件 2026-02-03 11:57:06 +08:00
796ffcfe00 feat: 开发者面板页面git工具支持推送远程仓库 2026-01-30 12:01:44 +08:00
37f9faf2a4 优化CLAUDE.md;添加本地快速部署脚本;form-dialog打开后能聚焦下拉菜单 2026-01-06 17:12:06 +08:00
7f15051f18 修复bug,进一步优化quickstart.sh 2025-12-11 10:39:55 +08:00
9d32874e1e 优化quickstart.sh,添加远程仓库推送相关配置 2025-12-10 15:43:21 +08:00
fab2b34a03 开发终端兼容云端环境;minio客户端直传兼容云端环境;开发容器增加vim和claude code插件 2025-12-10 13:35:41 +08:00
5024477b74 添加quickstart.sh脚本帮助用户快速使用模板项目 2025-12-09 15:07:17 +08:00
1349317f88 next修复CVE-2025-66478漏洞,编程代理改成claude 2025-12-08 17:01:59 +08:00
c1fda9bb7e fix: 删除部分会引发类型错误的无用代码 2025-11-18 23:17:31 +08:00
91d39b3145 fix: 手机端关闭侧边栏后刷新页面的延迟300ms=>350ms,instrumentation.ts好像得写在src内部以适配生产环境,
新增simple_deploy.sh便于部署
2025-11-18 20:32:28 +08:00
2a80a44972 feat: 增加DEFAULT_USER_PASSWORD,作为创建用户时的默认密码,添加p-limit库,添加DB_PARALLEL_LIMIT = 32环境变量作为“数据库批次操作默认并发数” 限制只有超级管理员才能创建超级管理员用户 删除用户时可以级联删除SelectionLog 添加zustand全局状态管理 一对多的院系管理功能 ,支持增删改查院系管理员信息、用户可以在header中切换管理的院系 2025-11-18 20:10:48 +08:00
7f3190a223 fix: docker-compose.yml添加全局的name属性吗,让docker-compose控制容器、卷和网络的命名 2025-11-16 15:51:15 +08:00
13 changed files with 610 additions and 249 deletions

View File

@@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y \
telnet \
redis-tools \
iputils-ping \
dnsutils \
potrace \
imagemagick \
zsh \
@@ -96,7 +97,8 @@ 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
# 创建工作目录
RUN mkdir -p /workspace /root/.local/share/code-server/User

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@
# next.js
/.next/
/.next-prod/
/out/
# production

View File

@@ -50,43 +50,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通信
- `lib/stores/`通过zustand管理的全局的状态
- `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便于在开发页面或其他地方与开发服务器交互

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
distDir: process.env.NODE_ENV === "production" ? ".next-prod" : '.next', // 开发和生产环境输出到不同的目录,这样可以同时运行开发服务器和生产服务器
};

View File

@@ -6,7 +6,7 @@
"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",
"start": "next start",
"lint": "next lint && tsc --noEmit",
"db:seed": "tsx prisma/seed.ts",
"build:analyze": "ANALYZE=true next build"
@@ -74,7 +74,7 @@
"minio": "^8.0.6",
"motion": "^12.23.22",
"nanoid": "^5.1.6",
"next": "~15.4.8",
"next": "~15.4.10",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"nuqs": "^2.6.0",

44
quickstart.md Normal file
View File

@@ -0,0 +1,44 @@
# Hair Keeper开发容器使用帮助
## AI编程代理用法
命令行中:
* `ccr code` 打开一个新对话
* `ccr code --resume` 回到之前某个对话
对话中:
* `ESC` 连按2次可以查看和回溯到之前的某一轮对话
* `alt+tab` 切换模型是否打开思维链,默认是开启的,请关闭,因为思考模式很慢,只在极度复杂的任务时才打开
* `\` 如果您的提示词过长,一行输入不下,可以在行末输入这个字符来换行
* `@` 如果您需要引用项目中的文件,可以用这个符号
* `/ide` 输入这个命令AI会和您的IDE进行联动这样您在IDE中选中的代码会被自动发送给AIAI在修改代码时也会在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 Studioprisma提供的数据库管理工具
```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` 将代码同步到远程仓库,避免代码丢失。

2
simple_deploy.sh Normal file → Executable file
View File

@@ -1,6 +1,6 @@
#!/bin/bash
# 此脚本用来一键部署到生产服务器
# 需事先配置好ssh免密登录目标服务器需要安装好node、pnpm、tsx、pm2来运行容器
# 需事先配置好ssh免密登录目标服务器需要安装好node、pnpm、tsx、pm2来运行程序
set -e
# 配置

28
simple_deploy_local.sh Executable file
View 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 "🎉 部署成功!"

View File

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

View File

@@ -267,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()

View File

@@ -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>
)
}
}

View File

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

View File

@@ -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)}`)
}
}