R59 A1-A6:极简考勤打卡

- 新增 AttendanceRecord 与 AttendancePolicy 模型
- 上下班打卡、今日状态、月度统计端点
- 考勤管理页面(我的/团队/设置三 Tab)
- 移动端快速打卡入口
- 管理员补卡与审计日志
- 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
2026-04-11 21:57:27 +08:00
父节点 ba48e7d1d4
当前提交 677aa6cac3
共修改 25 个文件,包含 3081 行新增和 2 行删除
+680
查看文件
@@ -0,0 +1,680 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
adminBackfillAttendance,
clockInAttendance,
clockOutAttendance,
fetchAttendancePolicy,
fetchMyMonthAttendance,
fetchMyTodayAttendance,
fetchTeamAttendance,
updateAttendancePolicy,
type AttendanceMonthItem,
type AttendancePolicy,
type AttendanceStatus,
type AttendanceTeamItem,
} from "../api/attendance";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
type AttendanceTab = "mine" | "team" | "settings";
type PolicyFormState = {
workStartTime: string;
workEndTime: string;
lunchStartTime: string;
lunchEndTime: string;
lateToleranceMinutes: string;
};
type BackfillState = {
employeeId: string;
employeeName: string;
workDate: string;
clockInAt: string;
clockOutAt: string;
note: string;
};
function currentDateInput() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function currentMonthInput() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function toPolicyForm(policy?: AttendancePolicy | null): PolicyFormState {
return {
workStartTime: policy?.workStartTime ?? "09:00",
workEndTime: policy?.workEndTime ?? "18:00",
lunchStartTime: policy?.lunchStartTime ?? "12:00",
lunchEndTime: policy?.lunchEndTime ?? "13:00",
lateToleranceMinutes: String(policy?.lateToleranceMinutes ?? 0),
};
}
function resolveErrorMessage(error: unknown, fallback = "请求失败,请稍后重试") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
function isAttendanceDisabledError(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
function formatDateTimeCompact(value: string | null) {
if (!value) return "-";
return new Date(value).toLocaleTimeString("zh-CN", { hour12: false });
}
function formatMinutes(value: number | null) {
if (value == null) return "-";
const hours = Math.floor(value / 60);
const minutes = value % 60;
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function attendanceStatusLabel(status: AttendanceStatus) {
if (status === "normal") return "正常";
if (status === "late") return "迟到";
if (status === "early_leave") return "早退";
if (status === "late_early_leave") return "迟到/早退";
if (status === "pending_clock_out") return "待下班卡";
if (status === "missed") return "缺卡";
if (status === "leave") return "请假";
if (status === "travel") return "差旅";
if (status === "holiday") return "节假日";
return "周末";
}
function attendanceStatusClass(status: AttendanceStatus) {
if (status === "late" || status === "early_leave" || status === "late_early_leave") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
if (status === "missed") {
return "border-red-200 bg-red-50 text-red-700";
}
if (status === "leave" || status === "travel" || status === "holiday" || status === "weekend") {
return "border-zinc-200 bg-zinc-100 text-zinc-600";
}
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
function buildBackfillState(item: AttendanceTeamItem, workDate: string): BackfillState {
return {
employeeId: item.employeeId,
employeeName: item.employeeName,
workDate,
clockInAt: item.clockInAt ? toDateTimeLocalValue(item.clockInAt) : "",
clockOutAt: item.clockOutAt ? toDateTimeLocalValue(item.clockOutAt) : "",
note: item.note ?? "",
};
}
function toIsoOrNull(value: string) {
const normalized = value.trim();
if (!normalized) return null;
return new Date(normalized).toISOString();
}
function toDateTimeLocalValue(value: string) {
const date = new Date(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function canBackfillItem(item: AttendanceTeamItem) {
return item.needClockIn && (!item.clockInAt || !item.clockOutAt);
}
function exemptionReasonLabel(reason: AttendanceMonthItem["exemptionReason"] | AttendanceTeamItem["exemptionReason"]) {
if (reason === "leave") return "已批准请假";
if (reason === "travel") return "已批准差旅";
if (reason === "holiday") return "节假日";
if (reason === "weekend") return "周末";
if (reason === "policy_disabled") return "策略停用";
return "-";
}
export function AttendancePage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canManageTeam = hasPermission(permissions, "system.users");
const canManagePolicy = hasPermission(permissions, "system.settings");
const [activeTab, setActiveTab] = useState<AttendanceTab>("mine");
const [monthValue, setMonthValue] = useState(currentMonthInput());
const [teamDate, setTeamDate] = useState(currentDateInput());
const [teamDepartmentId, setTeamDepartmentId] = useState("");
const [policyForm, setPolicyForm] = useState<PolicyFormState>(toPolicyForm());
const [backfillDialog, setBackfillDialog] = useState<BackfillState | null>(null);
const [year, month] = monthValue.split("-").map(Number);
const disabledTab =
activeTab === "team" && !canManageTeam
? "mine"
: activeTab === "settings" && !canManagePolicy
? (canManageTeam ? "team" : "mine")
: activeTab;
useEffect(() => {
if (disabledTab !== activeTab) {
setActiveTab(disabledTab);
}
}, [activeTab, disabledTab]);
const todayQuery = useQuery({
queryKey: ["attendance", "me", "today"],
queryFn: () => fetchMyTodayAttendance(),
});
const monthQuery = useQuery({
enabled: !isAttendanceDisabledError(todayQuery.error),
queryKey: ["attendance", "me", "month", year, month],
queryFn: () => fetchMyMonthAttendance({ year, month }),
});
const departmentsQuery = useQuery({
enabled: canManageTeam,
queryKey: ["departments", "attendance", "active"],
queryFn: () => fetchDepartments({ includeArchived: false }),
});
const teamQuery = useQuery({
enabled: canManageTeam && !isAttendanceDisabledError(todayQuery.error),
queryKey: ["attendance", "team", teamDate, teamDepartmentId],
queryFn: () =>
fetchTeamAttendance({
date: teamDate,
departmentId: teamDepartmentId || undefined,
}),
});
const policyQuery = useQuery({
enabled: canManagePolicy && !isAttendanceDisabledError(todayQuery.error),
queryKey: ["attendance", "policy"],
queryFn: () => fetchAttendancePolicy(),
});
useEffect(() => {
if (policyQuery.data?.item) {
setPolicyForm(toPolicyForm(policyQuery.data.item));
}
}, [policyQuery.data?.item]);
const clockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
]);
toast.success("上班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const clockOutMutation = useMutation({
mutationFn: () => clockOutAttendance(),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
]);
toast.success("下班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const policyMutation = useMutation({
mutationFn: () =>
updateAttendancePolicy({
workStartTime: policyForm.workStartTime,
workEndTime: policyForm.workEndTime,
lunchStartTime: policyForm.lunchStartTime,
lunchEndTime: policyForm.lunchEndTime,
lateToleranceMinutes: Number(policyForm.lateToleranceMinutes || 0),
}),
onSuccess: async (response) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "policy"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
]);
setPolicyForm(toPolicyForm(response.item));
toast.success("考勤策略已保存");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const backfillMutation = useMutation({
mutationFn: async () => {
if (!backfillDialog) {
throw new Error("未选择补卡对象");
}
return adminBackfillAttendance({
employeeId: backfillDialog.employeeId,
workDate: backfillDialog.workDate,
clockInAt: toIsoOrNull(backfillDialog.clockInAt),
clockOutAt: toIsoOrNull(backfillDialog.clockOutAt),
note: backfillDialog.note.trim() || null,
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
setBackfillDialog(null);
toast.success("补卡已保存");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const today = todayQuery.data;
const monthData = monthQuery.data;
const departments = departmentsQuery.data?.items ?? [];
const teamItems = teamQuery.data?.items ?? [];
const featureDisabled = isAttendanceDisabledError(todayQuery.error);
if (featureDisabled) {
return (
<div className="space-y-6">
<PageHeader title="考勤" subtitle="上下班打卡、月度统计和团队补卡入口。" />
<div className="card-std p-6 text-sm text-zinc-600">
考勤模块当前未启用,请联系管理员检查环境变量 `ATTENDANCE_ENABLED` 与功能开关 `attendance_v1`。
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="考勤" subtitle="上下班打卡、月度统计和团队补卡入口。" />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as AttendanceTab)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="mine">我的考勤</TabsTrigger>
{canManageTeam ? <TabsTrigger value="team">团队考勤</TabsTrigger> : null}
{canManagePolicy ? <TabsTrigger value="settings">考勤设置</TabsTrigger> : null}
</TabsList>
<TabsContent value="mine" className="space-y-6">
<SectionPanel
title="今日打卡状态"
description="工作日支持上下班打卡;请假、差旅、节假日与周末会自动免打卡。"
actions={
today?.needClockIn ? (
today.item?.clockInAt ? (
<Button
type="button"
onClick={() => clockOutMutation.mutate()}
disabled={clockOutMutation.isPending}
variant="secondary"
>
{today.item.clockOutAt ? "重新打下班卡" : "点击下班"}
</Button>
) : (
<Button
type="button"
onClick={() => clockInMutation.mutate()}
disabled={clockInMutation.isPending}
>
点击打上班卡
</Button>
)
) : null
}
>
{todayQuery.isLoading ? (
<div className="text-sm text-zinc-500">加载今日打卡状态中...</div>
) : todayQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(todayQuery.error)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-4">
<MetricCard title="考勤日期" value={today?.workDate ?? "-"} />
<MetricCard title="上班时间" value={formatDateTimeCompact(today?.item?.clockInAt ?? null)} />
<MetricCard title="下班时间" value={formatDateTimeCompact(today?.item?.clockOutAt ?? null)} />
<MetricCard
title="今日工时"
value={formatMinutes(today?.item?.workMinutes ?? null)}
note={
today?.needClockIn
? today?.item?.clockInAt
? today?.item?.clockOutAt
? "今日打卡已完成"
: "已打上班卡,待下班卡"
: "今日尚未打上班卡"
: `免打卡:${exemptionReasonLabel(today?.exemptionReason ?? null)}`
}
tone={today?.item?.isLate || today?.item?.isEarlyLeave ? "warning" : "neutral"}
/>
</div>
)}
</SectionPanel>
<SectionPanel
title="本月统计"
description="缺卡仅统计应出勤工作日;请假、差旅、节假日和周末自动排除。"
actions={
<div className="flex items-center gap-2">
<Input
type="month"
value={monthValue}
onChange={(event) => setMonthValue(event.target.value || currentMonthInput())}
className="w-[180px]"
/>
</div>
}
>
{monthQuery.isLoading ? (
<div className="grid gap-4 md:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="card-std h-24 animate-pulse bg-zinc-100" />
))}
</div>
) : monthQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(monthQuery.error)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-5">
<MetricCard title="应出勤" value={monthData?.summary.totalWorkDays ?? 0} />
<MetricCard title="已打卡" value={monthData?.summary.clockedDays ?? 0} />
<MetricCard title="迟到" value={monthData?.summary.lateDays ?? 0} tone="warning" />
<MetricCard title="早退" value={monthData?.summary.earlyLeaveDays ?? 0} tone="warning" />
<MetricCard title="缺卡" value={monthData?.summary.missedDays ?? 0} tone={(monthData?.summary.missedDays ?? 0) > 0 ? "danger" : "neutral"} />
</div>
)}
</SectionPanel>
<SectionPanel title="本月打卡记录" description="逐日展示上下班时间、工时和状态。">
{monthQuery.isLoading ? (
<TableSkeleton />
) : monthQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(monthQuery.error)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>日期</TableHead>
<TableHead>上班</TableHead>
<TableHead>下班</TableHead>
<TableHead>工时</TableHead>
<TableHead>状态</TableHead>
<TableHead>备注</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{monthData?.items.map((item) => (
<TableRow key={item.workDate}>
<TableCell>{item.workDate}</TableCell>
<TableCell>{formatDateTimeCompact(item.clockInAt)}</TableCell>
<TableCell>{formatDateTimeCompact(item.clockOutAt)}</TableCell>
<TableCell>{formatMinutes(item.workMinutes)}</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${attendanceStatusClass(item.attendanceStatus)}`}>
{attendanceStatusLabel(item.attendanceStatus)}
</span>
</TableCell>
<TableCell>{item.note ?? (item.needClockIn ? "-" : exemptionReasonLabel(item.exemptionReason))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</SectionPanel>
</TabsContent>
{canManageTeam ? (
<TabsContent value="team" className="space-y-6">
<SectionPanel
title="团队考勤"
description="查看指定日期团队上下班打卡状态,并为缺卡员工补卡。"
actions={
<div className="flex flex-wrap items-center gap-2">
<Input type="date" value={teamDate} onChange={(event) => setTeamDate(event.target.value || currentDateInput())} className="w-[170px]" />
<select
className="select-std h-10 w-[220px]"
value={teamDepartmentId}
onChange={(event) => setTeamDepartmentId(event.target.value)}
>
<option value="">全部部门</option>
{departments.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
}
>
{teamQuery.isLoading ? (
<TableSkeleton />
) : teamQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(teamQuery.error)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>员工</TableHead>
<TableHead>部门</TableHead>
<TableHead>上班</TableHead>
<TableHead>下班</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamItems.map((item) => (
<TableRow key={item.employeeId}>
<TableCell>
<div className="font-medium text-zinc-900">{item.employeeName}</div>
<div className="text-xs text-zinc-500">{item.employeeNo} · {item.title}</div>
</TableCell>
<TableCell>{item.departmentName ?? "-"}</TableCell>
<TableCell>{formatDateTimeCompact(item.clockInAt)}</TableCell>
<TableCell>{formatDateTimeCompact(item.clockOutAt)}</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${attendanceStatusClass(item.attendanceStatus)}`}>
{attendanceStatusLabel(item.attendanceStatus)}
</span>
</TableCell>
<TableCell>
{canBackfillItem(item) ? (
<Button type="button" size="sm" variant="secondary" onClick={() => setBackfillDialog(buildBackfillState(item, teamDate))}>
补卡
</Button>
) : (
<span className="text-xs text-zinc-400">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</SectionPanel>
</TabsContent>
) : null}
{canManagePolicy ? (
<TabsContent value="settings" className="space-y-6">
<SectionPanel title="考勤设置" description="维护全局工作时间、午休时间和迟到宽容分钟。">
{policyQuery.isLoading ? (
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="h-20 animate-pulse rounded-2xl bg-zinc-100" />
))}
</div>
) : policyQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(policyQuery.error)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="attendanceWorkStart">上班时间</Label>
<Input
id="attendanceWorkStart"
type="time"
value={policyForm.workStartTime}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, workStartTime: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceWorkEnd">下班时间</Label>
<Input
id="attendanceWorkEnd"
type="time"
value={policyForm.workEndTime}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, workEndTime: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceLunchStart">午休开始</Label>
<Input
id="attendanceLunchStart"
type="time"
value={policyForm.lunchStartTime}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lunchStartTime: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceLunchEnd">午休结束</Label>
<Input
id="attendanceLunchEnd"
type="time"
value={policyForm.lunchEndTime}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lunchEndTime: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceTolerance">迟到宽容分钟</Label>
<Input
id="attendanceTolerance"
type="number"
min={0}
value={policyForm.lateToleranceMinutes}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lateToleranceMinutes: event.target.value }))}
/>
</div>
</div>
)}
<div className="mt-4">
<Button type="button" onClick={() => policyMutation.mutate()} disabled={policyMutation.isPending || policyQuery.isLoading}>
保存设置
</Button>
</div>
</SectionPanel>
</TabsContent>
) : null}
</Tabs>
<Dialog open={Boolean(backfillDialog)} onOpenChange={(open) => !open && setBackfillDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>管理员补卡</DialogTitle>
</DialogHeader>
{backfillDialog ? (
<div className="space-y-4">
<div className="text-sm text-zinc-600">
为 <span className="font-medium text-zinc-900">{backfillDialog.employeeName}</span> 补录 {backfillDialog.workDate} 的考勤记录。
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="attendanceBackfillClockIn">上班时间</Label>
<Input
id="attendanceBackfillClockIn"
type="datetime-local"
value={backfillDialog.clockInAt}
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, clockInAt: event.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceBackfillClockOut">下班时间</Label>
<Input
id="attendanceBackfillClockOut"
type="datetime-local"
value={backfillDialog.clockOutAt}
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, clockOutAt: event.target.value } : prev)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceBackfillNote">备注</Label>
<Textarea
id="attendanceBackfillNote"
rows={3}
value={backfillDialog.note}
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, note: event.target.value } : prev)}
/>
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setBackfillDialog(null)}>
取消
</Button>
<Button type="button" onClick={() => backfillMutation.mutate()} disabled={backfillMutation.isPending}>
保存补卡
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AttendancePage;
+1
查看文件
@@ -57,6 +57,7 @@ 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 === "weekly_report_v1") return "周报工作台";
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
if (flagKey === "todo_board_v1") return "待办工作台";
+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>