feat: 增加DEFAULT_USER_PASSWORD,作为创建用户时的默认密码,添加p-limit库,添加DB_PARALLEL_LIMIT = 32环境变量作为“数据库批次操作默认并发数” 限制只有超级管理员才能创建超级管理员用户 删除用户时可以级联删除SelectionLog 添加zustand全局状态管理 一对多的院系管理功能 ,支持增删改查院系管理员信息、用户可以在header中切换管理的院系
This commit is contained in:
247
src/app/(main)/users/dept-admin/columns.tsx
Normal file
247
src/app/(main)/users/dept-admin/columns.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
'use client'
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Edit, Trash2, MoreHorizontal } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { DeptAdmin } from '@/server/routers/dept-admin'
|
||||
|
||||
// 操作回调类型
|
||||
export type DeptAdminActions = {
|
||||
onEdit: (id: number) => void
|
||||
onDelete: (id: number) => void
|
||||
}
|
||||
|
||||
// 列定义选项类型
|
||||
export type DeptAdminColumnsOptions = {
|
||||
depts?: Array<{ code: string; name: string; fullName: string }>
|
||||
}
|
||||
|
||||
// 创建院系管理员表格列定义
|
||||
export const createDeptAdminColumns = (
|
||||
actions: DeptAdminActions,
|
||||
options: DeptAdminColumnsOptions = {}
|
||||
): ColumnDef<DeptAdmin>[] => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="ID" />
|
||||
),
|
||||
cell: ({ row }) => <div className="font-medium">{row.original.id}</div>,
|
||||
meta: {
|
||||
label: 'ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'uid',
|
||||
accessorKey: 'uid',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="用户ID" />
|
||||
),
|
||||
cell: ({ row }) => <div className="font-medium">{row.original.uid}</div>,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '用户ID',
|
||||
filter: {
|
||||
placeholder: '请输入用户ID',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'userName',
|
||||
accessorKey: 'user.name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="姓名" />
|
||||
),
|
||||
cell: ({ row }) => <div>{row.original.user?.name || '-'}</div>,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '姓名',
|
||||
filter: {
|
||||
placeholder: '请输入姓名',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deptCode',
|
||||
accessorKey: 'deptCode',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="院系" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.dept?.name || '-'}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.original.deptCode}</div>
|
||||
</div>
|
||||
),
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '院系',
|
||||
filter: {
|
||||
variant: 'multiSelect',
|
||||
options: options.depts?.map(dept => ({
|
||||
id: dept.code,
|
||||
name: dept.fullName,
|
||||
})) || [],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'adminEmail',
|
||||
accessorKey: 'adminEmail',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="邮箱" />
|
||||
),
|
||||
cell: ({ row }) => <div>{row.original.adminEmail || '-'}</div>,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '邮箱',
|
||||
filter: {
|
||||
placeholder: '请输入邮箱',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'adminLinePhone',
|
||||
accessorKey: 'adminLinePhone',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="座机" />
|
||||
),
|
||||
cell: ({ row }) => <div>{row.original.adminLinePhone || '-'}</div>,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '座机',
|
||||
filter: {
|
||||
placeholder: '请输入座机',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'adminMobilePhone',
|
||||
accessorKey: 'adminMobilePhone',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="手机" />
|
||||
),
|
||||
cell: ({ row }) => <div>{row.original.adminMobilePhone || '-'}</div>,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '手机',
|
||||
filter: {
|
||||
placeholder: '请输入手机',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'note',
|
||||
accessorKey: 'note',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="备注" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[200px] truncate" title={row.original.note || ''}>
|
||||
{row.original.note || '-'}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
label: '备注',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="创建时间" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <div>{formatDate(row.original.createdAt) || '-'}</div>
|
||||
},
|
||||
meta: {
|
||||
label: '创建时间',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'updatedAt',
|
||||
accessorKey: 'updatedAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="更新时间" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <div>{formatDate(row.original.updatedAt) || '-'}</div>
|
||||
},
|
||||
meta: {
|
||||
label: '更新时间',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const deptAdmin = row.original
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 md:w-9">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">打开菜单</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => actions.onEdit(deptAdmin.id)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => actions.onDelete(deptAdmin.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
|
||||
import {
|
||||
AdvancedSelect,
|
||||
SelectPopover,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectInput,
|
||||
SelectItemList,
|
||||
SelectedName
|
||||
} from '@/components/common/advanced-select'
|
||||
import { useSmartSelectOptions } from '@/hooks/use-smart-select-options'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
// 创建院系管理员的 schema
|
||||
const createDeptAdminSchema = z.object({
|
||||
uid: z.string().min(1, '用户ID不能为空'),
|
||||
deptCode: z.string().min(1, '院系代码不能为空'),
|
||||
adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')),
|
||||
adminLinePhone: z.string().optional(),
|
||||
adminMobilePhone: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
type CreateDeptAdminInput = z.input<typeof createDeptAdminSchema>
|
||||
|
||||
const createDeptAdminDefaultValues: CreateDeptAdminInput = {
|
||||
uid: '',
|
||||
deptCode: '',
|
||||
adminEmail: '',
|
||||
adminLinePhone: '',
|
||||
adminMobilePhone: '',
|
||||
note: '',
|
||||
}
|
||||
|
||||
interface DeptAdminCreateDialogProps {
|
||||
onDeptAdminCreated: () => void
|
||||
}
|
||||
|
||||
export function DeptAdminCreateDialog({ onDeptAdminCreated }: DeptAdminCreateDialogProps) {
|
||||
// 表单 dialog 控制
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
|
||||
// react-hook-form 管理创建表单
|
||||
const createForm = useForm<CreateDeptAdminInput>({
|
||||
resolver: zodResolver(createDeptAdminSchema),
|
||||
defaultValues: createDeptAdminDefaultValues,
|
||||
})
|
||||
|
||||
// 获取院系列表
|
||||
const { data: depts } = trpc.common.getDepts.useQuery()
|
||||
|
||||
const deptOptions = depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || []
|
||||
const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({
|
||||
options: deptOptions,
|
||||
context: 'deptAdmin.create.dept',
|
||||
scope: 'personal',
|
||||
})
|
||||
|
||||
// 创建院系管理员 mutation
|
||||
const createDeptAdminMutation = trpc.deptAdmin.create.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsCreateDialogOpen(false)
|
||||
createForm.reset(createDeptAdminDefaultValues)
|
||||
toast.success('院系管理员创建成功')
|
||||
onDeptAdminCreated()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '创建院系管理员失败')
|
||||
},
|
||||
})
|
||||
|
||||
// 定义字段配置
|
||||
const formFields: FormFieldConfig[] = React.useMemo(() => [
|
||||
{
|
||||
name: 'uid',
|
||||
label: '用户ID',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入用户ID(职工号)" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'deptCode',
|
||||
label: '院系',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<AdvancedSelect
|
||||
{...field}
|
||||
options={sortedDeptOptions}
|
||||
onChange={(value) => { logDeptSelection(value); field.onChange(value) }}
|
||||
filterFunction={(option, searchValue) => {
|
||||
const search = searchValue.toLowerCase()
|
||||
return option.id.includes(search) || option.name.toLowerCase().includes(search) ||
|
||||
(option.shortName && option.shortName.toLowerCase().includes(search))
|
||||
}}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="请选择院系">
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectInput placeholder="搜索院系名称/代码" />
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adminEmail',
|
||||
label: '邮箱',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} type="email" placeholder="请输入邮箱" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adminLinePhone',
|
||||
label: '座机',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入座机号码" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adminMobilePhone',
|
||||
label: '手机',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入手机号码" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
label: '备注',
|
||||
render: ({ field }) => (
|
||||
<Textarea {...field} placeholder="请输入备注信息" className="min-h-[80px]" />
|
||||
),
|
||||
},
|
||||
], [sortedDeptOptions, logDeptSelection])
|
||||
|
||||
const handleSubmit = async (data: CreateDeptAdminInput) => {
|
||||
createDeptAdminMutation.mutate(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsCreateDialogOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
创建院系管理员
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Dialog>
|
||||
|
||||
<FormDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
title="创建院系管理员"
|
||||
description="请填写院系管理员信息"
|
||||
form={createForm}
|
||||
fields={formFields}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<FormGridContent />
|
||||
<FormActionBar>
|
||||
<FormCancelAction />
|
||||
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={createDeptAdminMutation.isPending}>创建</FormSubmitAction>
|
||||
</FormActionBar>
|
||||
</FormDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface DeptAdminDeleteDialogProps {
|
||||
deptAdminId: number | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onDeptAdminDeleted: () => void
|
||||
}
|
||||
|
||||
export function DeptAdminDeleteDialog({
|
||||
deptAdminId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDeptAdminDeleted,
|
||||
}: DeptAdminDeleteDialogProps) {
|
||||
// 获取院系管理员详情
|
||||
const { data: deptAdmin } = trpc.deptAdmin.getById.useQuery(
|
||||
{ id: deptAdminId! },
|
||||
{ enabled: !!deptAdminId && isOpen }
|
||||
)
|
||||
|
||||
// 删除院系管理员 mutation
|
||||
const deleteDeptAdminMutation = trpc.deptAdmin.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
toast.success('院系管理员删除成功')
|
||||
onDeptAdminDeleted()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '删除院系管理员失败')
|
||||
},
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (deptAdminId) {
|
||||
deleteDeptAdminMutation.mutate({ id: deptAdminId })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deptAdmin ? (
|
||||
<>
|
||||
确定要删除院系管理员 <strong>{deptAdmin.user?.name || deptAdmin.uid}</strong> 在 <strong>{deptAdmin.dept?.name || deptAdmin.deptCode}</strong> 的管理权限吗?
|
||||
<br />
|
||||
此操作无法撤销。
|
||||
</>
|
||||
) : (
|
||||
'确定要删除该院系管理员吗?此操作无法撤销。'
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteDeptAdminMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={deleteDeptAdminMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteDeptAdminMutation.isPending ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from 'sonner'
|
||||
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
|
||||
import {
|
||||
AdvancedSelect,
|
||||
SelectPopover,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectInput,
|
||||
SelectItemList,
|
||||
SelectedName
|
||||
} from '@/components/common/advanced-select'
|
||||
import { useSmartSelectOptions } from '@/hooks/use-smart-select-options'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
// 更新院系管理员的 schema
|
||||
const updateDeptAdminSchema = z.object({
|
||||
id: z.number(),
|
||||
uid: z.string().min(1, '用户ID不能为空'),
|
||||
deptCode: z.string().min(1, '院系代码不能为空'),
|
||||
adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')),
|
||||
adminLinePhone: z.string().optional(),
|
||||
adminMobilePhone: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
type UpdateDeptAdminInput = z.input<typeof updateDeptAdminSchema>
|
||||
|
||||
interface DeptAdminUpdateDialogProps {
|
||||
deptAdminId: number | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onDeptAdminUpdated: () => void
|
||||
}
|
||||
|
||||
export function DeptAdminUpdateDialog({
|
||||
deptAdminId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDeptAdminUpdated,
|
||||
}: DeptAdminUpdateDialogProps) {
|
||||
// react-hook-form 管理更新表单
|
||||
const updateForm = useForm<UpdateDeptAdminInput>({
|
||||
resolver: zodResolver(updateDeptAdminSchema),
|
||||
defaultValues: {
|
||||
id: 0,
|
||||
uid: '',
|
||||
deptCode: '',
|
||||
adminEmail: '',
|
||||
adminLinePhone: '',
|
||||
adminMobilePhone: '',
|
||||
note: '',
|
||||
},
|
||||
})
|
||||
|
||||
// 获取院系列表
|
||||
const { data: depts } = trpc.common.getDepts.useQuery()
|
||||
|
||||
const deptOptions = depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || []
|
||||
const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({
|
||||
options: deptOptions,
|
||||
context: 'deptAdmin.update.dept',
|
||||
scope: 'personal',
|
||||
})
|
||||
|
||||
// 获取院系管理员详情
|
||||
const { data: deptAdmin, isLoading } = trpc.deptAdmin.getById.useQuery(
|
||||
{ id: deptAdminId! },
|
||||
{ enabled: !!deptAdminId && isOpen }
|
||||
)
|
||||
|
||||
// 更新院系管理员 mutation
|
||||
const updateDeptAdminMutation = trpc.deptAdmin.update.useMutation({
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
toast.success('院系管理员更新成功')
|
||||
onDeptAdminUpdated()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '更新院系管理员失败')
|
||||
},
|
||||
})
|
||||
|
||||
// 当获取到院系管理员数据时,填充表单
|
||||
useEffect(() => {
|
||||
if (deptAdmin) {
|
||||
updateForm.reset({
|
||||
id: deptAdmin.id,
|
||||
uid: deptAdmin.uid,
|
||||
deptCode: deptAdmin.deptCode,
|
||||
adminEmail: deptAdmin.adminEmail || '',
|
||||
adminLinePhone: deptAdmin.adminLinePhone || '',
|
||||
adminMobilePhone: deptAdmin.adminMobilePhone || '',
|
||||
note: deptAdmin.note || '',
|
||||
})
|
||||
}
|
||||
}, [deptAdmin, updateForm])
|
||||
|
||||
// 定义字段配置
|
||||
const formFields: FormFieldConfig[] = React.useMemo(() => [
|
||||
{
|
||||
name: 'uid',
|
||||
label: '用户ID',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入用户ID(职工号)" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'deptCode',
|
||||
label: '院系',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<AdvancedSelect
|
||||
{...field}
|
||||
options={sortedDeptOptions}
|
||||
onChange={(value) => { logDeptSelection(value); field.onChange(value) }}
|
||||
filterFunction={(option, searchValue) => {
|
||||
const search = searchValue.toLowerCase()
|
||||
return option.id.includes(search) || option.name.toLowerCase().includes(search) ||
|
||||
(option.shortName && option.shortName.toLowerCase().includes(search))
|
||||
}}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="请选择院系">
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectInput placeholder="搜索院系名称/代码" />
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adminEmail',
|
||||
label: '邮箱',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} type="email" placeholder="请输入邮箱" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adminLinePhone',
|
||||
label: '座机',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入座机号码" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adminMobilePhone',
|
||||
label: '手机',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入手机号码" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
label: '备注',
|
||||
render: ({ field }) => (
|
||||
<Textarea {...field} placeholder="请输入备注信息" className="min-h-[80px]" />
|
||||
),
|
||||
},
|
||||
], [sortedDeptOptions, logDeptSelection])
|
||||
|
||||
const handleSubmit = async (data: UpdateDeptAdminInput) => {
|
||||
updateDeptAdminMutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
isOpen={isOpen}
|
||||
title="编辑院系管理员"
|
||||
description="修改院系管理员信息"
|
||||
form={updateForm}
|
||||
fields={formFields}
|
||||
onClose={onClose}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<FormGridContent />
|
||||
<FormActionBar>
|
||||
<FormCancelAction />
|
||||
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={updateDeptAdminMutation.isPending}>保存</FormSubmitAction>
|
||||
</FormActionBar>
|
||||
</FormDialog>
|
||||
)
|
||||
}
|
||||
143
src/app/(main)/users/dept-admin/page.tsx
Normal file
143
src/app/(main)/users/dept-admin/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback, useMemo, Suspense } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { DeptAdminCreateDialog } from './components/DeptAdminCreateDialog'
|
||||
import { DeptAdminUpdateDialog } from './components/DeptAdminUpdateDialog'
|
||||
import { DeptAdminDeleteDialog } from './components/DeptAdminDeleteDialog'
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
import { DataTableToolbar } from '@/components/data-table/toolbar'
|
||||
import { createDeptAdminColumns, type DeptAdminColumnsOptions } from './columns'
|
||||
import type { DeptAdmin } from '@/server/routers/dept-admin'
|
||||
import { useDataTable } from '@/hooks/use-data-table'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { DataTableSortList } from '@/components/data-table/sort-list'
|
||||
import { toast } from 'sonner'
|
||||
import { DataTableSkeleton } from '@/components/data-table/table-skeleton'
|
||||
|
||||
interface DeptAdminPageDataTableProps {
|
||||
onEdit: (id: number) => void
|
||||
onDelete: (id: number) => void
|
||||
}
|
||||
|
||||
function DeptAdminPageDataTable({ onEdit, onDelete }: DeptAdminPageDataTableProps) {
|
||||
// 获取部门列表用于过滤器选项
|
||||
const { data: depts } = trpc.common.getDepts.useQuery()
|
||||
|
||||
// 创建表格列定义选项
|
||||
const columnsOptions: DeptAdminColumnsOptions = useMemo(() => ({
|
||||
depts: depts || [],
|
||||
}), [depts])
|
||||
|
||||
// 创建表格列定义
|
||||
const columns = useMemo(() => createDeptAdminColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}, columnsOptions), [onEdit, onDelete, columnsOptions])
|
||||
|
||||
// 使用 useDataTable hook,传入 queryFn
|
||||
const { table, queryResult } = useDataTable<DeptAdmin>({
|
||||
columns,
|
||||
initialState: {
|
||||
pagination: { pageIndex: 1, pageSize: 10 },
|
||||
columnPinning: { left: ["select"], right: ["actions"] },
|
||||
columnVisibility: { id: false }
|
||||
},
|
||||
getRowId: (row) => row.id.toString(),
|
||||
queryFn: useCallback((params) => {
|
||||
const result = trpc.deptAdmin.list.useQuery(params, {
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.error("获取院系管理员数据失败:" + result.error.toString().substring(0, 100))
|
||||
}
|
||||
return result
|
||||
}, []),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataTable table={table} isLoading={queryResult.isLoading}>
|
||||
<DataTableToolbar table={table}>
|
||||
<DataTableSortList table={table} />
|
||||
</DataTableToolbar>
|
||||
</DataTable>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DeptAdminPage() {
|
||||
// 更新院系管理员对话框状态
|
||||
const [updateDeptAdminId, setUpdateDeptAdminId] = useState<number | null>(null)
|
||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
|
||||
|
||||
// 删除院系管理员对话框状态
|
||||
const [deleteDeptAdminId, setDeleteDeptAdminId] = useState<number | null>(null)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
|
||||
// 用于刷新数据的 utils
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// 处理编辑院系管理员
|
||||
const handleEditDeptAdmin = useCallback((id: number) => {
|
||||
setUpdateDeptAdminId(id)
|
||||
setIsUpdateDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// 关闭更新对话框
|
||||
const handleCloseUpdateDialog = useCallback(() => {
|
||||
setIsUpdateDialogOpen(false)
|
||||
setUpdateDeptAdminId(null)
|
||||
}, [])
|
||||
|
||||
// 处理删除院系管理员
|
||||
const handleDeleteDeptAdmin = useCallback((id: number) => {
|
||||
setDeleteDeptAdminId(id)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// 关闭删除对话框
|
||||
const handleCloseDeleteDialog = useCallback(() => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteDeptAdminId(null)
|
||||
}, [])
|
||||
|
||||
// 刷新院系管理员列表
|
||||
const handleRefreshDeptAdmins = useCallback(() => {
|
||||
utils.deptAdmin.list.invalidate()
|
||||
}, [utils])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 院系管理员列表和创建按钮 */}
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">院系管理员列表</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<DeptAdminCreateDialog onDeptAdminCreated={handleRefreshDeptAdmins} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense fallback={<DataTableSkeleton columnCount={10} rowCount={10} />}>
|
||||
<DeptAdminPageDataTable onEdit={handleEditDeptAdmin} onDelete={handleDeleteDeptAdmin} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 更新院系管理员对话框 */}
|
||||
<DeptAdminUpdateDialog
|
||||
deptAdminId={updateDeptAdminId}
|
||||
isOpen={isUpdateDialogOpen}
|
||||
onClose={handleCloseUpdateDialog}
|
||||
onDeptAdminUpdated={handleRefreshDeptAdmins}
|
||||
/>
|
||||
|
||||
{/* 删除院系管理员对话框 */}
|
||||
<DeptAdminDeleteDialog
|
||||
deptAdminId={deleteDeptAdminId}
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={handleCloseDeleteDialog}
|
||||
onDeptAdminDeleted={handleRefreshDeptAdmins}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
src/app/(main)/users/layout.tsx
Normal file
13
src/app/(main)/users/layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
export default function UsersLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -218,6 +218,7 @@ export function UserUpdateDialog({ userId, isOpen, onClose, onUserUpdated }: Use
|
||||
form={updateForm}
|
||||
fields={formFields}
|
||||
onClose={handleClose}
|
||||
isLoading={isLoadingUser}
|
||||
>
|
||||
<FormGridContent />
|
||||
<FormActionBar>
|
||||
@@ -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 中复用
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { User, LogOut, KeyRound } from 'lucide-react'
|
||||
import { DevPanel } from '@/app/(main)/dev/panel'
|
||||
import { ChangePasswordDialog } from '@/components/layout/change-password-dialog'
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { signOut } from 'next-auth/react'
|
||||
@@ -23,6 +22,11 @@ import { useTheme } from 'next-themes'
|
||||
import { ThemeToggleButton, useThemeTransition } from '@/components/common/theme-toggle-button'
|
||||
import { getMenuTitle } from '@/constants/menu'
|
||||
import type { User as AppUser } from '@/types/user'
|
||||
import { useUserStore } from '@/lib/stores/userStore'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { AdvancedSelect, SelectContent, SelectedName, SelectInput, SelectItemList, SelectPopover, SelectTrigger } from '@/components/common/advanced-select'
|
||||
import type { Dept } from '@prisma/client'
|
||||
|
||||
interface HeaderProps {
|
||||
user?: AppUser
|
||||
@@ -38,6 +42,47 @@ export function Header({ user }: HeaderProps) {
|
||||
|
||||
const pageTitle = getMenuTitle(pathname, 2) // 只匹配到第二级菜单
|
||||
|
||||
// 从zustand store获取当前管理的院系信息
|
||||
const { setCurrentManagedDept } = useUserStore()
|
||||
|
||||
// 获取可管理的院系列表(包含当前管理的院系信息)
|
||||
const { data: managedDeptsData } = trpc.users.getManagedDepts.useQuery()
|
||||
|
||||
// 本地状态管理
|
||||
const [currentManagedDeptCode, setCurrentManagedDeptCode] = useState<string | null>(null)
|
||||
const [managedDepts, setManagedDepts] = useState<Array<Dept>>([])
|
||||
|
||||
// 初始化数据
|
||||
useEffect(() => {
|
||||
if (managedDeptsData && managedDeptsData.currentDept !== undefined) {
|
||||
setCurrentManagedDeptCode(managedDeptsData.currentDept)
|
||||
setManagedDepts(managedDeptsData.depts)
|
||||
|
||||
// 更新store中的当前管理院系信息
|
||||
const deptInfo = managedDeptsData.depts.find(dept => dept.code === managedDeptsData.currentDept) || null
|
||||
setCurrentManagedDept(deptInfo)
|
||||
}
|
||||
}, [managedDeptsData, setCurrentManagedDept])
|
||||
|
||||
// 切换管理院系
|
||||
const switchManagedDeptMutation = trpc.users.switchManagedDept.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success('切换管理院系成功')
|
||||
// 更新本地状态
|
||||
setCurrentManagedDeptCode(data.deptCode)
|
||||
// 更新store中的当前管理院系信息
|
||||
setCurrentManagedDept(managedDepts.find(dept => dept.code === data.deptCode) || null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '切换管理院系失败')
|
||||
},
|
||||
})
|
||||
|
||||
// 处理院系切换
|
||||
const handleDeptChange = (deptCode: string | null) => {
|
||||
switchManagedDeptMutation.mutate({ deptCode })
|
||||
}
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
startTransition(() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
@@ -105,6 +150,52 @@ export function Header({ user }: HeaderProps) {
|
||||
<DropdownMenuItem disabled>
|
||||
{user.isSuperAdmin ? '超级管理员' : (Array.isArray(user.roles) ? user.roles.join('、') : user.roles)}
|
||||
</DropdownMenuItem>
|
||||
{/* 管理院系 - 根据可管理院系数量决定显示方式 */}
|
||||
{managedDepts.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>管理院系</DropdownMenuLabel>
|
||||
{managedDepts.length === 1 ? (
|
||||
// 只有一个可管理院系时,直接显示院系名称
|
||||
<DropdownMenuItem disabled>
|
||||
{managedDepts[0].fullName}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
// 多个可管理院系时,显示下拉选择器
|
||||
<div className="px-2 py-1.5">
|
||||
<AdvancedSelect
|
||||
value={currentManagedDeptCode}
|
||||
onChange={handleDeptChange}
|
||||
options={managedDepts.map(dept => ({
|
||||
id: dept.code,
|
||||
name: dept.fullName,
|
||||
shortName: dept.name
|
||||
}))}
|
||||
disabled={switchManagedDeptMutation.isPending}
|
||||
filterFunction={(option, searchValue) => {
|
||||
const search = searchValue.toLowerCase()
|
||||
return option.id.includes(search) ||
|
||||
option.name.toLowerCase().includes(search) ||
|
||||
(option.shortName && option.shortName.toLowerCase().includes(search))
|
||||
}}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger
|
||||
placeholder="请选择管理院系"
|
||||
className="h-9"
|
||||
>
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{managedDepts.length > 5 && <SelectInput placeholder="搜索院系名称/代码" />}
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 cursor-pointer"
|
||||
@@ -131,4 +222,4 @@ export function Header({ user }: HeaderProps) {
|
||||
/>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
FileBarChart,
|
||||
Globe,
|
||||
Menu,
|
||||
LucideIcon
|
||||
LucideIcon,
|
||||
User,
|
||||
Building
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,8 @@ export const menuIconMap: Record<string, LucideIcon> = {
|
||||
'ClipboardCheck': ClipboardCheck,
|
||||
'FileBarChart': FileBarChart,
|
||||
'Globe': Globe,
|
||||
'User': User,
|
||||
'Building': Building
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -220,6 +220,20 @@ export const menuItems: MenuItem[] = [
|
||||
href: '/users',
|
||||
icon: 'UserCog',
|
||||
permission: Permissions.USER_MANAGE,
|
||||
children: [
|
||||
{
|
||||
title: '用户与授权',
|
||||
href: '/users/user-info',
|
||||
icon: 'UserCog',
|
||||
permission: Permissions.USER_MANAGE,
|
||||
},
|
||||
{
|
||||
title: '院系管理员',
|
||||
href: '/users/dept-admin',
|
||||
icon: 'Building',
|
||||
permission: Permissions.USER_MANAGE,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
|
||||
18
src/lib/stores/userStore.ts
Normal file
18
src/lib/stores/userStore.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Dept } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* 用户状态管理Store
|
||||
* 用于管理用户相关的全局状态,如当前管理的院系
|
||||
*/
|
||||
interface UserStore {
|
||||
// 当前管理的院系
|
||||
currentManagedDept: Dept | null
|
||||
// 设置当前管理的院系
|
||||
setCurrentManagedDept: (deptInfo: Dept | null) => void
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserStore>((set) => ({
|
||||
currentManagedDept: null,
|
||||
setCurrentManagedDept: (deptInfo) => set({ currentManagedDept: deptInfo }),
|
||||
}))
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTRPCRouter } from '@/server/trpc'
|
||||
import { usersRouter } from './users'
|
||||
import { deptAdminRouter } from './dept-admin'
|
||||
import { selectionRouter } from './selection'
|
||||
import { uploadRouter } from './upload'
|
||||
import { globalRouter } from './global'
|
||||
@@ -14,6 +15,7 @@ import { commonRouter } from './common'
|
||||
export const appRouter = createTRPCRouter({
|
||||
common: commonRouter,
|
||||
users: usersRouter,
|
||||
deptAdmin: deptAdminRouter,
|
||||
selection: selectionRouter,
|
||||
upload: uploadRouter,
|
||||
global: globalRouter,
|
||||
@@ -27,4 +29,4 @@ export const appRouter = createTRPCRouter({
|
||||
} : {})
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// 通用接口,与特定业务关联性不强,需要在不同的地方反复使用
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { inferProcedureOutput } from '@trpc/server';
|
||||
|
||||
export const commonRouter = createTRPCRouter({
|
||||
getDepts: permissionRequiredProcedure('').query(({ ctx }) =>
|
||||
ctx.db.dept.findMany({ orderBy: { code: 'asc' } })
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
export type CommonRouter = typeof commonRouter;
|
||||
export type Dept = inferProcedureOutput<CommonRouter['getDepts']>[number]
|
||||
|
||||
206
src/server/routers/dept-admin.ts
Normal file
206
src/server/routers/dept-admin.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { Permissions } from '@/constants/permissions'
|
||||
import { dataTableQueryParamsSchema } from '@/lib/schema/data-table'
|
||||
import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
|
||||
// 创建院系管理员的 schema
|
||||
const createDeptAdminSchema = z.object({
|
||||
uid: z.string().min(1, '用户ID不能为空'),
|
||||
deptCode: z.string().min(1, '院系代码不能为空'),
|
||||
adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')),
|
||||
adminLinePhone: z.string().optional(),
|
||||
adminMobilePhone: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
// 更新院系管理员的 schema
|
||||
const updateDeptAdminSchema = z.object({
|
||||
id: z.number(),
|
||||
uid: z.string().min(1, '用户ID不能为空'),
|
||||
deptCode: z.string().min(1, '院系代码不能为空'),
|
||||
adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')),
|
||||
adminLinePhone: z.string().optional(),
|
||||
adminMobilePhone: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
export const deptAdminRouter = createTRPCRouter({
|
||||
list: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(dataTableQueryParamsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { where, orderBy, skip, take } = transformDataTableQueryParams(input, {
|
||||
model: 'DeptAdmin',
|
||||
columns: {
|
||||
id: { field: 'id', variant: 'number', sortable: true },
|
||||
uid: { field: 'uid', variant: 'text', sortable: true },
|
||||
userName: { field: 'user.name', variant: 'text', sortable: true },
|
||||
deptCode: { field: 'deptCode', variant: 'multiSelect', sortable: true },
|
||||
adminEmail: { field: 'adminEmail', variant: 'text', sortable: true },
|
||||
adminLinePhone: { field: 'adminLinePhone', variant: 'text', sortable: true },
|
||||
adminMobilePhone: { field: 'adminMobilePhone', variant: 'text', sortable: true },
|
||||
createdAt: { field: 'createdAt', sortable: true },
|
||||
updatedAt: { field: 'updatedAt', sortable: true },
|
||||
},
|
||||
})
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
ctx.db.deptAdmin.findMany({
|
||||
where,
|
||||
orderBy: orderBy.some(item => 'id' in item) ? orderBy : [...orderBy, { id: 'asc' }],
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
dept: { select: { code: true, name: true, fullName: true } },
|
||||
},
|
||||
}),
|
||||
ctx.db.deptAdmin.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}),
|
||||
|
||||
create: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(createDeptAdminSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { uid, deptCode, adminEmail, adminLinePhone, adminMobilePhone, note } = input
|
||||
|
||||
// 检查用户是否存在
|
||||
const user = await ctx.db.user.findUnique({ where: { id: uid } })
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
}
|
||||
|
||||
// 检查院系是否存在
|
||||
const dept = await ctx.db.dept.findUnique({ where: { code: deptCode } })
|
||||
if (!dept) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '院系不存在' })
|
||||
}
|
||||
|
||||
// 检查是否已存在相同的用户-院系组合
|
||||
const existing = await ctx.db.deptAdmin.findUnique({
|
||||
where: {
|
||||
uidx_uid_dept_code: {
|
||||
uid,
|
||||
deptCode,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (existing) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '该用户已是该院系的管理员' })
|
||||
}
|
||||
|
||||
return ctx.db.deptAdmin.create({
|
||||
data: {
|
||||
uid,
|
||||
deptCode,
|
||||
adminEmail: adminEmail?.trim() || null,
|
||||
adminLinePhone: adminLinePhone?.trim() || null,
|
||||
adminMobilePhone: adminMobilePhone?.trim() || null,
|
||||
note: note?.trim() || null,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
dept: { select: { code: true, name: true, fullName: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
update: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(updateDeptAdminSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, uid, deptCode, adminEmail, adminLinePhone, adminMobilePhone, note } = input
|
||||
|
||||
// 检查院系管理员是否存在
|
||||
const existing = await ctx.db.deptAdmin.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
const user = await ctx.db.user.findUnique({ where: { id: uid } })
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
}
|
||||
|
||||
// 检查院系是否存在
|
||||
const dept = await ctx.db.dept.findUnique({ where: { code: deptCode } })
|
||||
if (!dept) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '院系不存在' })
|
||||
}
|
||||
|
||||
// 如果修改了 uid 或 deptCode,检查新的组合是否已存在
|
||||
if (uid !== existing.uid || deptCode !== existing.deptCode) {
|
||||
const duplicate = await ctx.db.deptAdmin.findUnique({
|
||||
where: {
|
||||
uidx_uid_dept_code: {
|
||||
uid,
|
||||
deptCode,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (duplicate) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '该用户已是该院系的管理员' })
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.db.deptAdmin.update({
|
||||
where: { id },
|
||||
data: {
|
||||
uid,
|
||||
deptCode,
|
||||
adminEmail: adminEmail?.trim() || null,
|
||||
adminLinePhone: adminLinePhone?.trim() || null,
|
||||
adminMobilePhone: adminMobilePhone?.trim() || null,
|
||||
note: note?.trim() || null,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
dept: { select: { code: true, name: true, fullName: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
getById: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const deptAdmin = await ctx.db.deptAdmin.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
dept: { select: { code: true, name: true, fullName: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!deptAdmin) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
|
||||
}
|
||||
|
||||
return deptAdmin
|
||||
}),
|
||||
|
||||
delete: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id } = input
|
||||
|
||||
// 检查院系管理员是否存在
|
||||
const existing = await ctx.db.deptAdmin.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
|
||||
}
|
||||
|
||||
return ctx.db.deptAdmin.delete({
|
||||
where: { id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
dept: { select: { code: true, name: true, fullName: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
export type DeptAdminRouter = typeof deptAdminRouter
|
||||
export type DeptAdmin = inferProcedureOutput<DeptAdminRouter['list']>['data'][number]
|
||||
@@ -6,6 +6,10 @@ import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
// 从环境变量获取并发限制,默认为16
|
||||
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
|
||||
|
||||
export const usersRouter = createTRPCRouter({
|
||||
list: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
@@ -186,14 +190,16 @@ export const usersRouter = createTRPCRouter({
|
||||
|
||||
await Promise.all(
|
||||
batch.map(user =>
|
||||
ctx.db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
roles: action === 'grant'
|
||||
? { connect: { id: roleId } }
|
||||
: { disconnect: { id: roleId } }
|
||||
}
|
||||
})
|
||||
dbParallelLimit(() =>
|
||||
ctx.db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
roles: action === 'grant'
|
||||
? { connect: { id: roleId } }
|
||||
: { disconnect: { id: roleId } }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -206,6 +212,11 @@ export const usersRouter = createTRPCRouter({
|
||||
create: permissionRequiredProcedure(Permissions.USER_MANAGE).input(createUserSchema).mutation(async ({ ctx, input }) => {
|
||||
const { id, name, status, deptCode, password, roleIds, isSuperAdmin } = input
|
||||
|
||||
// 检查是否尝试创建超级管理员,只有超级管理员才能创建超级管理员
|
||||
if (isSuperAdmin && !ctx.session?.user?.isSuperAdmin) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '只有超级管理员才能创建超级管理员用户' })
|
||||
}
|
||||
|
||||
const existingUser = await ctx.db.user.findUnique({ where: { id } })
|
||||
if (existingUser) throw new TRPCError({ code: 'BAD_REQUEST', message: '用户ID已存在' })
|
||||
|
||||
@@ -235,6 +246,11 @@ export const usersRouter = createTRPCRouter({
|
||||
const existingUser = await ctx.db.user.findUnique({ where: { id } })
|
||||
if (!existingUser) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
|
||||
// 检查是否尝试修改 isSuperAdmin 字段,只有超级管理员才能操作
|
||||
if (isSuperAdmin !== existingUser.isSuperAdmin && !ctx.session?.user?.isSuperAdmin) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '只有超级管理员才能修改超级管理员权限' })
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
const updateData: any = {
|
||||
name: name?.trim() || '',
|
||||
@@ -372,6 +388,102 @@ export const usersRouter = createTRPCRouter({
|
||||
))
|
||||
).sort((a, b) => a.id - b.id),
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取当前用户可管理的院系列表和正在管理的院系
|
||||
getManagedDepts: permissionRequiredProcedure('')
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.session!.user.id
|
||||
|
||||
// 获取用户当前信息
|
||||
const currentUser = await ctx.db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { currentManagedDept: true }
|
||||
})
|
||||
|
||||
let depts: Array<{ code: string; name: string; fullName: string }>
|
||||
|
||||
// 超级管理员可以管理所有院系
|
||||
if (ctx.session?.user?.isSuperAdmin) {
|
||||
depts = await ctx.db.dept.findMany({
|
||||
orderBy: { code: 'asc' }
|
||||
})
|
||||
} else {
|
||||
// 普通用户只能管理自己被授权的院系
|
||||
const deptAdmins = await ctx.db.deptAdmin.findMany({
|
||||
where: { uid: userId },
|
||||
include: { dept: true },
|
||||
orderBy: { deptCode: 'asc' }
|
||||
})
|
||||
depts = deptAdmins.map(da => da.dept)
|
||||
}
|
||||
|
||||
// 如果用户当前没有管理院系,但有可管理的院系,自动设置为第一个
|
||||
let currentDept = currentUser?.currentManagedDept
|
||||
if (!currentDept && depts.length > 0) {
|
||||
currentDept = depts[0].code
|
||||
await ctx.db.user.update({
|
||||
where: { id: userId },
|
||||
data: { currentManagedDept: currentDept }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
currentDept,
|
||||
depts
|
||||
}
|
||||
}),
|
||||
|
||||
// 切换当前管理的院系
|
||||
switchManagedDept: permissionRequiredProcedure('')
|
||||
.input(z.object({
|
||||
deptCode: z.string().nullable()
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { deptCode } = input
|
||||
|
||||
// 如果要切换到某个院系,需要验证权限
|
||||
if (deptCode) {
|
||||
// 超级管理员可以切换到任意院系
|
||||
if (!ctx.session?.user?.isSuperAdmin) {
|
||||
// 普通用户需要验证是否有该院系的管理权限
|
||||
const deptAdmin = await ctx.db.deptAdmin.findUnique({
|
||||
where: {
|
||||
uidx_uid_dept_code: {
|
||||
uid: ctx.session!.user.id,
|
||||
deptCode: deptCode
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!deptAdmin) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '您没有该院系的管理权限'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 验证院系是否存在
|
||||
const dept = await ctx.db.dept.findUnique({
|
||||
where: { code: deptCode }
|
||||
})
|
||||
|
||||
if (!dept) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: '院系不存在'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户的当前管理院系
|
||||
await ctx.db.user.update({
|
||||
where: { id: ctx.session!.user.id },
|
||||
data: { currentManagedDept: deptCode }
|
||||
})
|
||||
|
||||
return { success: true, deptCode }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user