HRWD-0..9 实现人事生命周期企微通讯录同步

这个提交包含在:
2026-05-17 21:01:28 +08:00
父节点 778132760f
当前提交 9ae42dadc1
共修改 24 个文件,包含 1553 行新增和 123 行删除
+54
查看文件
@@ -7,6 +7,31 @@ 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 WecomDirectorySyncOperation = "create" | "update" | "disable";
export type WecomDirectorySyncStatus = "running" | "success" | "failed" | "skipped";
export type WecomDirectoryStateStatus = "unbound" | "bound" | "synced" | "sync_success" | "sync_skipped" | "sync_failed";
export type WecomDirectorySyncRun = {
id: string;
employeeId: string;
externalUserId: string | null;
operation: WecomDirectorySyncOperation;
status: WecomDirectorySyncStatus;
attempts: number;
errorCode: string | null;
errorMessage: string | null;
responseSummary: Record<string, unknown> | null;
triggeredBy: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type WecomDirectoryState = {
status: WecomDirectoryStateStatus;
externalUserId: string | null;
bound: boolean;
latestSync: WecomDirectorySyncRun | null;
};
export type EmployeeDepartmentSummary = {
id: string;
@@ -31,6 +56,7 @@ export type EmployeeSummary = {
roleCodes: string[];
createdAt: string | null;
updatedAt: string | null;
wecomDirectory?: WecomDirectoryState;
};
export type EmployeeListResponse = {
@@ -175,6 +201,7 @@ export type EmployeeProfile = {
roleCodes: string[];
department: EmployeeDepartmentSummary | null;
directManager: { id: string; employeeNo: string; name: string; avatarUrl: string | null } | null;
wecomDirectory?: WecomDirectoryState;
};
lifecycle: {
events: EmployeeLifecycleEvent[];
@@ -403,6 +430,33 @@ export async function fetchEmployeeLifecycle(employeeId: string) {
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/lifecycle`);
}
export async function fetchEmployeeWecomDirectorySync(employeeId: string) {
return apiRequestData<{
employeeId: string;
wecomDirectory: WecomDirectoryState;
items: WecomDirectorySyncRun[];
}>(`/api/employees/${employeeId}/wecom-directory-sync`);
}
export async function retryEmployeeWecomDirectorySync(employeeId: string, operation?: WecomDirectorySyncOperation) {
return apiRequestData<{
employeeId: string;
operation: WecomDirectorySyncOperation;
result: {
ok: boolean;
skipped: boolean;
status: WecomDirectorySyncStatus;
code: string;
message: string | null;
run: WecomDirectorySyncRun | null;
} | null;
wecomDirectory: WecomDirectoryState;
}>(`/api/employees/${employeeId}/wecom-directory-sync/retry`, {
method: "POST",
body: JSON.stringify(operation ? { operation } : {}),
});
}
export async function fetchEmployeeOrganization() {
return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization");
}
+77 -1
查看文件
@@ -18,6 +18,7 @@ import {
patchEmployeeProfile,
patchEmployeeTraining,
regularizeEmployee,
retryEmployeeWecomDirectorySync,
skipEmployeeChecklistTask,
startEmployeeOffboarding,
transferEmployee,
@@ -29,6 +30,7 @@ import {
type EmployeeTraining,
type EmployeeTrainingStatus,
type EmployeeTrainingType,
type WecomDirectoryState,
} from "../api/employees";
import { fetchEmployeePerformanceTimeline } from "../api/performance";
import { useAuth } from "../auth/AuthContext";
@@ -151,6 +153,24 @@ function trainingTypeLabel(type: EmployeeTrainingType) {
return TRAINING_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
}
function wecomDirectoryStatusView(state?: WecomDirectoryState) {
const errorCode = state?.latestSync?.errorCode ?? "";
if (!state) return { label: "未同步", status: "not_triggered", hint: "无记录" };
if (state.status === "synced" || state.status === "sync_success") {
return { label: "同步成功", status: "succeeded", hint: state.externalUserId ?? "已绑定" };
}
if (state.status === "bound") return { label: "已绑定", status: "bound", hint: state.externalUserId ?? "已绑定" };
if (state.status === "sync_failed") return { label: "同步失败", status: "failed", hint: state.latestSync?.errorCode ?? "待重试" };
if (state.status === "sync_skipped") {
return {
label: errorCode === "wecom_contact_config_missing" || errorCode === "wecom_contact_sync_disabled" ? "配置缺失" : "已跳过",
status: "skipped",
hint: state.latestSync?.errorCode ?? "未执行",
};
}
return { label: "未绑定", status: "not_triggered", hint: "待创建" };
}
export function EmployeeProfilePage() {
const { employeeId = "" } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
@@ -181,6 +201,7 @@ export function EmployeeProfilePage() {
const [certificateForm, setCertificateForm] = useState<CertificateFormState>(buildEmptyCertificateForm());
const [deleteAvatarConfirmOpen, setDeleteAvatarConfirmOpen] = useState(false);
const [deleteContractTarget, setDeleteContractTarget] = useState<EmployeeContract | null>(null);
const [offboardingConfirmOpen, setOffboardingConfirmOpen] = useState(false);
const [form, setForm] = useState({
title: "",
hireDate: "",
@@ -362,12 +383,34 @@ export function EmployeeProfilePage() {
const offboardingMutation = useMutation({
mutationFn: () => startEmployeeOffboarding(employeeId),
onSuccess: async () => {
setOffboardingConfirmOpen(false);
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
toast.success("已发起离职");
},
onError: (error) => toast.error(errorMessage(error, "离职失败")),
});
const wecomDirectoryRetryMutation = useMutation({
mutationFn: () => retryEmployeeWecomDirectorySync(employeeId),
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }),
queryClient.invalidateQueries({ queryKey: ["employees"] }),
]);
const code = result.result?.code;
if (result.result?.skipped) {
toast.warning(code ? `企微同步已跳过:${code}` : "企微同步已跳过");
return;
}
if (result.result?.status === "failed") {
toast.error(code ? `企微同步失败:${code}` : "企微同步失败");
return;
}
toast.success("企微同步已提交");
},
onError: (error) => toast.error(errorMessage(error, "企微同步重试失败")),
});
const contractMutation = useMutation({
mutationFn: async () => {
const payload = {
@@ -575,6 +618,7 @@ export function EmployeeProfilePage() {
const contracts = contractsQuery.data?.items ?? [];
const trainings = trainingsQuery.data?.items ?? [];
const certificates = certificatesQuery.data?.items ?? [];
const wecomDirectoryView = wecomDirectoryStatusView(profile.employee.wecomDirectory);
function resetContractEditor() {
setEditingContractId(null);
@@ -673,7 +717,7 @@ export function EmployeeProfilePage() {
<Button type="button" variant="secondary" onClick={() => transferMutation.mutate()} disabled={transferMutation.isPending}>
记录调岗
</Button>
<Button type="button" onClick={() => offboardingMutation.mutate()} disabled={offboardingMutation.isPending}>
<Button type="button" onClick={() => setOffboardingConfirmOpen(true)} disabled={offboardingMutation.isPending}>
发起离职
</Button>
</div>
@@ -760,6 +804,28 @@ export function EmployeeProfilePage() {
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm text-zinc-500">企业微信通讯录</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<StatusBadge status={wecomDirectoryView.status} label={wecomDirectoryView.label} />
<span className="text-sm text-zinc-500">{wecomDirectoryView.hint}</span>
</div>
</div>
{canManageEmployeeProfile ? (
<Button
type="button"
variant="secondary"
onClick={() => wecomDirectoryRetryMutation.mutate()}
disabled={wecomDirectoryRetryMutation.isPending}
>
{wecomDirectoryRetryMutation.isPending ? "同步中..." : "重试同步"}
</Button>
) : null}
</div>
</div>
{canEditExtendedFields ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
@@ -1459,6 +1525,16 @@ export function EmployeeProfilePage() {
}
}}
/>
<ActionConfirmDialog
open={offboardingConfirmOpen}
onOpenChange={setOffboardingConfirmOpen}
kind="delete"
targetName={profile.employee.name}
description="将发起离职清单;清单完成后会禁用 OA 账号并尝试禁用企业微信成员。"
onConfirm={async () => {
offboardingMutation.mutate();
}}
/>
</>
);
}
+165 -64
查看文件
@@ -9,9 +9,12 @@ import {
fetchEmployees,
fetchEmployeeOrganization,
regularizeEmployee,
retryEmployeeWecomDirectorySync,
startEmployeeOffboarding,
startEmployeeOnboarding,
type EmployeeEmploymentStatus,
type EmployeeSummary,
type WecomDirectoryState,
type OrganizationTreeNode,
} from "../api/employees";
import { exportEmployees } from "../api/exports";
@@ -59,6 +62,34 @@ function employmentStatusLabel(status: EmployeeEmploymentStatus | null) {
return EMPLOYMENT_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? "-";
}
function wecomDirectoryStatusView(state?: WecomDirectoryState) {
const errorCode = state?.latestSync?.errorCode ?? "";
if (!state) {
return { label: "未同步", status: "not_triggered", hint: "无记录" };
}
if (state.status === "synced" || state.status === "sync_success") {
return { label: "同步成功", status: "succeeded", hint: state.externalUserId ?? "已绑定" };
}
if (state.status === "bound") {
return { label: "已绑定", status: "bound", hint: state.externalUserId ?? "已绑定" };
}
if (state.status === "sync_failed") {
return { label: "同步失败", status: "failed", hint: state.latestSync?.errorCode ?? "待重试" };
}
if (state.status === "sync_skipped") {
return {
label: errorCode === "wecom_contact_config_missing" || errorCode === "wecom_contact_sync_disabled" ? "配置缺失" : "已跳过",
status: "skipped",
hint: state.latestSync?.errorCode ?? "未执行",
};
}
return { label: "未绑定", status: "not_triggered", hint: "待创建" };
}
function shouldShowWecomRetry(state?: WecomDirectoryState) {
return !state || ["unbound", "sync_failed", "sync_skipped"].includes(state.status);
}
function collectOrganizationEmployees(nodes: OrganizationTreeNode[], target: Map<string, { id: string; name: string; employeeNo: string; departmentName: string }>) {
for (const node of nodes) {
if (!target.has(node.id)) {
@@ -93,6 +124,7 @@ export function PeoplePage() {
const [managerDialogOpen, setManagerDialogOpen] = useState(false);
const [enableConfirmOpen, setEnableConfirmOpen] = useState(false);
const [disableConfirmOpen, setDisableConfirmOpen] = useState(false);
const [offboardingTarget, setOffboardingTarget] = useState<EmployeeSummary | null>(null);
const [targetDepartmentId, setTargetDepartmentId] = useState("");
const [targetManagerId, setTargetManagerId] = useState("");
@@ -161,6 +193,7 @@ export function PeoplePage() {
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
onSuccess: async () => {
await invalidatePeopleQueries();
setOffboardingTarget(null);
toast.success("已发起离职流程");
},
onError: (error) => {
@@ -168,6 +201,26 @@ export function PeoplePage() {
},
});
const wecomDirectoryRetryMutation = useMutation({
mutationFn: (employeeId: string) => retryEmployeeWecomDirectorySync(employeeId),
onSuccess: async (result) => {
await invalidatePeopleQueries();
const code = result.result?.code;
if (result.result?.skipped) {
toast.warning(code ? `企微同步已跳过:${code}` : "企微同步已跳过");
return;
}
if (result.result?.status === "failed") {
toast.error(code ? `企微同步失败:${code}` : "企微同步失败");
return;
}
toast.success("企微同步已提交");
},
onError: (error) => {
toast.error(mapApiMessage(error, "企微同步重试失败"));
},
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") =>
exportEmployees({
@@ -266,8 +319,9 @@ export function PeoplePage() {
transferDepartmentMutation.isPending ||
setManagerMutation.isPending ||
batchEnableMutation.isPending ||
batchDisableMutation.isPending;
const tableColumnCount = canManageUsers ? 7 : 6;
batchDisableMutation.isPending ||
wecomDirectoryRetryMutation.isPending;
const tableColumnCount = canManageUsers ? 8 : 7;
function toggleSelection(employeeId: string, checked: boolean) {
setSelectedEmployeeIds((current) => {
@@ -470,78 +524,105 @@ export function PeoplePage() {
<TableHead>岗位 / 雇用状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>账号状态</TableHead>
<TableHead>企业微信</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => (
<TableRow key={item.id}>
{canManageUsers ? (
items.map((item) => {
const wecomView = wecomDirectoryStatusView(item.wecomDirectory);
return (
<TableRow key={item.id}>
{canManageUsers ? (
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
</div>
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</div>
</div>
</div>
</TableCell>
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/people/${item.id}`}>查看档案</Link>
</Button>
{canManageUsers ? (
<>
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
发起入职
</Button>
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
转正
</Button>
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
发起离职
</Button>
</>
) : null}
</div>
</TableCell>
</TableRow>
))
</TableCell>
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="space-y-1">
<StatusBadge status={wecomView.status} label={wecomView.label} />
<div className="max-w-40 truncate text-xs text-zinc-500">{wecomView.hint}</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/people/${item.id}`}>查看档案</Link>
</Button>
{canManageUsers ? (
<>
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
发起入职
</Button>
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
转正
</Button>
{shouldShowWecomRetry(item.wecomDirectory) ? (
<Button
size="sm"
variant="secondary"
type="button"
disabled={wecomDirectoryRetryMutation.isPending}
onClick={() => wecomDirectoryRetryMutation.mutate(item.id)}
>
企微重试
</Button>
) : null}
<Button
size="sm"
variant="secondary"
type="button"
disabled={offboardingMutation.isPending}
onClick={() => setOffboardingTarget(item)}
>
发起离职
</Button>
</>
) : null}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
@@ -657,6 +738,26 @@ export function PeoplePage() {
await batchDisableMutation.mutateAsync();
}}
/>
<ConfirmDialog
open={Boolean(offboardingTarget)}
onOpenChange={(open) => {
if (!open) setOffboardingTarget(null);
}}
title="发起离职流程"
description={
offboardingTarget
? `将为 ${offboardingTarget.name} 发起离职清单;清单完成后会禁用 OA 账号并尝试禁用企业微信成员。`
: ""
}
confirmLabel={offboardingMutation.isPending ? "处理中..." : "确认发起"}
variant="destructive"
onConfirm={async () => {
if (offboardingTarget) {
await offboardingMutation.mutateAsync(offboardingTarget.id);
}
}}
/>
</div>
);
}