DG7/MRF7 移动信息架构与展示去重治理
这个提交包含在:
@@ -37,12 +37,11 @@ export function MobileBottomNav() {
|
||||
>
|
||||
<div className="relative z-[71] grid grid-cols-4 gap-1">
|
||||
{MOBILE_NAV_ITEMS.map((item) => {
|
||||
const disabled = item.key === "approvals"
|
||||
? !canAccessApprovals
|
||||
: item.anyOf
|
||||
const disabled = item.anyOf
|
||||
? !hasAnyPermission(permissions, [...item.anyOf])
|
||||
: false;
|
||||
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
||||
const label = item.key === "approvals" && !canAccessApprovals ? "进度" : item.label;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
@@ -53,14 +52,14 @@ export function MobileBottomNav() {
|
||||
className="touch-target flex h-12 min-h-0 min-w-0 select-none flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none text-zinc-400"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
title={`${item.label}不可用`}
|
||||
title={`${label}不可用`}
|
||||
>
|
||||
<MobileIcon name={item.icon} className="size-5" />
|
||||
<span
|
||||
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
data-testid={`mobile-nav-${item.key}-label`}
|
||||
>
|
||||
{item.label}
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -83,26 +82,26 @@ export function MobileBottomNav() {
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span>
|
||||
<span className="relative">
|
||||
<MobileIcon name={item.icon} className="size-5" />
|
||||
{badge ? (
|
||||
<span
|
||||
data-testid={`mobile-nav-${item.key}-badge`}
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-[calc(100%+1px)] top-0 min-w-4 -translate-y-1/2 rounded-full px-1 text-center text-[10px] font-semibold leading-4 shadow-sm",
|
||||
isActive ? "bg-red-500 text-white" : "bg-red-500 text-white",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
data-testid={`mobile-nav-${item.key}-label`}
|
||||
>
|
||||
{item.label}
|
||||
{label}
|
||||
</span>
|
||||
{badge ? (
|
||||
<span
|
||||
data-testid={`mobile-nav-${item.key}-badge`}
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 top-1 min-w-4 rounded-full px-1 text-[10px] font-semibold leading-4 shadow-sm",
|
||||
isActive ? "bg-red-500 text-white" : "bg-red-500 text-white",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
@@ -27,7 +27,10 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
||||
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
|
||||
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
|
||||
{ path: "/mobile/purchase-requests", title: "移动采购详情", group: "移动工作台" },
|
||||
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
|
||||
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
|
||||
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
||||
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
|
||||
|
||||
@@ -11,7 +11,7 @@ import { resolveMobileEquivalentPath, shouldPreferMobileWorkplace } from "../lib
|
||||
export function AppShellLayout() {
|
||||
const location = useLocation();
|
||||
const isMobileRoute = location.pathname.startsWith("/mobile");
|
||||
const isMobileDeepRoute = /^\/mobile\/reimbursements\/[^/]+$/.test(location.pathname);
|
||||
const isMobileDeepRoute = /^\/mobile\/(?:reimbursements|approvals|leave|purchase-requests)\/[^/]+$/.test(location.pathname);
|
||||
const showMobileBottomNav = isMobileRoute && !isMobileDeepRoute;
|
||||
const mobileEquivalentPath = !isMobileRoute && shouldPreferMobileWorkplace()
|
||||
? resolveMobileEquivalentPath(location.pathname, location.search)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { isUuidLike } from "./reimbursementDisplay";
|
||||
|
||||
export type BusinessEntityType = "reimbursement" | "leave" | "purchase_request" | "travel_request" | string | null | undefined;
|
||||
|
||||
const ENTITY_NOUN_LABELS: Record<string, string> = {
|
||||
reimbursement: "报销单",
|
||||
leave: "请假",
|
||||
purchase_request: "采购单",
|
||||
travel_request: "差旅单",
|
||||
};
|
||||
|
||||
const ENTITY_SHORT_LABELS: Record<string, string> = {
|
||||
reimbursement: "报销",
|
||||
leave: "请假",
|
||||
purchase_request: "采购",
|
||||
travel_request: "差旅",
|
||||
};
|
||||
|
||||
export function businessEntityLabel(entityType: BusinessEntityType, options: { noun?: boolean } = {}) {
|
||||
if (!entityType) {
|
||||
return options.noun ? "业务单据" : "业务";
|
||||
}
|
||||
const labels = options.noun ? ENTITY_NOUN_LABELS : ENTITY_SHORT_LABELS;
|
||||
return labels[entityType] ?? String(entityType);
|
||||
}
|
||||
|
||||
export function compactBusinessReference(value: string | null | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || isUuidLike(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function businessPrimaryLabel(input: {
|
||||
entityType: BusinessEntityType;
|
||||
reference?: string | null;
|
||||
title?: string | null;
|
||||
fallback?: string;
|
||||
}) {
|
||||
const reference = compactBusinessReference(input.reference);
|
||||
if (reference) {
|
||||
return `${businessEntityLabel(input.entityType, { noun: true })} ${reference}`;
|
||||
}
|
||||
|
||||
const title = input.title?.trim();
|
||||
if (title && !isUuidLike(title)) {
|
||||
return title;
|
||||
}
|
||||
|
||||
return input.fallback ?? businessEntityLabel(input.entityType, { noun: true });
|
||||
}
|
||||
|
||||
export function joinBusinessMeta(items: Array<string | null | undefined>) {
|
||||
return items.map((item) => item?.trim()).filter((item): item is string => Boolean(item)).join(" · ");
|
||||
}
|
||||
@@ -3,6 +3,10 @@ type NotificationTarget = {
|
||||
targetId: string | null;
|
||||
};
|
||||
|
||||
function encodeSegment(value: string) {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
export function resolveNotificationTargetLink(item: NotificationTarget) {
|
||||
if (!item.targetType || !item.targetId) return null;
|
||||
|
||||
@@ -41,6 +45,36 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveMobileNotificationTargetLink(item: NotificationTarget) {
|
||||
if (!item.targetType || !item.targetId) return null;
|
||||
|
||||
if (item.targetType === "workflow_task") {
|
||||
return `/mobile/approvals/${encodeSegment(item.targetId)}`;
|
||||
}
|
||||
|
||||
if (item.targetType === "reimbursement_form") {
|
||||
return `/mobile/reimbursements/${encodeSegment(item.targetId)}`;
|
||||
}
|
||||
|
||||
if (item.targetType === "leave_request") {
|
||||
return `/mobile/leave/${encodeSegment(item.targetId)}`;
|
||||
}
|
||||
|
||||
if (item.targetType === "purchase_request") {
|
||||
return `/mobile/purchase-requests/${encodeSegment(item.targetId)}`;
|
||||
}
|
||||
|
||||
if (item.targetType === "asset" || item.targetType === "asset_card" || item.targetType === "inventory_check") {
|
||||
return "/mobile/assets/scan";
|
||||
}
|
||||
|
||||
if (item.targetType === "employee") {
|
||||
return "/mobile/profile";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatNotificationRelativeTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
|
||||
@@ -43,6 +43,7 @@ function encodeSegment(value: string) {
|
||||
|
||||
export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
const normalizedPath = pathname.replace(/\/+$/, "") || "/";
|
||||
const searchParams = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||
|
||||
if (normalizedPath === "/" || normalizedPath === "/portal" || normalizedPath === "/dashboard") {
|
||||
return "/mobile";
|
||||
@@ -71,6 +72,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
}
|
||||
|
||||
if (normalizedPath === "/leave-requests") {
|
||||
const leaveRequestId = searchParams.get("highlight") ?? searchParams.get("id") ?? searchParams.get("requestId");
|
||||
if (leaveRequestId) {
|
||||
return `/mobile/leave/${encodeSegment(leaveRequestId)}`;
|
||||
}
|
||||
return "/mobile/leave";
|
||||
}
|
||||
|
||||
@@ -80,7 +85,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
|
||||
const workflowTaskMatch = normalizedPath.match(/^\/workflow\/tasks\/([^/]+)$/);
|
||||
if (workflowTaskMatch?.[1]) {
|
||||
return `/mobile/approvals?${new URLSearchParams({ taskId: workflowTaskMatch[1] }).toString()}`;
|
||||
return `/mobile/approvals/${encodeSegment(workflowTaskMatch[1])}`;
|
||||
}
|
||||
|
||||
if (normalizedPath === "/workflow/tasks") {
|
||||
@@ -104,7 +109,6 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
if (
|
||||
normalizedPath.startsWith("/projects") ||
|
||||
normalizedPath.startsWith("/contracts-registry") ||
|
||||
normalizedPath.startsWith("/purchase-requests") ||
|
||||
normalizedPath.startsWith("/travel-requests") ||
|
||||
normalizedPath.startsWith("/payment-batches") ||
|
||||
normalizedPath.startsWith("/ap-operations") ||
|
||||
@@ -119,5 +123,18 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
return "/mobile";
|
||||
}
|
||||
|
||||
if (normalizedPath === "/purchase-requests") {
|
||||
const purchaseRequestId = searchParams.get("highlight") ?? searchParams.get("id") ?? searchParams.get("requestId");
|
||||
if (purchaseRequestId) {
|
||||
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestId)}`;
|
||||
}
|
||||
return "/mobile";
|
||||
}
|
||||
|
||||
const purchaseRequestDetailMatch = normalizedPath.match(/^\/purchase-requests\/([^/]+)$/);
|
||||
if (purchaseRequestDetailMatch?.[1]) {
|
||||
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestDetailMatch[1])}`;
|
||||
}
|
||||
|
||||
return search ? `${pathname}${search}` : pathname;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
|
||||
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
|
||||
import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements";
|
||||
import { isApiError } from "../api/types";
|
||||
import {
|
||||
approveWorkflowTask,
|
||||
@@ -26,6 +29,9 @@ import { Spinner } from "../components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
|
||||
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import {
|
||||
cacheWorkflowTaskDetail,
|
||||
enqueueWorkflowActionDraft,
|
||||
@@ -61,6 +67,14 @@ type VisibleTaskCard = {
|
||||
detail: WorkflowTaskDetail | null;
|
||||
};
|
||||
|
||||
type EmployeeProgressCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "leave", label: "请假" },
|
||||
@@ -120,15 +134,8 @@ function businessStatusLabel(status: string | null | undefined) {
|
||||
return status ? labels[status] ?? status : "-";
|
||||
}
|
||||
|
||||
function isInternalId(value: string | null | undefined) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "");
|
||||
}
|
||||
|
||||
function publicReference(value: string | null | undefined) {
|
||||
if (!value || isInternalId(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
return compactBusinessReference(value);
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
@@ -209,13 +216,19 @@ function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
|
||||
const summary = task.businessSummary;
|
||||
const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId);
|
||||
const metric = summary?.amount ?? summary?.duration ?? null;
|
||||
const title = businessPrimaryLabel({
|
||||
entityType,
|
||||
reference,
|
||||
title: summary?.title ?? task.stepName,
|
||||
fallback: task.stepName,
|
||||
});
|
||||
const subtitle = task.status === "completed"
|
||||
? joinBusinessMeta([`节点:${task.stepName}`, `完成于 ${formatDateTime(task.completedAt)}`])
|
||||
: joinBusinessMeta([`节点:${task.stepName}`, summary?.applicantName ? `申请人:${summary.applicantName}` : null]);
|
||||
return {
|
||||
id: task.id,
|
||||
title: summary?.title ?? task.stepName,
|
||||
subtitle:
|
||||
task.status === "completed"
|
||||
? `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"} · 完成于 ${formatDateTime(task.completedAt)}`
|
||||
: `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"}`,
|
||||
title,
|
||||
subtitle,
|
||||
status: task.status,
|
||||
entityType,
|
||||
entityId: task.workflowInstance?.entityId ?? null,
|
||||
@@ -252,11 +265,66 @@ function matchesSearch(card: VisibleTaskCard, keyword: string) {
|
||||
return haystack.includes(searchValue);
|
||||
}
|
||||
|
||||
function toEmployeeProgressCards(input: {
|
||||
reimbursements: ReimbursementListItem[];
|
||||
leaveRequests: LeaveRequestItem[];
|
||||
purchaseRequests: PurchaseRequestSummary[];
|
||||
}): EmployeeProgressCard[] {
|
||||
const reimbursementCards = input.reimbursements.map((item) => ({
|
||||
id: `reimbursement-${item.id}`,
|
||||
title: businessPrimaryLabel({
|
||||
entityType: "reimbursement",
|
||||
reference: item.formNumber,
|
||||
title: resolveReimbursementDisplayCode(item),
|
||||
}),
|
||||
subtitle: joinBusinessMeta([
|
||||
item.owner?.name ? `报销人:${item.owner.name}` : null,
|
||||
item.totalAmount ? `金额:${item.totalAmount}` : null,
|
||||
item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null,
|
||||
]),
|
||||
status: item.status,
|
||||
to: `/mobile/reimbursements/${encodeURIComponent(item.id)}`,
|
||||
}));
|
||||
|
||||
const leaveCards = input.leaveRequests.map((item) => ({
|
||||
id: `leave-${item.id}`,
|
||||
title: joinBusinessMeta(["请假", item.leaveType?.name]) || "请假",
|
||||
subtitle: joinBusinessMeta([
|
||||
`${item.startDate} 至 ${item.endDate}`,
|
||||
`${item.requestedDays} 天`,
|
||||
item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null,
|
||||
]),
|
||||
status: item.status,
|
||||
to: `/mobile/leave/${encodeURIComponent(item.id)}`,
|
||||
}));
|
||||
|
||||
const purchaseCards = input.purchaseRequests.map((item) => ({
|
||||
id: `purchase-${item.id}`,
|
||||
title: businessPrimaryLabel({
|
||||
entityType: "purchase_request",
|
||||
reference: item.requestNo,
|
||||
title: resolvePurchaseDisplayCode(item),
|
||||
}),
|
||||
subtitle: joinBusinessMeta([
|
||||
item.applicant?.name ? `申请人:${item.applicant.name}` : null,
|
||||
item.totalAmount ? `金额:${item.totalAmount}` : null,
|
||||
item.updatedAt ? `更新:${formatDateTime(item.updatedAt)}` : null,
|
||||
]),
|
||||
status: item.status,
|
||||
to: `/mobile/purchase-requests/${encodeURIComponent(item.id)}`,
|
||||
}));
|
||||
|
||||
return [...reimbursementCards, ...leaveCards, ...purchaseCards].slice(0, 8);
|
||||
}
|
||||
|
||||
export function MobileApprovalsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams();
|
||||
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? "";
|
||||
const [searchParams] = useSearchParams();
|
||||
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
|
||||
const isPushDetail = Boolean(deepLinkTaskId);
|
||||
|
||||
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
|
||||
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
|
||||
@@ -274,13 +342,30 @@ export function MobileApprovalsPage() {
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
const detailSectionRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const ownerEmployeeId = me?.id ?? null;
|
||||
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
||||
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
||||
|
||||
const reimbursementProgressQuery = useQuery({
|
||||
enabled: !canAccessRemoteApprovals,
|
||||
queryKey: ["mobile", "approvals", "progress", "reimbursements"],
|
||||
queryFn: () => fetchReimbursements({ scope: "own", page: 1, pageSize: 5 }),
|
||||
});
|
||||
|
||||
const leaveProgressQuery = useQuery({
|
||||
enabled: !canAccessRemoteApprovals,
|
||||
queryKey: ["mobile", "approvals", "progress", "leave"],
|
||||
queryFn: () => fetchLeaveRequests({ scope: "own", page: 1, pageSize: 5 }),
|
||||
});
|
||||
|
||||
const purchaseProgressQuery = useQuery({
|
||||
enabled: !canAccessRemoteApprovals,
|
||||
queryKey: ["mobile", "approvals", "progress", "purchase"],
|
||||
queryFn: () => fetchPurchaseRequests({ mine: true, page: 1, pageSize: 5 }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTaskId(deepLinkTaskId);
|
||||
}, [deepLinkTaskId]);
|
||||
@@ -293,30 +378,8 @@ export function MobileApprovalsPage() {
|
||||
}
|
||||
}, [taskTab]);
|
||||
|
||||
function updateTaskSelection(taskId: string) {
|
||||
setSelectedTaskId(taskId);
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
if (taskId) {
|
||||
nextParams.set("taskId", taskId);
|
||||
} else {
|
||||
nextParams.delete("taskId");
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
function scrollDetailIntoViewOnMobile() {
|
||||
if (typeof window === "undefined" || !window.matchMedia("(max-width: 767px)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
detailSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleTaskSelection(taskId: string) {
|
||||
updateTaskSelection(taskId);
|
||||
scrollDetailIntoViewOnMobile();
|
||||
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
||||
@@ -391,8 +454,12 @@ export function MobileApprovalsPage() {
|
||||
.filter((item) => item.detail.task.status === "pending")
|
||||
.map((item) => ({
|
||||
id: item.detail.task.id,
|
||||
title: item.taskSnapshot.stepName ?? item.detail.task.stepName,
|
||||
subtitle: `业务:${entityLabel(item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType)} · 缓存于 ${formatDateTime(item.savedAt)}`,
|
||||
title: businessPrimaryLabel({
|
||||
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType,
|
||||
reference: item.taskSnapshot.reference,
|
||||
title: item.taskSnapshot.stepName ?? item.detail.task.stepName,
|
||||
}),
|
||||
subtitle: joinBusinessMeta([`节点:${item.taskSnapshot.stepName ?? item.detail.task.stepName}`, `缓存于 ${formatDateTime(item.savedAt)}`]),
|
||||
status: item.detail.task.status,
|
||||
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
|
||||
entityId: item.taskSnapshot.entityId,
|
||||
@@ -432,21 +499,50 @@ export function MobileApprovalsPage() {
|
||||
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
|
||||
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
||||
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
|
||||
const selectedDetailReference = selectedDisplayReference ??
|
||||
publicReference(selectedTask?.reimbursement?.formNumber) ??
|
||||
publicReference(selectedTask?.purchaseRequest?.requestNo) ??
|
||||
publicReference(selectedTask?.travelRequest?.requestNo);
|
||||
const selectedDetailTitle = selectedTask
|
||||
? businessPrimaryLabel({
|
||||
entityType: selectedTask.workflowInstance?.entityType,
|
||||
reference: selectedDetailReference,
|
||||
title: selectedTaskCard?.title ?? selectedTask.task.stepName,
|
||||
})
|
||||
: "";
|
||||
const selectedApplicantName = selectedTaskCard?.applicantName ??
|
||||
selectedTask?.reimbursement?.submitter?.name ??
|
||||
selectedTask?.leaveRequest?.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?.purchaseRequest?.totalAmount ??
|
||||
selectedTask?.travelRequest?.estimatedAmount ??
|
||||
null;
|
||||
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
|
||||
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
||||
const employeeProgressCards = useMemo(
|
||||
() =>
|
||||
toEmployeeProgressCards({
|
||||
reimbursements: reimbursementProgressQuery.data?.items ?? [],
|
||||
leaveRequests: leaveProgressQuery.data?.items ?? [],
|
||||
purchaseRequests: purchaseProgressQuery.data?.items ?? [],
|
||||
}),
|
||||
[leaveProgressQuery.data?.items, purchaseProgressQuery.data?.items, reimbursementProgressQuery.data?.items],
|
||||
);
|
||||
const employeeProgressLoading =
|
||||
reimbursementProgressQuery.isLoading || leaveProgressQuery.isLoading || purchaseProgressQuery.isLoading;
|
||||
const employeeProgressError =
|
||||
reimbursementProgressQuery.error ?? leaveProgressQuery.error ?? purchaseProgressQuery.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const visibleIds = new Set(filteredTaskCards.map((task) => task.id));
|
||||
setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId)));
|
||||
}, [filteredTaskCards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTaskId || !filteredTaskCards.length) {
|
||||
return;
|
||||
}
|
||||
updateTaskSelection(filteredTaskCards[0].id);
|
||||
}, [filteredTaskCards, selectedTaskId]);
|
||||
|
||||
const canTransfer = Boolean(
|
||||
canUseRemote &&
|
||||
selectedTaskAllowsActions &&
|
||||
@@ -566,7 +662,7 @@ export function MobileApprovalsPage() {
|
||||
setTransferToApproverId("");
|
||||
setError("");
|
||||
setMessage("");
|
||||
updateTaskSelection("");
|
||||
navigate("/mobile/approvals", { replace: true });
|
||||
toast.success("操作成功");
|
||||
},
|
||||
onError: (actionError) => {
|
||||
@@ -656,7 +752,7 @@ export function MobileApprovalsPage() {
|
||||
setError(result.failures.length ? result.failures.slice(0, 3).join(";") : "");
|
||||
setMessage(summary);
|
||||
if (canUseRemote && result.taskIds.includes(selectedTaskId)) {
|
||||
updateTaskSelection("");
|
||||
navigate("/mobile/approvals", { replace: true });
|
||||
}
|
||||
|
||||
if (result.failed > 0) {
|
||||
@@ -793,11 +889,16 @@ export function MobileApprovalsPage() {
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="移动审批"
|
||||
subtitle={deepLinkTaskId ? "已定位指定待办。" : "处理待办。"}
|
||||
title={isPushDetail ? "审批详情" : canAccessRemoteApprovals ? "审批" : "审批进度"}
|
||||
subtitle={isPushDetail ? "查看并处理指定待办。" : canAccessRemoteApprovals ? "待我审批和已处理。" : "查看我发起的申请进度。"}
|
||||
actions={isPushDetail ? (
|
||||
<Button asChild size="sm" type="button" variant="secondary">
|
||||
<Link to="/mobile/approvals">返回</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{!isOnline ? (
|
||||
{!isOnline && canAccessRemoteApprovals ? (
|
||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
||||
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
|
||||
</p>
|
||||
@@ -811,6 +912,43 @@ export function MobileApprovalsPage() {
|
||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
{!isPushDetail && !canAccessRemoteApprovals ? (
|
||||
<section className="mobile-card space-y-3" data-testid="mobile-approvals-progress">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">我发起的</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">报销、请假、采购进度</p>
|
||||
</div>
|
||||
<span className="badge-neutral">{employeeProgressCards.length} 条</span>
|
||||
</div>
|
||||
|
||||
{employeeProgressLoading ? <div className="mobile-empty-state">加载中...</div> : null}
|
||||
{employeeProgressError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(employeeProgressError)}
|
||||
</p>
|
||||
) : null}
|
||||
{!employeeProgressLoading && !employeeProgressError && !employeeProgressCards.length ? (
|
||||
<div className="mobile-empty-state">暂无申请进度</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{employeeProgressCards.map((item) => (
|
||||
<Link className="mobile-list-card block" data-testid="mobile-approval-progress-card" key={item.id} to={item.to}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 truncate text-xs text-zinc-500">{item.subtitle}</div> : null}
|
||||
</div>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!isPushDetail && canAccessRemoteApprovals ? (
|
||||
<section className="mobile-card space-y-4" data-testid="mobile-approvals-list">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -986,9 +1124,8 @@ export function MobileApprovalsPage() {
|
||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
|
||||
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
|
||||
</div>
|
||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||
</button>
|
||||
@@ -1011,9 +1148,8 @@ export function MobileApprovalsPage() {
|
||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
|
||||
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
|
||||
</div>
|
||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||
</button>
|
||||
@@ -1021,8 +1157,10 @@ export function MobileApprovalsPage() {
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section ref={detailSectionRef} className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
|
||||
{isPushDetail ? (
|
||||
<section className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
|
||||
<h3 className="text-base font-semibold text-zinc-900">处理</h3>
|
||||
|
||||
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
||||
@@ -1046,44 +1184,37 @@ export function MobileApprovalsPage() {
|
||||
{selectedTask ? (
|
||||
<>
|
||||
<div className="mobile-list-card text-sm">
|
||||
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
|
||||
<div>步骤:{selectedTask.task.stepName}</div>
|
||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||
<div>业务单号:{selectedDisplayReference ?? "-"}</div>
|
||||
<div>业务状态:{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
|
||||
<div>申请人:{selectedTaskCard?.applicantName ?? "-"}</div>
|
||||
<div>金额/天数:{selectedTaskCard?.metric ?? "-"}</div>
|
||||
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-zinc-900">{selectedDetailTitle}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</div>
|
||||
</div>
|
||||
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-zinc-600">
|
||||
<div>{joinBusinessMeta([`类型:${entityLabel(selectedTask.workflowInstance?.entityType)}`, `业务状态:${businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}`])}</div>
|
||||
<div>{joinBusinessMeta([selectedApplicantName ? `申请人:${selectedApplicantName}` : null, selectedMetric ? `金额/天数:${selectedMetric}` : null])}</div>
|
||||
{selectedTask.task.completedAt ? <div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div> : null}
|
||||
</div>
|
||||
{selectedTask.reimbursement ? (
|
||||
<>
|
||||
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
|
||||
<div>金额:{selectedTask.reimbursement.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{selectedTask.reimbursement.submitter?.name ?? "-"}</div>
|
||||
</>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.leaveRequest ? (
|
||||
<>
|
||||
<div>假期类型:{selectedTask.leaveRequest.leaveType?.name ?? "-"}</div>
|
||||
<div>申请人:{selectedTask.leaveRequest.owner?.name ?? "-"}</div>
|
||||
<div>请假天数:{selectedTask.leaveRequest.requestedDays}</div>
|
||||
</>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.purchaseRequest ? (
|
||||
<>
|
||||
<div>采购单号:{selectedTask.purchaseRequest.requestNo ?? "-"}</div>
|
||||
<div>申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}</div>
|
||||
<div>供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}</div>
|
||||
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
|
||||
</>
|
||||
<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])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.travelRequest ? (
|
||||
<>
|
||||
<div>差旅单号:{selectedTask.travelRequest.requestNo ?? "-"}</div>
|
||||
<div>申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}</div>
|
||||
<div>出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
||||
<div>预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}</div>
|
||||
</>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`出差类型:${selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}`, selectedTask.travelRequest.purpose])}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1191,6 +1322,7 @@ export function MobileApprovalsPage() {
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createLeaveRequest,
|
||||
fetchLeaveBalances,
|
||||
fetchLeaveRequestDetail,
|
||||
fetchLeaveRequests,
|
||||
fetchLeaveTypes,
|
||||
patchLeaveRequest,
|
||||
@@ -60,11 +62,13 @@ function LeaveRecordCard({
|
||||
isSaving,
|
||||
onEdit,
|
||||
onSubmit,
|
||||
showActions = true,
|
||||
}: {
|
||||
item: LeaveRequestItem;
|
||||
isSaving: boolean;
|
||||
onEdit: (item: LeaveRequestItem) => void;
|
||||
onSubmit: (item: LeaveRequestItem) => void;
|
||||
showActions?: boolean;
|
||||
}) {
|
||||
const canEdit = item.status === "draft" || item.status === "rejected";
|
||||
const canSubmit = item.status === "draft" || item.status === "rejected";
|
||||
@@ -104,7 +108,7 @@ function LeaveRecordCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit || canSubmit ? (
|
||||
{showActions && (canEdit || canSubmit) ? (
|
||||
<div className="mobile-two-action-grid mt-4">
|
||||
{canEdit ? (
|
||||
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
|
||||
@@ -124,6 +128,7 @@ function LeaveRecordCard({
|
||||
|
||||
export function MobileLeavePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { leaveRequestId = "" } = useParams();
|
||||
const { permissions } = useAuth();
|
||||
const canCreateLeave = hasPermission(permissions, "leave.create");
|
||||
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
|
||||
@@ -158,6 +163,12 @@ export function MobileLeavePage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(leaveRequestId),
|
||||
queryKey: ["mobile", "leave", "detail", leaveRequestId],
|
||||
queryFn: () => fetchLeaveRequestDetail(leaveRequestId),
|
||||
});
|
||||
|
||||
const invalidateLeaveQueries = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "leave"] }),
|
||||
@@ -265,6 +276,35 @@ export function MobileLeavePage() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
if (leaveRequestId) {
|
||||
return (
|
||||
<PageScaffold className="space-y-3 pb-4">
|
||||
<PageHeader
|
||||
title="请假详情"
|
||||
actions={
|
||||
<Button asChild size="sm" type="button" variant="secondary">
|
||||
<Link to="/mobile/leave">返回</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
|
||||
{detailQuery.isError ? (
|
||||
<div className="mobile-card text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</div>
|
||||
) : null}
|
||||
{detailQuery.data ? (
|
||||
<LeaveRecordCard
|
||||
item={detailQuery.data}
|
||||
isSaving={false}
|
||||
onEdit={startEditing}
|
||||
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
|
||||
showActions={false}
|
||||
/>
|
||||
) : null}
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-3 pb-4">
|
||||
<PageHeader title="请假" />
|
||||
|
||||
@@ -52,7 +52,6 @@ function MobileActionLink({
|
||||
|
||||
export function MobileMePage() {
|
||||
const { me, permissions, refreshMe, logout } = useAuth();
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
const canAccessAdmin = hasAnyPermission(permissions, [
|
||||
"*",
|
||||
"system.settings",
|
||||
@@ -63,6 +62,7 @@ export function MobileMePage() {
|
||||
]);
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(me?.id),
|
||||
@@ -74,7 +74,7 @@ export function MobileMePage() {
|
||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("外部推送偏好已更新");
|
||||
toast.success("企业微信消息提醒偏好已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
@@ -124,7 +124,7 @@ export function MobileMePage() {
|
||||
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
|
||||
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
|
||||
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
||||
{canAccessApprovals ? <MobileActionLink to="/mobile/approvals" label="审批" icon="fact_check" /> : null}
|
||||
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
|
||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@ export function MobileMePage() {
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="外部通知推送">
|
||||
<SectionPanel title="企业微信消息提醒">
|
||||
<label className="flex items-center gap-3 text-sm text-zinc-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -149,8 +149,11 @@ export function MobileMePage() {
|
||||
disabled={!me || notificationPreferenceMutation.isPending}
|
||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||
/>
|
||||
接收外部渠道推送
|
||||
接收企业微信消息提醒
|
||||
</label>
|
||||
<p className="mt-2 text-xs leading-5 text-zinc-500">
|
||||
关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。
|
||||
</p>
|
||||
</SectionPanel>
|
||||
|
||||
<Button type="button" variant="secondary" className="w-full border-red-200 text-red-600 hover:bg-red-50" onClick={() => void logout()}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../api/notifications";
|
||||
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationItem } from "../api/notifications";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
@@ -9,8 +9,7 @@ import { Pagination } from "../components/common/Pagination";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { resolveNotificationTargetLink } from "../lib/notificationLinks";
|
||||
import { resolveMobileEquivalentPath } from "../lib/runtimeContext";
|
||||
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -33,19 +32,13 @@ function resolveNotificationError(error: unknown) {
|
||||
return "加载通知失败";
|
||||
}
|
||||
|
||||
function resolveMobileNotificationTarget(item: Parameters<typeof resolveNotificationTargetLink>[0]) {
|
||||
const targetLink = resolveNotificationTargetLink(item);
|
||||
if (!targetLink) return null;
|
||||
const [pathname, search = ""] = targetLink.split("?");
|
||||
return resolveMobileEquivalentPath(pathname, search ? `?${search}` : "");
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export function MobileNotificationsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0";
|
||||
@@ -82,6 +75,21 @@ export function MobileNotificationsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const openNotificationTarget = async (item: NotificationItem, targetLink: string | null) => {
|
||||
if (!targetLink) {
|
||||
toast.info("这条通知没有可直达的业务详情");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!item.readAt) {
|
||||
await markReadMutation.mutateAsync(item.id);
|
||||
}
|
||||
navigate(targetLink);
|
||||
} catch (error) {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: () => markAllNotificationsRead(),
|
||||
onSuccess: async () => {
|
||||
@@ -174,9 +182,15 @@ export function MobileNotificationsPage() {
|
||||
) : null}
|
||||
|
||||
{notificationItems.map((item) => {
|
||||
const targetLink = resolveMobileNotificationTarget(item);
|
||||
const targetLink = resolveMobileNotificationTargetLink(item);
|
||||
return (
|
||||
<article data-testid="mobile-notification-card" key={item.id} className="mobile-list-card">
|
||||
<button
|
||||
data-testid="mobile-notification-card"
|
||||
key={item.id}
|
||||
className="mobile-list-card w-full text-left"
|
||||
type="button"
|
||||
onClick={() => void openNotificationTarget(item, targetLink)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
|
||||
@@ -188,28 +202,8 @@ export function MobileNotificationsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</div>
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
{targetLink ? (
|
||||
<Button asChild size="sm" variant="secondary" data-testid="mobile-notification-target-link">
|
||||
<Link to={targetLink}>查看</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{!item.readAt ? (
|
||||
<Button
|
||||
className={targetLink ? "" : "col-span-2"}
|
||||
data-testid="mobile-notification-mark-read"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={markReadMutation.isPending}
|
||||
onClick={() => markReadMutation.mutate(item.id)}
|
||||
>
|
||||
标为已读
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
<div className="mt-3 text-xs text-primary">{targetLink ? "查看详情" : "暂无直达详情"}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { isApiError } from "../api/types";
|
||||
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||
import { businessEntityLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
||||
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
@@ -22,7 +24,6 @@ import {
|
||||
MOBILE_LEAVE_PERMISSIONS,
|
||||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||
} from "../features/mobile/navigation";
|
||||
import { buildMobileApprovalsPath } from "../pwa";
|
||||
|
||||
type MobileAction = {
|
||||
key: string;
|
||||
@@ -97,14 +98,6 @@ function attendanceExemptionLabel(reason: unknown) {
|
||||
return "无需打卡";
|
||||
}
|
||||
|
||||
function workflowEntityLabel(entityType: string | null | undefined) {
|
||||
if (entityType === "leave") return "请假";
|
||||
if (entityType === "reimbursement") return "报销";
|
||||
if (entityType === "purchase_request") return "采购";
|
||||
if (entityType === "travel_request") return "差旅";
|
||||
return "审批";
|
||||
}
|
||||
|
||||
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
|
||||
if (!attendanceToday) {
|
||||
return null;
|
||||
@@ -180,7 +173,7 @@ export function MobilePortalPage() {
|
||||
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "notifications"],
|
||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
|
||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
|
||||
});
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
@@ -219,25 +212,22 @@ export function MobilePortalPage() {
|
||||
const pendingApprovalCount = canAccessApprovals
|
||||
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||||
: 0;
|
||||
const overdueApprovalCount = canAccessApprovals ? workflowSummaryQuery.data?.overdueTotal ?? 0 : 0;
|
||||
const workflowEntityTypeCount = workflowSummaryQuery.data?.byEntityType?.length ?? 0;
|
||||
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
||||
const notifications = notificationsQuery.data?.items ?? [];
|
||||
const homeNotifications = notifications
|
||||
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
||||
.slice(0, 3);
|
||||
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
||||
|
||||
const actions: MobileAction[] = [
|
||||
canAccessApprovals
|
||||
const primaryActions = [
|
||||
canAccessReimbursements
|
||||
? {
|
||||
key: "approvals",
|
||||
label: "审批",
|
||||
meta: overdueApprovalCount > 0
|
||||
? `${pendingApprovalCount} 待处理 / ${overdueApprovalCount} 超时`
|
||||
: pendingApprovalCount > 0
|
||||
? `${pendingApprovalCount} 条待处理`
|
||||
: "暂无待办",
|
||||
icon: "fact_check",
|
||||
to: "/mobile/approvals",
|
||||
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
||||
key: "reimbursements",
|
||||
label: "报销",
|
||||
meta: "新建与进度",
|
||||
icon: "request_quote",
|
||||
to: "/mobile/reimbursements",
|
||||
}
|
||||
: null,
|
||||
canAccessCapture
|
||||
@@ -250,15 +240,14 @@ export function MobilePortalPage() {
|
||||
tone: "primary",
|
||||
}
|
||||
: null,
|
||||
canAccessReimbursements
|
||||
? {
|
||||
key: "reimbursements",
|
||||
label: "报销",
|
||||
meta: "新建与进度",
|
||||
icon: "request_quote",
|
||||
to: "/mobile/reimbursements",
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "approvals",
|
||||
label: canAccessApprovals ? "审批" : "进度",
|
||||
meta: canAccessApprovals ? "待办与已处理" : "我的申请进度",
|
||||
icon: "fact_check",
|
||||
to: "/mobile/approvals",
|
||||
tone: "primary",
|
||||
},
|
||||
{
|
||||
key: "me",
|
||||
label: "我的",
|
||||
@@ -266,6 +255,8 @@ export function MobilePortalPage() {
|
||||
icon: "person",
|
||||
to: "/mobile/me",
|
||||
},
|
||||
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
|
||||
const secondaryActions = [
|
||||
canAccessLeave
|
||||
? {
|
||||
key: "leave",
|
||||
@@ -275,16 +266,69 @@ export function MobilePortalPage() {
|
||||
to: "/mobile/leave",
|
||||
}
|
||||
: null,
|
||||
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
|
||||
].filter((item): item is MobileAction => Boolean(item));
|
||||
const actions = [...primaryActions, ...secondaryActions].slice(0, 4);
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader title="今天" />
|
||||
|
||||
{canAccessApprovals && pendingApprovalCount > 0 ? (
|
||||
<section className="mobile-card" data-testid="mobile-pending-summary">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-base font-semibold text-zinc-900">待处理</div>
|
||||
<span className="badge-warning">{pendingApprovalCount} 待批</span>
|
||||
</div>
|
||||
{workflowSummaryQuery.data ? (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.pendingTotal}</div>
|
||||
<div className="mt-1 text-zinc-500">待办</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className={workflowSummaryQuery.data.overdueTotal > 0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
|
||||
{workflowSummaryQuery.data.overdueTotal}
|
||||
</div>
|
||||
<div className="mt-1 text-zinc-500">超时</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className="font-semibold text-zinc-900">{workflowEntityTypeCount}</div>
|
||||
<div className="mt-1 text-zinc-500">类型</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{pendingTasks.slice(0, 3).map((task) => {
|
||||
const entityType = task.workflowInstance?.entityType;
|
||||
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
|
||||
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 truncate font-medium text-zinc-900">
|
||||
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
|
||||
</div>
|
||||
<span className="badge-warning shrink-0">待处理</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-zinc-500">
|
||||
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||||
<Link to="/mobile/approvals">进入审批</Link>
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mobile-card" 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>
|
||||
{pendingApprovalCount > 0 ? <span className="badge-warning">{pendingApprovalCount} 待批</span> : null}
|
||||
<div className="text-base font-semibold text-zinc-900">常用</div>
|
||||
</div>
|
||||
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
|
||||
{actions.map((action) => (
|
||||
@@ -338,58 +382,6 @@ export function MobilePortalPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{canAccessApprovals ? (
|
||||
<section className="mobile-card" 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>
|
||||
|
||||
{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}
|
||||
{workflowSummaryQuery.data ? (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.pendingTotal}</div>
|
||||
<div className="mt-1 text-zinc-500">待办</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className={workflowSummaryQuery.data.overdueTotal > 0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
|
||||
{workflowSummaryQuery.data.overdueTotal}
|
||||
</div>
|
||||
<div className="mt-1 text-zinc-500">超时</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className="font-semibold text-zinc-900">{workflowEntityTypeCount}</div>
|
||||
<div className="mt-1 text-zinc-500">类型</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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="mobile-card" 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>
|
||||
@@ -404,15 +396,15 @@ export function MobilePortalPage() {
|
||||
{resolveErrorMessage(notificationsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{!notificationsQuery.isLoading && !notifications.length ? (
|
||||
{!notificationsQuery.isLoading && !homeNotifications.length ? (
|
||||
<p className="text-sm text-zinc-500">暂无通知。</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{notifications.map((item) => (
|
||||
{homeNotifications.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to="/mobile/notifications"
|
||||
to={resolveMobileNotificationTargetLink(item) ?? "/mobile/notifications"}
|
||||
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -96,7 +96,7 @@ export function MobileProfilePage() {
|
||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("外部推送偏好已更新");
|
||||
toast.success("企业微信消息提醒偏好已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveProfileError(error));
|
||||
@@ -144,9 +144,9 @@ export function MobileProfilePage() {
|
||||
</dl>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="外部通知推送">
|
||||
<SectionPanel title="企业微信消息提醒">
|
||||
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
|
||||
<span>接收外部渠道推送</span>
|
||||
<span>接收企业微信消息提醒</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-5 w-5 rounded border-zinc-300"
|
||||
@@ -155,6 +155,9 @@ export function MobileProfilePage() {
|
||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-xs leading-5 text-zinc-500">
|
||||
关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。
|
||||
</p>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="企业微信绑定">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
formatApprovalHistoryTime,
|
||||
resolvePurchaseDisplayCode,
|
||||
resolvePurchaseStatusHint,
|
||||
workflowActionLabel,
|
||||
} from "../lib/approvalDisplay";
|
||||
import { joinBusinessMeta } from "../lib/businessDisplay";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
function formatCurrency(value: string | null | undefined, currencyCode = "CNY") {
|
||||
const amount = Number(value ?? 0);
|
||||
if (!Number.isFinite(amount)) {
|
||||
return "-";
|
||||
}
|
||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
return value ? value.slice(0, 10) : "-";
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function MobilePurchaseRequestDetailPage() {
|
||||
const { purchaseRequestId = "" } = useParams();
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(purchaseRequestId),
|
||||
queryKey: ["mobile", "purchase-request", "detail", purchaseRequestId],
|
||||
queryFn: () => fetchPurchaseRequestDetail(purchaseRequestId),
|
||||
});
|
||||
|
||||
const detail = detailQuery.data ?? null;
|
||||
const displayCode = detail ? resolvePurchaseDisplayCode(detail) : "采购详情";
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-3 pb-4">
|
||||
<PageHeader
|
||||
title="采购详情"
|
||||
meta={detail ? <StatusBadge status={detail.status} /> : null}
|
||||
actions={
|
||||
<Button asChild size="sm" type="button" variant="secondary">
|
||||
<Link to="/mobile/approvals">返回</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{!purchaseRequestId ? (
|
||||
<div className="mobile-card text-sm text-red-600">缺少采购申请 ID。</div>
|
||||
) : null}
|
||||
|
||||
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
|
||||
|
||||
{detailQuery.isError ? (
|
||||
<div className="mobile-card text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</div>
|
||||
) : null}
|
||||
|
||||
{detail ? (
|
||||
<>
|
||||
<section className="mobile-card" data-testid="mobile-purchase-detail-summary">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-zinc-900">{displayCode}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">{detail.title || "未命名采购申请"}</p>
|
||||
</div>
|
||||
<StatusBadge status={detail.status} />
|
||||
</div>
|
||||
<div className="mobile-info-grid mt-4 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">金额</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">
|
||||
{formatCurrency(detail.totalAmount, detail.currencyCode)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">申请人</div>
|
||||
<div className="mt-1 truncate font-medium text-zinc-900">{detail.applicant?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600">
|
||||
{resolvePurchaseStatusHint(detail)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-purchase-detail-fields">
|
||||
<h3 className="text-base font-semibold text-zinc-900">业务信息</h3>
|
||||
<div className="mt-3 space-y-2 text-sm text-zinc-700">
|
||||
<div>{joinBusinessMeta([`供应商:${detail.supplier?.name ?? "-"}`, `期望到货:${formatDate(detail.expectedDeliveryDate)}`])}</div>
|
||||
<div>{joinBusinessMeta([`法体:${detail.legalEntity?.name ?? "-"}`, `成本中心:${detail.costCenter?.name ?? "-"}`])}</div>
|
||||
<div>{joinBusinessMeta([`预算池:${detail.budgetPool?.name ?? "未匹配"}`, `当前节点:${detail.pendingWorkflow?.task?.stepName ?? "-"}`])}</div>
|
||||
{detail.reason ? <p className="leading-6 text-zinc-500">{detail.reason}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-purchase-detail-items">
|
||||
<h3 className="text-base font-semibold text-zinc-900">采购明细</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.items.length ? detail.items.map((item) => (
|
||||
<article className="mobile-list-card text-sm" key={item.id}>
|
||||
<div className="font-medium text-zinc-900">{item.description}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{joinBusinessMeta([`数量:${item.quantity}`, `单价:${formatCurrency(item.unitPrice, detail.currencyCode)}`, `金额:${formatCurrency(item.lineAmount, detail.currencyCode)}`])}
|
||||
</div>
|
||||
{item.specification ? <div className="mt-1 text-xs text-zinc-500">{item.specification}</div> : null}
|
||||
</article>
|
||||
)) : <div className="mobile-empty-state">暂无采购明细</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-purchase-detail-attachments">
|
||||
<h3 className="text-base font-semibold text-zinc-900">附件</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.attachments.length ? detail.attachments.map((attachment) => (
|
||||
<article className="mobile-list-card text-sm" key={attachment.id}>
|
||||
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{joinBusinessMeta([formatBytes(attachment.fileSize), attachment.uploader?.name, formatDate(attachment.createdAt)])}
|
||||
</div>
|
||||
</article>
|
||||
)) : <div className="mobile-empty-state">暂无附件</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-purchase-detail-approval-history">
|
||||
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.approvalHistory.length ? detail.approvalHistory.map((item) => (
|
||||
<article className="mobile-list-card text-sm" key={item.id}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</div>
|
||||
<span className="text-xs text-zinc-500">{formatApprovalHistoryTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.actorName ?? item.actorEmployeeNo ?? "-"}</div>
|
||||
{item.comment ? <div className="mt-1 text-xs text-zinc-500">{item.comment}</div> : null}
|
||||
</article>
|
||||
)) : <div className="mobile-empty-state">暂无审批记录</div>}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobilePurchaseRequestDetailPage;
|
||||
@@ -73,7 +73,7 @@ export function ProfilePage() {
|
||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("外部推送偏好已更新");
|
||||
toast.success("企业微信消息提醒偏好已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveProfileError(error));
|
||||
@@ -137,7 +137,7 @@ export function ProfilePage() {
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="外部通知推送" description="控制是否接收企微等外部渠道推送;关闭后仅保留站内通知,不影响企业微信绑定与工作台登录。">
|
||||
<SectionPanel title="企业微信消息提醒" description="控制是否接收企业微信个人应用消息;关闭后仍保留 OA 站内通知,不影响企业微信绑定与工作台登录。">
|
||||
<div className="space-y-3 text-sm text-zinc-600">
|
||||
<label className="flex items-center gap-3 text-zinc-800">
|
||||
<input
|
||||
@@ -147,7 +147,7 @@ export function ProfilePage() {
|
||||
disabled={!me || notificationPreferenceMutation.isPending}
|
||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||
/>
|
||||
接收外部渠道推送
|
||||
接收企业微信消息提醒
|
||||
</label>
|
||||
<p>适用于审批待办、报销付款、资产流转等业务提醒。关闭后不会停止站内通知,也不会移除你的业务权限。</p>
|
||||
</div>
|
||||
|
||||
+5
-2
@@ -65,8 +65,7 @@ export function buildMobileApprovalsPath(taskId?: string | null) {
|
||||
return "/mobile/approvals";
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ taskId: normalizedTaskId });
|
||||
return `/mobile/approvals?${params.toString()}`;
|
||||
return `/mobile/approvals/${encodeURIComponent(normalizedTaskId)}`;
|
||||
}
|
||||
|
||||
export function extractWorkflowTaskId(pathname: string) {
|
||||
@@ -79,6 +78,10 @@ export function isOfflineSnapshotAllowedPath(pathname: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^\/mobile\/approvals\/[^/]+$/.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(extractWorkflowTaskId(pathname));
|
||||
}
|
||||
|
||||
|
||||
+54
-2
@@ -36,6 +36,7 @@ import {
|
||||
MobileNotificationsPage,
|
||||
MobilePortalPage,
|
||||
MobileProfilePage,
|
||||
MobilePurchaseRequestDetailPage,
|
||||
MobileReimbursementDetailPage,
|
||||
MobileReimbursementsPage,
|
||||
NotificationChannelsPage,
|
||||
@@ -345,6 +346,26 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/leave/:leaveRequestId"
|
||||
element={
|
||||
<RequirePermission anyOf={["leave.view_own", "leave.view_all"]}>
|
||||
{withRouteFallback(<MobileLeavePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/purchase-requests/:purchaseRequestId"
|
||||
element={
|
||||
<RequirePermission
|
||||
anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}
|
||||
>
|
||||
{withRouteFallback(<MobilePurchaseRequestDetailPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/assets/scan"
|
||||
element={
|
||||
@@ -359,9 +380,40 @@ export function AppRouter() {
|
||||
<Route
|
||||
path="mobile/approvals"
|
||||
element={
|
||||
<RequireBusinessApprover>
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
"expense.view_own",
|
||||
"leave.view_own",
|
||||
"procurement.view_own",
|
||||
"travel.view_own",
|
||||
"expense.approve",
|
||||
"leave.approve",
|
||||
"procurement.approve",
|
||||
"travel.approve",
|
||||
]}
|
||||
>
|
||||
{withRouteFallback(<MobileApprovalsPage />)}
|
||||
</RequireBusinessApprover>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/approvals/:taskId"
|
||||
element={
|
||||
<RequirePermission
|
||||
anyOf={[
|
||||
"expense.view_own",
|
||||
"leave.view_own",
|
||||
"procurement.view_own",
|
||||
"travel.view_own",
|
||||
"expense.approve",
|
||||
"leave.approve",
|
||||
"procurement.approve",
|
||||
"travel.approve",
|
||||
]}
|
||||
>
|
||||
{withRouteFallback(<MobileApprovalsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { MobilePurchaseRequestDetailPage as default } from "../../pages/MobilePurchaseRequestDetailPage";
|
||||
@@ -23,6 +23,7 @@ export const MobileMePage = lazy(() => import("./MobileMePage"));
|
||||
export const MobileNotificationsPage = lazy(() => import("./MobileNotificationsPage"));
|
||||
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
|
||||
export const MobileProfilePage = lazy(() => import("./MobileProfilePage"));
|
||||
export const MobilePurchaseRequestDetailPage = lazy(() => import("./MobilePurchaseRequestDetailPage"));
|
||||
export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage"));
|
||||
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
|
||||
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
gap: clamp(7px, 2vw, 9px);
|
||||
border: 1px solid rgb(228 228 231);
|
||||
border-radius: 14px;
|
||||
background: rgb(255 255 255);
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/mobile-oa-information-dedupe-governance");
|
||||
const IOS_WECOM_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
|
||||
type RolePreset = "employee" | "manager";
|
||||
|
||||
type MockNotification = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
targetType: string | null;
|
||||
targetId: string | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-dg7-mobile-information-dedupe", data };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
function screenshotPath(fileName: string) {
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
return path.join(SCREENSHOT_DIR, fileName.endsWith(".png") ? fileName : `${fileName}.png`);
|
||||
}
|
||||
|
||||
function buildMe(role: RolePreset) {
|
||||
return {
|
||||
id: `employee-dg7-${role}`,
|
||||
employeeNo: role === "manager" ? "M-DG7" : "E-DG7",
|
||||
name: role === "manager" ? "测试审批人" : "测试员工",
|
||||
phone: "13800000000",
|
||||
email: `${role}@hyinda.com`,
|
||||
departmentId: "dept-dg7",
|
||||
directManagerId: role === "manager" ? null : "employee-dg7-manager",
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: role === "manager" ? ["manager"] : ["employee"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 60,
|
||||
},
|
||||
wecomBinding: {
|
||||
externalUserId: `wxwork-dg7-${role}`,
|
||||
createdAt: "2026-05-12T08:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||||
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
const leaveType = {
|
||||
id: "leave-type-dg7",
|
||||
code: "annual",
|
||||
name: "年假",
|
||||
description: null,
|
||||
enabled: true,
|
||||
requiresAttachment: false,
|
||||
attachmentMimeTypes: [],
|
||||
sortOrder: 1,
|
||||
policy: {
|
||||
id: "leave-policy-dg7",
|
||||
annualQuotaDays: 5,
|
||||
enabled: true,
|
||||
description: null,
|
||||
},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const leaveDetail = {
|
||||
id: "leave-dg7-approved",
|
||||
ownerId: "employee-dg7-employee",
|
||||
departmentId: "dept-dg7",
|
||||
status: "approved",
|
||||
startDate: "2026-05-12",
|
||||
endDate: "2026-05-12",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
requestedDays: 1,
|
||||
reason: "年假",
|
||||
attachment: null,
|
||||
leaveType: {
|
||||
id: leaveType.id,
|
||||
code: leaveType.code,
|
||||
name: leaveType.name,
|
||||
requiresAttachment: false,
|
||||
attachmentMimeTypes: [],
|
||||
},
|
||||
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||
pendingWorkflow: null,
|
||||
approvalHistory: [
|
||||
{
|
||||
id: "history-leave-dg7",
|
||||
taskId: "task-leave-dg7",
|
||||
actorId: "employee-dg7-manager",
|
||||
actorName: "测试审批人",
|
||||
actorEmployeeNo: "M-DG7",
|
||||
action: "approve",
|
||||
comment: "同意",
|
||||
createdAt: "2026-05-12T09:00:00.000Z",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-05-12T08:00:00.000Z",
|
||||
updatedAt: "2026-05-12T09:00:00.000Z",
|
||||
};
|
||||
|
||||
const purchaseDetail = {
|
||||
id: "purchase-dg7-approved",
|
||||
requestNo: "CG20260512001",
|
||||
applicantId: "employee-dg7-employee",
|
||||
submitterId: "employee-dg7-employee",
|
||||
departmentId: "dept-dg7",
|
||||
legalEntityId: "legal-dg7",
|
||||
costCenterId: "cost-dg7",
|
||||
supplierId: "supplier-dg7",
|
||||
documentCategoryId: null,
|
||||
budgetPoolId: null,
|
||||
title: "办公用品采购",
|
||||
reason: "补充办公用品",
|
||||
currencyCode: "CNY",
|
||||
totalAmount: "320.00",
|
||||
expectedDeliveryDate: "2026-05-20",
|
||||
status: "approved",
|
||||
createdAt: "2026-05-12T08:00:00.000Z",
|
||||
updatedAt: "2026-05-12T09:00:00.000Z",
|
||||
applicant: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||
department: { id: "dept-dg7", name: "研发部" },
|
||||
legalEntity: { id: "legal-dg7", code: "HYD", name: "海音达" },
|
||||
costCenter: { id: "cost-dg7", code: "RD", name: "研发中心", legalEntityId: "legal-dg7" },
|
||||
supplier: { id: "supplier-dg7", code: "SUP-DG7", name: "测试供应商" },
|
||||
documentCategory: null,
|
||||
budgetPool: null,
|
||||
items: [
|
||||
{
|
||||
id: "purchase-item-dg7",
|
||||
description: "键盘",
|
||||
specification: "无线",
|
||||
quantity: "2",
|
||||
unitPrice: "160.00",
|
||||
lineAmount: "320.00",
|
||||
isAssetCandidate: false,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
id: "attachment-dg7",
|
||||
originalName: "采购说明.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 10240,
|
||||
uploadedBy: "employee-dg7-employee",
|
||||
createdAt: "2026-05-12T08:10:00.000Z",
|
||||
uploader: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||
},
|
||||
],
|
||||
pendingWorkflow: null,
|
||||
approvalHistory: [
|
||||
{
|
||||
id: "history-purchase-dg7",
|
||||
taskId: "task-purchase-dg7",
|
||||
actorId: "employee-dg7-manager",
|
||||
actorName: "测试审批人",
|
||||
actorEmployeeNo: "M-DG7",
|
||||
action: "approve",
|
||||
comment: "同意",
|
||||
createdAt: "2026-05-12T09:00:00.000Z",
|
||||
},
|
||||
],
|
||||
archiveOrder: null,
|
||||
permissions: {
|
||||
canEditDraft: false,
|
||||
canSubmit: false,
|
||||
canWithdraw: false,
|
||||
canArchiveOrder: false,
|
||||
},
|
||||
riskFlags: [],
|
||||
};
|
||||
|
||||
function buildNotifications() {
|
||||
return [
|
||||
{
|
||||
id: "notification-task-dg7",
|
||||
type: "workflow.task_assigned",
|
||||
title: "报销待审批",
|
||||
body: "报销单 BX20260512001 等待处理",
|
||||
targetType: "workflow_task",
|
||||
targetId: "task-dg7",
|
||||
readAt: null,
|
||||
createdAt: "2026-05-12T08:15:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "notification-purchase-dg7",
|
||||
type: "purchase_request.approved",
|
||||
title: "采购已通过",
|
||||
body: "采购单 CG20260512001 已通过",
|
||||
targetType: "purchase_request",
|
||||
targetId: purchaseDetail.id,
|
||||
readAt: null,
|
||||
createdAt: "2026-05-12T08:10:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "notification-leave-dg7",
|
||||
type: "leave.approved",
|
||||
title: "请假已通过",
|
||||
body: "年假申请已通过",
|
||||
targetType: "leave_request",
|
||||
targetId: leaveDetail.id,
|
||||
readAt: null,
|
||||
createdAt: "2026-05-12T08:05:00.000Z",
|
||||
},
|
||||
] satisfies MockNotification[];
|
||||
}
|
||||
|
||||
function workflowTaskSummary() {
|
||||
return {
|
||||
id: "task-dg7",
|
||||
instanceId: "instance-dg7",
|
||||
stepId: "step-dg7",
|
||||
stepName: "主管审批",
|
||||
assigneeId: "employee-dg7-manager",
|
||||
assigneeRole: null,
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
createdAt: "2026-05-12T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
workflowInstance: {
|
||||
entityType: "reimbursement",
|
||||
entityId: "reimbursement-dg7",
|
||||
currentStatus: "reviewing",
|
||||
},
|
||||
businessSummary: {
|
||||
reference: "BX20260512001",
|
||||
title: "差旅报销",
|
||||
applicantName: "测试员工",
|
||||
amount: "168.00",
|
||||
duration: null,
|
||||
businessStatus: "reviewing",
|
||||
},
|
||||
allowedActions: ["approve", "reject", "return", "transfer"],
|
||||
};
|
||||
}
|
||||
|
||||
function workflowTaskDetail() {
|
||||
return {
|
||||
task: {
|
||||
id: "task-dg7",
|
||||
instanceId: "instance-dg7",
|
||||
stepId: "step-dg7",
|
||||
stepName: "主管审批",
|
||||
assigneeId: "employee-dg7-manager",
|
||||
assigneeRole: null,
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
comment: null,
|
||||
createdAt: "2026-05-12T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
workflowInstance: {
|
||||
id: "instance-dg7",
|
||||
entityType: "reimbursement",
|
||||
currentStatus: "reviewing",
|
||||
currentStepId: "step-dg7",
|
||||
contextData: { standardViolations: [] },
|
||||
startedAt: "2026-05-12T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
reimbursement: {
|
||||
id: "reimbursement-dg7",
|
||||
formNumber: "BX20260512001",
|
||||
status: "reviewing",
|
||||
reimbursementDate: "2026-05-12",
|
||||
totalAmount: "168.00",
|
||||
description: "差旅报销",
|
||||
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||
},
|
||||
leaveRequest: null,
|
||||
purchaseRequest: null,
|
||||
travelRequest: null,
|
||||
allowedActions: ["approve", "reject", "return", "transfer"],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
function reimbursementListItem() {
|
||||
return {
|
||||
id: "reimbursement-dg7",
|
||||
formNumber: "BX20260512001",
|
||||
status: "reviewing",
|
||||
reimbursementDate: "2026-05-12",
|
||||
totalAmount: "168.00",
|
||||
description: "差旅报销",
|
||||
payment: {
|
||||
paymentVoucherNo: null,
|
||||
paymentDate: null,
|
||||
paymentChannel: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
},
|
||||
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||
project: null,
|
||||
pendingWorkflow: {
|
||||
instanceId: "instance-dg7",
|
||||
stepId: "step-dg7",
|
||||
allowedActions: [],
|
||||
task: {
|
||||
id: "task-dg7",
|
||||
stepName: "主管审批",
|
||||
assigneeId: "employee-dg7-manager",
|
||||
assigneeRole: null,
|
||||
},
|
||||
},
|
||||
createdAt: "2026-05-12T08:00:00.000Z",
|
||||
updatedAt: "2026-05-12T08:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function mockApis(page: Page, role: RolePreset, notifications: MockNotification[]) {
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathName = url.pathname;
|
||||
|
||||
if (pathName === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "ww-test",
|
||||
agentId: "1000002",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/mobile",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({ accessToken: `mock-dg7-${role}-token`, expiresIn: 900, mustChangePassword: false })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/me") {
|
||||
await fulfillJson(route, buildMe(role));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-dg7",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "wecom",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "mobile",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [
|
||||
navigationItem("portal", "/portal", "工作台", "home"),
|
||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
workspace: { id: "workspace-dg7", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: buildMe(role).name },
|
||||
cards: [],
|
||||
pendingTasks: { count: role === "manager" ? 1 : 0, href: "/workflow/tasks" },
|
||||
myApplications: [],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [],
|
||||
recentActivities: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/attendance/me/today") {
|
||||
await fulfillJson(route, { workDate: "2026-05-12", needClockIn: false, exemptionReason: null, item: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks/summary") {
|
||||
await fulfillJson(route, {
|
||||
scope: "mine",
|
||||
pendingTotal: role === "manager" ? 1 : 0,
|
||||
overdueTotal: 0,
|
||||
earliestPendingAt: role === "manager" ? "2026-05-12T08:00:00.000Z" : null,
|
||||
averagePendingAgeHours: role === "manager" ? 1 : 0,
|
||||
byEntityType: role === "manager" ? [{ key: "reimbursement", pendingTotal: 1, overdueTotal: 0 }] : [],
|
||||
byStep: role === "manager" ? [{ key: "主管审批", pendingTotal: 1, overdueTotal: 0 }] : [],
|
||||
byAssigneeRole: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 100),
|
||||
total: role === "manager" ? 1 : 0,
|
||||
items: role === "manager" ? [workflowTaskSummary()] : [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks/task-dg7") {
|
||||
await fulfillJson(route, workflowTaskDetail());
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/approvers") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/notifications" && request.method() === "GET") {
|
||||
const unreadOnly = url.searchParams.get("unreadOnly") === "1" || url.searchParams.get("unreadOnly") === "true";
|
||||
const typeFilter = url.searchParams.get("type");
|
||||
const types = typeFilter ? typeFilter.split(",") : null;
|
||||
const items = notifications.filter((item) => {
|
||||
if (unreadOnly && item.readAt) return false;
|
||||
if (types && !types.includes(item.type)) return false;
|
||||
return true;
|
||||
});
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||
total: items.length,
|
||||
unreadOnly,
|
||||
unreadCount: notifications.filter((item) => !item.readAt).length,
|
||||
items,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const markReadMatch = pathName.match(/^\/api\/notifications\/([^/]+)\/mark-read$/);
|
||||
if (markReadMatch && request.method() === "POST") {
|
||||
const item = notifications.find((candidate) => candidate.id === markReadMatch[1]);
|
||||
if (item) {
|
||||
item.readAt = "2026-05-12T08:20:00.000Z";
|
||||
}
|
||||
await fulfillJson(route, { id: markReadMatch[1], readAt: item?.readAt ?? null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/notifications/mark-all-read" && request.method() === "POST") {
|
||||
for (const item of notifications) {
|
||||
item.readAt = item.readAt ?? "2026-05-12T08:20:00.000Z";
|
||||
}
|
||||
await fulfillJson(route, { updatedCount: notifications.length });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/reimbursements" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||
total: 1,
|
||||
scope: "own",
|
||||
items: [reimbursementListItem()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/leave-types") {
|
||||
await fulfillJson(route, { items: [leaveType] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/leave-balances") {
|
||||
await fulfillJson(route, {
|
||||
employeeId: "employee-dg7-employee",
|
||||
items: [
|
||||
{
|
||||
leaveType,
|
||||
policy: leaveType.policy,
|
||||
granted: 5,
|
||||
adjusted: 0,
|
||||
reserved: 0,
|
||||
used: 1,
|
||||
available: 4,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-12-31",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/leave-requests/${leaveDetail.id}`) {
|
||||
await fulfillJson(route, leaveDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/leave-requests") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||
total: 1,
|
||||
scope: "own",
|
||||
items: [leaveDetail],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/purchase-requests/${purchaseDetail.id}`) {
|
||||
await fulfillJson(route, purchaseDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/purchase-requests" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||
total: 1,
|
||||
mine: true,
|
||||
items: [purchaseDetail],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { message: `unhandled mock api: ${pathName}` }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
async function loginForMobile(page: Page, role: RolePreset) {
|
||||
await page.goto("/login");
|
||||
const accountLoginButton = page.getByRole("button", { name: "使用账号密码登录" });
|
||||
if (await accountLoginButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await accountLoginButton.click();
|
||||
}
|
||||
await expect(page.getByTestId("login-identifier")).toBeVisible();
|
||||
await page.getByTestId("login-identifier").fill(`${role}@hyinda.com`);
|
||||
await page.getByTestId("login-password").fill("password");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
}
|
||||
|
||||
async function newMobilePage(browser: Browser, width = 390) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: width < 360 ? 568 : 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: IOS_WECOM_UA,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
return { context, page: await context.newPage() };
|
||||
}
|
||||
|
||||
async function expectNoMobileShellRegression(page: Page) {
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
const metrics = await page.evaluate(() => {
|
||||
function isVisible(element: Element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||||
}
|
||||
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
||||
visibleTables: Array.from(document.querySelectorAll("table")).filter(isVisible).length,
|
||||
pcShellGridCount: document.querySelectorAll(".page-shell-grid").length,
|
||||
};
|
||||
});
|
||||
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
|
||||
expect(metrics.visibleTables).toBe(0);
|
||||
expect(metrics.pcShellGridCount).toBe(0);
|
||||
}
|
||||
|
||||
async function expectActionTileGapStable(page: Page) {
|
||||
const gaps = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>("[data-testid='mobile-action-grid'] a")).map((tile) => {
|
||||
const icon = tile.querySelector<HTMLElement>("[data-testid$='-icon']");
|
||||
const label = tile.querySelector<HTMLElement>("[data-testid$='-label']");
|
||||
if (!icon || !label) return null;
|
||||
return label.getBoundingClientRect().top - icon.getBoundingClientRect().bottom;
|
||||
}).filter((value): value is number => value !== null),
|
||||
);
|
||||
expect(gaps.length).toBeGreaterThan(0);
|
||||
for (const gap of gaps) {
|
||||
expect(gap).toBeGreaterThanOrEqual(5);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("DG7 移动信息架构与展示去重治理", () => {
|
||||
test.skip(process.env.E2E_MODE === "live", "DG7 本地专项使用 mock 数据验证信息架构。");
|
||||
|
||||
test("普通员工底部入口变为进度视角,首页宫格间距在多视口稳定", async ({ browser }) => {
|
||||
for (const width of [320, 360, 375, 390, 393, 414, 430]) {
|
||||
const notifications = buildNotifications();
|
||||
const mobile = await newMobilePage(browser, width);
|
||||
try {
|
||||
await mockApis(mobile.page, "employee", notifications);
|
||||
await loginForMobile(mobile.page, "employee");
|
||||
|
||||
await mobile.page.goto("/mobile");
|
||||
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
|
||||
await expect(mobile.page.getByTestId("mobile-action-approvals-label")).toHaveText("进度");
|
||||
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(0);
|
||||
await expectActionTileGapStable(mobile.page);
|
||||
await expectNoMobileShellRegression(mobile.page);
|
||||
|
||||
await mobile.page.getByTestId("mobile-nav-approvals").click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-progress")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
|
||||
await expectNoMobileShellRegression(mobile.page);
|
||||
await mobile.page.screenshot({ path: screenshotPath(`employee-progress-w${width}`), fullPage: true });
|
||||
} finally {
|
||||
await mobile.context.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("审批人首页只保留一处待办摘要,审批详情使用 Push 页并隐藏底部 Tab", async ({ browser }) => {
|
||||
const notifications = buildNotifications();
|
||||
const mobile = await newMobilePage(browser, 390);
|
||||
try {
|
||||
await mockApis(mobile.page, "manager", notifications);
|
||||
await loginForMobile(mobile.page, "manager");
|
||||
|
||||
await mobile.page.goto("/mobile");
|
||||
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批");
|
||||
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(1);
|
||||
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
|
||||
await expect(mobile.page.getByTestId("mobile-action-approvals")).not.toContainText("待批");
|
||||
|
||||
const badgeMetrics = await mobile.page.evaluate(() => {
|
||||
const nav = document.querySelector<HTMLElement>("[data-testid='mobile-nav-approvals']");
|
||||
const icon = nav?.querySelector<HTMLElement>("[aria-hidden='true']");
|
||||
const badge = nav?.querySelector<HTMLElement>("[data-testid='mobile-nav-approvals-badge']");
|
||||
if (!icon || !badge) return null;
|
||||
const iconRect = icon.getBoundingClientRect();
|
||||
const badgeRect = badge.getBoundingClientRect();
|
||||
return {
|
||||
iconLeft: iconRect.left,
|
||||
iconRight: iconRect.right,
|
||||
badgeLeft: badgeRect.left,
|
||||
badgeRight: badgeRect.right,
|
||||
};
|
||||
});
|
||||
expect(badgeMetrics).not.toBeNull();
|
||||
expect(badgeMetrics!.badgeLeft).toBeLessThanOrEqual(badgeMetrics!.iconRight + 4);
|
||||
expect(badgeMetrics!.badgeRight).toBeGreaterThanOrEqual(badgeMetrics!.iconLeft - 14);
|
||||
|
||||
await mobile.page.getByTestId("mobile-pending-summary").getByRole("link").first().click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-detail")).not.toContainText("业务单号");
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toContainText("报销单 BX20260512001");
|
||||
await expectNoMobileShellRegression(mobile.page);
|
||||
await mobile.page.screenshot({ path: screenshotPath("manager-approval-push-detail"), fullPage: true });
|
||||
} finally {
|
||||
await mobile.context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("通知中心点击通知直达请假和采购详情,不再回到通知列表或首页", async ({ browser }) => {
|
||||
const notifications = buildNotifications();
|
||||
const mobile = await newMobilePage(browser, 390);
|
||||
try {
|
||||
await mockApis(mobile.page, "employee", notifications);
|
||||
await loginForMobile(mobile.page, "employee");
|
||||
|
||||
await mobile.page.goto("/mobile/notifications");
|
||||
await expect(mobile.page.getByTestId("mobile-notification-card")).toHaveCount(3);
|
||||
|
||||
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "采购已通过" }).click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/purchase-requests\/purchase-dg7-approved$/);
|
||||
await expect(mobile.page.getByTestId("mobile-purchase-detail-summary")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||
await expectNoMobileShellRegression(mobile.page);
|
||||
|
||||
await mobile.page.goto("/mobile/notifications");
|
||||
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "请假已通过" }).click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/leave\/leave-dg7-approved$/);
|
||||
await expect(mobile.page.getByTestId("mobile-leave-record-card")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||
await expectNoMobileShellRegression(mobile.page);
|
||||
await mobile.page.screenshot({ path: screenshotPath("notification-direct-leave-detail"), fullPage: true });
|
||||
} finally {
|
||||
await mobile.context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户