R51 移动端增强:入口页、底部导航与审批批量处理

这个提交包含在:
2026-04-05 22:20:08 +08:00
父节点 47e10e6acc
当前提交 ba29ede573
共修改 12 个文件,包含 1168 行新增和 163 行删除
+282
查看文件
@@ -0,0 +1,282 @@
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchNotifications } from "../api/notifications";
import { mapApiErrorMessage } from "../api/errorMessages";
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 { 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 },
];
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 formatDateTime(value: string) {
try {
return new Date(value).toLocaleString("zh-CN");
} catch {
return value;
}
}
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 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 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>
);
}
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>
);
}
export function MobilePortalPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
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]);
const presenceQuery = useQuery({
queryKey: ["mobile", "portal", "presence"],
queryFn: () => fetchMyPresence(),
});
const pendingCountQuery = useQuery({
enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "pending-count"],
queryFn: async () => {
const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 });
return response.total;
},
});
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));
},
});
const presenceItem = presenceQuery.data?.item ?? null;
const notifications = notificationsQuery.data?.items ?? [];
return (
<PageScaffold className="space-y-4">
<PageHeader
title="移动首页"
subtitle="处理今日考勤、审批和高频入口。"
/>
<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>
</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 ? "点击进入移动审批" : "当前账号没有移动审批权限"}
</div>
</div>
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
{canAccessApprovals ? `${pendingCountQuery.data ?? 0} 条` : "不可用"}
</span>
</div>
{canAccessApprovals ? (
<Button asChild type="button" className="w-full">
<Link to="/mobile/approvals">进入移动审批</Link>
</Button>
) : (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
你当前没有审批权限,审批页入口已禁用。
</div>
)}
</section>
<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="/reimbursements" disabled={!canAccessReimbursements} />
<QuickLinkCard label="拍照上传" description="打开移动拍照上传" to="/mobile/capture" disabled={!canAccessCapture} />
<QuickLinkCard label="资产扫描" description="进入扫码与盘点" to="/mobile/assets/scan" disabled={!canAccessAssetScan} />
</div>
</section>
<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>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/notifications">查看全部</Link>
</Button>
</div>
{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">
{resolveErrorMessage(notificationsQuery.error)}
</p>
) : null}
{!notificationsQuery.isLoading && !notifications.length ? (
<p className="text-sm text-zinc-500">暂无通知。</p>
) : null}
<div className="space-y-2">
{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"
>
<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>
{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>
</Link>
))}
</div>
</section>
</PageScaffold>
);
}