add
This commit is contained in:
parent
cabec45c64
commit
2b57515c31
|
@ -4,14 +4,9 @@ import {
|
|||
BadRequestException,
|
||||
Logger,
|
||||
InternalServerErrorException,
|
||||
|
||||
} from '@nestjs/common';
|
||||
import { StaffService } from '../models/staff/staff.service';
|
||||
import {
|
||||
db,
|
||||
AuthSchema,
|
||||
JwtPayload,
|
||||
} from '@nice/common';
|
||||
import { db, AuthSchema, JwtPayload } from '@nice/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { redis } from '@server/utils/redis/redis.service';
|
||||
|
@ -24,14 +19,12 @@ import { TusService } from '@server/upload/tus.service';
|
|||
import { extractFileIdFromNginxUrl } from '@server/upload/utils';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private logger = new Logger(AuthService.name)
|
||||
private logger = new Logger(AuthService.name);
|
||||
constructor(
|
||||
private readonly staffService: StaffService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly sessionService: SessionService
|
||||
) {
|
||||
|
||||
}
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
|
||||
try {
|
||||
// 基础参数验证
|
||||
|
@ -39,27 +32,32 @@ export class AuthService {
|
|||
return { isValid: false, error: FileValidationErrorType.INVALID_URI };
|
||||
}
|
||||
const fileId = extractFileIdFromNginxUrl(params.originalUri);
|
||||
console.log(params.originalUri, fileId)
|
||||
console.log(params.originalUri, fileId);
|
||||
const resource = await db.resource.findFirst({ where: { fileId } });
|
||||
|
||||
// 资源验证
|
||||
if (!resource) {
|
||||
return { isValid: false, error: FileValidationErrorType.RESOURCE_NOT_FOUND };
|
||||
return {
|
||||
isValid: false,
|
||||
error: FileValidationErrorType.RESOURCE_NOT_FOUND,
|
||||
};
|
||||
}
|
||||
// 处理公开资源
|
||||
if (resource.isPublic) {
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
resourceType: resource.type || 'unknown'
|
||||
resourceType: resource.type || 'unknown',
|
||||
};
|
||||
}
|
||||
// 处理私有资源
|
||||
const token = extractTokenFromAuthorization(params.authorization);
|
||||
if (!token) {
|
||||
return { isValid: false, error: FileValidationErrorType.AUTHORIZATION_REQUIRED };
|
||||
return {
|
||||
isValid: false,
|
||||
error: FileValidationErrorType.AUTHORIZATION_REQUIRED,
|
||||
};
|
||||
}
|
||||
const payload: JwtPayload = await this.jwtService.verify(token)
|
||||
const payload: JwtPayload = await this.jwtService.verify(token);
|
||||
if (!payload.sub) {
|
||||
return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN };
|
||||
}
|
||||
|
@ -67,9 +65,8 @@ export class AuthService {
|
|||
return {
|
||||
isValid: true,
|
||||
userId: payload.sub,
|
||||
resourceType: resource.type || 'unknown'
|
||||
resourceType: resource.type || 'unknown',
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('File validation error:', error);
|
||||
return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR };
|
||||
|
@ -93,7 +90,9 @@ export class AuthService {
|
|||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async signIn(data: z.infer<typeof AuthSchema.signInRequset>): Promise<SessionInfo> {
|
||||
async signIn(
|
||||
data: z.infer<typeof AuthSchema.signInRequset>,
|
||||
): Promise<SessionInfo> {
|
||||
const { username, password, phoneNumber } = data;
|
||||
|
||||
let staff = await db.staff.findFirst({
|
||||
|
@ -113,7 +112,8 @@ export class AuthService {
|
|||
if (!staff.enabled) {
|
||||
throw new UnauthorizedException('帐号已禁用');
|
||||
}
|
||||
const isPasswordMatch = phoneNumber || await argon2.verify(staff.password, password);
|
||||
const isPasswordMatch =
|
||||
phoneNumber || (await argon2.verify(staff.password, password));
|
||||
if (!isPasswordMatch) {
|
||||
throw new UnauthorizedException('帐号或密码错误');
|
||||
}
|
||||
|
@ -138,12 +138,20 @@ export class AuthService {
|
|||
}
|
||||
}
|
||||
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
|
||||
const { username, phoneNumber, officerId } = data;
|
||||
|
||||
const {
|
||||
username,
|
||||
phoneNumber,
|
||||
password,
|
||||
officerId,
|
||||
showname,
|
||||
photoUrl,
|
||||
deptId,
|
||||
...others
|
||||
} = data;
|
||||
const existingUser = await db.staff.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { officerId }, { phoneNumber }],
|
||||
deletedAt: null
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -153,9 +161,22 @@ export class AuthService {
|
|||
|
||||
return this.staffService.create({
|
||||
data: {
|
||||
...data,
|
||||
domainId: data.deptId,
|
||||
}
|
||||
username,
|
||||
phoneNumber,
|
||||
password,
|
||||
officerId,
|
||||
showname,
|
||||
department: {
|
||||
connect: { id: deptId },
|
||||
},
|
||||
domain: {
|
||||
connect: { id: deptId },
|
||||
},
|
||||
// domainId: data.deptId,
|
||||
meta: {
|
||||
photoUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
|
||||
|
@ -168,12 +189,17 @@ export class AuthService {
|
|||
throw new UnauthorizedException('用户会话已过期');
|
||||
}
|
||||
|
||||
const session = await this.sessionService.getSession(payload.sub, sessionId);
|
||||
const session = await this.sessionService.getSession(
|
||||
payload.sub,
|
||||
sessionId,
|
||||
);
|
||||
if (!session || session.refresh_token !== refreshToken) {
|
||||
throw new UnauthorizedException('用户会话已过期');
|
||||
}
|
||||
|
||||
const user = await db.staff.findUnique({ where: { id: payload.sub, deletedAt: null } });
|
||||
const user = await db.staff.findUnique({
|
||||
where: { id: payload.sub, deletedAt: null },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
@ -186,14 +212,17 @@ export class AuthService {
|
|||
const updatedSession = {
|
||||
...session,
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: Date.now() + tokenConfig.accessToken.expirationMs,
|
||||
access_token_expires_at:
|
||||
Date.now() + tokenConfig.accessToken.expirationMs,
|
||||
};
|
||||
await this.sessionService.saveSession(
|
||||
payload.sub,
|
||||
updatedSession,
|
||||
tokenConfig.accessToken.expirationTTL,
|
||||
);
|
||||
await redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub));
|
||||
await redis.del(
|
||||
UserProfileService.instance.getProfileCacheKey(payload.sub),
|
||||
);
|
||||
return {
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: updatedSession.access_token_expires_at,
|
||||
|
@ -212,7 +241,7 @@ export class AuthService {
|
|||
where: { id: user?.id },
|
||||
data: {
|
||||
password: newPassword,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { message: '密码已修改' };
|
||||
|
@ -232,5 +261,4 @@ export class AuthService {
|
|||
|
||||
return { message: '注销成功' };
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
export const tokenConfig = {
|
||||
accessToken: {
|
||||
expirationMs: 7 * 24 * 3600000, // 7 days
|
||||
expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds
|
||||
},
|
||||
refreshToken: {
|
||||
expirationMs: 30 * 24 * 3600000, // 30 days
|
||||
},
|
||||
};
|
||||
accessToken: {
|
||||
expirationMs: 7 * 24 * 3600000, // 7 days
|
||||
expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds
|
||||
},
|
||||
refreshToken: {
|
||||
expirationMs: 30 * 24 * 3600000, // 30 days
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
ObjectType,
|
||||
StaffMethodSchema,
|
||||
UserProfile,
|
||||
RolePerms,
|
||||
ResPerm,
|
||||
Staff,
|
||||
RowModelRequest,
|
||||
db,
|
||||
ObjectType,
|
||||
StaffMethodSchema,
|
||||
UserProfile,
|
||||
RolePerms,
|
||||
ResPerm,
|
||||
Staff,
|
||||
RowModelRequest,
|
||||
} from '@nice/common';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { RowCacheService } from '../base/row-cache.service';
|
||||
|
@ -15,121 +15,116 @@ import { z } from 'zod';
|
|||
import { isFieldCondition } from '../base/sql-builder';
|
||||
@Injectable()
|
||||
export class StaffRowService extends RowCacheService {
|
||||
constructor(
|
||||
private readonly departmentService: DepartmentService,
|
||||
) {
|
||||
super(ObjectType.STAFF, false);
|
||||
constructor(private readonly departmentService: DepartmentService) {
|
||||
super(ObjectType.STAFF, false);
|
||||
}
|
||||
createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||
const result = super
|
||||
.createUnGroupingRowSelect(request)
|
||||
.concat([
|
||||
`${this.tableName}.id AS id`,
|
||||
`${this.tableName}.username AS username`,
|
||||
`${this.tableName}.showname AS showname`,
|
||||
`${this.tableName}.avatar AS avatar`,
|
||||
`${this.tableName}.officer_id AS officer_id`,
|
||||
`${this.tableName}.phone_number AS phone_number`,
|
||||
`${this.tableName}.order AS order`,
|
||||
`${this.tableName}.enabled AS enabled`,
|
||||
'dept.name AS dept_name',
|
||||
'domain.name AS domain_name',
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return [
|
||||
`LEFT JOIN department dept ON ${this.tableName}.dept_id = dept.id`,
|
||||
`LEFT JOIN department domain ON ${this.tableName}.domain_id = domain.id`,
|
||||
];
|
||||
}
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof StaffMethodSchema.getRows>,
|
||||
staff: UserProfile,
|
||||
) {
|
||||
const condition = super.createGetRowsFilters(request);
|
||||
const { domainId, includeDeleted = false } = request;
|
||||
if (isFieldCondition(condition)) {
|
||||
return;
|
||||
}
|
||||
createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||
const result = super.createUnGroupingRowSelect(request).concat([
|
||||
`${this.tableName}.id AS id`,
|
||||
`${this.tableName}.username AS username`,
|
||||
`${this.tableName}.showname AS showname`,
|
||||
`${this.tableName}.avatar AS avatar`,
|
||||
`${this.tableName}.officer_id AS officer_id`,
|
||||
`${this.tableName}.phone_number AS phone_number`,
|
||||
`${this.tableName}.order AS order`,
|
||||
`${this.tableName}.enabled AS enabled`,
|
||||
'dept.name AS dept_name',
|
||||
'domain.name AS domain_name',
|
||||
]);
|
||||
return result
|
||||
if (domainId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
value: domainId,
|
||||
op: 'equals',
|
||||
});
|
||||
} else {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return [
|
||||
`LEFT JOIN department dept ON ${this.tableName}.dept_id = dept.id`,
|
||||
`LEFT JOIN department domain ON ${this.tableName}.domain_id = domain.id`,
|
||||
];
|
||||
if (!includeDeleted) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
type: 'date',
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof StaffMethodSchema.getRows>,
|
||||
staff: UserProfile,
|
||||
) {
|
||||
const condition = super.createGetRowsFilters(request);
|
||||
const { domainId, includeDeleted = false } = request;
|
||||
if (isFieldCondition(condition)) {
|
||||
return;
|
||||
}
|
||||
if (domainId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
value: domainId,
|
||||
op: 'equals',
|
||||
});
|
||||
} else {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
if (!includeDeleted) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
type: 'date',
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
condition.OR = [];
|
||||
if (!staff.permissions.includes(RolePerms.MANAGE_ANY_STAFF)) {
|
||||
if (staff.permissions.includes(RolePerms.MANAGE_DOM_STAFF)) {
|
||||
condition.OR.push({
|
||||
field: 'dept.id',
|
||||
value: staff.domainId,
|
||||
op: 'equals',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return condition;
|
||||
}
|
||||
|
||||
async getPermissionContext(id: string, staff: UserProfile) {
|
||||
const data = await db.staff.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
deptId: true,
|
||||
domainId: true,
|
||||
},
|
||||
condition.OR = [];
|
||||
if (!staff.permissions.includes(RolePerms.MANAGE_ANY_STAFF)) {
|
||||
if (staff.permissions.includes(RolePerms.MANAGE_DOM_STAFF)) {
|
||||
condition.OR.push({
|
||||
field: 'dept.id',
|
||||
value: staff.domainId,
|
||||
op: 'equals',
|
||||
});
|
||||
const deptId = data?.deptId;
|
||||
const isFromSameDept = staff.deptIds?.includes(deptId);
|
||||
const domainChildDeptIds = await this.departmentService.getDescendantIds(
|
||||
staff.domainId, true
|
||||
);
|
||||
const belongsToDomain = domainChildDeptIds.includes(
|
||||
deptId,
|
||||
);
|
||||
return { isFromSameDept, belongsToDomain };
|
||||
}
|
||||
protected async setResPermissions(
|
||||
data: Staff,
|
||||
staff: UserProfile,
|
||||
) {
|
||||
const permissions: ResPerm = {};
|
||||
const { isFromSameDept, belongsToDomain } = await this.getPermissionContext(
|
||||
data.id,
|
||||
staff,
|
||||
);
|
||||
const setManagePermissions = (permissions: ResPerm) => {
|
||||
Object.assign(permissions, {
|
||||
read: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
});
|
||||
};
|
||||
staff.permissions.forEach((permission) => {
|
||||
switch (permission) {
|
||||
case RolePerms.MANAGE_ANY_STAFF:
|
||||
setManagePermissions(permissions);
|
||||
break;
|
||||
case RolePerms.MANAGE_DOM_STAFF:
|
||||
if (belongsToDomain) {
|
||||
setManagePermissions(permissions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
return { ...data, perm: permissions };
|
||||
}
|
||||
}
|
||||
|
||||
return condition;
|
||||
}
|
||||
|
||||
async getPermissionContext(id: string, staff: UserProfile) {
|
||||
const data = await db.staff.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
deptId: true,
|
||||
domainId: true,
|
||||
},
|
||||
});
|
||||
const deptId = data?.deptId;
|
||||
const isFromSameDept = staff.deptIds?.includes(deptId);
|
||||
const domainChildDeptIds = await this.departmentService.getDescendantIds(
|
||||
staff.domainId,
|
||||
true,
|
||||
);
|
||||
const belongsToDomain = domainChildDeptIds.includes(deptId);
|
||||
return { isFromSameDept, belongsToDomain };
|
||||
}
|
||||
protected async setResPermissions(data: Staff, staff: UserProfile) {
|
||||
const permissions: ResPerm = {};
|
||||
const { isFromSameDept, belongsToDomain } = await this.getPermissionContext(
|
||||
data.id,
|
||||
staff,
|
||||
);
|
||||
const setManagePermissions = (permissions: ResPerm) => {
|
||||
Object.assign(permissions, {
|
||||
read: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
});
|
||||
};
|
||||
staff.permissions.forEach((permission) => {
|
||||
switch (permission) {
|
||||
case RolePerms.MANAGE_ANY_STAFF:
|
||||
setManagePermissions(permissions);
|
||||
break;
|
||||
case RolePerms.MANAGE_DOM_STAFF:
|
||||
if (belongsToDomain) {
|
||||
setManagePermissions(permissions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
return { ...data, perm: permissions };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,131 +1,138 @@
|
|||
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
||||
import DepartmentSelect from "@web/src/components/models/department/department-select";
|
||||
import { Form, Input, Button, Select } from "antd";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export interface RegisterFormData {
|
||||
deptId: string;
|
||||
username: string;
|
||||
showname: string;
|
||||
officerId: string;
|
||||
password: string;
|
||||
repeatPass: string;
|
||||
deptId: string;
|
||||
username: string;
|
||||
showname: string;
|
||||
officerId: string;
|
||||
password: string;
|
||||
repeatPass: string;
|
||||
}
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSubmit: (data: RegisterFormData) => void;
|
||||
isLoading: boolean;
|
||||
onSubmit: (data: RegisterFormData) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
|
||||
const [form] = Form.useForm<RegisterFormData>();
|
||||
const [form] = Form.useForm<RegisterFormData>();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
scrollToFirstError>
|
||||
<div className=" flex items-center gap-4 mb-2">
|
||||
<div>
|
||||
<Form.Item name="photoUrl" label="头像" noStyle>
|
||||
<AvatarUploader
|
||||
placeholder="点击上传头像"
|
||||
style={{
|
||||
height: 120,
|
||||
width: 100,
|
||||
}}></AvatarUploader>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 flex-1">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
noStyle
|
||||
rules={[
|
||||
{ required: true, message: "请输入用户名" },
|
||||
{ min: 2, message: "用户名至少需要2个字符" },
|
||||
]}>
|
||||
<Input placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="showname"
|
||||
label="姓名"
|
||||
noStyle
|
||||
rules={[
|
||||
{ required: true, message: "请输入姓名" },
|
||||
{ min: 2, message: "姓名至少需要2个字符" },
|
||||
]}>
|
||||
<Input placeholder="姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="deptId"
|
||||
noStyle
|
||||
label="部门"
|
||||
rules={[{ required: true, message: "请选择部门" }]}>
|
||||
<DepartmentSelect></DepartmentSelect>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
scrollToFirstError
|
||||
>
|
||||
<Form.Item
|
||||
name="deptId"
|
||||
label="部门"
|
||||
rules={[
|
||||
{ required: true, message: "请选择部门" }
|
||||
]}
|
||||
>
|
||||
<DepartmentSelect></DepartmentSelect>
|
||||
<Form.Item
|
||||
name="officerId"
|
||||
label="证件号"
|
||||
rules={[
|
||||
{ required: true, message: "请输入证件号" },
|
||||
{
|
||||
pattern: /^\d{5,12}$/,
|
||||
message: "请输入有效的证件号(5-12位数字)",
|
||||
},
|
||||
]}>
|
||||
<Input placeholder="证件号" />
|
||||
</Form.Item>
|
||||
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: "请输入密码" },
|
||||
{ min: 8, message: "密码至少需要8个字符" },
|
||||
{
|
||||
pattern:
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
||||
message: "密码必须包含大小写字母、数字和特殊字符",
|
||||
},
|
||||
]}>
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[
|
||||
{ required: true, message: "请输入用户名" },
|
||||
{ min: 2, message: "用户名至少需要2个字符" }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="repeatPass"
|
||||
label="确认密码"
|
||||
dependencies={["password"]}
|
||||
rules={[
|
||||
{ required: true, message: "请确认密码" },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (
|
||||
!value ||
|
||||
getFieldValue("password") === value
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error("两次输入的密码不一致")
|
||||
);
|
||||
},
|
||||
}),
|
||||
]}>
|
||||
<Input.Password placeholder="确认密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="showname"
|
||||
label="姓名"
|
||||
rules={[
|
||||
{ required: true, message: "请输入姓名" },
|
||||
{ min: 2, message: "姓名至少需要2个字符" }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="姓名" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="officerId"
|
||||
label="证件号"
|
||||
rules={[
|
||||
{ required: true, message: "请输入证件号" },
|
||||
{
|
||||
pattern: /^\d{5,12}$/,
|
||||
message: "请输入有效的证件号(5-12位数字)"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input placeholder="证件号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: "请输入密码" },
|
||||
{ min: 8, message: "密码至少需要8个字符" },
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
||||
message: "密码必须包含大小写字母、数字和特殊字符"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="repeatPass"
|
||||
label="确认密码"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: "请确认密码" },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="确认密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
className="w-full h-10 rounded-lg"
|
||||
>
|
||||
{isLoading ? "正在注册..." : "注册"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</motion.div>
|
||||
);
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
className="w-full h-10 rounded-lg">
|
||||
{isLoading ? "正在注册..." : "注册"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useClickOutside } from "@web/src/hooks/useClickOutside";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState, useRef, useCallback, useMemo } from "react";
|
||||
import React,{ useState, useRef, useCallback, useMemo, createContext } from "react";
|
||||
import { Avatar } from "../../common/element/Avatar";
|
||||
import {
|
||||
UserOutlined,
|
||||
|
@ -9,9 +9,10 @@ import {
|
|||
QuestionCircleOutlined,
|
||||
LogoutOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Spin } from "antd";
|
||||
import { FormInstance, Spin } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MenuItemType } from "./types";
|
||||
import { RolePerms } from "@nice/common";
|
||||
const menuVariants = {
|
||||
hidden: { opacity: 0, scale: 0.95, y: -10 },
|
||||
visible: {
|
||||
|
@ -34,42 +35,69 @@ const menuVariants = {
|
|||
},
|
||||
};
|
||||
|
||||
export const UserEditorContext = createContext<{
|
||||
domainId: string;
|
||||
modalOpen: boolean;
|
||||
setDomainId: React.Dispatch<React.SetStateAction<string>>;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editId: string;
|
||||
setEditId: React.Dispatch<React.SetStateAction<string>>;
|
||||
form: FormInstance<any>;
|
||||
formLoading: boolean;
|
||||
setFormLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
canManageAnyStaff: boolean;
|
||||
}>({
|
||||
domainId: undefined,
|
||||
modalOpen: false,
|
||||
setDomainId: undefined,
|
||||
setModalOpen: undefined,
|
||||
editId: undefined,
|
||||
setEditId: undefined,
|
||||
form: undefined,
|
||||
formLoading: undefined,
|
||||
setFormLoading: undefined,
|
||||
canManageAnyStaff: false,
|
||||
});
|
||||
|
||||
export function UserMenu() {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
const { user, logout, isLoading, hasSomePermissions } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const toggleMenu = useCallback(() => {
|
||||
setShowMenu((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const canManageAnyStaff = useMemo(() => {
|
||||
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
|
||||
}, [user]);
|
||||
const menuItems: MenuItemType[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <UserOutlined className="text-lg" />,
|
||||
label: "个人信息",
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
icon: <SettingOutlined className="text-lg" />,
|
||||
label: "设置",
|
||||
action: () => {
|
||||
navigate("/admin/staff");
|
||||
() =>
|
||||
[
|
||||
{
|
||||
icon: <UserOutlined className="text-lg" />,
|
||||
label: "个人信息",
|
||||
action: () => {},
|
||||
},
|
||||
},
|
||||
// {
|
||||
// icon: <QuestionCircleOutlined className="text-lg" />,
|
||||
// label: '帮助',
|
||||
// action: () => { },
|
||||
// },
|
||||
{
|
||||
icon: <LogoutOutlined className="text-lg" />,
|
||||
label: "注销",
|
||||
action: () => logout(),
|
||||
},
|
||||
],
|
||||
canManageAnyStaff && {
|
||||
icon: <SettingOutlined className="text-lg" />,
|
||||
label: "设置",
|
||||
action: () => {
|
||||
navigate("/admin/staff");
|
||||
},
|
||||
},
|
||||
// {
|
||||
// icon: <QuestionCircleOutlined className="text-lg" />,
|
||||
// label: '帮助',
|
||||
// action: () => { },
|
||||
// },
|
||||
{
|
||||
icon: <LogoutOutlined className="text-lg" />,
|
||||
label: "注销",
|
||||
action: () => logout(),
|
||||
},
|
||||
].filter(Boolean),
|
||||
[logout]
|
||||
);
|
||||
|
||||
|
@ -87,99 +115,100 @@ export function UserMenu() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
aria-label="用户菜单"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showMenu}
|
||||
aria-controls="user-menu"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={toggleMenu}
|
||||
className="flex items-center rounded-full transition-all duration-200 ease-in-out">
|
||||
{/* Avatar 容器,相对定位 */}
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white hover:ring-[#00538E]/90
|
||||
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
aria-label="用户菜单"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showMenu}
|
||||
aria-controls="user-menu"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={toggleMenu}
|
||||
className="flex items-center rounded-full transition-all duration-200 ease-in-out">
|
||||
{/* Avatar 容器,相对定位 */}
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white hover:ring-[#00538E]/90
|
||||
transition-all duration-200 ease-in-out shadow-md
|
||||
hover:shadow-lg focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
|
||||
focus:ring-offset-white "
|
||||
/>
|
||||
{/* 小绿点 */}
|
||||
<span
|
||||
className="absolute bottom-0 right-0 h-3 w-3
|
||||
/>
|
||||
{/* 小绿点 */}
|
||||
<span
|
||||
className="absolute bottom-0 right-0 h-3 w-3
|
||||
rounded-full bg-emerald-500 ring-2 ring-white
|
||||
shadow-sm transition-transform duration-200
|
||||
ease-in-out hover:scale-110"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 用户信息,显示在 Avatar 右侧 */}
|
||||
<div className="flex flex-col space-y-0.5 ml-3 items-start">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-white flex items-center gap-1.5">
|
||||
{user?.department?.name}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
{/* 用户信息,显示在 Avatar 右侧 */}
|
||||
<div className="flex flex-col space-y-0.5 ml-3 items-start">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-white flex items-center gap-1.5">
|
||||
{user?.department?.name}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={menuVariants}
|
||||
role="menu"
|
||||
id="user-menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
style={{ zIndex: 100 }}
|
||||
className="absolute right-0 mt-3 w-64 origin-top-right
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={menuVariants}
|
||||
role="menu"
|
||||
id="user-menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
style={{ zIndex: 100 }}
|
||||
className="absolute right-0 mt-3 w-64 origin-top-right
|
||||
bg-white rounded-xl overflow-hidden shadow-lg
|
||||
border border-[#E5EDF5]">
|
||||
{/* User Profile Section */}
|
||||
<div
|
||||
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||
{/* User Profile Section */}
|
||||
<div
|
||||
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||
border-b border-[#E5EDF5] ">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white shadow-sm"
|
||||
/>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="text-sm font-semibold text-[#00538E]">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-[#718096] flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
在线
|
||||
</span>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white shadow-sm"
|
||||
/>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="text-sm font-semibold text-[#00538E]">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-[#718096] flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
role="menuitem"
|
||||
tabIndex={showMenu ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuItemClick(item.action);
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full px-4 py-3
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
role="menuitem"
|
||||
tabIndex={showMenu ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuItemClick(item.action);
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full px-4 py-3
|
||||
text-sm font-medium rounded-lg transition-all
|
||||
focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/20
|
||||
|
@ -190,8 +219,8 @@ export function UserMenu() {
|
|||
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
|
||||
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
|
||||
}`}>
|
||||
<span
|
||||
className={`w-5 h-5 flex items-center justify-center
|
||||
<span
|
||||
className={`w-5 h-5 flex items-center justify-center
|
||||
transition-all duration-200 ease-in-out
|
||||
group-hover:scale-110 group-hover:rotate-6
|
||||
group-hover:translate-x-0.5 ${
|
||||
|
@ -199,15 +228,16 @@ export function UserMenu() {
|
|||
? "group-hover:text-red-600"
|
||||
: "group-hover:text-[#003F6A]"
|
||||
}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
|
|
@ -124,11 +124,7 @@ export default function StaffForm() {
|
|||
onFinish={handleFinish}>
|
||||
<div className=" flex items-center gap-4 mb-2">
|
||||
<div>
|
||||
<Form.Item
|
||||
name={"photoUrl"}
|
||||
label="头像"
|
||||
noStyle
|
||||
rules={[{ required: true }]}>
|
||||
<Form.Item name={"photoUrl"} label="头像" noStyle>
|
||||
<AvatarUploader
|
||||
style={{
|
||||
width: "100px",
|
||||
|
|
|
@ -8,6 +8,7 @@ export const AuthSchema = {
|
|||
officerId: z.string().nullish(),
|
||||
showname: z.string().nullish(),
|
||||
phoneNumber: z.string().nullish(),
|
||||
photoUrl: z.string().nullish(),
|
||||
}),
|
||||
refreshTokenRequest: z.object({
|
||||
refreshToken: z.string(),
|
||||
|
@ -66,7 +67,7 @@ export const RowRequestSchema = z.object({
|
|||
groupKeys: z.array(z.any()),
|
||||
filterModel: z.any().nullish(),
|
||||
sortModel: z.array(SortModel).nullish(),
|
||||
includeDeleted: z.boolean().nullish()
|
||||
includeDeleted: z.boolean().nullish(),
|
||||
});
|
||||
export const StaffMethodSchema = {
|
||||
create: z.object({
|
||||
|
@ -170,7 +171,6 @@ export const DepartmentMethodSchema = {
|
|||
};
|
||||
|
||||
export const TransformMethodSchema = {
|
||||
|
||||
importStaffs: z.object({
|
||||
base64: z.string(),
|
||||
domainId: z.string().nullish(),
|
||||
|
@ -186,7 +186,6 @@ export const TransformMethodSchema = {
|
|||
domainId: z.string().nullish(),
|
||||
parentId: z.string().nullish(),
|
||||
}),
|
||||
|
||||
};
|
||||
export const TermMethodSchema = {
|
||||
getRows: RowRequestSchema.extend({
|
||||
|
@ -390,5 +389,3 @@ export const BaseCursorSchema = z.object({
|
|||
createEndDate: z.date().nullish(),
|
||||
deptId: z.string().nullish(),
|
||||
});
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue