UI6 修复 iPhone11 企业微信移动导航
这个提交包含在:
@@ -34,10 +34,10 @@ export function MobileBottomNav() {
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 px-2 pb-[max(0.35rem,env(safe-area-inset-bottom))] pt-1.5 shadow-[0_-6px_18px_rgba(15,23,42,0.08)] backdrop-blur md:hidden"
|
||||
className="pointer-events-auto fixed inset-x-0 bottom-0 z-[70] min-h-[calc(var(--mobile-bottom-nav-height,64px)+env(safe-area-inset-bottom,0px))] transform-gpu border-t border-zinc-200 bg-white/95 px-2 pb-[calc(0.35rem+env(safe-area-inset-bottom,0px))] pt-1.5 shadow-[0_-6px_18px_rgba(15,23,42,0.08)] backdrop-blur md:hidden"
|
||||
data-testid="mobile-bottom-nav"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
<div className="relative z-[71] grid grid-cols-4 gap-1">
|
||||
{MOBILE_NAV_ITEMS.map((item) => {
|
||||
const disabled = item.anyOf ? !hasAnyPermission(permissions, [...item.anyOf]) : false;
|
||||
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
||||
@@ -48,13 +48,13 @@ export function MobileBottomNav() {
|
||||
key={item.key}
|
||||
type="button"
|
||||
data-testid={`mobile-nav-${item.key}`}
|
||||
className="touch-target flex h-12 min-h-0 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"
|
||||
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}不可用`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function MobileBottomNav() {
|
||||
data-testid={`mobile-nav-${item.key}`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none transition-colors",
|
||||
"touch-target pointer-events-auto 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 transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
@@ -89,7 +89,7 @@ export function MobileBottomNav() {
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
@@ -29,7 +29,7 @@ export type MobileNavItem = {
|
||||
|
||||
export const MOBILE_NAV_ITEMS: MobileNavItem[] = [
|
||||
{ key: "home", to: "/mobile", label: "首页", icon: "home" },
|
||||
{ key: "approvals", to: "/mobile/approvals", label: "审批", icon: "fact_check", anyOf: MOBILE_APPROVAL_PERMISSIONS },
|
||||
{ key: "approvals", to: "/mobile/approvals", label: "审批", icon: "fact_check" },
|
||||
{ key: "reimbursements", to: "/mobile/reimbursements", label: "报销", icon: "request_quote", anyOf: MOBILE_REIMBURSEMENT_PERMISSIONS },
|
||||
{ key: "me", to: "/mobile/me", label: "我的", icon: "person" },
|
||||
];
|
||||
|
||||
@@ -23,19 +23,23 @@ export function AppShellLayout() {
|
||||
}
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
const height = window.visualViewport?.height ?? window.innerHeight;
|
||||
const height = Math.max(320, Math.floor(window.visualViewport?.height ?? window.innerHeight));
|
||||
document.documentElement.style.setProperty("--mobile-viewport-height", `${height}px`);
|
||||
};
|
||||
|
||||
updateViewportHeight();
|
||||
const frameId = window.requestAnimationFrame(updateViewportHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
window.addEventListener("orientationchange", updateViewportHeight);
|
||||
window.visualViewport?.addEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.addEventListener("scroll", updateViewportHeight);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
window.removeEventListener("orientationchange", updateViewportHeight);
|
||||
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
|
||||
window.visualViewport?.removeEventListener("scroll", updateViewportHeight);
|
||||
};
|
||||
}, [isMobileRoute]);
|
||||
|
||||
@@ -57,11 +61,11 @@ export function AppShellLayout() {
|
||||
|
||||
<main
|
||||
className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "overflow-hidden" : "lg:ml-72"}`}
|
||||
style={isMobileRoute ? { height: "var(--mobile-viewport-height, 100vh)", minHeight: "var(--mobile-viewport-height, 100vh)" } : undefined}
|
||||
style={isMobileRoute ? { height: "var(--mobile-viewport-height, 100vh)", maxHeight: "var(--mobile-viewport-height, 100vh)", minHeight: "var(--mobile-viewport-height, 100vh)" } : undefined}
|
||||
>
|
||||
{isMobileRoute ? null : <Topbar />}
|
||||
|
||||
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? `min-h-0 overflow-y-auto pt-4 sm:px-5 sm:pt-5 ${showMobileBottomNav ? "pb-[calc(var(--mobile-bottom-nav-height,64px)+env(safe-area-inset-bottom,0px)+1rem)]" : "pb-5 sm:pb-6"}` : "sm:px-7 sm:py-7 lg:px-8"}`}>
|
||||
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? `min-h-0 overscroll-contain overflow-y-auto px-3 py-3 sm:px-5 sm:py-5 ${showMobileBottomNav ? "pb-[calc(var(--mobile-bottom-nav-height,64px)+env(safe-area-inset-bottom,0px)+1rem)]" : "pb-5 sm:pb-6"}` : "sm:px-7 sm:py-7 lg:px-8"}`}>
|
||||
<div className="layout-container max-w-none">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type WorkflowTaskSummary,
|
||||
} from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
@@ -24,6 +25,7 @@ import { Input } from "../components/ui/input";
|
||||
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 {
|
||||
cacheWorkflowTaskDetail,
|
||||
enqueueWorkflowActionDraft,
|
||||
@@ -252,7 +254,7 @@ function matchesSearch(card: VisibleTaskCard, keyword: string) {
|
||||
|
||||
export function MobileApprovalsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, accessToken, offlineAccessMode } = useAuth();
|
||||
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? "";
|
||||
|
||||
@@ -275,7 +277,8 @@ export function MobileApprovalsPage() {
|
||||
const detailSectionRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const ownerEmployeeId = me?.id ?? null;
|
||||
const canUseRemote = isOnline && Boolean(accessToken);
|
||||
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
||||
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -944,9 +947,11 @@ export function MobileApprovalsPage() {
|
||||
<p className="text-sm text-zinc-500">
|
||||
{taskTab === "completed"
|
||||
? "最近 30 天暂无符合筛选条件的已处理任务。"
|
||||
: canUseRemote
|
||||
? "暂无符合筛选条件的待办任务。"
|
||||
: "暂无符合筛选条件的已缓存待办。"}
|
||||
: !canAccessRemoteApprovals && isOnline
|
||||
? "暂无审批待办;收到企业微信审批消息后可从这里查看。"
|
||||
: canUseRemote
|
||||
? "暂无符合筛选条件的待办任务。"
|
||||
: "暂无符合筛选条件的已缓存待办。"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -358,11 +358,7 @@ export function AppRouter() {
|
||||
|
||||
<Route
|
||||
path="mobile/approvals"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]}>
|
||||
{withRouteFallback(<MobileApprovalsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
element={withRouteFallback(<MobileApprovalsPage />)}
|
||||
/>
|
||||
|
||||
<Route path="notifications" element={withRouteFallback(<NotificationsPage />)} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r5-real-device-feedback-mobile-ux");
|
||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r6-iphone11-wecom-mobile-layout-hotfix");
|
||||
const DEVICE_CASES = [
|
||||
{
|
||||
key: "iphone11-ios14",
|
||||
@@ -164,6 +164,26 @@ async function mockApis(page: Page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/approvers" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/expense-types") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
@@ -219,11 +239,22 @@ async function collectMobileMetrics(page: Page) {
|
||||
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
|
||||
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
const centerX = rect ? rect.left + rect.width / 2 : 0;
|
||||
const centerY = rect ? rect.top + rect.height / 2 : 0;
|
||||
const hit = rect ? document.elementFromPoint(centerX, centerY) : null;
|
||||
const hitOwner = hit instanceof Element ? hit.closest(`[data-testid='mobile-nav-${key}']`) : null;
|
||||
return {
|
||||
key,
|
||||
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
||||
width: rect?.width ?? 0,
|
||||
height: rect?.height ?? 0,
|
||||
centerX,
|
||||
centerY,
|
||||
hitWithin: Boolean(element && hitOwner === element),
|
||||
hitTarget:
|
||||
hit instanceof HTMLElement
|
||||
? hit.dataset.testid ?? hit.getAttribute("aria-label") ?? hit.tagName.toLowerCase()
|
||||
: null,
|
||||
};
|
||||
});
|
||||
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
||||
@@ -287,6 +318,19 @@ test.describe("R5 真机反馈移动交互", () => {
|
||||
expect(item.visible, `${device.label} ${item.key} nav item visible`).toBe(true);
|
||||
expect(item.width, `${device.label} ${item.key} nav item touch width`).toBeGreaterThanOrEqual(44);
|
||||
expect(item.height, `${device.label} ${item.key} nav item touch height`).toBeGreaterThanOrEqual(44);
|
||||
expect(item.hitWithin, `${device.label} ${item.key} nav item hit target: ${item.hitTarget}`).toBe(true);
|
||||
}
|
||||
|
||||
await page.getByTestId("mobile-nav-approvals").click();
|
||||
await expect(page).toHaveURL(/\/mobile\/approvals$/);
|
||||
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-approvals-tab-hit`) });
|
||||
const approvalsMetrics = await collectMobileMetrics(page);
|
||||
expect(approvalsMetrics.bottomNavVisible).toBe(true);
|
||||
expect(approvalsMetrics.bottomNavFixed).toBe(true);
|
||||
expect(approvalsMetrics.scrollWidth).toBeLessThanOrEqual(approvalsMetrics.width + 2);
|
||||
for (const item of approvalsMetrics.navItems) {
|
||||
expect(item.hitWithin, `${device.label} ${item.key} approvals page hit target: ${item.hitTarget}`).toBe(true);
|
||||
}
|
||||
|
||||
await page.goto("/mobile/reimbursements");
|
||||
|
||||
在新工单中引用
屏蔽一个用户