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,
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: '注销成功' };
}
}
}

View File

@ -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
},
};

View File

@ -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 };
}
}

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 { 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>
);
};

View File

@ -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>
);
}

View File

@ -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",

View File

@ -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(),
});