R96 U1-U7 精简页面设计与文案

这个提交包含在:
2026-05-06 16:47:13 +08:00
父节点 6127a537d8
当前提交 3741a3d626
共修改 19 个文件,包含 329 行新增和 549 行删除
+200 -412
查看文件
@@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -6,33 +5,33 @@ import {
clockInAttendance,
clockOutAttendance,
fetchMyTodayAttendance,
type AttendanceTodayResponse,
} from "../api/attendance";
import { fetchNotifications } from "../api/notifications";
import { fetchPortalTeamStatus } from "../api/portal";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchNotifications } from "../api/notifications";
import { isApiError } from "../api/types";
import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence";
import { fetchWorkflowTasks } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
MOBILE_APPROVAL_PERMISSIONS,
MOBILE_ASSET_SCAN_PERMISSIONS,
MOBILE_CAPTURE_PERMISSIONS,
MOBILE_LEAVE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
import { buildMobileApprovalsPath } from "../pwa";
const QUICK_PRESENCE_OPTIONS = [
{ label: "在岗", value: "office" as const },
{ label: "居家", value: "remote" as const },
{ label: "外出", value: "out" as const },
];
type MobileAction = {
key: string;
label: string;
meta: string;
icon: string;
to: string;
tone?: "primary" | "warning";
};
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
@@ -44,12 +43,22 @@ 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 "-";
}
try {
return new Date(value).toLocaleString("zh-CN");
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
} catch {
return value;
}
@@ -70,44 +79,6 @@ function formatShortTime(value: string | null) {
}
}
function presenceStatusLabel(item: PresenceItem | null) {
if (!item) {
return "未更新";
}
switch (item.status) {
case "office":
return "在岗";
case "remote":
return "居家";
case "out":
return "外出";
case "travel":
return "出差";
case "leave":
return "请假";
default:
return item.status;
}
}
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 "-";
@@ -117,27 +88,13 @@ function formatWorkMinutes(value: number | null | undefined) {
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function resolveNotificationTargetPath(
item: { targetType: string | null; targetId: string | null },
canAccessApprovals: boolean,
) {
if (!item.targetType || !item.targetId) {
return "/notifications";
}
if (item.targetType === "workflow_task" && canAccessApprovals) {
return buildMobileApprovalsPath(item.targetId);
}
if (item.targetType === "employee") {
return "/mobile/me";
}
if (item.targetType === "reimbursement_form") {
return `/reimbursements/${item.targetId}`;
}
return "/notifications";
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 workflowEntityLabel(entityType: string | null | undefined) {
@@ -145,69 +102,46 @@ function workflowEntityLabel(entityType: string | null | undefined) {
if (entityType === "reimbursement") return "报销";
if (entityType === "purchase_request") return "采购";
if (entityType === "travel_request") return "差旅";
return entityType || "审批";
return "审批";
}
function resolveTeamStatusBadge(item: {
onLeave: boolean;
leaveType: string | null;
presenceStatus: string | null;
attendanceClockIn: string | null;
}) {
if (item.onLeave) {
return { label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", className: "badge-warning" };
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
if (!attendanceToday) {
return null;
}
if (item.presenceStatus === "office") {
return { label: "在岗", className: "badge-success" };
if (!attendanceToday.needClockIn) {
return null;
}
if (item.presenceStatus === "out") {
return {
label: "外出",
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
};
if (!attendanceToday.item?.clockInAt) {
return "clock-in" as const;
}
if (item.presenceStatus === "travel") {
return {
label: "差旅",
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
};
if (!attendanceToday.item.clockOutAt) {
return "clock-out" as const;
}
if (item.presenceStatus === "remote") {
return { label: "居家", className: "badge-neutral" };
}
if (!item.attendanceClockIn) {
return { label: "未打卡", className: "badge-neutral" };
}
return {
label: "已打卡",
className: "inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
};
return null;
}
function QuickLinkCard({
label,
description,
to,
disabled = false,
}: {
label: string;
description: string;
to: string;
disabled?: boolean;
}) {
if (disabled) {
return (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-400">
<div className="font-medium">{label}</div>
<div className="mt-1 text-xs">{description}</div>
</div>
);
}
function ActionTile({ action }: { action: MobileAction }) {
const isPrimary = action.tone === "primary";
const isWarning = action.tone === "warning";
return (
<Link to={to} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm text-zinc-700 shadow-sm transition-colors hover:bg-zinc-50">
<div className="font-medium text-zinc-900">{label}</div>
<div className="mt-1 text-xs text-zinc-500">{description}</div>
<Link
to={action.to}
className={
isPrimary
? "flex min-h-24 flex-col justify-between rounded-lg bg-zinc-900 p-3 text-white shadow-sm"
: isWarning
? "flex min-h-24 flex-col justify-between rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900"
: "flex min-h-24 flex-col justify-between rounded-lg border border-zinc-200 bg-white p-3 text-zinc-900 shadow-sm"
}
data-testid={`mobile-action-${action.key}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold">{action.label}</span>
<span className="material-symbols-outlined text-[20px]">{action.icon}</span>
</div>
<div className={isPrimary ? "text-xs text-white/75" : "text-xs text-zinc-500"}>{action.meta}</div>
</Link>
);
}
@@ -215,27 +149,11 @@ 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(),
@@ -245,34 +163,12 @@ export function MobilePortalPage() {
const pendingCountQuery = useQuery({
enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "pending-items"],
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 5 }),
});
const teamStatusQuery = useQuery({
queryKey: ["mobile", "portal", "team-status"],
queryFn: () => fetchPortalTeamStatus(),
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }),
});
const notificationsQuery = useQuery({
queryKey: ["mobile", "portal", "notifications"],
queryFn: () => fetchNotifications({ page: 1, pageSize: 5 }),
});
const presenceMutation = useMutation({
mutationFn: (status: "office" | "remote" | "out") => upsertMyPresence({ status }),
onSuccess: (response) => {
queryClient.setQueryData(
["mobile", "portal", "presence"],
(current: { item: PresenceItem | null; date: string } | undefined) => ({
item: response.item,
date: current?.date ?? response.item.date,
}),
);
toast.success("今日考勤状态已更新");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
});
const attendanceClockInMutation = useMutation({
@@ -283,11 +179,7 @@ export function MobilePortalPage() {
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success(
response.item.isLate
? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟`
: "上班打卡成功",
);
toast.success(response.item.isLate ? `上班打卡成功,迟到 ${response.item.lateMinutes} 分钟` : "上班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
@@ -309,273 +201,170 @@ export function MobilePortalPage() {
},
});
const presenceItem = presenceQuery.data?.item ?? null;
const attendanceToday = attendanceTodayQuery.data ?? null;
const attendanceAction = resolveAttendanceAction(attendanceToday);
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const teamStatusItems = teamStatusQuery.data ?? [];
const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0;
const pendingTasks = pendingCountQuery.data?.items ?? [];
const notifications = notificationsQuery.data?.items ?? [];
const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0;
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
const actions: MobileAction[] = [
canAccessApprovals
? {
key: "approvals",
label: "审批",
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
icon: "fact_check",
to: "/mobile/approvals",
tone: pendingApprovalCount > 0 ? "warning" : "primary",
}
: null,
canAccessCapture
? {
key: "capture",
label: "传票据",
meta: "拍照或选文件",
icon: "receipt_long",
to: "/mobile/capture",
tone: "primary",
}
: null,
canAccessReimbursements
? {
key: "reimbursements",
label: "报销",
meta: "新建和查进度",
icon: "request_quote",
to: "/reimbursements",
}
: null,
canAccessLeave
? {
key: "leave",
label: "请假",
meta: "提交申请",
icon: "event_available",
to: "/leave-requests",
}
: null,
{
key: "me",
label: "我的",
meta: unreadNotificationCount > 0 ? `${unreadNotificationCount} 条新通知` : "资料和通知",
icon: "person",
to: "/mobile/me",
},
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return (
<PageScaffold className="space-y-4">
<PageHeader
title="今天"
subtitle="先处理待办,再进入报销、票据和团队事项。"
/>
<PageHeader title="今天" />
<section className="card-std space-y-4 p-4" data-testid="mobile-today-focus">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">今日重点</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">移动端只保留高频动作,低频模块放到“我的”。</div>
</div>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">先处理这几件事</div>
<span className={pendingApprovalCount > 0 ? "badge-warning" : "badge-success"}>
{pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待审批` : "暂无审批待办"}
{pendingApprovalCount > 0 ? `${pendingApprovalCount} 待批` : "清爽"}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
<div className="text-2xl font-semibold text-zinc-900">{pendingApprovalCount}</div>
<div className="mt-1 text-xs text-zinc-500">审批待办</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
<div className="text-2xl font-semibold text-zinc-900">{unreadNotificationCount}</div>
<div className="mt-1 text-xs text-zinc-500">未读通知</div>
</div>
</div>
<div className="grid gap-3">
<QuickLinkCard label="处理审批" description="查看待办并快速处理" to="/mobile/approvals" disabled={!canAccessApprovals} />
<QuickLinkCard label="发起报销" description="提交报销和查看进度" to="/reimbursements" disabled={!canAccessReimbursements} />
<QuickLinkCard label="上传票据" description="拍照或选择文件上传" to="/mobile/capture" disabled={!canAccessCapture} />
</div>
</section>
{!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>
<div className="text-sm font-semibold text-zinc-900">团队状态</div>
<div className="mt-1 text-xs text-zinc-500">查看全员今日在岗、请假和打卡状态。</div>
</div>
<span className="badge-neutral">{teamStatusItems.length} 人</span>
</div>
{teamStatusQuery.isLoading ? <p className="text-sm text-zinc-500">加载团队状态中...</p> : null}
{teamStatusQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(teamStatusQuery.error)}
</p>
) : null}
{!teamStatusQuery.isLoading && !teamStatusQuery.isError && !teamStatusItems.length ? (
<p className="text-sm text-zinc-500">暂无团队状态。</p>
) : null}
<div className="grid grid-cols-2 gap-3">
{teamStatusItems.map((item) => {
const badge = resolveTeamStatusBadge(item);
return (
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center gap-2">
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-[11px] text-zinc-500">
{item.attendanceClockIn ? `${formatShortTime(item.attendanceClockIn)} 已打卡` : "暂无打卡"}
</div>
</div>
</div>
<div className="mt-2">
<span className={badge.className}>{badge.label}</span>
</div>
</div>
);
})}
</div>
</section>
<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">
{presenceQuery.isError ? "当前无法读取考勤状态" : `当前状态:${presenceStatusLabel(presenceItem)}`}
</div>
</div>
<span className="badge-neutral">{presenceQuery.data?.date ?? "今日"}</span>
</div>
{presenceItem?.status === "leave" && presenceItem.source === "leave_sync" ? (
<p className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
当前日期已被审批通过的请假覆盖,不能在移动首页手动修改。
</p>
) : null}
<div className="grid grid-cols-3 gap-2">
{QUICK_PRESENCE_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={presenceItem?.status === option.value ? "default" : "secondary"}
className="touch-target"
disabled={presenceMutation.isPending || presenceQuery.isError || !canQuickUpdatePresence(presenceItem)}
onClick={() => presenceMutation.mutate(option.value)}
>
{option.label}
</Button>
<div className="grid grid-cols-2 gap-2">
{actions.map((action) => (
<ActionTile key={action.key} action={action} />
))}
</div>
</section>
<section className="card-std space-y-3 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">
{canAccessApprovals ? "展示前 5 条审批待办,可直接进入移动审批。" : "当前账号没有移动审批权限"}
</div>
{!attendanceFeatureDisabled ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-attendance-card">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">打卡</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div>
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
{canAccessApprovals ? `${pendingCountQuery.data?.total ?? 0} 条` : "不可用"}
</span>
</div>
{canAccessApprovals ? (
<>
<Button asChild type="button" className="w-full">
<Link to="/mobile/approvals">进入移动审批</Link>
{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}
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">加载待办中...</p> : null}
{pendingCountQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(pendingCountQuery.error)}
</p>
) : null}
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
<p className="text-sm text-zinc-500">当前没有审批待办。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<Link
key={task.id}
to={buildMobileApprovalsPath(task.id)}
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">
业务:{workflowEntityLabel(task.workflowInstance?.entityType)} · 实体:{task.workflowInstance?.entityId ?? "-"}
</div>
</Link>
))}
</div>
</>
) : (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
你当前没有审批权限,审批页入口已禁用。
{canAccessApprovals ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-pending-tasks">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待审批</div>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">全部</Link>
</Button>
</div>
)}
</section>
{(canAccessLeave || canAccessAssetScan) ? (
<section className="card-std space-y-3 p-4">
<div className="text-sm font-semibold text-zinc-900">其他快捷入口</div>
<div className="grid gap-3 sm:grid-cols-2">
<QuickLinkCard label="提交请假" description="进入请假申请页" to="/leave-requests" disabled={!canAccessLeave} />
<QuickLinkCard label="资产扫描" description="进入扫码与盘点" to="/mobile/assets/scan" disabled={!canAccessAssetScan} />
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{pendingCountQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(pendingCountQuery.error)}
</p>
) : null}
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
<p className="text-sm text-zinc-500">没有待处理审批。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<Link
key={task.id}
to={buildMobileApprovalsPath(task.id)}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">{workflowEntityLabel(task.workflowInstance?.entityType)}</div>
</Link>
))}
</div>
</section>
) : null}
<section className="card-std space-y-3 p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">最近通知</div>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-notifications">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">通知</div>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/notifications">查看全部</Link>
<Link to="/notifications">全部</Link>
</Button>
</div>
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">加载通知中...</p> : null}
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{notificationsQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(notificationsQuery.error)}
</p>
) : null}
@@ -587,15 +376,14 @@ export function MobilePortalPage() {
{notifications.map((item) => (
<Link
key={item.id}
to={resolveNotificationTargetPath(item, canAccessApprovals)}
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
to="/notifications"
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="font-medium text-zinc-900">{item.title}</div>
{!item.readAt ? <span className="badge-warning shrink-0">未读</span> : null}
<div className="flex items-center justify-between gap-3">
<div className="truncate font-medium text-zinc-900">{item.title}</div>
{!item.readAt ? <span className="badge-warning shrink-0">新</span> : null}
</div>
{item.body ? <div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-500">{item.body}</div> : null}
<div className="mt-2 text-[11px] text-zinc-400">{formatDateTime(item.createdAt)}</div>
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</Link>
))}
</div>