feat: 增加DEFAULT_USER_PASSWORD,作为创建用户时的默认密码,添加p-limit库,添加DB_PARALLEL_LIMIT = 32环境变量作为“数据库批次操作默认并发数” 限制只有超级管理员才能创建超级管理员用户 删除用户时可以级联删除SelectionLog 添加zustand全局状态管理 一对多的院系管理功能 ,支持增删改查院系管理员信息、用户可以在header中切换管理的院系

This commit is contained in:
2025-11-18 20:07:42 +08:00
parent 7f3190a223
commit 2a80a44972
31 changed files with 1651 additions and 96 deletions

View File

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

View File

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