R59 A1-A6:极简考勤打卡

- 新增 AttendanceRecord 与 AttendancePolicy 模型
- 上下班打卡、今日状态、月度统计端点
- 考勤管理页面(我的/团队/设置三 Tab)
- 移动端快速打卡入口
- 管理员补卡与审计日志
- 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
2026-04-11 21:57:27 +08:00
父节点 ba48e7d1d4
当前提交 677aa6cac3
共修改 25 个文件,包含 3081 行新增和 2 行删除
+159 -1
查看文件
@@ -1,6 +1,12 @@
import { 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,
} from "../api/attendance";
import { fetchNotifications } from "../api/notifications";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
@@ -36,7 +42,10 @@ function resolveErrorMessage(error: unknown) {
return "请求失败,请稍后重试。";
}
function formatDateTime(value: string) {
function formatDateTime(value: string | null) {
if (!value) {
return "-";
}
try {
return new Date(value).toLocaleString("zh-CN");
} catch {
@@ -69,6 +78,28 @@ function canQuickUpdatePresence(item: PresenceItem | null) {
return !(item?.status === "leave" && item.source === "leave_sync");
}
function isAttendanceFeatureDisabled(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
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 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 resolveNotificationTargetPath(
item: { targetType: string | null; targetId: string | null },
canAccessApprovals: boolean,
@@ -123,17 +154,33 @@ function QuickLinkCard({
export function MobilePortalPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [nowLabel, setNowLabel] = useState(() =>
new Date().toLocaleTimeString("zh-CN", { hour12: false }),
);
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
useEffect(() => {
const timer = window.setInterval(() => {
setNowLabel(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
}, 1000);
return () => window.clearInterval(timer);
}, []);
const presenceQuery = useQuery({
queryKey: ["mobile", "portal", "presence"],
queryFn: () => fetchMyPresence(),
});
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-count"],
@@ -165,7 +212,43 @@ 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 presenceItem = presenceQuery.data?.item ?? null;
const attendanceToday = attendanceTodayQuery.data ?? null;
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const notifications = notificationsQuery.data?.items ?? [];
return (
@@ -175,6 +258,81 @@ export function MobilePortalPage() {
subtitle="处理今日考勤、审批和高频入口。"
/>
{!attendanceFeatureDisabled ? (
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">今日考勤</div>
<div className="mt-1 text-xs text-zinc-500">
{attendanceToday?.workDate ?? "今日"} · 当前时间 {nowLabel}
</div>
</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div>
{attendanceTodayQuery.isLoading ? (
<p className="text-sm text-zinc-500">加载今日考勤中...</p>
) : attendanceTodayQuery.isError ? (
<p className="rounded-xl 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="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-base font-semibold text-zinc-900">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
<div className="mt-2 text-sm text-zinc-500">
{attendanceToday.item?.clockInAt || attendanceToday.item?.clockOutAt
? `已有补录记录:${formatDateTime(attendanceToday.item.clockInAt)} / ${formatDateTime(attendanceToday.item.clockOutAt)}`
: "系统已自动排除缺卡统计。"}
</div>
</div>
) : attendanceToday?.item?.clockInAt && !attendanceToday.item.clockOutAt ? (
<div className="space-y-3">
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 text-sm text-amber-700">
已打上班卡:{formatDateTime(attendanceToday.item.clockInAt)}
{attendanceToday.item.isLate ? `,迟到 ${attendanceToday.item.lateMinutes} 分钟` : ""}
</div>
<Button
type="button"
size="lg"
className="w-full bg-amber-500 text-white hover:bg-amber-600"
disabled={attendanceClockOutMutation.isPending}
onClick={() => attendanceClockOutMutation.mutate()}
>
下班打卡
</Button>
</div>
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-base font-semibold text-zinc-900">今日已完成</div>
<div className="mt-2 text-sm text-zinc-600">
上班:{formatDateTime(attendanceToday.item.clockInAt)}
</div>
<div className="mt-1 text-sm text-zinc-600">
下班:{formatDateTime(attendanceToday.item.clockOutAt)}
</div>
<div className="mt-1 text-sm text-zinc-600">
工时:{formatWorkMinutes(attendanceToday.item.workMinutes)}
</div>
</div>
) : (
<div className="space-y-3">
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-700">
今日尚未打上班卡。
</div>
<Button
type="button"
size="lg"
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
disabled={attendanceClockInMutation.isPending}
onClick={() => attendanceClockInMutation.mutate()}
>
上班打卡
</Button>
</div>
)}
</section>
) : null}
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>