R89 A6-A7 移动触控与回归补齐

这个提交包含在:
2026-05-05 19:52:04 +08:00
父节点 ee64909811
当前提交 99ad4c6aa6
共修改 6 个文件,包含 546 行新增和 31 行删除
+40 -12
查看文件
@@ -230,6 +230,7 @@ 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 canUseRemote = isOnline && Boolean(accessToken);
@@ -258,6 +259,21 @@ export function MobileApprovalsPage() {
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();
}
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
@@ -781,11 +797,12 @@ export function MobileApprovalsPage() {
/>
{taskTab === "pending" ? (
<div className="flex flex-wrap items-center gap-2">
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
<Button
type="button"
size="sm"
variant={batchMode ? "default" : "secondary"}
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length}
onClick={() => {
setBatchMode((current) => !current);
@@ -800,13 +817,14 @@ export function MobileApprovalsPage() {
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length}
onClick={toggleSelectAllVisibleTasks}
>
{allVisibleSelected ? "清空选择" : "全选当前筛选结果"}
</Button>
) : null}
<span className="text-sm text-zinc-500">
<span className="text-sm text-zinc-500 sm:ml-1">
当前结果 {filteredTaskCards.length} 条
{batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""}
</span>
@@ -820,10 +838,11 @@ export function MobileApprovalsPage() {
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div>
<div className="flex flex-wrap gap-2">
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
onClick={() => handleBatchAction("approve")}
>
@@ -833,6 +852,7 @@ export function MobileApprovalsPage() {
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
onClick={() => handleBatchAction("reject")}
>
@@ -899,7 +919,7 @@ export function MobileApprovalsPage() {
/>
<button
type="button"
className="min-w-0 flex-1 text-left"
className="min-h-16 min-w-0 flex-1 text-left"
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => toggleBatchTask(task.id)}
>
@@ -918,11 +938,11 @@ export function MobileApprovalsPage() {
return (
<button
key={task.id}
className={`w-full rounded-xl border px-3 py-3 text-left ${
className={`min-h-20 w-full rounded-xl border px-3 py-3 text-left transition-colors ${
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
}`}
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => updateTaskSelection(task.id)}
onClick={() => handleTaskSelection(task.id)}
type="button"
>
<div className="flex items-center justify-between gap-3">
@@ -941,7 +961,7 @@ export function MobileApprovalsPage() {
</div>
</section>
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-detail">
<section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" 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}
@@ -1021,8 +1041,9 @@ export function MobileApprovalsPage() {
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
) : null}
<div className="flex flex-wrap gap-2">
<div className="grid gap-2 sm:grid-cols-3">
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-approve"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
onClick={() => {
@@ -1037,6 +1058,7 @@ export function MobileApprovalsPage() {
{canUseRemote ? "通过" : "离线加入通过草稿"}
</Button>
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-reject"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
onClick={() => {
@@ -1052,6 +1074,7 @@ export function MobileApprovalsPage() {
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
</Button>
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-return"
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
onClick={() => actionMutation.mutate("return")}
@@ -1063,7 +1086,7 @@ export function MobileApprovalsPage() {
</div>
{canTransfer ? (
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3 text-sm">
<p className="font-medium text-zinc-800">转审</p>
<select
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
@@ -1088,6 +1111,7 @@ export function MobileApprovalsPage() {
/>
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-transfer"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
onClick={() => actionMutation.mutate("transfer")}
@@ -1111,8 +1135,9 @@ export function MobileApprovalsPage() {
</div>
{currentQueueItems.length ? (
<div className="flex flex-wrap gap-2">
<div className="grid gap-2 sm:flex sm:flex-wrap">
<Button
className="min-h-11 w-full sm:w-auto"
data-testid="mobile-approvals-sync-queue"
disabled={!canUseRemote || syncMutation.isPending || !retryableQueueItems.length}
onClick={() => syncMutation.mutate(retryableQueueItems)}
@@ -1135,12 +1160,13 @@ export function MobileApprovalsPage() {
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
<div className="mt-2 flex flex-wrap gap-2">
<div className="mt-2 grid gap-2 sm:flex sm:flex-wrap">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => updateTaskSelection(item.taskId)}
className="min-h-11 w-full sm:w-auto"
onClick={() => handleTaskSelection(item.taskId)}
>
查看任务
</Button>
@@ -1149,6 +1175,7 @@ export function MobileApprovalsPage() {
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={syncMutation.isPending}
onClick={async () => {
await updateWorkflowActionDraftStatus({
@@ -1170,6 +1197,7 @@ export function MobileApprovalsPage() {
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
onClick={async () => {
await removeWorkflowActionDraft(item.taskId, ownerEmployeeId);
await refreshDraftQueueCache();
+32 -15
查看文件
@@ -5,6 +5,7 @@ import { uploadInvoice } from "../api/invoicePool";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import { Spinner } from "../components/ui/spinner";
import {
@@ -125,10 +126,10 @@ export function MobileCapturePage() {
}, [queuedItems, syncMutation]);
return (
<div className="space-y-4">
<PageScaffold className="space-y-4">
<PageHeader
title="移动拍照上传"
subtitle="支持离线缓存与联网自动重放。"
title="票据上传"
subtitle="拍照或选择发票文件,离线时自动加入待上传队列。"
/>
{error ? (
@@ -139,18 +140,30 @@ export function MobileCapturePage() {
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null}
<div className="card-std space-y-3 p-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">拍照/选择文件</h3>
<span className={isOnline ? "text-emerald-600" : "text-orange-600"}>
<div className="card-std space-y-4 p-4">
<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 leading-5 text-zinc-500">支持图片和 PDF,手机会优先打开相机。</p>
</div>
<span className={isOnline ? "shrink-0 text-sm text-emerald-600" : "shrink-0 text-sm text-orange-600"}>
{isOnline ? "在线" : "离线"}
</span>
</div>
<label
htmlFor="mobile-capture-file"
className="flex min-h-36 cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 px-4 py-6 text-center transition-colors hover:border-zinc-400 hover:bg-white"
>
<span className="material-symbols-outlined text-[32px] text-zinc-700">add_photo_alternate</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选择文件</span>
<span className="mt-1 text-xs leading-5 text-zinc-500">JPG、PNG、PDF 均可上传</span>
</label>
<input
id="mobile-capture-file"
accept="image/*,application/pdf"
capture="environment"
className="w-full text-sm"
className="sr-only"
data-testid="mobile-capture-file"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
@@ -159,12 +172,14 @@ export function MobileCapturePage() {
type="file"
/>
<div className="text-sm text-zinc-600">
当前文件:{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
<div className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-600">
<span className="font-medium text-zinc-800">当前文件:</span>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
</div>
<div className="flex flex-wrap gap-2">
<div className="grid gap-2 sm:grid-cols-2">
<Button
className="min-h-12 w-full"
data-testid="mobile-capture-upload"
disabled={uploadMutation.isPending || !selectedFile}
onClick={async () => {
@@ -202,6 +217,7 @@ export function MobileCapturePage() {
</Button>
<Button
className="min-h-12 w-full"
data-testid="mobile-capture-sync-queue"
disabled={!queuedItems.length || syncMutation.isPending || !isOnline}
onClick={() => syncMutation.mutate(queuedItems)}
@@ -214,9 +230,9 @@ export function MobileCapturePage() {
</div>
<div className="card-std space-y-3 p-4" data-testid="mobile-capture-queue">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">离线上传队列</h3>
<span className="text-sm text-zinc-500">{queuedCountLabel}</span>
<span className="shrink-0 text-sm text-zinc-500">{queuedCountLabel}</span>
</div>
{queueQuery.isLoading ? <div className="flex items-center gap-2 text-sm text-zinc-500"><Spinner size="sm" />加载中...</div> : null}
@@ -226,11 +242,12 @@ export function MobileCapturePage() {
) : null}
{queuedItems.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div key={item.id} className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm">
<div className="font-medium text-zinc-800">{item.fileName}</div>
<div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div>
<div className="mt-2">
<Button
className="min-h-11 w-full sm:w-auto"
data-testid={`mobile-capture-remove-${item.id}`}
onClick={async () => {
await removeQueuedInvoiceUpload(item.id);
@@ -246,6 +263,6 @@ export function MobileCapturePage() {
</div>
))}
</div>
</div>
</PageScaffold>
);
}
@@ -0,0 +1,435 @@
import { devices, expect, test, type Page, type Route } from "@playwright/test";
function browserAgnosticDevice(name: keyof typeof devices) {
const device = devices[name];
return {
viewport: device.viewport,
userAgent: device.userAgent,
deviceScaleFactor: device.deviceScaleFactor,
isMobile: device.isMobile,
hasTouch: device.hasTouch,
};
}
function envelope(data: unknown) {
return {
requestId: "mock-r89",
data,
};
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
const mockMe = {
id: "employee-mock-1",
employeeNo: "M001",
name: "Manager",
phone: "13800000000",
email: "[email protected]",
departmentId: "dept-1",
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: ["manager"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 50,
},
wecomBinding: null,
};
const pendingTask = {
id: "task-r89-1",
instanceId: "instance-r89-1",
stepId: "manager_approve",
stepName: "经理审批",
assigneeId: "employee-mock-1",
assigneeRole: "manager",
status: "pending",
outcome: null,
createdAt: "2026-05-05T09:00:00.000Z",
completedAt: null,
workflowInstance: {
entityType: "reimbursement",
entityId: "reimbursement-r89-1",
currentStatus: "submitted",
},
allowedActions: ["approve", "reject", "return"],
};
const pendingTaskDetail = {
task: {
id: pendingTask.id,
instanceId: pendingTask.instanceId,
stepId: pendingTask.stepId,
stepName: pendingTask.stepName,
assigneeId: pendingTask.assigneeId,
assigneeRole: pendingTask.assigneeRole,
status: "pending",
outcome: null,
comment: null,
createdAt: pendingTask.createdAt,
completedAt: null,
},
workflowInstance: {
id: pendingTask.instanceId,
entityType: "reimbursement",
currentStatus: "submitted",
currentStepId: pendingTask.stepId,
contextData: { standardViolations: [] },
startedAt: "2026-05-05T08:50:00.000Z",
completedAt: null,
},
reimbursement: {
id: "reimbursement-r89-1",
formNumber: "BX-R89-001",
status: "submitted",
reimbursementDate: "2026-05-05",
totalAmount: "1683.00",
description: "R89 移动审批验证",
owner: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" },
submitter: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" },
},
leaveRequest: null,
purchaseRequest: null,
travelRequest: null,
allowedActions: ["approve", "reject", "return"],
history: [],
};
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
async function mockR89Apis(page: Page) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
if (path === "/api/auth/wecom/login") {
await route.fulfill({
status: 200,
contentType: "text/plain",
body: "mock wecom login entry",
});
return;
}
if (path === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (path === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
entryEnabled: true,
oauthEnabled: true,
corpId: "wx-test-corp",
agentId: "1000002",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
bindEnabled: true,
defaultReturnTo: "/portal",
status: "ready",
statusReasons: [],
});
return;
}
if (path === "/api/auth/login" || path === "/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-r89-access-token",
expiresIn: 900,
mustChangePassword: false,
})),
});
return;
}
if (path === "/api/auth/logout") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" },
body: JSON.stringify(envelope({})),
});
return;
}
if (path === "/api/me") {
await fulfillJson(route, mockMe);
return;
}
if (path === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-1",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "desktop",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: {
main: [
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
navigationItem("team", "/team", "团队", "groups"),
navigationItem("leave", "/leave-requests", "请假", "event_available"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
],
admin: [],
backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")],
},
recommendedRolePresets: [],
});
return;
}
if (path === "/api/presence/me") {
await fulfillJson(route, {
date: "2026-05-05",
item: {
id: "presence-r89",
employeeId: mockMe.id,
date: "2026-05-05",
status: "office",
timeRange: "full_day",
note: null,
source: "manual",
updatedAt: "2026-05-05T08:30:00.000Z",
},
});
return;
}
if (path === "/api/attendance/me/today") {
await fulfillJson(route, {
workDate: "2026-05-05",
needClockIn: false,
exemptionReason: "policy_disabled",
item: null,
});
return;
}
if (path === "/api/portal/team-status") {
await fulfillJson(route, [
{
employeeId: mockMe.id,
name: mockMe.name,
avatarUrl: null,
presenceStatus: "office",
attendanceClockIn: "2026-05-05T00:30:00.000Z",
attendanceClockOut: null,
onLeave: false,
leaveType: null,
},
]);
return;
}
if (path === "/api/notifications") {
const unreadOnly = url.searchParams.get("unreadOnly") === "1";
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 5),
total: unreadOnly ? 1 : 2,
unreadOnly,
unreadCount: 1,
items: [
{
id: "notification-r89-1",
type: "workflow",
title: "有新的审批待办",
body: "请处理 R89 测试单据",
targetType: "workflow_task",
targetId: pendingTask.id,
readAt: null,
createdAt: "2026-05-05T09:10:00.000Z",
},
],
});
return;
}
if (path === "/api/workflow/tasks") {
const status = url.searchParams.get("status") ?? "pending";
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 100),
total: status === "pending" ? 1 : 0,
items: status === "pending" ? [pendingTask] : [],
});
return;
}
if (/^\/api\/workflow\/tasks\/[^/]+\/(approve|reject|return|transfer)$/.test(path)) {
await fulfillJson(route, { ok: true });
return;
}
if (/^\/api\/workflow\/tasks\/[^/]+$/.test(path)) {
await fulfillJson(route, pendingTaskDetail);
return;
}
if (path === "/api/workflow/approvers") {
await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] });
return;
}
if (path === `/api/employees/${mockMe.id}/profile`) {
await fulfillJson(route, {
employee: {
id: mockMe.id,
employeeNo: mockMe.employeeNo,
name: mockMe.name,
phone: mockMe.phone,
email: mockMe.email,
status: "active",
employmentStatus: "active",
departmentId: "dept-1",
title: "负责人",
directManagerId: null,
hireDate: "2026-01-01",
probationEndDate: null,
lastWorkingDate: null,
workLocation: "北京",
emergencyContactName: null,
emergencyContactPhone: null,
notes: null,
avatarUrl: null,
archivedAt: null,
roleCodes: mockMe.roleCodes,
department: { id: "dept-1", name: "研发" },
directManager: null,
},
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
assignedAssets: [],
organization: { reportingChain: [], directReports: [] },
});
return;
}
if (path === "/api/invoice-pool/upload") {
await fulfillJson(route, {
invoicePoolId: "invoice-r89-1",
status: "pending_ocr",
fileSha256: "mock-sha256",
});
return;
}
await fulfillJson(route, {});
});
}
async function loginByPassword(page: Page) {
await page.goto("/login");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("ManagerPassword1234");
await page.getByTestId("login-submit").click();
}
test.describe("R89 企业微信移动入口", () => {
test.use({
...browserAgnosticDevice("iPhone 13"),
userAgent: "Mozilla/5.0 iPhone wxwork/4.1.20 MicroMessenger/7.0.1",
});
test("企业微信移动场景默认生成移动工作台入口", async ({ page }) => {
await mockR89Apis(page);
await page.goto("/login");
await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible();
await expect(page.getByTestId("wecom-entry-primary")).toBeVisible();
await expect(page.getByTestId("login-password-primary-panel")).toHaveCount(0);
await page.getByTestId("wecom-entry-primary").click();
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=%2Fmobile/);
});
});
test.describe("R89 移动底部导航与核心页面", () => {
test.use(browserAgnosticDevice("iPhone 13"));
test("密码登录后可完成移动首页、审批、票据和我的页关键路径", async ({ page }) => {
await mockR89Apis(page);
await loginByPassword(page);
await expect(page).toHaveURL(/\/mobile$/);
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await expect(page.getByTestId("topbar-profile-trigger")).toBeHidden();
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
await expect(page.getByTestId("mobile-nav-approvals")).toBeVisible();
await expect(page.getByTestId("mobile-nav-capture")).toBeVisible();
await expect(page.getByTestId("mobile-nav-me")).toBeVisible();
await page.getByTestId("mobile-nav-approvals").click();
await expect(page).toHaveURL(/\/mobile\/approvals/);
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
await page.getByTestId(`mobile-approvals-task-${pendingTask.id}`).click();
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible();
await page.getByTestId("mobile-nav-capture").click();
await expect(page).toHaveURL(/\/mobile\/capture/);
await expect(page.getByText("拍照或选择文件")).toBeVisible();
await page.getByTestId("mobile-capture-file").setInputFiles({
name: "r89-invoice.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\n% R89"),
});
await expect(page.getByTestId("mobile-capture-upload")).toBeEnabled();
await page.getByTestId("mobile-nav-me").click();
await expect(page).toHaveURL(/\/mobile\/me/);
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
await expect(page.getByRole("link", { name: /个人设置/ })).toBeVisible();
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
});
});
test.describe("R89 小屏桌面菜单兜底", () => {
test.use(browserAgnosticDevice("Pixel 5"));
test("非移动路由的小屏菜单可以打开并关闭", async ({ page }) => {
await mockR89Apis(page);
await loginByPassword(page);
await page.getByTestId("mobile-nav-me").click();
await page.getByRole("link", { name: /通知中心/ }).click();
await expect(page).toHaveURL(/\/notifications/);
await page.getByRole("button", { name: "打开菜单" }).click();
await expect(page.getByRole("button", { name: "关闭菜单" })).toBeVisible();
await expect(page.getByRole("link", { name: "Portal" })).toBeVisible();
await page.getByRole("button", { name: "关闭菜单" }).click();
await expect(page.getByRole("button", { name: "关闭菜单" })).toBeHidden();
});
});