From cf2bba02d51a7845186c6a0c8e9c117f65d236be Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 13 May 2026 10:17:50 +0800 Subject: [PATCH] =?UTF-8?q?MIF7T-1/MIF7T-7=20=E6=94=B6=E5=8F=A3=E7=9C=9F?= =?UTF-8?q?=E6=9C=BA=E5=8F=8D=E9=A6=88=E5=B9=B6=E7=BB=9F=E4=B8=80=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/mobile/MobileDetailHeader.tsx | 18 +- client/src/components/mobile/MobileIcon.tsx | 4 + .../src/components/patterns/PageScaffold.tsx | 12 +- client/src/features/mobile/navigation.ts | 13 + client/src/features/navigation/schema.ts | 3 +- client/src/lib/runtimeContext.ts | 2 +- client/src/pages/MobileApprovalsPage.tsx | 34 +- client/src/pages/MobileLeavePage.tsx | 369 +++++++++++------- client/src/pages/MobileMePage.tsx | 3 + client/src/pages/MobilePortalPage.tsx | 18 +- .../pages/MobilePurchaseRequestDetailPage.tsx | 36 +- .../src/pages/MobilePurchaseRequestsPage.tsx | 164 ++++++++ client/src/pages/MobileReimbursementsPage.tsx | 66 ++-- client/src/router/index.tsx | 12 + .../router/lazy/MobilePurchaseRequestsPage.ts | 1 + client/src/router/lazy/index.ts | 1 + client/src/styles/mobile-touch.css | 26 ++ .../e2e/dg7-mobile-information-dedupe.spec.ts | 29 +- .../e2e/permission-direct-access.spec.ts | 13 +- .../e2e/r101-mobile-wip-closeout.spec.ts | 11 +- .../e2e/r108-mobile-wecom-visual.spec.ts | 28 +- ...r109-mobile-reimbursement-feedback.spec.ts | 61 +-- .../e2e/r124-mobile-entry-matrix.spec.ts | 4 +- ...r6-mobile-responsive-system-hotfix.spec.ts | 16 +- .../r6-mobile-unified-design-rollout.spec.ts | 43 +- ...6-wecom-mobile-rendering-hardening.spec.ts | 12 +- docs/trialrun/人工试运行报告.md | 46 ++- ...41-00-r6-mobile-experience-second-pass.csv | 2 +- ...12_21-48-40-mobile-ia-flow-remediation.csv | 2 +- ...eal-device-feedback-mobile-unification.csv | 8 + 30 files changed, 793 insertions(+), 264 deletions(-) create mode 100644 client/src/pages/MobilePurchaseRequestsPage.tsx create mode 100644 client/src/router/lazy/MobilePurchaseRequestsPage.ts create mode 100644 issues/2026-05-13_09-18-05-mif7-real-device-feedback-mobile-unification.csv diff --git a/client/src/components/mobile/MobileDetailHeader.tsx b/client/src/components/mobile/MobileDetailHeader.tsx index c1ecd37f..3bacd924 100644 --- a/client/src/components/mobile/MobileDetailHeader.tsx +++ b/client/src/components/mobile/MobileDetailHeader.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { ArrowLeft } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import { Button } from "../ui/button"; @@ -26,14 +28,26 @@ export function MobileDetailHeader({ title, subtitle, status, fallbackTo, testId const currentPath = `${location.pathname}${location.search}`; const from = isSafeMobileSource(state?.from, currentPath) ? state?.from : null; + useEffect(() => { + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: "auto" }); + document.querySelector(".hyb-mobile-shell .overflow-y-auto")?.scrollTo({ top: 0, behavior: "auto" }); + }; + + scrollToTop(); + const frameId = window.requestAnimationFrame(scrollToTop); + return () => window.cancelAnimationFrame(frameId); + }, [currentPath]); + const handleBack = () => { navigate(from ?? fallbackTo, { replace: true }); }; return (
-
{title}
diff --git a/client/src/components/mobile/MobileIcon.tsx b/client/src/components/mobile/MobileIcon.tsx index 5d155fe9..4fb4f63d 100644 --- a/client/src/components/mobile/MobileIcon.tsx +++ b/client/src/components/mobile/MobileIcon.tsx @@ -12,8 +12,10 @@ import { IdCard, Network, PackageSearch, + QrCode, ReceiptText, Settings, + ShoppingCart, UserCog, UserRound, Users, @@ -34,9 +36,11 @@ const MOBILE_ICON_MAP: Record = { notifications: Bell, person: UserRound, picture_as_pdf: FileText, + qr_code_scanner: QrCode, receipt_long: ReceiptText, request_quote: FileCheck2, settings: Settings, + shopping_cart: ShoppingCart, workspaces: BriefcaseBusiness, }; diff --git a/client/src/components/patterns/PageScaffold.tsx b/client/src/components/patterns/PageScaffold.tsx index 42ffd67b..771b00f8 100644 --- a/client/src/components/patterns/PageScaffold.tsx +++ b/client/src/components/patterns/PageScaffold.tsx @@ -1,6 +1,12 @@ -import type { ReactNode } from "react"; +import type { ComponentPropsWithoutRef } from "react"; import { cn } from "../../lib/utils"; -export function PageScaffold({ className, children }: { className?: string; children: ReactNode }) { - return
{children}
; +type PageScaffoldProps = ComponentPropsWithoutRef<"div">; + +export function PageScaffold({ className, children, ...props }: PageScaffoldProps) { + return ( +
+ {children} +
+ ); } diff --git a/client/src/features/mobile/navigation.ts b/client/src/features/mobile/navigation.ts index ee2162cd..fd840fdf 100644 --- a/client/src/features/mobile/navigation.ts +++ b/client/src/features/mobile/navigation.ts @@ -15,8 +15,21 @@ export const MOBILE_ASSET_SCAN_PERMISSIONS = [ "asset.inventory", ] as const; +export const MOBILE_HOME_ASSET_SCAN_PERMISSIONS = [ + "asset.manage", + "asset.transfer", + "asset.inventory", +] as const; + export const MOBILE_LEAVE_PERMISSIONS = ["leave.view_own", "leave.view_all"] as const; +export const MOBILE_PURCHASE_PERMISSIONS = [ + "procurement.create", + "procurement.view_own", + "procurement.view_all", + "procurement.manage", +] as const; + export const MOBILE_REIMBURSEMENT_PERMISSIONS = ["expense.view_own", "expense.view_all"] as const; export type MobileNavItem = { diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index d02e4a4f..5e987f1e 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -29,7 +29,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [ { path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" }, { path: "/mobile/leave", title: "移动请假", group: "移动工作台" }, { path: "/mobile/me", title: "移动我的", group: "移动工作台" }, - { path: "/mobile/purchase-requests", title: "移动采购详情", group: "移动工作台" }, + { path: "/mobile/purchase-requests/", title: "移动采购详情", group: "移动工作台" }, + { path: "/mobile/purchase-requests", title: "移动采购", group: "移动工作台" }, { path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" }, { path: "/mobile", title: "移动首页", group: "移动工作台" }, { path: "/settings/profile", title: "个人设置", group: "账号设置" }, diff --git a/client/src/lib/runtimeContext.ts b/client/src/lib/runtimeContext.ts index c9a24d4b..9aff04df 100644 --- a/client/src/lib/runtimeContext.ts +++ b/client/src/lib/runtimeContext.ts @@ -128,7 +128,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") { if (purchaseRequestId) { return `/mobile/purchase-requests/${encodeSegment(purchaseRequestId)}`; } - return "/mobile"; + return "/mobile/purchase-requests"; } const purchaseRequestDetailMatch = normalizedPath.match(/^\/purchase-requests\/([^/]+)$/); diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index f98e04b8..069a30fb 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -940,6 +940,7 @@ export function MobileApprovalsPage() { const listTitle = taskTab === "pending" ? "待我审批" : "已处理"; const allVisibleSelected = filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id)); + const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0; return ( @@ -1271,10 +1272,33 @@ export function MobileApprovalsPage() {
-
-
{joinBusinessMeta([`类型:${entityLabel(selectedTask.workflowInstance?.entityType)}`, `业务状态:${businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}`])}
-
{joinBusinessMeta([selectedApplicantName ? `申请人:${selectedApplicantName}` : null, selectedMetric ? `金额/天数:${selectedMetric}` : null])}
- {selectedTask.task.completedAt ?
完成时间:{formatDateTime(selectedTask.task.completedAt)}
: null} +
+
+
类型
+
{entityLabel(selectedTask.workflowInstance?.entityType)}
+
+
+
业务状态
+
{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}
+
+ {selectedApplicantName ? ( +
+
申请人
+
{selectedApplicantName}
+
+ ) : null} + {selectedMetric ? ( +
+
金额/天数
+
{selectedMetric}
+
+ ) : null} + {selectedTask.task.completedAt ? ( +
+
完成时间
+
{formatDateTime(selectedTask.task.completedAt)}
+
+ ) : null}
{selectedTask.reimbursement ? (
@@ -1404,7 +1428,7 @@ export function MobileApprovalsPage() { ) : null} - {!isPushDetail ? ( + {!isPushDetail && shouldShowDraftQueue ? (

离线草稿

diff --git a/client/src/pages/MobileLeavePage.tsx b/client/src/pages/MobileLeavePage.tsx index 97588820..0b8b2445 100644 --- a/client/src/pages/MobileLeavePage.tsx +++ b/client/src/pages/MobileLeavePage.tsx @@ -1,5 +1,5 @@ -import { useMemo, useRef, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useLocation, useParams, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -29,6 +29,7 @@ import { } from "../lib/approvalDisplay"; type LeaveStatusFilter = "all" | LeaveRequestStatus; +type LeaveView = "form" | "records"; const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [ { value: "all", label: "全部" }, @@ -53,6 +54,14 @@ function leaveProgressFallback(status: LeaveRequestStatus | undefined) { return "/mobile/approvals?progress=reviewing"; } +function isLeaveStatusFilter(value: string | null): value is LeaveStatusFilter { + return ["all", "draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? ""); +} + +function resolveLeaveView(value: string | null): LeaveView { + return value === "form" ? "form" : "records"; +} + function todayDate() { return new Date().toISOString().slice(0, 10); } @@ -70,20 +79,23 @@ function LeaveRecordCard({ isSaving, onEdit, onSubmit, + detailTo, + currentMobileSource, showActions = true, }: { item: LeaveRequestItem; isSaving: boolean; onEdit: (item: LeaveRequestItem) => void; onSubmit: (item: LeaveRequestItem) => void; + detailTo?: string; + currentMobileSource?: string; showActions?: boolean; }) { const canEdit = item.status === "draft" || item.status === "rejected"; const canSubmit = item.status === "draft" || item.status === "rejected"; const approvalHistory = item.approvalHistory ?? []; - - return ( -
+ const content = ( + <>
{item.leaveType?.name ?? "请假"}
@@ -115,16 +127,26 @@ function LeaveRecordCard({ {formatApprovalHistoryTime(approvalHistory[0].createdAt)}
) : null} + + ); + + return ( +
+ {detailTo ? ( + + {content} + + ) : content} {showActions && (canEdit || canSubmit) ? ( -
+
{canEdit ? ( - ) : null} {canSubmit ? ( - ) : null} @@ -136,10 +158,16 @@ function LeaveRecordCard({ export function MobileLeavePage() { const queryClient = useQueryClient(); + const location = useLocation(); const { leaveRequestId = "" } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); const { permissions } = useAuth(); const canCreateLeave = hasPermission(permissions, "leave.create"); - const [statusFilter, setStatusFilter] = useState("all"); + const [view, setView] = useState(() => resolveLeaveView(searchParams.get("view"))); + const [statusFilter, setStatusFilter] = useState(() => { + const queryStatus = searchParams.get("status"); + return isLeaveStatusFilter(queryStatus) ? queryStatus : "all"; + }); const [editingItem, setEditingItem] = useState(null); const [leaveTypeId, setLeaveTypeId] = useState(""); const [startDate, setStartDate] = useState(todayDate); @@ -149,6 +177,19 @@ export function MobileLeavePage() { const [reason, setReason] = useState(""); const [attachmentFile, setAttachmentFile] = useState(null); const attachmentInputRef = useRef(null); + const currentMobileSource = useMemo(() => { + const params = new URLSearchParams(location.search); + params.set("view", view); + params.set("status", statusFilter); + const query = params.toString(); + return `${location.pathname}${query ? `?${query}` : ""}`; + }, [location.pathname, location.search, statusFilter, view]); + + useEffect(() => { + setView(resolveLeaveView(searchParams.get("view"))); + const queryStatus = searchParams.get("status"); + setStatusFilter(isLeaveStatusFilter(queryStatus) ? queryStatus : "all"); + }, [searchParams]); const leaveTypesQuery = useQuery({ queryKey: ["mobile", "leave", "types"], @@ -273,6 +314,11 @@ export function MobileLeavePage() { const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending; const startEditing = (item: LeaveRequestItem) => { + setView("form"); + const nextParams = new URLSearchParams(searchParams); + nextParams.set("view", "form"); + nextParams.set("status", statusFilter); + setSearchParams(nextParams, { replace: true }); setEditingItem(item); setLeaveTypeId(item.leaveType?.id ?? ""); setStartDate(item.startDate); @@ -284,6 +330,26 @@ export function MobileLeavePage() { window.scrollTo({ top: 0, behavior: "smooth" }); }; + function handleViewChange(value: string) { + const nextView = resolveLeaveView(value); + setView(nextView); + const nextParams = new URLSearchParams(searchParams); + nextParams.set("view", nextView); + nextParams.set("status", statusFilter); + setSearchParams(nextParams, { replace: true }); + } + + function handleStatusChange(value: string) { + if (!isLeaveStatusFilter(value)) { + return; + } + setStatusFilter(value); + const nextParams = new URLSearchParams(searchParams); + nextParams.set("view", "records"); + nextParams.set("status", value); + setSearchParams(nextParams, { replace: true }); + } + if (leaveRequestId) { const detail = detailQuery.data; @@ -318,158 +384,172 @@ export function MobileLeavePage() { -
-
-
-
可用
-
{balanceSummary.available.toFixed(1)}
-
-
-
冻结
-
{balanceSummary.reserved.toFixed(1)}
-
-
-
已用
-
{balanceSummary.used.toFixed(1)}
-
-
+
+ + + 请假记录 + 新建请假 + +
-
-
-

{editingItem ? "编辑请假" : "新建请假"}

- {editingItem ? ( - - ) : null} -
+ {view === "form" ? ( + <> +
+
+
+
可用
+
{balanceSummary.available.toFixed(1)}
+
+
+
冻结
+
{balanceSummary.reserved.toFixed(1)}
+
+
+
已用
+
{balanceSummary.used.toFixed(1)}
+
+
+
-
- +
+
+

{editingItem ? "编辑请假" : "新建请假"}

+ {editingItem ? ( + + ) : null} +
-
- - -
+
+ -
- - -
+
+ + +
-