R45 A2-A7 员工附件与培训证书管理
这个提交包含在:
+146
-1
@@ -1,9 +1,12 @@
|
||||
import { apiRequestData } from "./http";
|
||||
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
|
||||
|
||||
export type EmployeeStatus = "active" | "archived";
|
||||
export type EmployeeEmploymentStatus = "onboarding" | "probation" | "active" | "offboarding" | "inactive";
|
||||
export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship";
|
||||
export type EmployeeContractStatus = "active" | "expired" | "terminated";
|
||||
export type EmployeeTrainingType = "internal" | "external" | "online";
|
||||
export type EmployeeTrainingStatus = "planned" | "in_progress" | "completed" | "cancelled";
|
||||
export type EmployeeCertificateStatus = "valid" | "expired";
|
||||
|
||||
export type EmployeeDepartmentSummary = {
|
||||
id: string;
|
||||
@@ -87,6 +90,7 @@ export type EmployeeContract = {
|
||||
endDate: string | null;
|
||||
terminatedDate: string | null;
|
||||
attachmentPath: string | null;
|
||||
attachmentUrl: string | null;
|
||||
status: EmployeeContractStatus;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
@@ -94,6 +98,37 @@ export type EmployeeContract = {
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type EmployeeTraining = {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
name: string;
|
||||
trainingType: EmployeeTrainingType;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
status: EmployeeTrainingStatus;
|
||||
notes: string | null;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type EmployeeCertificate = {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
name: string;
|
||||
issuer: string;
|
||||
obtainedDate: string | null;
|
||||
expiryDate: string | null;
|
||||
certificateNo: string | null;
|
||||
attachmentPath: string | null;
|
||||
status: EmployeeCertificateStatus;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type OrganizationTreeNode = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
@@ -209,6 +244,12 @@ export type EmployeeStatsResponse = {
|
||||
dateTo: string;
|
||||
count: number;
|
||||
};
|
||||
certificateSummary: {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
validHolderCount: number;
|
||||
expiringSoonCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type BatchTransferDepartmentPayload = {
|
||||
@@ -255,6 +296,12 @@ export async function uploadEmployeeAvatar(employeeId: string, file: File) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEmployeeAvatar(employeeId: string) {
|
||||
return apiRequestData<{ employeeId: string; avatarUrl: string | null }>(`/api/employees/${employeeId}/avatar`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchEmployeeProfile(
|
||||
employeeId: string,
|
||||
payload: Partial<{
|
||||
@@ -373,6 +420,104 @@ export async function patchEmployeeContract(
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadEmployeeContractAttachment(employeeId: string, contractId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
return apiRequestData<EmployeeContract>(`/api/employees/${employeeId}/contracts/${contractId}/attachment`, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadEmployeeContractAttachment(attachmentUrl: string): Promise<ApiBlobResponse> {
|
||||
return apiRequestBlob(attachmentUrl);
|
||||
}
|
||||
|
||||
export async function deleteEmployeeContract(employeeId: string, contractId: string) {
|
||||
return apiRequestData<{ contractId: string; deleted: boolean }>(`/api/employees/${employeeId}/contracts/${contractId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchEmployeeTrainings(employeeId: string) {
|
||||
return apiRequestData<{ items: EmployeeTraining[] }>(`/api/employees/${employeeId}/trainings`);
|
||||
}
|
||||
|
||||
export async function createEmployeeTraining(
|
||||
employeeId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
trainingType: EmployeeTrainingType;
|
||||
startDate: string;
|
||||
endDate?: string | null;
|
||||
status: EmployeeTrainingStatus;
|
||||
notes?: string | null;
|
||||
},
|
||||
) {
|
||||
return apiRequestData<EmployeeTraining>(`/api/employees/${employeeId}/trainings`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchEmployeeTraining(
|
||||
employeeId: string,
|
||||
trainingId: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
trainingType: EmployeeTrainingType;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
status: EmployeeTrainingStatus;
|
||||
notes: string | null;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<EmployeeTraining>(`/api/employees/${employeeId}/trainings/${trainingId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchEmployeeCertificates(employeeId: string) {
|
||||
return apiRequestData<{ items: EmployeeCertificate[] }>(`/api/employees/${employeeId}/certificates`);
|
||||
}
|
||||
|
||||
export async function createEmployeeCertificate(
|
||||
employeeId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
issuer: string;
|
||||
obtainedDate: string;
|
||||
expiryDate?: string | null;
|
||||
certificateNo?: string | null;
|
||||
attachmentPath?: string | null;
|
||||
},
|
||||
) {
|
||||
return apiRequestData<EmployeeCertificate>(`/api/employees/${employeeId}/certificates`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchEmployeeCertificate(
|
||||
employeeId: string,
|
||||
certificateId: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
issuer: string;
|
||||
obtainedDate: string;
|
||||
expiryDate: string | null;
|
||||
certificateNo: string | null;
|
||||
attachmentPath: string | null;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<EmployeeCertificate>(`/api/employees/${employeeId}/certificates/${certificateId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeEmployeeChecklistTask(employeeId: string, taskId: string) {
|
||||
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/checklist-tasks/${taskId}/complete`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -14,8 +14,11 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
pending_ocr: { label: "识别中", className: "badge-warning" },
|
||||
approved: { label: "已通过", className: "badge-success" },
|
||||
completed: { label: "已完成", className: "badge-success" },
|
||||
in_progress: { label: "进行中", className: "badge-warning" },
|
||||
available: { label: "可用", className: "badge-success" },
|
||||
active: { label: "生效中", className: "badge-success" },
|
||||
valid: { label: "有效", className: "badge-success" },
|
||||
planned: { label: "已计划", className: "badge-primary" },
|
||||
in_stock: { label: "库存中", className: "badge-primary" },
|
||||
in_use: { label: "使用中", className: "badge-success" },
|
||||
under_repair: { label: "维修中", className: "badge-warning" },
|
||||
|
||||
@@ -3,18 +3,32 @@ import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
completeEmployeeChecklistTask,
|
||||
createEmployeeCertificate,
|
||||
createEmployeeContract,
|
||||
createEmployeeTraining,
|
||||
deleteEmployeeAvatar,
|
||||
deleteEmployeeContract,
|
||||
downloadEmployeeContractAttachment,
|
||||
fetchEmployeeCertificates,
|
||||
fetchEmployeeContracts,
|
||||
fetchEmployeeProfile,
|
||||
fetchEmployeeTrainings,
|
||||
patchEmployeeCertificate,
|
||||
patchEmployeeContract,
|
||||
patchEmployeeProfile,
|
||||
patchEmployeeTraining,
|
||||
regularizeEmployee,
|
||||
skipEmployeeChecklistTask,
|
||||
startEmployeeOffboarding,
|
||||
transferEmployee,
|
||||
uploadEmployeeContractAttachment,
|
||||
uploadEmployeeAvatar,
|
||||
type EmployeeCertificate,
|
||||
type EmployeeContract,
|
||||
type EmployeeContractType,
|
||||
type EmployeeTraining,
|
||||
type EmployeeTrainingStatus,
|
||||
type EmployeeTrainingType,
|
||||
} from "../api/employees";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
@@ -29,8 +43,9 @@ import { Textarea } from "../components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
|
||||
type ProfileTab = "profile" | "contracts";
|
||||
type ProfileTab = "profile" | "contracts" | "trainingCertificates";
|
||||
|
||||
type ContractFormState = {
|
||||
contractNo: string;
|
||||
@@ -38,6 +53,23 @@ type ContractFormState = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
terminatedDate: string;
|
||||
};
|
||||
|
||||
type TrainingFormState = {
|
||||
name: string;
|
||||
trainingType: EmployeeTrainingType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: EmployeeTrainingStatus;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type CertificateFormState = {
|
||||
name: string;
|
||||
issuer: string;
|
||||
obtainedDate: string;
|
||||
expiryDate: string;
|
||||
certificateNo: string;
|
||||
attachmentPath: string;
|
||||
};
|
||||
|
||||
@@ -62,6 +94,27 @@ function buildEmptyContractForm(): ContractFormState {
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
terminatedDate: "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyTrainingForm(): TrainingFormState {
|
||||
return {
|
||||
name: "",
|
||||
trainingType: "internal",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
status: "planned",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyCertificateForm(): CertificateFormState {
|
||||
return {
|
||||
name: "",
|
||||
issuer: "",
|
||||
obtainedDate: "",
|
||||
expiryDate: "",
|
||||
certificateNo: "",
|
||||
attachmentPath: "",
|
||||
};
|
||||
}
|
||||
@@ -70,6 +123,23 @@ function contractTypeLabel(type: EmployeeContractType) {
|
||||
return CONTRACT_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
|
||||
}
|
||||
|
||||
const TRAINING_TYPE_OPTIONS: Array<{ value: EmployeeTrainingType; label: string }> = [
|
||||
{ value: "internal", label: "内部培训" },
|
||||
{ value: "external", label: "外部培训" },
|
||||
{ value: "online", label: "在线课程" },
|
||||
];
|
||||
|
||||
const TRAINING_STATUS_OPTIONS: Array<{ value: EmployeeTrainingStatus; label: string }> = [
|
||||
{ value: "planned", label: "已计划" },
|
||||
{ value: "in_progress", label: "进行中" },
|
||||
{ value: "completed", label: "已完成" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
function trainingTypeLabel(type: EmployeeTrainingType) {
|
||||
return TRAINING_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
|
||||
}
|
||||
|
||||
export function EmployeeProfilePage() {
|
||||
const { employeeId = "" } = useParams();
|
||||
const { permissions, me } = useAuth();
|
||||
@@ -88,7 +158,14 @@ export function EmployeeProfilePage() {
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarInputKey, setAvatarInputKey] = useState(0);
|
||||
const [editingContractId, setEditingContractId] = useState<string | null>(null);
|
||||
const [contractAttachmentFile, setContractAttachmentFile] = useState<File | null>(null);
|
||||
const [contractAttachmentInputKey, setContractAttachmentInputKey] = useState(0);
|
||||
const [downloadingContractId, setDownloadingContractId] = useState<string | null>(null);
|
||||
const [contractForm, setContractForm] = useState<ContractFormState>(buildEmptyContractForm());
|
||||
const [editingTrainingId, setEditingTrainingId] = useState<string | null>(null);
|
||||
const [trainingForm, setTrainingForm] = useState<TrainingFormState>(buildEmptyTrainingForm());
|
||||
const [editingCertificateId, setEditingCertificateId] = useState<string | null>(null);
|
||||
const [certificateForm, setCertificateForm] = useState<CertificateFormState>(buildEmptyCertificateForm());
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
hireDate: "",
|
||||
@@ -105,6 +182,16 @@ export function EmployeeProfilePage() {
|
||||
queryKey: ["employee-contracts", employeeId],
|
||||
queryFn: () => fetchEmployeeContracts(employeeId),
|
||||
});
|
||||
const trainingsQuery = useQuery({
|
||||
enabled: Boolean(employeeId) && canManageEmployeeProfile && activeTab === "trainingCertificates",
|
||||
queryKey: ["employee-trainings", employeeId],
|
||||
queryFn: () => fetchEmployeeTrainings(employeeId),
|
||||
});
|
||||
const certificatesQuery = useQuery({
|
||||
enabled: Boolean(employeeId) && canManageEmployeeProfile && activeTab === "trainingCertificates",
|
||||
queryKey: ["employee-certificates", employeeId],
|
||||
queryFn: () => fetchEmployeeCertificates(employeeId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileQuery.data) {
|
||||
@@ -123,7 +210,7 @@ export function EmployeeProfilePage() {
|
||||
}, [profileQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageEmployeeProfile || activeTab !== "contracts") {
|
||||
if (canManageEmployeeProfile || activeTab === "profile") {
|
||||
return;
|
||||
}
|
||||
setActiveTab("profile");
|
||||
@@ -134,6 +221,12 @@ export function EmployeeProfilePage() {
|
||||
setAvatarInputKey((current) => current + 1);
|
||||
setEditingContractId(null);
|
||||
setContractForm(buildEmptyContractForm());
|
||||
setContractAttachmentFile(null);
|
||||
setContractAttachmentInputKey((current) => current + 1);
|
||||
setEditingTrainingId(null);
|
||||
setTrainingForm(buildEmptyTrainingForm());
|
||||
setEditingCertificateId(null);
|
||||
setCertificateForm(buildEmptyCertificateForm());
|
||||
}, [employeeId]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
@@ -184,6 +277,22 @@ export function EmployeeProfilePage() {
|
||||
onError: (error) => toast.error(errorMessage(error, "头像上传失败")),
|
||||
});
|
||||
|
||||
const deleteAvatarMutation = useMutation({
|
||||
mutationFn: () => deleteEmployeeAvatar(employeeId),
|
||||
onSuccess: async () => {
|
||||
setAvatarFile(null);
|
||||
setAvatarInputKey((current) => current + 1);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["employees"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["presence"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["employee-organization"] }),
|
||||
]);
|
||||
toast.success("头像已删除");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "删除头像失败")),
|
||||
});
|
||||
|
||||
const taskMutation = useMutation({
|
||||
mutationFn: ({ taskId, action }: { taskId: string; action: "complete" | "skip" }) =>
|
||||
action === "complete"
|
||||
@@ -231,7 +340,6 @@ export function EmployeeProfilePage() {
|
||||
startDate: contractForm.startDate,
|
||||
endDate: contractForm.endDate,
|
||||
terminatedDate: contractForm.terminatedDate || null,
|
||||
attachmentPath: contractForm.attachmentPath.trim() ? contractForm.attachmentPath.trim() : null,
|
||||
};
|
||||
|
||||
if (editingContractId) {
|
||||
@@ -248,6 +356,95 @@ export function EmployeeProfilePage() {
|
||||
onError: (error) => toast.error(errorMessage(error, editingContractId ? "更新合同失败" : "新增合同失败")),
|
||||
});
|
||||
|
||||
const contractAttachmentMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!editingContractId) {
|
||||
throw new Error("请先选择一个已保存的合同");
|
||||
}
|
||||
if (!contractAttachmentFile) {
|
||||
throw new Error("请先选择附件文件");
|
||||
}
|
||||
return uploadEmployeeContractAttachment(employeeId, editingContractId, contractAttachmentFile);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setContractAttachmentFile(null);
|
||||
setContractAttachmentInputKey((current) => current + 1);
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] });
|
||||
toast.success("合同附件已更新");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "上传合同附件失败")),
|
||||
});
|
||||
|
||||
const deleteContractMutation = useMutation({
|
||||
mutationFn: (contractId: string) => deleteEmployeeContract(employeeId, contractId),
|
||||
onSuccess: async (_result, contractId) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] });
|
||||
if (editingContractId === contractId) {
|
||||
setEditingContractId(null);
|
||||
setContractForm(buildEmptyContractForm());
|
||||
setContractAttachmentFile(null);
|
||||
setContractAttachmentInputKey((current) => current + 1);
|
||||
}
|
||||
toast.success("合同已删除");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "删除合同失败")),
|
||||
});
|
||||
|
||||
const trainingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
name: trainingForm.name.trim(),
|
||||
trainingType: trainingForm.trainingType,
|
||||
startDate: trainingForm.startDate,
|
||||
endDate: trainingForm.endDate || null,
|
||||
status: trainingForm.status,
|
||||
notes: trainingForm.notes.trim() ? trainingForm.notes.trim() : null,
|
||||
};
|
||||
|
||||
if (editingTrainingId) {
|
||||
return patchEmployeeTraining(employeeId, editingTrainingId, payload);
|
||||
}
|
||||
|
||||
return createEmployeeTraining(employeeId, payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-trainings", employeeId] });
|
||||
setEditingTrainingId(null);
|
||||
setTrainingForm(buildEmptyTrainingForm());
|
||||
toast.success(editingTrainingId ? "培训记录已更新" : "培训记录已新增");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, editingTrainingId ? "更新培训记录失败" : "新增培训记录失败")),
|
||||
});
|
||||
|
||||
const certificateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
name: certificateForm.name.trim(),
|
||||
issuer: certificateForm.issuer.trim(),
|
||||
obtainedDate: certificateForm.obtainedDate,
|
||||
expiryDate: certificateForm.expiryDate || null,
|
||||
certificateNo: certificateForm.certificateNo.trim() ? certificateForm.certificateNo.trim() : null,
|
||||
attachmentPath: certificateForm.attachmentPath.trim() ? certificateForm.attachmentPath.trim() : null,
|
||||
};
|
||||
|
||||
if (editingCertificateId) {
|
||||
return patchEmployeeCertificate(employeeId, editingCertificateId, payload);
|
||||
}
|
||||
|
||||
return createEmployeeCertificate(employeeId, payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["employee-certificates", employeeId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["employee-stats"] }),
|
||||
]);
|
||||
setEditingCertificateId(null);
|
||||
setCertificateForm(buildEmptyCertificateForm());
|
||||
toast.success(editingCertificateId ? "证书记录已更新" : "证书记录已新增");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, editingCertificateId ? "更新证书记录失败" : "新增证书记录失败")),
|
||||
});
|
||||
|
||||
if (profileQuery.isLoading) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
||||
}
|
||||
@@ -272,10 +469,14 @@ export function EmployeeProfilePage() {
|
||||
];
|
||||
const directReports = profile.organization.directReports;
|
||||
const contracts = contractsQuery.data?.items ?? [];
|
||||
const trainings = trainingsQuery.data?.items ?? [];
|
||||
const certificates = certificatesQuery.data?.items ?? [];
|
||||
|
||||
function resetContractEditor() {
|
||||
setEditingContractId(null);
|
||||
setContractForm(buildEmptyContractForm());
|
||||
setContractAttachmentFile(null);
|
||||
setContractAttachmentInputKey((current) => current + 1);
|
||||
}
|
||||
|
||||
function startEditingContract(contract: EmployeeContract) {
|
||||
@@ -286,8 +487,77 @@ export function EmployeeProfilePage() {
|
||||
startDate: contract.startDate ?? "",
|
||||
endDate: contract.endDate ?? "",
|
||||
terminatedDate: contract.terminatedDate ?? "",
|
||||
attachmentPath: contract.attachmentPath ?? "",
|
||||
});
|
||||
setContractAttachmentFile(null);
|
||||
setContractAttachmentInputKey((current) => current + 1);
|
||||
}
|
||||
|
||||
function resetTrainingEditor() {
|
||||
setEditingTrainingId(null);
|
||||
setTrainingForm(buildEmptyTrainingForm());
|
||||
}
|
||||
|
||||
function startEditingTraining(training: EmployeeTraining) {
|
||||
setEditingTrainingId(training.id);
|
||||
setTrainingForm({
|
||||
name: training.name,
|
||||
trainingType: training.trainingType,
|
||||
startDate: training.startDate ?? "",
|
||||
endDate: training.endDate ?? "",
|
||||
status: training.status,
|
||||
notes: training.notes ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function resetCertificateEditor() {
|
||||
setEditingCertificateId(null);
|
||||
setCertificateForm(buildEmptyCertificateForm());
|
||||
}
|
||||
|
||||
function startEditingCertificate(certificate: EmployeeCertificate) {
|
||||
setEditingCertificateId(certificate.id);
|
||||
setCertificateForm({
|
||||
name: certificate.name,
|
||||
issuer: certificate.issuer,
|
||||
obtainedDate: certificate.obtainedDate ?? "",
|
||||
expiryDate: certificate.expiryDate ?? "",
|
||||
certificateNo: certificate.certificateNo ?? "",
|
||||
attachmentPath: certificate.attachmentPath ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDownloadContractAttachment(contract: EmployeeContract) {
|
||||
if (!contract.attachmentUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDownloadingContractId(contract.id);
|
||||
const response = await downloadEmployeeContractAttachment(contract.attachmentUrl);
|
||||
downloadBlob(response.blob, response.filename ?? `${contract.contractNo}.bin`);
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, "下载合同附件失败"));
|
||||
} finally {
|
||||
setDownloadingContractId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteAvatar() {
|
||||
if (!window.confirm("确认删除当前头像?")) {
|
||||
return;
|
||||
}
|
||||
deleteAvatarMutation.mutate();
|
||||
}
|
||||
|
||||
function handleDeleteContract(contract: EmployeeContract) {
|
||||
if (contract.status === "active") {
|
||||
toast.error("生效中的合同需先终止后再删除");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除合同 ${contract.contractNo}?`)) {
|
||||
return;
|
||||
}
|
||||
deleteContractMutation.mutate(contract.id);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -316,6 +586,7 @@ export function EmployeeProfilePage() {
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
<TabsTrigger value="profile">档案</TabsTrigger>
|
||||
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
|
||||
{canManageEmployeeProfile ? <TabsTrigger value="trainingCertificates">培训与证书</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
@@ -349,6 +620,14 @@ export function EmployeeProfilePage() {
|
||||
<Button type="button" variant="secondary" onClick={() => avatarMutation.mutate()} disabled={!avatarFile || avatarMutation.isPending}>
|
||||
{avatarMutation.isPending ? "上传中..." : "上传头像"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleDeleteAvatar}
|
||||
disabled={!profile.employee.avatarUrl || deleteAvatarMutation.isPending}
|
||||
>
|
||||
{deleteAvatarMutation.isPending ? "删除中..." : "删除头像"}
|
||||
</Button>
|
||||
<div className="text-xs text-zinc-500">支持 jpg/png/webp,最大 2MB,自动裁剪为 200x200</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -615,7 +894,7 @@ export function EmployeeProfilePage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">合同列表</h3>
|
||||
<div className="mt-1 text-sm text-zinc-500">状态由终止日期与结束日期自动推导,本轮仅支持新增与编辑。</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">状态由终止日期与结束日期自动推导,附件支持上传/下载,active 合同需先终止后删除。</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">{contracts.length} 份合同</div>
|
||||
</div>
|
||||
@@ -628,7 +907,7 @@ export function EmployeeProfilePage() {
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>日期</TableHead>
|
||||
<TableHead>附件路径</TableHead>
|
||||
<TableHead>附件</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -645,11 +924,39 @@ export function EmployeeProfilePage() {
|
||||
<div>{contract.startDate ?? "-"} ~ {contract.endDate ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">终止:{contract.terminatedDate ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px] truncate">{contract.attachmentPath ?? "-"}</TableCell>
|
||||
<TableCell className="max-w-[220px]">
|
||||
{contract.attachmentUrl ? (
|
||||
<div className="space-y-2">
|
||||
<div className="truncate text-xs text-zinc-500">{contract.attachmentPath ?? "-"}</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDownloadContractAttachment(contract)}
|
||||
disabled={downloadingContractId === contract.id}
|
||||
>
|
||||
{downloadingContractId === contract.id ? "下载中..." : "下载附件"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">-</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingContract(contract)}>
|
||||
编辑
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingContract(contract)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDeleteContract(contract)}
|
||||
disabled={deleteContractMutation.isPending || contract.status === "active"}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -668,7 +975,7 @@ export function EmployeeProfilePage() {
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editingContractId ? "编辑合同" : "新增合同"}</h3>
|
||||
<div className="mt-1 text-sm text-zinc-500">附件本轮只保存路径字符串,不做上传。</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">新合同保存后再上传附件;附件仅支持 PDF/JPG/PNG,最大 10MB。</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
||||
@@ -702,14 +1009,44 @@ export function EmployeeProfilePage() {
|
||||
<label className="text-sm text-zinc-500">终止日期</label>
|
||||
<Input type="date" value={contractForm.terminatedDate} onChange={(event) => setContractForm((prev) => ({ ...prev, terminatedDate: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">附件路径</label>
|
||||
<Input value={contractForm.attachmentPath} onChange={(event) => setContractForm((prev) => ({ ...prev, attachmentPath: event.target.value }))} placeholder="例如:contracts/labor/E1001.pdf" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingContractId ? (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div className="text-sm font-medium text-zinc-900">附件管理</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
当前附件:{contracts.find((item) => item.id === editingContractId)?.attachmentPath ?? "未上传"}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<Input
|
||||
key={contractAttachmentInputKey}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
onChange={(event) => setContractAttachmentFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => contractAttachmentMutation.mutate()}
|
||||
disabled={!contractAttachmentFile || contractAttachmentMutation.isPending}
|
||||
>
|
||||
{contractAttachmentMutation.isPending ? "上传中..." : "上传/替换附件"}
|
||||
</Button>
|
||||
<div className="text-xs text-zinc-500">同扩展名覆盖,变更扩展名时自动清理旧文件。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-zinc-300 px-4 py-3 text-sm text-zinc-500">请先保存合同,再进行附件上传。</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={() => contractMutation.mutate()} disabled={contractMutation.isPending}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => contractMutation.mutate()}
|
||||
disabled={contractMutation.isPending || !contractForm.contractNo.trim() || !contractForm.startDate || !contractForm.endDate}
|
||||
>
|
||||
{contractMutation.isPending ? "保存中..." : editingContractId ? "保存修改" : "新增合同"}
|
||||
</Button>
|
||||
{editingContractId ? (
|
||||
@@ -722,6 +1059,255 @@ export function EmployeeProfilePage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canManageEmployeeProfile ? (
|
||||
<TabsContent value="trainingCertificates" className="space-y-6">
|
||||
{trainingsQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{errorMessage(trainingsQuery.error, "加载培训记录失败")}
|
||||
</div>
|
||||
) : null}
|
||||
{certificatesQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{errorMessage(certificatesQuery.error, "加载证书记录失败")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
||||
<div className="card-std p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">培训记录</h3>
|
||||
<div className="mt-1 text-sm text-zinc-500">记录员工已计划、进行中和已完成培训。</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">{trainings.length} 条记录</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>日期</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{trainings.length ? (
|
||||
trainings.map((training) => (
|
||||
<TableRow key={training.id}>
|
||||
<TableCell>{training.name}</TableCell>
|
||||
<TableCell>{trainingTypeLabel(training.trainingType)}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={training.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{training.startDate ?? "-"} ~ {training.endDate ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px] truncate">{training.notes ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingTraining(training)}>
|
||||
编辑
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-3 text-zinc-500" colSpan={6}>
|
||||
{trainingsQuery.isLoading ? "加载培训记录中..." : "暂无培训记录"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editingTrainingId ? "编辑培训记录" : "新增培训记录"}</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">培训名称</label>
|
||||
<Input value={trainingForm.name} onChange={(event) => setTrainingForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">培训类型</label>
|
||||
<select
|
||||
className="select-std"
|
||||
value={trainingForm.trainingType}
|
||||
onChange={(event) => setTrainingForm((prev) => ({ ...prev, trainingType: event.target.value as EmployeeTrainingType }))}
|
||||
>
|
||||
{TRAINING_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">开始日期</label>
|
||||
<Input type="date" value={trainingForm.startDate} onChange={(event) => setTrainingForm((prev) => ({ ...prev, startDate: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">结束日期</label>
|
||||
<Input type="date" value={trainingForm.endDate} onChange={(event) => setTrainingForm((prev) => ({ ...prev, endDate: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">状态</label>
|
||||
<select
|
||||
className="select-std"
|
||||
value={trainingForm.status}
|
||||
onChange={(event) => setTrainingForm((prev) => ({ ...prev, status: event.target.value as EmployeeTrainingStatus }))}
|
||||
>
|
||||
{TRAINING_STATUS_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">备注</label>
|
||||
<Textarea value={trainingForm.notes} onChange={(event) => setTrainingForm((prev) => ({ ...prev, notes: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => trainingMutation.mutate()}
|
||||
disabled={trainingMutation.isPending || !trainingForm.name.trim() || !trainingForm.startDate}
|
||||
>
|
||||
{trainingMutation.isPending ? "保存中..." : editingTrainingId ? "保存修改" : "新增培训"}
|
||||
</Button>
|
||||
{editingTrainingId ? (
|
||||
<Button type="button" variant="secondary" onClick={resetTrainingEditor}>
|
||||
取消编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
||||
<div className="card-std p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">证书记录</h3>
|
||||
<div className="mt-1 text-sm text-zinc-500">本轮只保存证书附件路径字符串,不做文件上传。</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">{certificates.length} 条记录</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>机构</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>日期</TableHead>
|
||||
<TableHead>编号 / 附件</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{certificates.length ? (
|
||||
certificates.map((certificate) => (
|
||||
<TableRow key={certificate.id}>
|
||||
<TableCell>{certificate.name}</TableCell>
|
||||
<TableCell>{certificate.issuer}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={certificate.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>取得:{certificate.obtainedDate ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">到期:{certificate.expiryDate ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px]">
|
||||
<div className="truncate">{certificate.certificateNo ?? "-"}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{certificate.attachmentPath ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingCertificate(certificate)}>
|
||||
编辑
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-3 text-zinc-500" colSpan={6}>
|
||||
{certificatesQuery.isLoading ? "加载证书记录中..." : "暂无证书记录"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editingCertificateId ? "编辑证书记录" : "新增证书记录"}</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">证书名称</label>
|
||||
<Input value={certificateForm.name} onChange={(event) => setCertificateForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">颁发机构</label>
|
||||
<Input value={certificateForm.issuer} onChange={(event) => setCertificateForm((prev) => ({ ...prev, issuer: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">获取日期</label>
|
||||
<Input type="date" value={certificateForm.obtainedDate} onChange={(event) => setCertificateForm((prev) => ({ ...prev, obtainedDate: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">到期日期</label>
|
||||
<Input type="date" value={certificateForm.expiryDate} onChange={(event) => setCertificateForm((prev) => ({ ...prev, expiryDate: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">证书编号</label>
|
||||
<Input value={certificateForm.certificateNo} onChange={(event) => setCertificateForm((prev) => ({ ...prev, certificateNo: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">附件路径</label>
|
||||
<Input
|
||||
value={certificateForm.attachmentPath}
|
||||
onChange={(event) => setCertificateForm((prev) => ({ ...prev, attachmentPath: event.target.value }))}
|
||||
placeholder="例如:certificates/pmp.pdf"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => certificateMutation.mutate()}
|
||||
disabled={certificateMutation.isPending || !certificateForm.name.trim() || !certificateForm.issuer.trim() || !certificateForm.obtainedDate}
|
||||
>
|
||||
{certificateMutation.isPending ? "保存中..." : editingCertificateId ? "保存修改" : "新增证书"}
|
||||
</Button>
|
||||
{editingCertificateId ? (
|
||||
<Button type="button" variant="secondary" onClick={resetCertificateEditor}>
|
||||
取消编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ export function PeopleStatsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">在职人数</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.employmentStatusSummary.total}</div>
|
||||
@@ -71,6 +71,18 @@ export function PeopleStatsPage() {
|
||||
{stats.expiringContracts.dateFrom} 至 {stats.expiringContracts.dateTo}
|
||||
</div>
|
||||
</section>
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">持有效证书人数</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.certificateSummary.validHolderCount}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">按 active 员工去重统计有效证书持有人</div>
|
||||
</section>
|
||||
<section className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">30 天内证书到期</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.certificateSummary.expiringSoonCount}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{stats.certificateSummary.dateFrom} 至 {stats.certificateSummary.dateTo}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
|
||||
在新工单中引用
屏蔽一个用户