ATTV1 实现考勤统一入口与企微同步

这个提交包含在:
2026-05-17 16:56:24 +08:00
父节点 d50b7db143
当前提交 916cc3b7f7
共修改 51 个文件,包含 3323 行新增和 1762 行删除
文件差异内容过多而无法显示 加载差异
+2 -1
查看文件
@@ -57,7 +57,8 @@ function toDisplayName(flagKey: string) {
if (flagKey === "workflow_definition_admin_v1") return "流程定义治理";
if (flagKey === "cutover_console_v1") return "切换控制台";
if (flagKey === "presence_board_v1") return "团队状态板";
if (flagKey === "attendance_v1") return "极简考勤";
if (flagKey === "attendance_v1") return "历史 OA 极简打卡";
if (flagKey === "attendance_unified_v1") return "企微考勤统一汇总";
if (flagKey === "weekly_report_v1") return "周报工作台";
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
if (flagKey === "todo_board_v1") return "待办工作台";
+16 -1
查看文件
@@ -53,7 +53,7 @@ type MobileAction = "approve" | "reject" | "return" | "transfer";
type MobileTaskTab = "pending" | "completed";
type EmployeeProgressTab = "draft" | "reviewing" | "approved" | "returned";
type EmployeeProgressQueryStatus = "draft" | "reviewing" | "approved" | "rejected";
type EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request";
type EntityFilter = "all" | "leave" | "reimbursement" | "attendance_correction" | "purchase_request" | "travel_request";
type ReplayClassification = "failed" | "conflicted";
type VisibleTaskCard = {
@@ -85,6 +85,7 @@ const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
{ value: "all", label: "全部" },
{ value: "leave", label: "请假" },
{ value: "reimbursement", label: "报销" },
{ value: "attendance_correction", label: "考勤" },
{ value: "purchase_request", label: "采购" },
{ value: "travel_request", label: "差旅" },
];
@@ -118,6 +119,9 @@ function entityLabel(entityType: string | undefined | null) {
if (entityType === "reimbursement") {
return "报销";
}
if (entityType === "attendance_correction") {
return "考勤";
}
if (entityType === "purchase_request") {
return "采购";
}
@@ -596,12 +600,14 @@ export function MobileApprovalsPage() {
const selectedApplicantName = selectedTaskCard?.applicantName ??
selectedTask?.reimbursement?.submitter?.name ??
selectedTask?.leaveRequest?.owner?.name ??
selectedTask?.attendanceCorrection?.owner?.name ??
selectedTask?.purchaseRequest?.applicant?.name ??
selectedTask?.travelRequest?.applicant?.name ??
null;
const selectedMetric = selectedTaskCard?.metric ??
selectedTask?.reimbursement?.totalAmount ??
(selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ??
(selectedTask?.attendanceCorrection ? selectedTask.attendanceCorrection.workDate : null) ??
selectedTask?.purchaseRequest?.totalAmount ??
selectedTask?.travelRequest?.estimatedAmount ??
null;
@@ -1387,6 +1393,15 @@ export function MobileApprovalsPage() {
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
</div>
) : null}
{selectedTask.attendanceCorrection ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([
`类型:${entityLabel("attendance_correction")}`,
`日期:${selectedTask.attendanceCorrection.workDate ?? "-"}`,
selectedTask.attendanceCorrection.reason ?? null,
])}
</div>
) : null}
{selectedTask.purchaseRequest ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])}
+19 -142
查看文件
@@ -2,12 +2,6 @@ import { useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
clockInAttendance,
clockOutAttendance,
fetchMyTodayAttendance,
type AttendanceTodayResponse,
} from "../api/attendance";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
import { fetchPortalHome } from "../api/portal";
@@ -49,10 +43,6 @@ function resolveErrorMessage(error: unknown) {
return "请求失败,请稍后重试。";
}
function isAttendanceFeatureDisabled(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
function formatDateTime(value: string | null) {
if (!value) {
return "-";
@@ -70,30 +60,6 @@ function formatDateTime(value: string | null) {
}
}
function formatShortTime(value: string | null) {
if (!value) {
return "--:--";
}
try {
return new Date(value).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
} catch {
return value;
}
}
function formatWorkMinutes(value: number | null | undefined) {
if (value == null) {
return "-";
}
const hours = Math.floor(value / 60);
const minutes = value % 60;
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function notificationTypeLabel(type: string) {
if (type.startsWith("workflow.")) return "审批事件";
if (type.startsWith("reimbursement.")) return "报销事件";
@@ -104,31 +70,6 @@ function notificationTypeLabel(type: string) {
return "系统事件";
}
function attendanceExemptionLabel(reason: unknown) {
if (reason === "leave") return "今日请假";
if (reason === "travel") return "今日差旅";
if (reason === "holiday") return "节假日";
if (reason === "weekend") return "周末";
if (reason === "policy_disabled") return "无需打卡";
return "无需打卡";
}
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
if (!attendanceToday) {
return null;
}
if (!attendanceToday.needClockIn) {
return null;
}
if (!attendanceToday.item?.clockInAt) {
return "clock-in" as const;
}
if (!attendanceToday.item.clockOutAt) {
return "clock-out" as const;
}
return null;
}
function ActionTile({ action }: { action: MobileAction }) {
const isPrimary = action.tone === "primary";
const isWarning = action.tone === "warning";
@@ -176,12 +117,6 @@ export function MobilePortalPage() {
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
const currentMobileSource = `${location.pathname}${location.search}`;
const attendanceTodayQuery = useQuery({
queryKey: ["mobile", "portal", "attendance", "today"],
queryFn: () => fetchMyTodayAttendance(),
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
});
const pendingCountQuery = useQuery({
enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "pending-items"],
@@ -229,39 +164,6 @@ export function MobilePortalPage() {
},
});
const attendanceClockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async (response) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success(response.item.isLate ? `上班打卡成功,迟到 ${response.item.lateMinutes} 分钟` : "上班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const attendanceClockOutMutation = useMutation({
mutationFn: () => clockOutAttendance(),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success("下班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const attendanceToday = attendanceTodayQuery.data ?? null;
const attendanceAction = resolveAttendanceAction(attendanceToday);
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const pendingApprovalCount = canAccessApprovals
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
: 0;
@@ -320,6 +222,13 @@ export function MobilePortalPage() {
to: "/mobile/leave?view=form",
}
: null,
{
key: "attendance",
label: "考勤",
meta: "汇总修正",
icon: "schedule",
to: "/attendance",
},
canAccessPurchase
? {
key: "purchase",
@@ -443,50 +352,18 @@ export function MobilePortalPage() {
</div>
</section>
{!attendanceFeatureDisabled ? (
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-card">
<div className="mobile-section-header">
<div className="mobile-section-title">打卡</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div>
{attendanceTodayQuery.isLoading ? (
<p className="text-sm text-zinc-500">读取中...</p>
) : attendanceTodayQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(attendanceTodayQuery.error)}
</p>
) : attendanceToday && !attendanceToday.needClockIn ? (
<div className="text-sm text-zinc-600">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
<div className="text-sm text-zinc-600">
{formatShortTime(attendanceToday.item.clockInAt)} - {formatShortTime(attendanceToday.item.clockOutAt)}
<span className="ml-2 text-zinc-400">工时 {formatWorkMinutes(attendanceToday.item.workMinutes)}</span>
</div>
) : attendanceToday?.item?.clockInAt ? (
<div className="text-sm text-zinc-600">已上班 {formatShortTime(attendanceToday.item.clockInAt)}</div>
) : (
<div className="text-sm text-zinc-600">今天还没打卡</div>
)}
{attendanceAction ? (
<Button
type="button"
className="mt-3 w-full"
disabled={attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending}
onClick={() => {
if (attendanceAction === "clock-in") {
attendanceClockInMutation.mutate();
} else {
attendanceClockOutMutation.mutate();
}
}}
>
{attendanceAction === "clock-in" ? "上班打卡" : "下班打卡"}
</Button>
) : null}
</section>
) : null}
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
<div className="mobile-section-header">
<div className="mobile-section-title">考勤</div>
<span className="badge-neutral">企业微信打卡</span>
</div>
<div className="text-sm leading-6 text-zinc-600">
日常打卡在企业微信完成;补卡、外出、加班和月度解释在 OA 处理。
</div>
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/attendance">查看考勤汇总</Link>
</Button>
</section>
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
<div className="mobile-section-header">
+11 -195
查看文件
@@ -1,13 +1,6 @@
import { type ReactNode, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
clockInAttendance,
clockOutAttendance,
fetchMyTodayAttendance,
type AttendanceTodayResponse,
} from "../api/attendance";
import { useQuery } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchPortalHome,
@@ -141,20 +134,6 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
return payload.status === "submitted" ? "已提交" : "待填写";
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
}
function isAttendanceFeatureDisabled(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
function formatRelativeTime(value: string | null) {
if (!value) {
return "-";
@@ -184,30 +163,6 @@ function formatRelativeTime(value: string | null) {
return `${diffDays} 天前`;
}
function formatShortTime(value: string | null) {
if (!value) {
return "--:--";
}
try {
return new Date(value).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
} catch {
return value;
}
}
function formatWorkMinutes(value: number | null | undefined) {
if (value == null) {
return "-";
}
const hours = Math.floor(value / 60);
const minutes = value % 60;
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function buildTodoAggregateItems(payload: PortalHomePayload) {
const cards = Array.isArray(payload.cards) ? payload.cards : [];
const items: TodoAggregateItem[] = [];
@@ -283,11 +238,8 @@ function resolveTeamStatusBadge(item: PortalTeamStatusItem) {
if (item.presenceStatus === "remote") {
return { label: "居家", className: "badge-neutral" };
}
if (!item.attendanceClockIn) {
return { label: "未打卡", className: "badge-neutral" };
}
return {
label: "已打卡",
label: "考勤",
className: "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
};
}
@@ -296,12 +248,6 @@ function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
if (item.onLeave) {
return item.leaveType ? `${item.leaveType} 已审批` : "今日请假";
}
if (item.attendanceClockIn && item.attendanceClockOut) {
return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`;
}
if (item.attendanceClockIn) {
return `${formatShortTime(item.attendanceClockIn)} 已打卡`;
}
if (item.presenceStatus === "travel") {
return "今日差旅";
}
@@ -311,80 +257,7 @@ function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
if (item.presenceStatus === "remote") {
return "今日居家办公";
}
return "暂无打卡记录";
}
function resolveClockAction(
attendanceToday: AttendanceTodayResponse | null,
state: {
isLoading: boolean;
isError: boolean;
featureDisabled: boolean;
pending: boolean;
},
) {
if (state.pending) {
return {
label: "处理中...",
description: "正在提交考勤动作",
disabled: true,
action: null,
} as const;
}
if (state.isLoading) {
return {
label: "打卡读取中",
description: "正在读取今日考勤状态",
disabled: true,
action: null,
} as const;
}
if (state.isError && !state.featureDisabled) {
return {
label: "打卡不可用",
description: "当前无法读取今日考勤状态",
disabled: true,
action: null,
} as const;
}
if (!attendanceToday) {
return {
label: "今日考勤",
description: "暂无可用考勤数据",
disabled: true,
action: null,
} as const;
}
if (!attendanceToday.needClockIn) {
return {
label: "今日无需打卡",
description: "系统已自动排除缺卡统计",
disabled: true,
action: null,
} as const;
}
if (!attendanceToday.item?.clockInAt) {
return {
label: "上班打卡",
description: "记录今日到岗时间",
disabled: false,
action: "clock-in",
} as const;
}
if (!attendanceToday.item.clockOutAt) {
return {
label: "下班打卡",
description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`,
disabled: false,
action: "clock-out",
} as const;
}
return {
label: "今日已完成",
description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`,
disabled: true,
action: null,
} as const;
return "企微考勤见考勤页";
}
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
@@ -921,16 +794,10 @@ function ActivityAnnouncementsPanel({
}
export function PortalHomePage() {
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
const [showWelcome, setShowWelcome] = useState(false);
const [showGettingStarted, setShowGettingStarted] = useState(false);
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
const attendanceTodayQuery = useQuery({
queryKey: ["portal-home", "attendance", "today"],
queryFn: () => fetchMyTodayAttendance(),
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
});
const workflowSummaryQuery = useQuery({
enabled: hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
queryKey: ["workflow", "tasks", "summary", "mine"],
@@ -980,42 +847,6 @@ export function PortalHomePage() {
];
const gettingStartedCompletedCount = gettingStartedItems.filter((item) => item.completed).length;
const attendanceClockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async (response) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success(
response.item.isLate
? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟`
: "上班打卡成功",
);
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const attendanceClockOutMutation = useMutation({
mutationFn: () => clockOutAttendance(),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success("下班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
if (portalQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
}
@@ -1052,14 +883,6 @@ export function PortalHomePage() {
const primaryCards = pickCards(cards, portalSchema.heroCards);
const primaryCardKeySet = primaryCards.map((card) => card.key);
const secondaryCards = compactCards(cards, primaryCardKeySet);
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const attendanceToday = attendanceTodayQuery.data ?? null;
const clockAction = resolveClockAction(attendanceToday, {
isLoading: attendanceTodayQuery.isLoading,
isError: attendanceTodayQuery.isError,
featureDisabled: attendanceFeatureDisabled,
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
});
const todoAggregateItems = buildTodoAggregateItems({ ...payload, cards });
function handleCloseWelcome() {
@@ -1222,20 +1045,13 @@ export function PortalHomePage() {
to: "/reimbursements",
});
}
if (!attendanceFeatureDisabled) {
quickActions.push({
key: "attendance",
icon: clockAction.action === "clock-out" ? "logout" : "schedule",
label: clockAction.label,
description: clockAction.description,
disabled: clockAction.disabled,
onClick: clockAction.action === "clock-in"
? () => attendanceClockInMutation.mutate()
: clockAction.action === "clock-out"
? () => attendanceClockOutMutation.mutate()
: undefined,
});
}
quickActions.push({
key: "attendance",
icon: "schedule",
label: "考勤汇总",
description: "查看企微打卡摘要和 OA 修正",
to: "/attendance",
});
if (payload.weeklyReport || payload.weeklySummary) {
quickActions.push({
key: "weekly",
@@ -1262,7 +1078,7 @@ export function PortalHomePage() {
? "配置、审计、通知。"
: isManagerView
? "审批、团队、周报。"
: "待办、打卡、申请。"}
: "待办、申请、考勤。"}
actions={
!payload.workspace.persisted && canSetupWorkspace ? (
<Button asChild variant="secondary" type="button">
@@ -37,6 +37,9 @@ function entityTitle(entityType: string | undefined) {
if (entityType === "reimbursement") {
return "报销单";
}
if (entityType === "attendance_correction") {
return "考勤修正";
}
if (entityType === "purchase_request") {
return "采购申请";
}
@@ -46,6 +49,13 @@ function entityTitle(entityType: string | undefined) {
return entityType || "业务单据";
}
function correctionKindLabel(kind: string | undefined | null) {
if (kind === "missed_punch") return "补卡";
if (kind === "out") return "外出";
if (kind === "overtime") return "加班";
return "考勤修正";
}
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
@@ -306,6 +316,25 @@ export function WorkflowTaskDetailPage() {
<DetailField label="附件" value={detail.leaveRequest.attachment?.originalName ?? "-"} />
</>
) : null}
{detail.attendanceCorrection ? (
<>
<DetailField label="修正类型" value={correctionKindLabel(detail.attendanceCorrection.kind)} />
<DetailField label="申请人" value={detail.attendanceCorrection.owner?.name ?? "-"} />
<DetailField label="考勤日期" value={detail.attendanceCorrection.workDate ?? "-"} />
<DetailField label="状态" value={workflowInstanceStatusLabel(detail.attendanceCorrection.status)} />
<DetailField
label="时间"
value={
detail.attendanceCorrection.kind === "missed_punch"
? formatDateTime(detail.attendanceCorrection.missedPunchAt)
: `${formatDateTime(detail.attendanceCorrection.startAt)} → ${formatDateTime(detail.attendanceCorrection.endAt)}`
}
/>
<DetailField label="外出地点" value={detail.attendanceCorrection.locationSummary ?? "-"} />
<DetailField label="补偿口径" value={detail.attendanceCorrection.compensationPolicy ?? "-"} />
<DetailField label="说明" value={detail.attendanceCorrection.reason ?? "-"} />
</>
) : null}
{detail.purchaseRequest ? (
<>
<DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} />
+4
查看文件
@@ -42,6 +42,7 @@ const WORKFLOW_ENTITY_OPTIONS = [
{ value: "all", label: "全部业务" },
{ value: "reimbursement", label: "报销" },
{ value: "leave", label: "请假" },
{ value: "attendance_correction", label: "考勤" },
{ value: "purchase_request", label: "采购" },
{ value: "travel_request", label: "差旅" },
] as const;
@@ -68,6 +69,9 @@ function entityLabel(entityType: string | undefined) {
if (entityType === "reimbursement") {
return "报销";
}
if (entityType === "attendance_correction") {
return "考勤";
}
if (entityType === "purchase_request") {
return "采购";
}