This commit is contained in:
ditiqi 2025-01-26 11:36:31 +08:00
parent cabec45c64
commit 2b57515c31
7 changed files with 456 additions and 403 deletions

View File

@ -4,14 +4,9 @@ import {
BadRequestException, BadRequestException,
Logger, Logger,
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { StaffService } from '../models/staff/staff.service'; import { StaffService } from '../models/staff/staff.service';
import { import { db, AuthSchema, JwtPayload } from '@nice/common';
db,
AuthSchema,
JwtPayload,
} from '@nice/common';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { redis } from '@server/utils/redis/redis.service'; 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'; import { extractFileIdFromNginxUrl } from '@server/upload/utils';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private logger = new Logger(AuthService.name) private logger = new Logger(AuthService.name);
constructor( constructor(
private readonly staffService: StaffService, private readonly staffService: StaffService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly sessionService: SessionService private readonly sessionService: SessionService,
) { ) {}
}
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> { async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
try { try {
// 基础参数验证 // 基础参数验证
@ -39,27 +32,32 @@ export class AuthService {
return { isValid: false, error: FileValidationErrorType.INVALID_URI }; return { isValid: false, error: FileValidationErrorType.INVALID_URI };
} }
const fileId = extractFileIdFromNginxUrl(params.originalUri); const fileId = extractFileIdFromNginxUrl(params.originalUri);
console.log(params.originalUri, fileId) console.log(params.originalUri, fileId);
const resource = await db.resource.findFirst({ where: { fileId } }); const resource = await db.resource.findFirst({ where: { fileId } });
// 资源验证 // 资源验证
if (!resource) { if (!resource) {
return { isValid: false, error: FileValidationErrorType.RESOURCE_NOT_FOUND }; return {
isValid: false,
error: FileValidationErrorType.RESOURCE_NOT_FOUND,
};
} }
// 处理公开资源 // 处理公开资源
if (resource.isPublic) { if (resource.isPublic) {
return { return {
isValid: true, isValid: true,
resourceType: resource.type || 'unknown' resourceType: resource.type || 'unknown',
}; };
} }
// 处理私有资源 // 处理私有资源
const token = extractTokenFromAuthorization(params.authorization); const token = extractTokenFromAuthorization(params.authorization);
if (!token) { 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) { if (!payload.sub) {
return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN }; return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN };
} }
@ -67,9 +65,8 @@ export class AuthService {
return { return {
isValid: true, isValid: true,
userId: payload.sub, userId: payload.sub,
resourceType: resource.type || 'unknown' resourceType: resource.type || 'unknown',
}; };
} catch (error) { } catch (error) {
this.logger.error('File validation error:', error); this.logger.error('File validation error:', error);
return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR }; return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR };
@ -93,7 +90,9 @@ export class AuthService {
return { accessToken, refreshToken }; 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; const { username, password, phoneNumber } = data;
let staff = await db.staff.findFirst({ let staff = await db.staff.findFirst({
@ -113,7 +112,8 @@ export class AuthService {
if (!staff.enabled) { if (!staff.enabled) {
throw new UnauthorizedException('帐号已禁用'); throw new UnauthorizedException('帐号已禁用');
} }
const isPasswordMatch = phoneNumber || await argon2.verify(staff.password, password); const isPasswordMatch =
phoneNumber || (await argon2.verify(staff.password, password));
if (!isPasswordMatch) { if (!isPasswordMatch) {
throw new UnauthorizedException('帐号或密码错误'); throw new UnauthorizedException('帐号或密码错误');
} }
@ -138,12 +138,20 @@ export class AuthService {
} }
} }
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) { 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({ const existingUser = await db.staff.findFirst({
where: { where: {
OR: [{ username }, { officerId }, { phoneNumber }], OR: [{ username }, { officerId }, { phoneNumber }],
deletedAt: null deletedAt: null,
}, },
}); });
@ -153,9 +161,22 @@ export class AuthService {
return this.staffService.create({ return this.staffService.create({
data: { data: {
...data, username,
domainId: data.deptId, 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>) { async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
@ -168,12 +189,17 @@ export class AuthService {
throw new UnauthorizedException('用户会话已过期'); 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) { if (!session || session.refresh_token !== refreshToken) {
throw new UnauthorizedException('用户会话已过期'); 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) { if (!user) {
throw new UnauthorizedException('用户不存在'); throw new UnauthorizedException('用户不存在');
} }
@ -186,14 +212,17 @@ export class AuthService {
const updatedSession = { const updatedSession = {
...session, ...session,
access_token: accessToken, 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( await this.sessionService.saveSession(
payload.sub, payload.sub,
updatedSession, updatedSession,
tokenConfig.accessToken.expirationTTL, tokenConfig.accessToken.expirationTTL,
); );
await redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub)); await redis.del(
UserProfileService.instance.getProfileCacheKey(payload.sub),
);
return { return {
access_token: accessToken, access_token: accessToken,
access_token_expires_at: updatedSession.access_token_expires_at, access_token_expires_at: updatedSession.access_token_expires_at,
@ -212,7 +241,7 @@ export class AuthService {
where: { id: user?.id }, where: { id: user?.id },
data: { data: {
password: newPassword, password: newPassword,
} },
}); });
return { message: '密码已修改' }; return { message: '密码已修改' };
@ -232,5 +261,4 @@ export class AuthService {
return { message: '注销成功' }; return { message: '注销成功' };
} }
}
}

View File

@ -1,9 +1,9 @@
export const tokenConfig = { export const tokenConfig = {
accessToken: { accessToken: {
expirationMs: 7 * 24 * 3600000, // 7 days expirationMs: 7 * 24 * 3600000, // 7 days
expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds
}, },
refreshToken: { refreshToken: {
expirationMs: 30 * 24 * 3600000, // 30 days expirationMs: 30 * 24 * 3600000, // 30 days
}, },
}; };

View File

@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
db, db,
ObjectType, ObjectType,
StaffMethodSchema, StaffMethodSchema,
UserProfile, UserProfile,
RolePerms, RolePerms,
ResPerm, ResPerm,
Staff, Staff,
RowModelRequest, RowModelRequest,
} from '@nice/common'; } from '@nice/common';
import { DepartmentService } from '../department/department.service'; import { DepartmentService } from '../department/department.service';
import { RowCacheService } from '../base/row-cache.service'; import { RowCacheService } from '../base/row-cache.service';
@ -15,121 +15,116 @@ import { z } from 'zod';
import { isFieldCondition } from '../base/sql-builder'; import { isFieldCondition } from '../base/sql-builder';
@Injectable() @Injectable()
export class StaffRowService extends RowCacheService { export class StaffRowService extends RowCacheService {
constructor( constructor(private readonly departmentService: DepartmentService) {
private readonly departmentService: DepartmentService, super(ObjectType.STAFF, false);
) { }
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[] { if (domainId) {
const result = super.createUnGroupingRowSelect(request).concat([ condition.AND.push({
`${this.tableName}.id AS id`, field: `${this.tableName}.domain_id`,
`${this.tableName}.username AS username`, value: domainId,
`${this.tableName}.showname AS showname`, op: 'equals',
`${this.tableName}.avatar AS avatar`, });
`${this.tableName}.officer_id AS officer_id`, } else {
`${this.tableName}.phone_number AS phone_number`, condition.AND.push({
`${this.tableName}.order AS order`, field: `${this.tableName}.domain_id`,
`${this.tableName}.enabled AS enabled`, op: 'blank',
'dept.name AS dept_name', });
'domain.name AS domain_name',
]);
return result
} }
createJoinSql(request?: RowModelRequest): string[] { if (!includeDeleted) {
return [ condition.AND.push({
`LEFT JOIN department dept ON ${this.tableName}.dept_id = dept.id`, field: `${this.tableName}.deleted_at`,
`LEFT JOIN department domain ON ${this.tableName}.domain_id = domain.id`, type: 'date',
]; op: 'blank',
});
} }
protected createGetRowsFilters( condition.OR = [];
request: z.infer<typeof StaffMethodSchema.getRows>, if (!staff.permissions.includes(RolePerms.MANAGE_ANY_STAFF)) {
staff: UserProfile, if (staff.permissions.includes(RolePerms.MANAGE_DOM_STAFF)) {
) { condition.OR.push({
const condition = super.createGetRowsFilters(request); field: 'dept.id',
const { domainId, includeDeleted = false } = request; value: staff.domainId,
if (isFieldCondition(condition)) { op: 'equals',
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,
},
}); });
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 };
}
} }

View File

@ -1,131 +1,138 @@
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
import DepartmentSelect from "@web/src/components/models/department/department-select"; import DepartmentSelect from "@web/src/components/models/department/department-select";
import { Form, Input, Button, Select } from "antd"; import { Form, Input, Button, Select } from "antd";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
export interface RegisterFormData { export interface RegisterFormData {
deptId: string; deptId: string;
username: string; username: string;
showname: string; showname: string;
officerId: string; officerId: string;
password: string; password: string;
repeatPass: string; repeatPass: string;
} }
interface RegisterFormProps { interface RegisterFormProps {
onSubmit: (data: RegisterFormData) => void; onSubmit: (data: RegisterFormData) => void;
isLoading: boolean; isLoading: boolean;
} }
export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => { export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
const [form] = Form.useForm<RegisterFormData>(); const [form] = Form.useForm<RegisterFormData>();
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} 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.Item
form={form} name="officerId"
layout="vertical" label="证件号"
onFinish={onSubmit} rules={[
scrollToFirstError { required: true, message: "请输入证件号" },
> {
<Form.Item pattern: /^\d{5,12}$/,
name="deptId" message: "请输入有效的证件号5-12位数字",
label="部门" },
rules={[ ]}>
{ required: true, message: "请选择部门" } <Input placeholder="证件号" />
]} </Form.Item>
>
<DepartmentSelect></DepartmentSelect>
</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
<Form.Item name="repeatPass"
name="username" label="确认密码"
label="用户名" dependencies={["password"]}
rules={[ rules={[
{ required: true, message: "请输入用户名" }, { required: true, message: "请确认密码" },
{ min: 2, message: "用户名至少需要2个字符" } ({ getFieldValue }) => ({
]} validator(_, value) {
> if (
<Input placeholder="用户名" /> !value ||
</Form.Item> getFieldValue("password") === value
) {
return Promise.resolve();
}
return Promise.reject(
new Error("两次输入的密码不一致")
);
},
}),
]}>
<Input.Password placeholder="确认密码" />
</Form.Item>
<Form.Item <Form.Item>
name="showname" <Button
label="姓名" type="primary"
rules={[ htmlType="submit"
{ required: true, message: "请输入姓名" }, loading={isLoading}
{ min: 2, message: "姓名至少需要2个字符" } className="w-full h-10 rounded-lg">
]} {isLoading ? "正在注册..." : "注册"}
> </Button>
<Input placeholder="姓名" /> </Form.Item>
</Form.Item> </Form>
</div> </motion.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>
);
}; };

View File

@ -1,7 +1,7 @@
import { useClickOutside } from "@web/src/hooks/useClickOutside"; import { useClickOutside } from "@web/src/hooks/useClickOutside";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { motion, AnimatePresence } from "framer-motion"; 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 { Avatar } from "../../common/element/Avatar";
import { import {
UserOutlined, UserOutlined,
@ -9,9 +9,10 @@ import {
QuestionCircleOutlined, QuestionCircleOutlined,
LogoutOutlined, LogoutOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Spin } from "antd"; import { FormInstance, Spin } from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { MenuItemType } from "./types"; import { MenuItemType } from "./types";
import { RolePerms } from "@nice/common";
const menuVariants = { const menuVariants = {
hidden: { opacity: 0, scale: 0.95, y: -10 }, hidden: { opacity: 0, scale: 0.95, y: -10 },
visible: { 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() { export function UserMenu() {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const { user, logout, isLoading } = useAuth(); const { user, logout, isLoading, hasSomePermissions } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
useClickOutside(menuRef, () => setShowMenu(false)); useClickOutside(menuRef, () => setShowMenu(false));
const [modalOpen, setModalOpen] = useState<boolean>(false);
const toggleMenu = useCallback(() => { const toggleMenu = useCallback(() => {
setShowMenu((prev) => !prev); setShowMenu((prev) => !prev);
}, []); }, []);
const canManageAnyStaff = useMemo(() => {
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
}, [user]);
const menuItems: MenuItemType[] = useMemo( const menuItems: MenuItemType[] = useMemo(
() => [ () =>
{ [
icon: <UserOutlined className="text-lg" />, {
label: "个人信息", icon: <UserOutlined className="text-lg" />,
action: () => {}, label: "个人信息",
}, action: () => {},
{
icon: <SettingOutlined className="text-lg" />,
label: "设置",
action: () => {
navigate("/admin/staff");
}, },
}, canManageAnyStaff && {
// { icon: <SettingOutlined className="text-lg" />,
// icon: <QuestionCircleOutlined className="text-lg" />, label: "设置",
// label: '帮助', action: () => {
// action: () => { }, navigate("/admin/staff");
// }, },
{ },
icon: <LogoutOutlined className="text-lg" />, // {
label: "注销", // icon: <QuestionCircleOutlined className="text-lg" />,
action: () => logout(), // label: '帮助',
}, // action: () => { },
], // },
{
icon: <LogoutOutlined className="text-lg" />,
label: "注销",
action: () => logout(),
},
].filter(Boolean),
[logout] [logout]
); );
@ -87,99 +115,100 @@ export function UserMenu() {
} }
return ( return (
<div ref={menuRef} className="relative">
<motion.button <div ref={menuRef} className="relative">
aria-label="用户菜单" <motion.button
aria-haspopup="true" aria-label="用户菜单"
aria-expanded={showMenu} aria-haspopup="true"
aria-controls="user-menu" aria-expanded={showMenu}
whileHover={{ scale: 1.02 }} aria-controls="user-menu"
whileTap={{ scale: 0.98 }} whileHover={{ scale: 1.02 }}
onClick={toggleMenu} whileTap={{ scale: 0.98 }}
className="flex items-center rounded-full transition-all duration-200 ease-in-out"> onClick={toggleMenu}
{/* Avatar 容器,相对定位 */} className="flex items-center rounded-full transition-all duration-200 ease-in-out">
<div className="relative"> {/* Avatar 容器,相对定位 */}
<Avatar <div className="relative">
src={user?.avatar} <Avatar
name={user?.showname || user?.username} src={user?.avatar}
size={40} name={user?.showname || user?.username}
className="ring-2 ring-white hover:ring-[#00538E]/90 size={40}
className="ring-2 ring-white hover:ring-[#00538E]/90
transition-all duration-200 ease-in-out shadow-md transition-all duration-200 ease-in-out shadow-md
hover:shadow-lg focus:outline-none hover:shadow-lg focus:outline-none
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2 focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
focus:ring-offset-white " focus:ring-offset-white "
/> />
{/* 小绿点 */} {/* 小绿点 */}
<span <span
className="absolute bottom-0 right-0 h-3 w-3 className="absolute bottom-0 right-0 h-3 w-3
rounded-full bg-emerald-500 ring-2 ring-white rounded-full bg-emerald-500 ring-2 ring-white
shadow-sm transition-transform duration-200 shadow-sm transition-transform duration-200
ease-in-out hover:scale-110" ease-in-out hover:scale-110"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
{/* 用户信息,显示在 Avatar 右侧 */} {/* 用户信息,显示在 Avatar 右侧 */}
<div className="flex flex-col space-y-0.5 ml-3 items-start"> <div className="flex flex-col space-y-0.5 ml-3 items-start">
<span className="text-sm font-semibold text-white"> <span className="text-sm font-semibold text-white">
{user?.showname || user?.username} {user?.showname || user?.username}
</span> </span>
<span className="text-xs text-white flex items-center gap-1.5"> <span className="text-xs text-white flex items-center gap-1.5">
{user?.department?.name} {user?.department?.name}
</span> </span>
</div> </div>
</motion.button> </motion.button>
<AnimatePresence> <AnimatePresence>
{showMenu && ( {showMenu && (
<motion.div <motion.div
initial="hidden" initial="hidden"
animate="visible" animate="visible"
exit="exit" exit="exit"
variants={menuVariants} variants={menuVariants}
role="menu" role="menu"
id="user-menu" id="user-menu"
aria-orientation="vertical" aria-orientation="vertical"
aria-labelledby="user-menu-button" aria-labelledby="user-menu-button"
style={{ zIndex: 100 }} style={{ zIndex: 100 }}
className="absolute right-0 mt-3 w-64 origin-top-right className="absolute right-0 mt-3 w-64 origin-top-right
bg-white rounded-xl overflow-hidden shadow-lg bg-white rounded-xl overflow-hidden shadow-lg
border border-[#E5EDF5]"> border border-[#E5EDF5]">
{/* User Profile Section */} {/* User Profile Section */}
<div <div
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
border-b border-[#E5EDF5] "> border-b border-[#E5EDF5] ">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Avatar <Avatar
src={user?.avatar} src={user?.avatar}
name={user?.showname || user?.username} name={user?.showname || user?.username}
size={40} size={40}
className="ring-2 ring-white shadow-sm" className="ring-2 ring-white shadow-sm"
/> />
<div className="flex flex-col space-y-0.5"> <div className="flex flex-col space-y-0.5">
<span className="text-sm font-semibold text-[#00538E]"> <span className="text-sm font-semibold text-[#00538E]">
{user?.showname || user?.username} {user?.showname || user?.username}
</span> </span>
<span className="text-xs text-[#718096] flex items-center gap-1.5"> <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 className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
线 线
</span> </span>
</div>
</div> </div>
</div> </div>
</div>
{/* Menu Items */} {/* Menu Items */}
<div className="p-2"> <div className="p-2">
{menuItems.map((item, index) => ( {menuItems.map((item, index) => (
<button <button
key={index} key={index}
role="menuitem" role="menuitem"
tabIndex={showMenu ? 0 : -1} tabIndex={showMenu ? 0 : -1}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleMenuItemClick(item.action); handleMenuItemClick(item.action);
}} }}
className={`flex items-center gap-3 w-full px-4 py-3 className={`flex items-center gap-3 w-full px-4 py-3
text-sm font-medium rounded-lg transition-all text-sm font-medium rounded-lg transition-all
focus:outline-none focus:outline-none
focus:ring-2 focus:ring-[#00538E]/20 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-[#B22234] hover:bg-red-50/80 hover:text-red-700"
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]" : "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
}`}> }`}>
<span <span
className={`w-5 h-5 flex items-center justify-center className={`w-5 h-5 flex items-center justify-center
transition-all duration-200 ease-in-out transition-all duration-200 ease-in-out
group-hover:scale-110 group-hover:rotate-6 group-hover:scale-110 group-hover:rotate-6
group-hover:translate-x-0.5 ${ group-hover:translate-x-0.5 ${
@ -199,15 +228,16 @@ export function UserMenu() {
? "group-hover:text-red-600" ? "group-hover:text-red-600"
: "group-hover:text-[#003F6A]" : "group-hover:text-[#003F6A]"
}`}> }`}>
{item.icon} {item.icon}
</span> </span>
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
))} ))}
</div> </div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
); );
} }

View File

@ -124,11 +124,7 @@ export default function StaffForm() {
onFinish={handleFinish}> onFinish={handleFinish}>
<div className=" flex items-center gap-4 mb-2"> <div className=" flex items-center gap-4 mb-2">
<div> <div>
<Form.Item <Form.Item name={"photoUrl"} label="头像" noStyle>
name={"photoUrl"}
label="头像"
noStyle
rules={[{ required: true }]}>
<AvatarUploader <AvatarUploader
style={{ style={{
width: "100px", width: "100px",

View File

@ -8,6 +8,7 @@ export const AuthSchema = {
officerId: z.string().nullish(), officerId: z.string().nullish(),
showname: z.string().nullish(), showname: z.string().nullish(),
phoneNumber: z.string().nullish(), phoneNumber: z.string().nullish(),
photoUrl: z.string().nullish(),
}), }),
refreshTokenRequest: z.object({ refreshTokenRequest: z.object({
refreshToken: z.string(), refreshToken: z.string(),
@ -66,7 +67,7 @@ export const RowRequestSchema = z.object({
groupKeys: z.array(z.any()), groupKeys: z.array(z.any()),
filterModel: z.any().nullish(), filterModel: z.any().nullish(),
sortModel: z.array(SortModel).nullish(), sortModel: z.array(SortModel).nullish(),
includeDeleted: z.boolean().nullish() includeDeleted: z.boolean().nullish(),
}); });
export const StaffMethodSchema = { export const StaffMethodSchema = {
create: z.object({ create: z.object({
@ -170,7 +171,6 @@ export const DepartmentMethodSchema = {
}; };
export const TransformMethodSchema = { export const TransformMethodSchema = {
importStaffs: z.object({ importStaffs: z.object({
base64: z.string(), base64: z.string(),
domainId: z.string().nullish(), domainId: z.string().nullish(),
@ -186,7 +186,6 @@ export const TransformMethodSchema = {
domainId: z.string().nullish(), domainId: z.string().nullish(),
parentId: z.string().nullish(), parentId: z.string().nullish(),
}), }),
}; };
export const TermMethodSchema = { export const TermMethodSchema = {
getRows: RowRequestSchema.extend({ getRows: RowRequestSchema.extend({
@ -390,5 +389,3 @@ export const BaseCursorSchema = z.object({
createEndDate: z.date().nullish(), createEndDate: z.date().nullish(),
deptId: z.string().nullish(), deptId: z.string().nullish(),
}); });