397 lines
11 KiB
TypeScript
Executable File
397 lines
11 KiB
TypeScript
Executable File
'use client';
|
||
|
||
import * as React from 'react';
|
||
import { useForm } from 'react-hook-form';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
import { z } from 'zod';
|
||
import { Button } from '@nice/ui/components/button';
|
||
import { Input } from '@nice/ui/components/input';
|
||
import { Textarea } from '@nice/ui/components/textarea';
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormDescription,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage,
|
||
} from '@nice/ui/components/form';
|
||
import { CheckIcon, X as XIcon } from 'lucide-react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useTRPC, useUser } from '@fenghuo/client';
|
||
import { DeptSelect } from '@/components/selector/dept-select';
|
||
import { MultipleRoleSelector } from '@/components/selector/role-select';
|
||
import { UserWithRelations, userWithRelationsSelect } from '@fenghuo/common';
|
||
import { toast } from '@nice/ui/components/sonner';
|
||
import { SystemPermission, useAuth } from '@/components/providers/auth-provider';
|
||
import { useEffect } from 'react';
|
||
|
||
|
||
const UserEditFormSchema = z.object({
|
||
username: z.string().min(1, { message: '请输入账户名称' }),
|
||
password: z.string().optional(),
|
||
confirmPassword: z.string().optional(),
|
||
roleIds: z.string().array(),
|
||
organizationId: z.string().optional(),
|
||
description: z.string().optional(),
|
||
}).refine((data) => {
|
||
// 如果设置了密码,则必须确认密码一致(仅对当前用户修改自己密码时)
|
||
if (data.password && data.password.length > 0) {
|
||
return data.password === data.confirmPassword;
|
||
}
|
||
return true;
|
||
}, {
|
||
message: '两次输入的密码不一致',
|
||
path: ['confirmPassword'],
|
||
});
|
||
|
||
type UserEditFormValues = z.infer<typeof UserEditFormSchema>;
|
||
|
||
// 加载状态组件
|
||
function LoadingIndicator() {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[400px]">
|
||
<div className="flex flex-col items-center space-y-4">
|
||
<div className="size-8 animate-spin rounded-full border-2 border-muted border-t-primary" />
|
||
<p className="text-sm text-muted-foreground">加载用户信息中...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface UserEditFormProps {
|
||
userId: string;
|
||
isCurrentUser?: boolean;
|
||
}
|
||
|
||
export function UserEditForm({ userId }: UserEditFormProps) {
|
||
const { update, changePassword } = useUser();
|
||
const { user: currentUser } = useAuth();
|
||
const isCurrentUser = React.useMemo(() => {
|
||
return currentUser!.id === userId;
|
||
}, [currentUser]);
|
||
|
||
// 权限检查
|
||
const userPermissions = currentUser?.permissions || [];
|
||
const isSuperAdmin = userPermissions.includes(SystemPermission.SUPER_ADMIN);
|
||
const hasUserEditPermission = userPermissions.includes(SystemPermission.USER_EDIT);
|
||
const canEditUser = isSuperAdmin || hasUserEditPermission || isCurrentUser;
|
||
|
||
// 密码修改权限:超级管理员和有USER_EDIT权限的用户可以直接修改他人密码
|
||
const canDirectlyChangePassword = isSuperAdmin || hasUserEditPermission;
|
||
// 当前用户修改自己密码需要确认密码
|
||
const needPasswordConfirmation = isCurrentUser && !canDirectlyChangePassword;
|
||
|
||
const trpc = useTRPC();
|
||
|
||
// 初始化表单
|
||
const form = useForm<UserEditFormValues>({
|
||
resolver: zodResolver(UserEditFormSchema),
|
||
mode: 'onChange', // 启用onChange模式以实时检测变化
|
||
defaultValues: {
|
||
username: '',
|
||
password: '',
|
||
confirmPassword: '',
|
||
roleIds: [],
|
||
organizationId: '',
|
||
description: '',
|
||
},
|
||
});
|
||
|
||
// 获取用户数据
|
||
const { data: user, isLoading: isLoadingUser } = useQuery({
|
||
...trpc.user.findFirst.queryOptions({
|
||
where: {
|
||
id: userId,
|
||
},
|
||
select: userWithRelationsSelect,
|
||
}),
|
||
enabled: !!userId,
|
||
}) as { data: UserWithRelations | undefined; isLoading: boolean };
|
||
|
||
// 当用户数据加载完成时,初始化表单
|
||
useEffect(() => {
|
||
if (user) {
|
||
// 获取用户的所有角色ID
|
||
const userRoleIds = user.roles?.map((r) => r.id) || [];
|
||
|
||
const organization = user.organization;
|
||
|
||
const initialValues = {
|
||
username: user.username || '',
|
||
roleIds: userRoleIds,
|
||
organizationId: organization?.id || '',
|
||
description: user.description || '',
|
||
password: '',
|
||
confirmPassword: ''
|
||
};
|
||
|
||
form.reset(initialValues);
|
||
}
|
||
}, [user, form]);
|
||
|
||
// 检测表单是否有变化
|
||
const hasChanges = form.formState.isDirty;
|
||
|
||
// 检测密码字段是否有值
|
||
const passwordValue = form.watch('password');
|
||
const hasPasswordChange = passwordValue && passwordValue.length > 0;
|
||
|
||
// 处理表单提交
|
||
const onSubmit = async (values: UserEditFormValues) => {
|
||
if (!canEditUser) {
|
||
toast.error('您没有权限编辑此用户');
|
||
return;
|
||
}
|
||
try {
|
||
const { roleIds, confirmPassword, ...others } = values;
|
||
|
||
// 如果密码为空,则不更新密码字段
|
||
const updateData: any = {
|
||
...others,
|
||
roles: {
|
||
set: roleIds.map(id => ({ id }))
|
||
}
|
||
};
|
||
|
||
// 只有当密码不为空时才包含密码字段
|
||
if (!others.password || others.password.trim() === '') {
|
||
delete updateData.password;
|
||
}
|
||
|
||
await update.mutateAsync({
|
||
where: {
|
||
id: userId
|
||
},
|
||
data: updateData
|
||
});
|
||
|
||
// 清空密码字段
|
||
form.setValue('password', '');
|
||
form.setValue('confirmPassword', '');
|
||
toast.success('用户信息更新成功');
|
||
} catch (error: any) {
|
||
console.error('更新用户失败:', error);
|
||
toast.error('保存失败', {
|
||
description: error?.message || '更新失败,请重试',
|
||
duration: 5000,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 显示加载状态
|
||
if (isLoadingUser) {
|
||
return <LoadingIndicator />;
|
||
}
|
||
|
||
return (
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||
{/* 基本信息区域 */}
|
||
<div className="bg-card border rounded-lg p-6 space-y-6">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-lg font-semibold">基本信息</h3>
|
||
<div className="flex-1 h-px bg-border"></div>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{/* 账户名称 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="username"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>账户名称 <span className="text-destructive">*</span></FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
placeholder="请输入账户名称"
|
||
autoComplete="username"
|
||
disabled={!canEditUser}
|
||
{...field}
|
||
value={field.value || ''}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
{/* 角色选择 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="roleIds"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>角色 <span className="text-destructive">*</span></FormLabel>
|
||
<FormControl>
|
||
<MultipleRoleSelector
|
||
value={field.value}
|
||
onValueChange={field.onChange}
|
||
placeholder="选择角色"
|
||
className="w-full"
|
||
showDescription={true}
|
||
disabled={!canEditUser}
|
||
/>
|
||
</FormControl>
|
||
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
{/* 部门选择 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="organizationId"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>所属部门</FormLabel>
|
||
<FormControl>
|
||
<DeptSelect
|
||
value={field.value}
|
||
onValueChange={(value) => {
|
||
const departmentValue = Array.isArray(value) ? value[0] || '' : value;
|
||
field.onChange(departmentValue);
|
||
}}
|
||
placeholder="选择部门"
|
||
className="w-full"
|
||
disabled={!canEditUser}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 个人简介区域 */}
|
||
<div className="bg-card border rounded-lg p-6 space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-lg font-semibold">个人简介</h3>
|
||
<div className="flex-1 h-px bg-border"></div>
|
||
</div>
|
||
<FormField
|
||
control={form.control}
|
||
name="description"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>简介内容</FormLabel>
|
||
<FormControl>
|
||
<Textarea
|
||
placeholder="请输入个人简介,让其他人更好地了解您..."
|
||
rows={4}
|
||
disabled={!canEditUser}
|
||
className="resize-none"
|
||
{...field}
|
||
value={field.value || ''}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription className="text-xs text-muted-foreground">
|
||
最多 500 个字符,当前 {(field.value || '').length} 个字符
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
{/* 安全设置区域 - 仅可编辑密码时显示 */}
|
||
{canEditUser && (
|
||
<div className="bg-card border rounded-lg p-6 space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-lg font-semibold">安全设置</h3>
|
||
<div className="flex-1 h-px bg-border"></div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{/* 新密码 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="password"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>
|
||
{isCurrentUser ? '修改密码' : '重置用户密码'}
|
||
</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type="password"
|
||
placeholder="请输入新密码(至少6个字符)"
|
||
autoComplete="new-password"
|
||
disabled={!canEditUser}
|
||
{...field}
|
||
value={field.value || ''}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription className="text-xs text-muted-foreground">
|
||
{isCurrentUser ? '留空则不修改密码' : '留空则不重置密码'}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
{/* 确认密码 - 仅当前用户修改自己密码时显示 */}
|
||
{needPasswordConfirmation && hasPasswordChange && (
|
||
<FormField
|
||
control={form.control}
|
||
name="confirmPassword"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>确认新密码</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type="password"
|
||
placeholder="请再次输入新密码"
|
||
autoComplete="new-password"
|
||
disabled={!canEditUser}
|
||
{...field}
|
||
value={field.value || ''}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription className="text-xs text-muted-foreground">
|
||
请再次输入新密码以确认
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮区域 - 只有在有变化且有编辑权限时才显示 */}
|
||
{canEditUser && hasChanges && (
|
||
<div className="flex items-center justify-end gap-3 pt-6 border-t">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => form.reset()}
|
||
disabled={form.formState.isSubmitting}
|
||
>
|
||
<XIcon className="size-4 mr-2" />
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
disabled={form.formState.isSubmitting || update.isPending}
|
||
>
|
||
{form.formState.isSubmitting || update.isPending ? (
|
||
<>
|
||
<div className="size-4 animate-spin rounded-full border-2 border-background border-t-foreground mr-2" />
|
||
保存中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<CheckIcon className="size-4 mr-2" />
|
||
保存更改
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</form>
|
||
</Form>
|
||
);
|
||
}
|