This commit is contained in:
ditiqi 2025-01-26 10:34:44 +08:00
parent ac18602e58
commit f121322bbd
5 changed files with 290 additions and 340 deletions

View File

@ -0,0 +1,131 @@
import { env } from "@web/src/env";
import { message, Progress, Spin, theme } from "antd";
import React, { useState, useEffect, useRef } from "react";
import { useTusUpload } from "@web/src/hooks/useTusUpload";
export interface AvatarUploaderProps {
value?: string;
placeholder?: string;
className?: string;
onChange?: (value: string) => void;
style?: React.CSSProperties; // 添加style属性
}
interface UploadingFile {
name: string;
progress: number;
status: "uploading" | "done" | "error";
fileId?: string;
fileKey?: string;
}
const AvatarUploader: React.FC<AvatarUploaderProps> = ({
value,
onChange,
className,
placeholder = "点击上传",
style, // 解构style属性
}) => {
const { handleFileUpload, uploadProgress } = useTusUpload();
const [file, setFile] = useState<UploadingFile | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>(value || "");
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { token } = theme.useToken();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (!selectedFile) return;
setFile({
name: selectedFile.name,
progress: 0,
status: "uploading",
fileKey: `${selectedFile.name}-${Date.now()}`,
});
setUploading(true);
try {
const fileId = await new Promise<string>((resolve, reject) => {
handleFileUpload(
selectedFile,
(result) => {
setFile((prev) => ({
...prev!,
progress: 100,
status: "done",
fileId: result.fileId,
}));
resolve(result.fileId);
},
(error) => {
reject(error);
},
file?.fileKey
);
});
setPreviewUrl(`${env.SERVER_IP}/uploads/${fileId}`);
onChange?.(fileId);
message.success("头像上传成功");
} catch (error) {
console.error("上传错误:", error);
message.error("头像上传失败");
setFile((prev) => ({ ...prev!, status: "error" }));
} finally {
setUploading(false);
}
};
const triggerUpload = () => {
inputRef.current?.click();
};
return (
<div
className={`relative w-24 h-24 overflow-hidden cursor-pointer ${className}`}
onClick={triggerUpload}
style={{
border: `1px solid ${token.colorBorder}`,
background: token.colorBgContainer,
...style, // 应用外部传入的样式
}}>
<input
type="file"
ref={inputRef}
onChange={handleChange}
accept="image/*"
style={{ display: "none" }}
/>
{previewUrl ? (
<img
src={previewUrl}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center w-full h-full text-sm text-gray-500">
{placeholder}
</div>
)}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
<Spin />
</div>
)}
{file && file.status === "uploading" && (
<div className="absolute bottom-0 left-0 right-0 bg-white bg-opacity-75">
<Progress
percent={Math.round(
uploadProgress?.[file.fileKey!] || 0
)}
showInfo={false}
strokeColor={token.colorPrimary}
/>
</div>
)}
</div>
);
};
export default AvatarUploader;

View File

@ -1,241 +0,0 @@
// FileUploader.tsx
import React, { useRef, memo, useState } from "react";
import {
CloudArrowUpIcon,
XMarkIcon,
DocumentIcon,
ExclamationCircleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "react-hot-toast";
import { useTusUpload } from "@web/src/hooks/useTusUpload";
interface FileUploaderProps {
endpoint?: string;
onSuccess?: (result: { url: string; fileId: string }) => void;
onError?: (error: Error) => void;
maxSize?: number;
allowedTypes?: string[];
placeholder?: string;
}
interface FileItemProps {
file: File;
progress?: number;
onRemove: (name: string) => void;
isUploaded: boolean;
}
const FileItem: React.FC<FileItemProps> = memo(
({ file, progress, onRemove, isUploaded }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200">
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
<div className="ml-3 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
{file.name}
</p>
<button
onClick={() => onRemove(file.name)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
aria-label={`Remove ${file.name}`}>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{!isUploaded && progress !== undefined && (
<div className="mt-2">
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
<motion.div
className="bg-blue-500 h-1.5 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<span className="text-xs text-gray-500 mt-1">
{progress}%
</span>
</div>
)}
{isUploaded && (
<div className="mt-2 flex items-center text-green-500">
<CheckCircleIcon className="w-4 h-4 mr-1" />
<span className="text-xs"></span>
</div>
)}
</div>
</motion.div>
)
);
const FileUploader: React.FC<FileUploaderProps> = ({
onSuccess,
onError,
maxSize = 100,
placeholder = "点击或拖拽文件到这里上传",
allowedTypes = ["*/*"],
}) => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<
Array<{ file: File; isUploaded: boolean }>
>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { progress, isUploading, uploadError, handleFileUpload } =
useTusUpload();
const handleError = (error: Error) => {
toast.error(error.message);
onError?.(error);
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setIsDragging(true);
} else if (e.type === "dragleave") {
setIsDragging(false);
}
};
const validateFile = (file: File) => {
if (file.size > maxSize * 1024 * 1024) {
throw new Error(`文件大小不能超过 ${maxSize}MB`);
}
if (
!allowedTypes.includes("*/*") &&
!allowedTypes.includes(file.type)
) {
throw new Error(
`不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}`
);
}
};
const uploadFile = (file: File) => {
try {
validateFile(file);
handleFileUpload(
file,
(upload) => {
console.log("Upload complete:", {
url: upload.url,
fileId: upload.fileId,
// resource: upload.resource
});
onSuccess?.(upload);
setFiles((prev) =>
prev.map((f) =>
f.file.name === file.name
? { ...f, isUploaded: true }
: f
)
);
},
handleError
);
} catch (error) {
handleError(error as Error);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles((prev) => [
...prev,
...droppedFiles.map((file) => ({ file, isUploaded: false })),
]);
droppedFiles.forEach(uploadFile);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
setFiles((prev) => [
...prev,
...selectedFiles.map((file) => ({ file, isUploaded: false })),
]);
selectedFiles.forEach(uploadFile);
}
};
const removeFile = (fileName: string) => {
setFiles((prev) => prev.filter(({ file }) => file.name !== fileName));
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<div className="w-full space-y-4">
<div
onClick={handleClick}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`
relative flex flex-col items-center justify-center w-full h-32
border-2 border-dashed rounded-lg cursor-pointer
transition-colors duration-200 ease-in-out
${
isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-500"
}
`}>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
accept={allowedTypes.join(",")}
className="hidden"
/>
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
<p className="text-blue-500 font-medium">
</p>
</div>
)}
</div>
<AnimatePresence>
<div className="space-y-3">
{files.map(({ file, isUploaded }) => (
<FileItem
key={file.name}
file={file}
progress={isUploaded ? 100 : progress}
onRemove={removeFile}
isUploaded={isUploaded}
/>
))}
</div>
</AnimatePresence>
{uploadError && (
<div className="flex items-center text-red-500 text-sm">
<ExclamationCircleIcon className="w-4 h-4 mr-1" />
<span>{uploadError}</span>
</div>
)}
</div>
);
};
export default FileUploader;

View File

@ -96,24 +96,38 @@ export function UserMenu() {
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={toggleMenu} onClick={toggleMenu}
className="relative rounded-full focus:outline-none className="flex items-center rounded-full transition-all duration-200 ease-in-out">
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2 {/* Avatar 容器,相对定位 */}
focus:ring-offset-white transition-all duration-200 ease-in-out"> <div className="relative">
<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 hover:ring-[#00538E]/90 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" hover:shadow-lg focus:outline-none
/> focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
<span focus:ring-offset-white "
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 <span
ease-in-out hover:scale-110" className="absolute bottom-0 right-0 h-3 w-3
aria-hidden="true" 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>
{/* 用户信息,显示在 Avatar 右侧 */}
<div className="flex flex-col space-y-0.5 ml-3">
<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> </motion.button>
<AnimatePresence> <AnimatePresence>

View File

@ -1,8 +1,8 @@
import StaffList from "./staff-list"; import StaffList from "./staff-list";
import { ObjectType, RolePerms } from "@nice/common" import { ObjectType, RolePerms } from "@nice/common";
import { Icon } from "@nice/iconer" import { Icon } from "@nice/iconer";
import StaffModal from "./staff-modal"; import StaffModal from "./staff-modal";
import { createContext, useEffect, useMemo, useState } from "react"; import React,{ createContext, useEffect, useMemo, useState } from "react";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { Button } from "antd"; import { Button } from "antd";
import DepartmentSelect from "../department/department-select"; import DepartmentSelect from "../department/department-select";
@ -10,63 +10,87 @@ import { FormInstance, useForm } from "antd/es/form/Form";
import AdminHeader from "../../layout/admin/AdminHeader"; import AdminHeader from "../../layout/admin/AdminHeader";
export const StaffEditorContext = createContext<{ export const StaffEditorContext = createContext<{
domainId: string, domainId: string;
modalOpen: boolean, modalOpen: boolean;
setDomainId: React.Dispatch<React.SetStateAction<string>>, setDomainId: React.Dispatch<React.SetStateAction<string>>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>, setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
editId: string, editId: string;
setEditId: React.Dispatch<React.SetStateAction<string>>, setEditId: React.Dispatch<React.SetStateAction<string>>;
form: FormInstance<any>, form: FormInstance<any>;
formLoading: boolean, formLoading: boolean;
setFormLoading: React.Dispatch<React.SetStateAction<boolean>>, setFormLoading: React.Dispatch<React.SetStateAction<boolean>>;
canManageAnyStaff: boolean canManageAnyStaff: boolean;
}>({ }>({
domainId: undefined, domainId: undefined,
modalOpen: false, modalOpen: false,
setDomainId: undefined, setDomainId: undefined,
setModalOpen: undefined, setModalOpen: undefined,
editId: undefined, editId: undefined,
setEditId: undefined, setEditId: undefined,
form: undefined, form: undefined,
formLoading: undefined, formLoading: undefined,
setFormLoading: undefined, setFormLoading: undefined,
canManageAnyStaff: false canManageAnyStaff: false,
}); });
export default function StaffEditor() { export default function StaffEditor() {
const [form] = useForm() const [form] = useForm();
const [domainId, setDomainId] = useState<string>(); const [domainId, setDomainId] = useState<string>();
const [modalOpen, setModalOpen] = useState<boolean>(false); const [modalOpen, setModalOpen] = useState<boolean>(false);
const [editId, setEditId] = useState<string>() const [editId, setEditId] = useState<string>();
const { user, hasSomePermissions } = useAuth() const { user, hasSomePermissions } = useAuth();
const [formLoading, setFormLoading] = useState<boolean>() const [formLoading, setFormLoading] = useState<boolean>();
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setDomainId(user.domainId) setDomainId(user.domainId);
} }
}, [user]) }, [user]);
const canManageStaff = useMemo(() => { const canManageStaff = useMemo(() => {
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF, RolePerms.MANAGE_DOM_STAFF) return hasSomePermissions(
}, [user]) RolePerms.MANAGE_ANY_STAFF,
const canManageAnyStaff = useMemo(() => { RolePerms.MANAGE_DOM_STAFF
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF) );
}, [user]) }, [user]);
return <StaffEditorContext.Provider value={{ canManageAnyStaff, formLoading, setFormLoading, form, editId, setEditId, domainId, modalOpen, setDomainId, setModalOpen }}> const canManageAnyStaff = useMemo(() => {
<AdminHeader roomId="staff-editor"> return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
<div className="flex items-center gap-4"> }, [user]);
<DepartmentSelect rootId={user?.domainId} onChange={(value) => setDomainId(value as string)} disabled={!canManageAnyStaff} value={domainId} className="w-48" domain={true}></DepartmentSelect> return (
{canManageStaff && <Button <StaffEditorContext.Provider
value={{
type="primary" canManageAnyStaff,
icon={<Icon name="add"></Icon>} formLoading,
onClick={() => { setFormLoading,
setModalOpen(true) form,
}}> editId,
setEditId,
</Button>} domainId,
</div> modalOpen,
</AdminHeader> setDomainId,
<StaffList domainId={domainId}></StaffList> setModalOpen,
<StaffModal></StaffModal> }}>
</StaffEditorContext.Provider> <AdminHeader roomId="staff-editor">
} <div className="flex items-center gap-4">
<DepartmentSelect
rootId={user?.domainId}
onChange={(value) => setDomainId(value as string)}
disabled={!canManageAnyStaff}
value={domainId}
className="w-48"
domain={true}></DepartmentSelect>
{canManageStaff && (
<Button
type="primary"
icon={<Icon name="add"></Icon>}
onClick={() => {
setModalOpen(true);
}}>
</Button>
)}
</div>
</AdminHeader>
<StaffList domainId={domainId}></StaffList>
<StaffModal></StaffModal>
</StaffEditorContext.Provider>
);
}

View File

@ -1,10 +1,11 @@
import { Button, Form, Input, Spin, Switch, message } from "antd"; import { Button, Form, Input, Spin, Switch, message } from "antd";
import { useContext, useEffect} from "react"; import { useContext, useEffect } from "react";
import { useStaff } from "@nice/client"; import { useStaff } from "@nice/client";
import DepartmentSelect from "../department/department-select"; import DepartmentSelect from "../department/department-select";
import { api } from "@nice/client" import { api } from "@nice/client";
import { StaffEditorContext } from "./staff-editor"; import { StaffEditorContext } from "./staff-editor";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import AvatarUploader from "../../common/uploader/AvatarUploader";
export default function StaffForm() { export default function StaffForm() {
const { create, update } = useStaff(); // Ensure you have these methods in your hooks const { create, update } = useStaff(); // Ensure you have these methods in your hooks
const { const {
@ -31,8 +32,8 @@ export default function StaffForm() {
password, password,
phoneNumber, phoneNumber,
officerId, officerId,
enabled enabled,
} = values } = values;
setFormLoading(true); setFormLoading(true);
try { try {
if (data && editId) { if (data && editId) {
@ -46,8 +47,8 @@ export default function StaffForm() {
password, password,
phoneNumber, phoneNumber,
officerId, officerId,
enabled enabled,
} },
}); });
} else { } else {
await create.mutateAsync({ await create.mutateAsync({
@ -58,8 +59,8 @@ export default function StaffForm() {
domainId: fieldDomainId ? fieldDomainId : domainId, domainId: fieldDomainId ? fieldDomainId : domainId,
password, password,
officerId, officerId,
phoneNumber phoneNumber,
} },
}); });
form.resetFields(); form.resetFields();
if (deptId) form.setFieldValue("deptId", deptId); if (deptId) form.setFieldValue("deptId", deptId);
@ -83,7 +84,7 @@ export default function StaffForm() {
form.setFieldValue("deptId", data.deptId); form.setFieldValue("deptId", data.deptId);
form.setFieldValue("officerId", data.officerId); form.setFieldValue("officerId", data.officerId);
form.setFieldValue("phoneNumber", data.phoneNumber); form.setFieldValue("phoneNumber", data.phoneNumber);
form.setFieldValue("enabled", data.enabled) form.setFieldValue("enabled", data.enabled);
} }
}, [data]); }, [data]);
useEffect(() => { useEffect(() => {
@ -91,7 +92,7 @@ export default function StaffForm() {
form.setFieldValue("domainId", domainId); form.setFieldValue("domainId", domainId);
form.setFieldValue("deptId", domainId); form.setFieldValue("deptId", domainId);
} }
}, [domainId, data]); }, [domainId, data as any]);
return ( return (
<div className="relative"> <div className="relative">
{isLoading && ( {isLoading && (
@ -106,6 +107,16 @@ export default function StaffForm() {
requiredMark="optional" requiredMark="optional"
autoComplete="off" autoComplete="off"
onFinish={handleFinish}> onFinish={handleFinish}>
<Form.Item
name={"meta.photoUrl"}
label="头像"
rules={[{ required: true }]}>
<AvatarUploader
style={{
width: "150px",
height: "200px",
}}></AvatarUploader>
</Form.Item>
{canManageAnyStaff && ( {canManageAnyStaff && (
<Form.Item <Form.Item
name={"domainId"} name={"domainId"}
@ -127,7 +138,8 @@ export default function StaffForm() {
rules={[{ required: true }]} rules={[{ required: true }]}
name={"username"} name={"username"}
label="帐号"> label="帐号">
<Input allowClear <Input
allowClear
autoComplete="new-username" // 使用非标准的自动完成值 autoComplete="new-username" // 使用非标准的自动完成值
spellCheck={false} spellCheck={false}
/> />
@ -136,7 +148,8 @@ export default function StaffForm() {
rules={[{ required: true }]} rules={[{ required: true }]}
name={"showname"} name={"showname"}
label="姓名"> label="姓名">
<Input allowClear <Input
allowClear
autoComplete="new-name" // 使用非标准的自动完成值 autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false} spellCheck={false}
/> />
@ -146,8 +159,8 @@ export default function StaffForm() {
{ {
required: false, required: false,
pattern: /^\d{5,18}$/, pattern: /^\d{5,18}$/,
message: "请输入正确的证件号(数字)" message: "请输入正确的证件号(数字)",
} },
]} ]}
name={"officerId"} name={"officerId"}
label="证件号"> label="证件号">
@ -158,20 +171,29 @@ export default function StaffForm() {
{ {
required: false, required: false,
pattern: /^\d{6,11}$/, pattern: /^\d{6,11}$/,
message: "请输入正确的手机号(数字)" message: "请输入正确的手机号(数字)",
} },
]} ]}
name={"phoneNumber"} name={"phoneNumber"}
label="手机号"> label="手机号">
<Input autoComplete="new-phone" // 使用非标准的自动完成值 <Input
spellCheck={false} allowClear /> autoComplete="new-phone" // 使用非标准的自动完成值
spellCheck={false}
allowClear
/>
</Form.Item> </Form.Item>
<Form.Item label="密码" name={"password"}> <Form.Item label="密码" name={"password"}>
<Input.Password spellCheck={false} visibilityToggle autoComplete="new-password" /> <Input.Password
spellCheck={false}
visibilityToggle
autoComplete="new-password"
/>
</Form.Item> </Form.Item>
{editId && <Form.Item label="是否启用" name={"enabled"}> {editId && (
<Switch></Switch> <Form.Item label="是否启用" name={"enabled"}>
</Form.Item>} <Switch></Switch>
</Form.Item>
)}
</Form> </Form>
</div> </div>
); );