R108/A1-A7 固化企业微信移动端视觉门禁
这个提交包含在:
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"generatedAt": "2026-05-07T13:13:00.518Z",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"label": "企微 390-首页",
|
||||||
|
"route": "/mobile",
|
||||||
|
"screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r108/mobile-390-home.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"scrollWidth": 390,
|
||||||
|
"visibleTableCount": 0,
|
||||||
|
"uuidCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "企微 390-报销",
|
||||||
|
"route": "/mobile/reimbursements",
|
||||||
|
"screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r108/mobile-390-reimbursements.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"scrollWidth": 390,
|
||||||
|
"visibleTableCount": 0,
|
||||||
|
"uuidCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "企微 390-请假",
|
||||||
|
"route": "/mobile/leave",
|
||||||
|
"screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r108/mobile-390-leave.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"scrollWidth": 390,
|
||||||
|
"visibleTableCount": 0,
|
||||||
|
"uuidCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "企微 390-我的",
|
||||||
|
"route": "/mobile/me",
|
||||||
|
"screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r108/mobile-390-me.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"scrollWidth": 390,
|
||||||
|
"visibleTableCount": 0,
|
||||||
|
"uuidCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "企微 390-票据上传队列",
|
||||||
|
"route": "/mobile/capture",
|
||||||
|
"screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r108/mobile-390-capture-queue.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"scrollWidth": 390,
|
||||||
|
"visibleTableCount": 0,
|
||||||
|
"uuidCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "企微 414-首页",
|
||||||
|
"route": "/mobile",
|
||||||
|
"screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r108/mobile-414-home.png",
|
||||||
|
"width": 414,
|
||||||
|
"height": 896,
|
||||||
|
"scrollWidth": 414,
|
||||||
|
"visibleTableCount": 0,
|
||||||
|
"uuidCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "企微 414-票据上传",
|
||||||
|
"route": "/mobile/capture",
|
||||||
|
"screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r108/mobile-414-capture.png",
|
||||||
|
"width": 414,
|
||||||
|
"height": 896,
|
||||||
|
"scrollWidth": 414,
|
||||||
|
"visibleTableCount": 0,
|
||||||
|
"uuidCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "企微 390-审批详情",
|
||||||
|
"route": "/mobile/approvals?taskId=[uuid]",
|
||||||
|
"screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r108/mobile-390-approvals-detail.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"scrollWidth": 390,
|
||||||
|
"visibleTableCount": 0,
|
||||||
|
"uuidCount": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"findings": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# R108 企业微信移动端视觉巡检报告
|
||||||
|
|
||||||
|
生成时间:2026-05-07T13:13:00.518Z
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
- 巡检记录数:8
|
||||||
|
- 发现问题数:0
|
||||||
|
- 截图目录:/home/shunli/Projects/hyb-oa/output/playwright/r108
|
||||||
|
|
||||||
|
## 发现的问题
|
||||||
|
|
||||||
|
- 未发现规则命中的移动端视觉/交互/展示问题。
|
||||||
|
|
||||||
|
## 巡检页面
|
||||||
|
|
||||||
|
- 企微 390-首页:/mobile,viewport=390x844,scrollWidth=390
|
||||||
|
- 企微 390-报销:/mobile/reimbursements,viewport=390x844,scrollWidth=390
|
||||||
|
- 企微 390-请假:/mobile/leave,viewport=390x844,scrollWidth=390
|
||||||
|
- 企微 390-我的:/mobile/me,viewport=390x844,scrollWidth=390
|
||||||
|
- 企微 390-票据上传队列:/mobile/capture,viewport=390x844,scrollWidth=390
|
||||||
|
- 企微 414-首页:/mobile,viewport=414x896,scrollWidth=414
|
||||||
|
- 企微 414-票据上传:/mobile/capture,viewport=414x896,scrollWidth=414
|
||||||
|
- 企微 390-审批详情:/mobile/approvals?taskId=[uuid],viewport=390x844,scrollWidth=390
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# R108 真实企业微信手机最终复核
|
||||||
|
|
||||||
|
日期:2026-05-07
|
||||||
|
|
||||||
|
## 目的
|
||||||
|
|
||||||
|
确认真实手机企业微信客户端中,已绑定 OA 账号的用户从工作台打开 OA 应用后,能直接进入移动工作台,并完成移动端高频动作的基本浏览。
|
||||||
|
|
||||||
|
Playwright 已覆盖普通浏览器、移动视口、企业微信 UA、登录页、移动首页、报销、请假、票据上传、审批、我的页面和本地临时库自动巡检;真实企业微信 App 内 OAuth、Cookie 与文件选择器仍需要手机最终确认。
|
||||||
|
|
||||||
|
## 操作步骤
|
||||||
|
|
||||||
|
1. 打开手机企业微信。
|
||||||
|
2. 进入当前企业的工作台。
|
||||||
|
3. 点击 OA 应用。
|
||||||
|
4. 等待页面完成跳转,不要复制链接到普通浏览器替代。
|
||||||
|
5. 确认最终页面为 OA 移动工作台 `/mobile` 或移动内页。
|
||||||
|
6. 点击底部“票据”,进入票据上传页,确认能看到“拍照或选择文件”和“立即上传”。
|
||||||
|
7. 点击底部“审批”,确认能看到待处理/已处理切换;如有待办,打开一条待办并确认操作区可见。
|
||||||
|
8. 点击底部“我的”,确认头像/姓名区域和常用入口显示正常。
|
||||||
|
|
||||||
|
## 通过标准
|
||||||
|
|
||||||
|
- 不要求输入 OA 账号密码即可进入移动端。
|
||||||
|
- 底部导航显示:首页、审批、票据、我的。
|
||||||
|
- 页面没有横向滚动、黑色大块底栏、桌面表格、按钮遮挡或明显文字裁切。
|
||||||
|
- 页面没有出现 `AUTH_003`、`missing_user_id`、`当前企业微信尚未绑定`、反复登录等提示。
|
||||||
|
- 票据上传页不显示英文 `Choose File`。
|
||||||
|
- 审批页操作按钮没有被底部导航遮挡。
|
||||||
|
|
||||||
|
## 失败时记录
|
||||||
|
|
||||||
|
- 北京时间:
|
||||||
|
- 手机系统:
|
||||||
|
- 企业微信版本:
|
||||||
|
- 最终页面地址:
|
||||||
|
- 当前页面截图:
|
||||||
|
- 页面显示的 `requestId`:
|
||||||
|
- 错误码和错误文案:
|
||||||
|
- 是否已在个人设置中绑定企业微信:
|
||||||
|
- 是否从企业微信工作台入口进入:
|
||||||
|
- 失败发生在:首页 / 票据上传 / 审批 / 我的 / 其他:
|
||||||
|
|
||||||
|
## 自动化边界
|
||||||
|
|
||||||
|
已自动化覆盖:
|
||||||
|
|
||||||
|
- `/api/auth/wecom/config` 默认移动入口配置。
|
||||||
|
- 企业微信 UA 下的登录页与移动工作台。
|
||||||
|
- 本地临时库中的移动首页、报销、请假、票据上传、审批、我的页面视觉巡检。
|
||||||
|
- 移动票据多文件队列、离线入队、删除队列项、恢复在线同步。
|
||||||
|
- 移动审批 deep link、详情页、意见输入和审批操作区。
|
||||||
|
|
||||||
|
仍需人工确认:
|
||||||
|
|
||||||
|
- 真实企业微信客户端 OAuth 完成后是否直接落到 `/mobile`。
|
||||||
|
- 手机系统文件/相机选择器是否能正常返回文件给网页。
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"test:e2e:smoke": "playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/wecom-entry.spec.ts",
|
"test:e2e:smoke": "playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/wecom-entry.spec.ts",
|
||||||
"test:e2e:full": "playwright test",
|
"test:e2e:full": "playwright test",
|
||||||
"test:e2e:r106-visual-audit": "playwright test tests/e2e/r106-visual-audit.spec.ts --workers=1 --reporter=list",
|
"test:e2e:r106-visual-audit": "playwright test tests/e2e/r106-visual-audit.spec.ts --workers=1 --reporter=list",
|
||||||
|
"test:e2e:r108-mobile-visual": "playwright test tests/e2e/r108-mobile-wecom-visual.spec.ts --workers=1 --reporter=list",
|
||||||
"test:e2e:r75-gap-fill": "playwright test tests/e2e/r75-automation-gap-fill.spec.ts --workers=1 --reporter=list",
|
"test:e2e:r75-gap-fill": "playwright test tests/e2e/r75-automation-gap-fill.spec.ts --workers=1 --reporter=list",
|
||||||
"test:e2e:r70-assist": "R70_TRIALRUN_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-assist.spec.ts --workers=1 --reporter=list",
|
"test:e2e:r70-assist": "R70_TRIALRUN_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-assist.spec.ts --workers=1 --reporter=list",
|
||||||
"test:e2e:r70-deep-assist": "PLAYWRIGHT_MANUAL=1 R70_TRIALRUN_DEEP_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-deep-assist.spec.ts --workers=1 --reporter=list",
|
"test:e2e:r70-deep-assist": "PLAYWRIGHT_MANUAL=1 R70_TRIALRUN_DEEP_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-deep-assist.spec.ts --workers=1 --reporter=list",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function MobileBottomNav() {
|
|||||||
const pendingCount = pendingCountQuery.data ?? 0;
|
const pendingCount = pendingCountQuery.data ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed inset-x-0 bottom-0 z-20 border-t border-zinc-200 bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-8px_24px_rgba(15,23,42,0.08)] backdrop-blur md:hidden">
|
<nav className="z-20 shrink-0 border-t border-zinc-200 bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-8px_24px_rgba(15,23,42,0.08)] backdrop-blur md:hidden">
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{MOBILE_NAV_ITEMS.map((item) => {
|
{MOBILE_NAV_ITEMS.map((item) => {
|
||||||
const disabled = item.anyOf ? !hasAnyPermission(permissions, [...item.anyOf]) : false;
|
const disabled = item.anyOf ? !hasAnyPermission(permissions, [...item.anyOf]) : false;
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export function AppShellLayout() {
|
|||||||
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
|
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
|
||||||
{isMobileRoute ? null : <Sidebar />}
|
{isMobileRoute ? null : <Sidebar />}
|
||||||
|
|
||||||
<main className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "" : "lg:ml-72"}`}>
|
<main className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "h-[100dvh] overflow-hidden" : "lg:ml-72"}`}>
|
||||||
{isMobileRoute ? null : <Topbar />}
|
{isMobileRoute ? null : <Topbar />}
|
||||||
|
|
||||||
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? "pb-24 pt-4 sm:px-5 sm:py-5" : "sm:px-7 sm:py-7 lg:px-8"}`}>
|
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? "min-h-0 overflow-y-auto pb-5 pt-4 sm:px-5 sm:py-5" : "sm:px-7 sm:py-7 lg:px-8"}`}>
|
||||||
<div className="layout-container max-w-none">
|
<div className="layout-container max-w-none">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ function workflowStatusLabel(status: string) {
|
|||||||
return status;
|
return 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;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveErrorMessage(error: unknown) {
|
function resolveErrorMessage(error: unknown) {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
@@ -170,18 +181,17 @@ function matchesEntityFilter(entityType: string | null, filter: EntityFilter) {
|
|||||||
|
|
||||||
function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
|
function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
|
||||||
const entityType = task.workflowInstance?.entityType ?? null;
|
const entityType = task.workflowInstance?.entityType ?? null;
|
||||||
const entityId = task.workflowInstance?.entityId ?? null;
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.stepName,
|
title: task.stepName,
|
||||||
subtitle:
|
subtitle:
|
||||||
task.status === "completed"
|
task.status === "completed"
|
||||||
? `业务:${entityLabel(entityType)} · 完成于 ${formatDateTime(task.completedAt)}`
|
? `业务:${entityLabel(entityType)} · 完成于 ${formatDateTime(task.completedAt)}`
|
||||||
: `业务:${entityLabel(entityType)} · 实体ID:${entityId ?? "-"}`,
|
: `业务:${entityLabel(entityType)}`,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
entityType,
|
entityType,
|
||||||
entityId,
|
entityId: task.workflowInstance?.entityId ?? null,
|
||||||
reference: entityId ?? task.id,
|
reference: publicReference(task.workflowInstance?.entityId),
|
||||||
allowedActions: task.allowedActions,
|
allowedActions: task.allowedActions,
|
||||||
completedAt: task.completedAt,
|
completedAt: task.completedAt,
|
||||||
isCached: false,
|
isCached: false,
|
||||||
@@ -351,7 +361,7 @@ export function MobileApprovalsPage() {
|
|||||||
status: item.detail.task.status,
|
status: item.detail.task.status,
|
||||||
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
|
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
|
||||||
entityId: item.taskSnapshot.entityId,
|
entityId: item.taskSnapshot.entityId,
|
||||||
reference: item.taskSnapshot.reference ?? item.detail.task.id,
|
reference: publicReference(item.taskSnapshot.reference),
|
||||||
allowedActions: item.detail.allowedActions,
|
allowedActions: item.detail.allowedActions,
|
||||||
completedAt: item.detail.task.completedAt,
|
completedAt: item.detail.task.completedAt,
|
||||||
isCached: true,
|
isCached: true,
|
||||||
@@ -379,6 +389,7 @@ export function MobileApprovalsPage() {
|
|||||||
|
|
||||||
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
|
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
|
||||||
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
||||||
|
const selectedDisplayReference = publicReference(selectedReference?.reference);
|
||||||
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
|
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
|
||||||
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
||||||
|
|
||||||
@@ -738,7 +749,7 @@ export function MobileApprovalsPage() {
|
|||||||
<PageScaffold className="space-y-4">
|
<PageScaffold className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="移动审批"
|
title="移动审批"
|
||||||
subtitle={deepLinkTaskId ? `待办:${deepLinkTaskId}` : "处理待办。"}
|
subtitle={deepLinkTaskId ? "已定位指定待办。" : "处理待办。"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isOnline ? (
|
{!isOnline ? (
|
||||||
@@ -915,7 +926,7 @@ export function MobileApprovalsPage() {
|
|||||||
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||||||
checked={isBatchSelected}
|
checked={isBatchSelected}
|
||||||
onChange={() => toggleBatchTask(task.id)}
|
onChange={() => toggleBatchTask(task.id)}
|
||||||
aria-label={`选择任务 ${task.id}`}
|
aria-label={`选择${entityLabel(task.entityType)}待办`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -927,8 +938,8 @@ export function MobileApprovalsPage() {
|
|||||||
<div className="font-medium text-zinc-900">{task.title}</div>
|
<div className="font-medium text-zinc-900">{task.title}</div>
|
||||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">任务:{task.id}</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</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}
|
||||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -949,11 +960,8 @@ export function MobileApprovalsPage() {
|
|||||||
<div className="font-medium text-zinc-900">{task.title}</div>
|
<div className="font-medium text-zinc-900">{task.title}</div>
|
||||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">任务:{task.id}</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||||
{task.reference && task.reference !== task.id ? (
|
{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.reference}</div>
|
|
||||||
) : null}
|
|
||||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -978,7 +986,7 @@ export function MobileApprovalsPage() {
|
|||||||
|
|
||||||
{isOfflineUncachedDeepLink ? (
|
{isOfflineUncachedDeepLink ? (
|
||||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
||||||
离线未缓存该待办,请联网后重试。任务号:{selectedTaskId}
|
离线未缓存该待办,请联网后重试。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -988,7 +996,7 @@ export function MobileApprovalsPage() {
|
|||||||
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
|
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
|
||||||
<div>步骤:{selectedTask.task.stepName}</div>
|
<div>步骤:{selectedTask.task.stepName}</div>
|
||||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||||
<div>业务参考:{selectedReference?.reference ?? "-"}</div>
|
<div>业务单号:{selectedDisplayReference ?? "-"}</div>
|
||||||
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
|
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
|
||||||
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
|
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
|
||||||
{selectedTask.reimbursement ? (
|
{selectedTask.reimbursement ? (
|
||||||
@@ -1007,7 +1015,7 @@ export function MobileApprovalsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{selectedTask.purchaseRequest ? (
|
{selectedTask.purchaseRequest ? (
|
||||||
<>
|
<>
|
||||||
<div>采购单号:{selectedTask.purchaseRequest.requestNo ?? selectedTask.purchaseRequest.id}</div>
|
<div>采购单号:{selectedTask.purchaseRequest.requestNo ?? "-"}</div>
|
||||||
<div>申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}</div>
|
<div>申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}</div>
|
||||||
<div>供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}</div>
|
<div>供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}</div>
|
||||||
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
|
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
|
||||||
@@ -1015,7 +1023,7 @@ export function MobileApprovalsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{selectedTask.travelRequest ? (
|
{selectedTask.travelRequest ? (
|
||||||
<>
|
<>
|
||||||
<div>差旅单号:{selectedTask.travelRequest.requestNo ?? selectedTask.travelRequest.id}</div>
|
<div>差旅单号:{selectedTask.travelRequest.requestNo ?? "-"}</div>
|
||||||
<div>申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}</div>
|
<div>申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}</div>
|
||||||
<div>出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
<div>出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
||||||
<div>预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}</div>
|
<div>预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}</div>
|
||||||
@@ -1154,7 +1162,7 @@ export function MobileApprovalsPage() {
|
|||||||
{currentQueueItems.map((item) => (
|
{currentQueueItems.map((item) => (
|
||||||
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||||
<div className="font-medium text-zinc-900">
|
<div className="font-medium text-zinc-900">
|
||||||
{item.taskSnapshot.reference ?? item.taskId} · {item.action === "approve" ? "通过" : "驳回"}
|
{publicReference(item.taskSnapshot.reference) ?? entityLabel(item.taskSnapshot.entityType)} · {item.action === "approve" ? "通过" : "驳回"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-zinc-500">业务:{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
|
<div className="text-zinc-500">业务:{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
|
||||||
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
|
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
|
||||||
|
|||||||
@@ -243,10 +243,13 @@ export function MobileCapturePage() {
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
|
const enqueuedItems: MobileUploadQueueItem[] = [];
|
||||||
for (const file of selectedFiles) {
|
for (const file of selectedFiles) {
|
||||||
await enqueueInvoiceUpload(file);
|
enqueuedItems.push(await enqueueInvoiceUpload(file));
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
|
queryClient.setQueryData<MobileUploadQueueItem[]>(["mobile", "invoice-upload-queue"], (current) =>
|
||||||
|
[...(current ?? []), ...enqueuedItems].sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
|
||||||
|
);
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
setMessage(`当前离线,已加入待上传队列 ${selectedFiles.length} 项`);
|
setMessage(`当前离线,已加入待上传队列 ${selectedFiles.length} 项`);
|
||||||
return;
|
return;
|
||||||
@@ -296,7 +299,9 @@ export function MobileCapturePage() {
|
|||||||
data-testid={`mobile-capture-remove-${item.id}`}
|
data-testid={`mobile-capture-remove-${item.id}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await removeQueuedInvoiceUpload(item.id);
|
await removeQueuedInvoiceUpload(item.id);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
|
queryClient.setQueryData<MobileUploadQueueItem[]>(["mobile", "invoice-upload-queue"], (current) =>
|
||||||
|
(current ?? []).filter((queuedItem) => queuedItem.id !== item.id),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMPLOYEE,
|
||||||
|
MANAGER,
|
||||||
|
createAuthenticatedApiClient,
|
||||||
|
type AuthenticatedApiClient,
|
||||||
|
type E2EUser,
|
||||||
|
} from "./helpers/auth";
|
||||||
|
import { createSubmittedTravelRequestViaApi } from "./helpers/travel";
|
||||||
|
import { waitForWorkflowTask } from "./helpers/workflow";
|
||||||
|
|
||||||
|
type Finding = {
|
||||||
|
severity: "P1" | "P2";
|
||||||
|
page: string;
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
evidence?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CheckRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
type MobileAuditOptions = {
|
||||||
|
label: string;
|
||||||
|
route: string;
|
||||||
|
screenshotName: string;
|
||||||
|
skipNavigation?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MobileMetrics = {
|
||||||
|
text: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
scrollWidth: number;
|
||||||
|
visibleTables: Array<Record<string, unknown>>;
|
||||||
|
bannedFound: string[];
|
||||||
|
darkSurfaces: Array<Record<string, unknown>>;
|
||||||
|
clipped: Array<Record<string, unknown>>;
|
||||||
|
bottomOverlap: Array<Record<string, unknown>>;
|
||||||
|
moreMenuRatio: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||||
|
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r108");
|
||||||
|
const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r108-mobile-wecom-visual");
|
||||||
|
const WECOM_UA =
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||||
|
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
||||||
|
|
||||||
|
const findings: Finding[] = [];
|
||||||
|
const checks: CheckRecord[] = [];
|
||||||
|
|
||||||
|
function screenshotPath(fileName: string) {
|
||||||
|
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||||
|
return path.join(SCREENSHOT_DIR, `${fileName}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactPath(fileName: string) {
|
||||||
|
mkdirSync(ARTIFACT_DIR, { recursive: true });
|
||||||
|
return path.join(ARTIFACT_DIR, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(value: string) {
|
||||||
|
return value.replace(UUID_RE, "[uuid]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFinding(finding: Finding) {
|
||||||
|
findings.push({
|
||||||
|
...finding,
|
||||||
|
detail: redact(finding.detail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginForMobile(page: Page, user: E2EUser) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.getByTestId("login-identifier")).toBeVisible();
|
||||||
|
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||||
|
await page.getByTestId("login-password").fill(user.password);
|
||||||
|
await page.getByTestId("login-submit").click();
|
||||||
|
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
||||||
|
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newWecomPage(browser: Browser, viewport: { width: number; height: number }) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
userAgent: WECOM_UA,
|
||||||
|
locale: "zh-CN",
|
||||||
|
serviceWorkers: "block",
|
||||||
|
});
|
||||||
|
return { context, page: await context.newPage() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) {
|
||||||
|
const response = await client.api.get("/api/expense-types?enabled=true");
|
||||||
|
const payload = await response.json() as {
|
||||||
|
data?: { items?: Array<{ id: string; enabled?: boolean }> };
|
||||||
|
};
|
||||||
|
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0];
|
||||||
|
if (!response.ok() || !item?.id) {
|
||||||
|
throw new Error(`无法读取可用费用类型:HTTP ${response.status()}`);
|
||||||
|
}
|
||||||
|
return item.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedEmployeeMobileData() {
|
||||||
|
const client = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
|
try {
|
||||||
|
const expenseTypeId = await firstEnabledExpenseTypeId(client);
|
||||||
|
const reimbursementResponse = await client.api.post("/api/reimbursements", {
|
||||||
|
data: { description: "R108 移动端巡检报销草稿" },
|
||||||
|
});
|
||||||
|
const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } };
|
||||||
|
if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) {
|
||||||
|
throw new Error(`创建移动端报销草稿失败:HTTP ${reimbursementResponse.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, {
|
||||||
|
data: {
|
||||||
|
expenseTypeId,
|
||||||
|
expenseDate: "2026-05-07",
|
||||||
|
amount: "88.80",
|
||||||
|
description: "R108 移动巡检明细",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!itemResponse.ok()) {
|
||||||
|
throw new Error(`创建移动端报销明细失败:HTTP ${itemResponse.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reimbursementId: reimbursementPayload.data.id };
|
||||||
|
} finally {
|
||||||
|
await client.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditMobilePage(page: Page, options: MobileAuditOptions): Promise<MobileMetrics> {
|
||||||
|
if (!options.skipNavigation) {
|
||||||
|
await page.goto(options.route);
|
||||||
|
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const shot = screenshotPath(options.screenshotName);
|
||||||
|
await page.screenshot({ path: shot, fullPage: true });
|
||||||
|
|
||||||
|
const metrics = await page.evaluate(() => {
|
||||||
|
function parseRgb(value: string) {
|
||||||
|
const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
r: Number(match[1]),
|
||||||
|
g: Number(match[2]),
|
||||||
|
b: Number(match[3]),
|
||||||
|
a: match[4] == null ? 1 : Number(match[4]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = document.body?.innerText ?? "";
|
||||||
|
const documentElement = document.documentElement;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const scrollWidth = Math.max(documentElement.scrollWidth, document.body?.scrollWidth ?? 0);
|
||||||
|
const visibleTables = Array.from(document.querySelectorAll("table"))
|
||||||
|
.filter(isVisible)
|
||||||
|
.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
testid: element.getAttribute("data-testid") ?? "",
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
height: Math.round(rect.height),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const bannedTerms = ["先处理", "清爽", "低频模块放在这里", "Choose File", "/invoice-pool", "/purchase-requests"];
|
||||||
|
const bannedFound = bannedTerms.filter((term) => text.includes(term));
|
||||||
|
|
||||||
|
const darkSurfaces: Array<Record<string, unknown>> = [];
|
||||||
|
for (const element of Array.from(document.querySelectorAll("body *"))) {
|
||||||
|
if (!isVisible(element)) continue;
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const rgb = parseRgb(style.backgroundColor);
|
||||||
|
if (!rgb || rgb.a < 0.2) continue;
|
||||||
|
const average = (rgb.r + rgb.g + rgb.b) / 3;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const area = rect.width * rect.height;
|
||||||
|
if (average < 36 && area > 2200) {
|
||||||
|
darkSurfaces.push({
|
||||||
|
tag: element.tagName.toLowerCase(),
|
||||||
|
testid: element.getAttribute("data-testid") ?? "",
|
||||||
|
text: (element.textContent ?? "").trim().slice(0, 40),
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
area: Math.round(area),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipped: Array<Record<string, unknown>> = [];
|
||||||
|
for (const element of Array.from(document.querySelectorAll("button,a,[role='button'],select,input,textarea,[data-testid]"))) {
|
||||||
|
if (!isVisible(element)) continue;
|
||||||
|
if (element.classList.contains("sr-only")) continue;
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const overflowHidden = ["hidden", "clip"].includes(style.overflowX) || ["hidden", "clip"].includes(style.overflowY);
|
||||||
|
const isClipped = element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2;
|
||||||
|
if (isClipped && overflowHidden) {
|
||||||
|
clipped.push({
|
||||||
|
tag: element.tagName.toLowerCase(),
|
||||||
|
testid: element.getAttribute("data-testid") ?? "",
|
||||||
|
text: (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("placeholder") ?? "").trim().slice(0, 60),
|
||||||
|
client: `${element.clientWidth}x${element.clientHeight}`,
|
||||||
|
scroll: `${element.scrollWidth}x${element.scrollHeight}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = document.querySelector("[data-testid='mobile-nav-home']")?.closest("nav");
|
||||||
|
const navRect = nav?.getBoundingClientRect() ?? null;
|
||||||
|
const bottomOverlap: Array<Record<string, unknown>> = [];
|
||||||
|
function canScrollDownFrom(element: Element) {
|
||||||
|
let current = element.parentElement;
|
||||||
|
while (current && current !== document.body) {
|
||||||
|
const style = getComputedStyle(current);
|
||||||
|
const canScroll = /(auto|scroll)/.test(style.overflowY) && current.scrollHeight > current.clientHeight + 4;
|
||||||
|
if (canScroll) {
|
||||||
|
return current.scrollTop < current.scrollHeight - current.clientHeight - 4;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.scrollingElement ?? document.documentElement;
|
||||||
|
return root.scrollTop < root.scrollHeight - root.clientHeight - 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navRect) {
|
||||||
|
for (const element of Array.from(document.querySelectorAll("main button, main a, main input, main select, main textarea, main [role='button']"))) {
|
||||||
|
if (!isVisible(element) || nav?.contains(element)) continue;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const area = rect.width * rect.height;
|
||||||
|
if (area > 300 && rect.bottom > navRect.top + 4 && rect.top < navRect.bottom - 4 && !canScrollDownFrom(element)) {
|
||||||
|
bottomOverlap.push({
|
||||||
|
tag: element.tagName.toLowerCase(),
|
||||||
|
testid: element.getAttribute("data-testid") ?? "",
|
||||||
|
text: (element.textContent ?? element.getAttribute("aria-label") ?? "").trim().slice(0, 40),
|
||||||
|
rect: `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)}x${Math.round(rect.height)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moreLinks = document.querySelector("[data-testid='mobile-more-links']");
|
||||||
|
const moreRect = moreLinks?.getBoundingClientRect() ?? null;
|
||||||
|
const moreMenuRatio = moreRect ? moreRect.height / window.innerHeight : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scrollWidth,
|
||||||
|
visibleTables,
|
||||||
|
bannedFound,
|
||||||
|
darkSurfaces: darkSurfaces.slice(0, 8),
|
||||||
|
clipped: clipped.slice(0, 12),
|
||||||
|
bottomOverlap: bottomOverlap.slice(0, 8),
|
||||||
|
moreMenuRatio,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const uuids = Array.from(new Set(metrics.text.match(UUID_RE) ?? [])).slice(0, 8);
|
||||||
|
checks.push({
|
||||||
|
label: options.label,
|
||||||
|
route: options.route,
|
||||||
|
screenshot: shot,
|
||||||
|
width: metrics.width,
|
||||||
|
height: metrics.height,
|
||||||
|
scrollWidth: metrics.scrollWidth,
|
||||||
|
visibleTableCount: metrics.visibleTables.length,
|
||||||
|
uuidCount: uuids.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (metrics.scrollWidth > metrics.width + 2) {
|
||||||
|
addFinding({
|
||||||
|
severity: "P1",
|
||||||
|
page: options.label,
|
||||||
|
category: "响应式",
|
||||||
|
title: "手机页面存在横向溢出",
|
||||||
|
detail: `viewport=${metrics.width}px, scrollWidth=${metrics.scrollWidth}px`,
|
||||||
|
evidence: shot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metrics.visibleTables.length > 0) {
|
||||||
|
addFinding({
|
||||||
|
severity: "P1",
|
||||||
|
page: options.label,
|
||||||
|
category: "移动端",
|
||||||
|
title: "手机视口仍出现桌面表格",
|
||||||
|
detail: JSON.stringify(metrics.visibleTables),
|
||||||
|
evidence: shot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metrics.bannedFound.length > 0) {
|
||||||
|
addFinding({
|
||||||
|
severity: "P1",
|
||||||
|
page: options.label,
|
||||||
|
category: "文案",
|
||||||
|
title: "出现移动端禁用文案",
|
||||||
|
detail: metrics.bannedFound.join("、"),
|
||||||
|
evidence: shot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (uuids.length > 0) {
|
||||||
|
addFinding({
|
||||||
|
severity: "P1",
|
||||||
|
page: options.label,
|
||||||
|
category: "业务展示",
|
||||||
|
title: "手机页面向用户暴露内部 UUID",
|
||||||
|
detail: `${uuids.length} 个 UUID`,
|
||||||
|
evidence: shot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metrics.darkSurfaces.length > 0) {
|
||||||
|
addFinding({
|
||||||
|
severity: "P2",
|
||||||
|
page: options.label,
|
||||||
|
category: "视觉",
|
||||||
|
title: "手机页面存在大块近黑背景",
|
||||||
|
detail: JSON.stringify(metrics.darkSurfaces.slice(0, 3)),
|
||||||
|
evidence: shot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metrics.clipped.length > 0) {
|
||||||
|
addFinding({
|
||||||
|
severity: "P2",
|
||||||
|
page: options.label,
|
||||||
|
category: "视觉",
|
||||||
|
title: "存在疑似文字/控件裁切",
|
||||||
|
detail: JSON.stringify(metrics.clipped.slice(0, 5)),
|
||||||
|
evidence: shot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metrics.bottomOverlap.length > 0) {
|
||||||
|
addFinding({
|
||||||
|
severity: "P2",
|
||||||
|
page: options.label,
|
||||||
|
category: "移动导航",
|
||||||
|
title: "底部导航疑似遮挡主操作",
|
||||||
|
detail: JSON.stringify(metrics.bottomOverlap.slice(0, 4)),
|
||||||
|
evidence: shot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metrics.moreMenuRatio != null && metrics.moreMenuRatio > 0.6) {
|
||||||
|
addFinding({
|
||||||
|
severity: "P2",
|
||||||
|
page: options.label,
|
||||||
|
category: "移动菜单",
|
||||||
|
title: "更多菜单占用过多首屏空间",
|
||||||
|
detail: `ratio=${metrics.moreMenuRatio.toFixed(2)}`,
|
||||||
|
evidence: shot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeReport() {
|
||||||
|
const generatedAt = new Date().toISOString();
|
||||||
|
const report = { generatedAt, checks, findings };
|
||||||
|
writeFileSync(artifactPath("report.json"), redact(JSON.stringify(report, null, 2)));
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
"# R108 企业微信移动端视觉巡检报告",
|
||||||
|
"",
|
||||||
|
`生成时间:${generatedAt}`,
|
||||||
|
"",
|
||||||
|
"## 结论",
|
||||||
|
"",
|
||||||
|
`- 巡检记录数:${checks.length}`,
|
||||||
|
`- 发现问题数:${findings.length}`,
|
||||||
|
`- 截图目录:${SCREENSHOT_DIR}`,
|
||||||
|
"",
|
||||||
|
"## 发现的问题",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!findings.length) {
|
||||||
|
lines.push("- 未发现规则命中的移动端视觉/交互/展示问题。");
|
||||||
|
} else {
|
||||||
|
findings.forEach((finding, index) => {
|
||||||
|
lines.push(`${index + 1}. [${finding.severity}] ${finding.page} / ${finding.category}:${finding.title}`);
|
||||||
|
lines.push(` - 详情:${finding.detail.slice(0, 600)}`);
|
||||||
|
if (finding.evidence) lines.push(` - 截图:${finding.evidence}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("", "## 巡检页面", "");
|
||||||
|
checks.forEach((item) => {
|
||||||
|
lines.push(`- ${String(item.label)}:${redact(String(item.route))},viewport=${String(item.width)}x${String(item.height)},scrollWidth=${String(item.scrollWidth)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(artifactPath("report.md"), lines.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.serial("R108 企业微信移动端视觉专项", () => {
|
||||||
|
test.skip(process.env.E2E_MODE === "live", "R108 本地移动专项只在临时本地环境运行");
|
||||||
|
|
||||||
|
test("员工移动页面、票据上传队列与经理移动审批可巡检", async ({ browser }) => {
|
||||||
|
await seedEmployeeMobileData();
|
||||||
|
const travel = await createSubmittedTravelRequestViaApi({
|
||||||
|
user: EMPLOYEE,
|
||||||
|
purposePrefix: "R108移动审批视觉",
|
||||||
|
amount: "1098.00",
|
||||||
|
});
|
||||||
|
const managerClient = await createAuthenticatedApiClient(MANAGER);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await waitForWorkflowTask(managerClient.api, {
|
||||||
|
entityId: travel.travelRequest.requestId,
|
||||||
|
entityType: "travel_request",
|
||||||
|
});
|
||||||
|
|
||||||
|
const employee390 = await newWecomPage(browser, { width: 390, height: 844 });
|
||||||
|
await loginForMobile(employee390.page, EMPLOYEE);
|
||||||
|
await auditMobilePage(employee390.page, { label: "企微 390-首页", route: "/mobile", screenshotName: "mobile-390-home" });
|
||||||
|
await auditMobilePage(employee390.page, { label: "企微 390-报销", route: "/mobile/reimbursements", screenshotName: "mobile-390-reimbursements" });
|
||||||
|
await auditMobilePage(employee390.page, { label: "企微 390-请假", route: "/mobile/leave", screenshotName: "mobile-390-leave" });
|
||||||
|
await auditMobilePage(employee390.page, { label: "企微 390-我的", route: "/mobile/me", screenshotName: "mobile-390-me" });
|
||||||
|
|
||||||
|
await employee390.page.goto("/mobile/capture");
|
||||||
|
await expect(employee390.page.getByRole("heading", { name: "票据上传", exact: true })).toBeVisible();
|
||||||
|
await employee390.page.context().setOffline(true);
|
||||||
|
await employee390.page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||||
|
await expect(employee390.page.getByText("离线", { exact: true })).toBeVisible();
|
||||||
|
await employee390.page.getByTestId("mobile-capture-file").setInputFiles([
|
||||||
|
{ name: "r108-mobile-a.jpg", mimeType: "image/jpeg", buffer: Buffer.from("r108-mobile-a") },
|
||||||
|
{ name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
|
||||||
|
]);
|
||||||
|
await expect(employee390.page.getByText("已选择 2 个")).toBeVisible();
|
||||||
|
await employee390.page.getByTestId("mobile-capture-upload").click();
|
||||||
|
await expect(employee390.page.getByTestId("mobile-capture-queue")).toContainText("待上传 2 项", { timeout: 15_000 });
|
||||||
|
await employee390.page.getByRole("button", { name: "移除" }).first().click();
|
||||||
|
await expect(employee390.page.getByTestId("mobile-capture-queue")).toContainText("待上传 1 项", { timeout: 15_000 });
|
||||||
|
await auditMobilePage(employee390.page, { label: "企微 390-票据上传队列", route: "/mobile/capture", screenshotName: "mobile-390-capture-queue", skipNavigation: true });
|
||||||
|
await employee390.page.context().setOffline(false);
|
||||||
|
await employee390.page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||||
|
await expect(employee390.page.getByTestId("mobile-capture-queue")).toContainText("无待上传项", { timeout: 30_000 });
|
||||||
|
await employee390.context.close();
|
||||||
|
|
||||||
|
const employee414 = await newWecomPage(browser, { width: 414, height: 896 });
|
||||||
|
await loginForMobile(employee414.page, EMPLOYEE);
|
||||||
|
await auditMobilePage(employee414.page, { label: "企微 414-首页", route: "/mobile", screenshotName: "mobile-414-home" });
|
||||||
|
await auditMobilePage(employee414.page, { label: "企微 414-票据上传", route: "/mobile/capture", screenshotName: "mobile-414-capture" });
|
||||||
|
await employee414.context.close();
|
||||||
|
|
||||||
|
const manager390 = await newWecomPage(browser, { width: 390, height: 844 });
|
||||||
|
await loginForMobile(manager390.page, MANAGER);
|
||||||
|
await manager390.page.goto(`/mobile/approvals?taskId=${encodeURIComponent(task.id)}`);
|
||||||
|
await expect(manager390.page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(manager390.page.getByTestId("mobile-approvals-detail")).toContainText(travel.travelRequest.requestNo);
|
||||||
|
await expect(manager390.page.getByTestId("mobile-approvals-approve")).toBeEnabled();
|
||||||
|
await expect(manager390.page.getByTestId("mobile-approvals-reject")).toBeEnabled();
|
||||||
|
await manager390.page.getByTestId("mobile-approvals-comment").fill("R108 移动审批操作区验证");
|
||||||
|
await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail" });
|
||||||
|
await manager390.page.getByTestId("mobile-approvals-approve").click();
|
||||||
|
await expect(manager390.page.getByText("操作成功")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await manager390.context.close();
|
||||||
|
|
||||||
|
writeReport();
|
||||||
|
if (process.env.R108_MOBILE_ALLOW_FINDINGS !== "1") {
|
||||||
|
const summary = findings.map((finding) => `${finding.severity} ${finding.page} / ${finding.category}: ${finding.title}`).join("\n");
|
||||||
|
expect(findings, summary || "R108 mobile visual findings should be empty").toEqual([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await Promise.all([travel.client.dispose(), managerClient.dispose()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
A1,R108 移动企微专项合同,"以 R105/R106/R107 为基础,固定企业微信手机端与移动视觉专项范围:移动页面专项巡检、票据上传队列、移动审批、真实企微人工最终证据模板。","本 CSV 校验通过;不修改 Prisma schema、审批状态机和权限模型;自动化与人工边界清晰。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-07_20-20-42-r108-mobile-wecom-visual-hardening.csv;业务流程:固定真实试运行手机入口专项范围。","python|manual",DONE,DONE,DONE,"issues/2026-05-07_20-20-42-r108-mobile-wecom-visual-hardening.csv","[]","回滚路径:删除本批新增测试、脚本、报告和模板;不涉及数据迁移。"
|
||||||
|
A2,本地移动视觉专项巡检,"新增 R108 本地移动视觉 Playwright,用企业微信 UA 和 390x844/414x896 视口覆盖移动首页、报销、请假、票据上传、审批、我的。","用例输出 Markdown/JSON 报告和截图;严格模式下发现横向溢出、桌面表格、黑色大块背景、禁用文案、UUID 暴露时失败。","env NODE_OPTIONS=--max-old-space-size=1536 E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:3199 BASE_URL=http://127.0.0.1:3199 npx playwright test tests/e2e/r108-mobile-wecom-visual.spec.ts --workers=1 --reporter=list;业务流程:员工/管理员在企业微信手机端浏览高频移动页面。","playwright|typescript",DONE,DONE,DONE,"client/tests/e2e/r108-mobile-wecom-visual.spec.ts|client/src/layouts/AppShellLayout.tsx|client/src/components/mobile/MobileBottomNav.tsx|artifacts/r108-mobile-wecom-visual","[""A1""]","截图输出到 output/playwright/r108,不纳入 git。"
|
||||||
|
A3,移动票据上传队列验证,"补强移动票据上传页自动化,验证多文件选择、队列展示、删除队列项、上传按钮状态和处理中/失败提示。","移动票据上传页不暴露英文原生上传文案;多文件队列可见;删除队列项可用;上传状态不长期只有一句处理中。","env NODE_OPTIONS=--max-old-space-size=1536 E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:3199 BASE_URL=http://127.0.0.1:3199 npx playwright test tests/e2e/r108-mobile-wecom-visual.spec.ts --workers=1 --reporter=list;业务流程:手机选择多张票据 -> 查看队列 -> 删除一项 -> 上传 -> 查看状态。","playwright|typescript",DONE,DONE,DONE,"client/src/pages/MobileCapturePage.tsx|client/tests/e2e/r108-mobile-wecom-visual.spec.ts","[""A2""]","修复离线入队和移除队列项后仅 invalidate、UI 不稳定更新的问题;不改 OCR 后端队列。"
|
||||||
|
A4,移动审批深链与操作区验证,"补强移动审批页自动化,验证待办列表、任务详情、批准/驳回/退回按钮、意见输入和离线队列区域不遮挡主操作。","移动审批页无桌面表格;深链 taskId 能定位详情;操作按钮稳定可点击;离线队列不遮挡底部导航;手机界面不向用户暴露内部 UUID。","env NODE_OPTIONS=--max-old-space-size=1536 E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:3199 BASE_URL=http://127.0.0.1:3199 npx playwright test tests/e2e/r108-mobile-wecom-visual.spec.ts --workers=1 --reporter=list;业务流程:经理从移动审批进入待办详情并执行审批动作。","playwright|typescript",DONE,DONE,DONE,"client/src/pages/MobileApprovalsPage.tsx|client/src/layouts/AppShellLayout.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/tests/e2e/r108-mobile-wecom-visual.spec.ts","[""A2""]","不改审批状态机;只修移动展示、导航占位和视觉检测规则。"
|
||||||
|
A5,真实企微最终复核模板升级,"更新真实手机企业微信最终复核模板,补充 R108 后的操作路径、通过标准、失败记录项和自动化已覆盖边界。","模板能指导用户在企业微信工作台打开 OA、确认免登进入 /mobile、检查票据上传/审批/我的,并记录失败 requestId。","人工步骤:手机企业微信 -> 工作台 -> OA -> 检查 /mobile、底部导航、票据上传、审批、我的;业务流程:真实企业微信客户端最终落点确认。","manual",DONE,DONE,DONE,"artifacts/r108-mobile-wecom-visual/wecom-mobile-final-check.md","[""A1""]","真实企业微信客户端 OAuth/Cookie 行为不能被 Playwright 完全替代。"
|
||||||
|
A6,移动门禁脚本集成,"将 R108 移动专项接入本地门禁,提供 r108-mobile-local 模式;可复用已构建 dist 以适应远程开发机内存限制。","bash scripts/qa/run-web-gates.sh r108-mobile-local 已接入模式;本轮在当前远程 exec 会话中多次被外部 SIGTERM,中止点不稳定;已用同等临时库、server、Playwright 步骤完成验证。","bash -n scripts/qa/run-web-gates.sh;env NODE_OPTIONS=--max-old-space-size=1536 npx playwright test tests/e2e/r108-mobile-wecom-visual.spec.ts --list;业务流程:一条命令复现企业微信手机端核心巡检,必要时按 A7 手动等价步骤验证。","bash|playwright",DONE,DONE,DONE,"scripts/qa/run-web-gates.sh|client/package.json","[""A2"",""A3"",""A4""]","回滚路径:移除新增脚本模式和 package script;当前远程机长链路门禁受进程 SIGTERM 干扰,建议后续在 CI 或空闲 shell 再跑一次 r108-mobile-local。"
|
||||||
|
A7,R108 验证回填与中文提交,"执行 CSV 校验、前端 typecheck/build、R108 移动门禁等价流程,回填状态并中文提交。","R108 相关命令通过;报告不含敏感凭据和 UUID 原文;中文 commit 引用 R108/A1-A7。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-07_20-20-42-r108-mobile-wecom-visual-hardening.csv;bash -n scripts/qa/run-web-gates.sh;env NODE_OPTIONS=--max-old-space-size=1536 ./node_modules/.bin/vite build;env NODE_OPTIONS=--max-old-space-size=1536 E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:3199 BASE_URL=http://127.0.0.1:3199 npx playwright test tests/e2e/r108-mobile-wecom-visual.spec.ts --workers=1 --reporter=list;git diff --check;业务流程:移动入口、票据队列和移动审批可回归。","python|npm|playwright|git",DONE,DONE,DONE,"issues/2026-05-07_20-20-42-r108-mobile-wecom-visual-hardening.csv|artifacts/r108-mobile-wecom-visual|client|scripts/qa","[""A1"",""A2"",""A3"",""A4"",""A5"",""A6""]","不提交 output/playwright 截图;真实手机企业微信会话行为仍按 A5 模板做最终人工确认。"
|
||||||
|
+26
-1
@@ -36,7 +36,7 @@ qa_require_cmd() {
|
|||||||
|
|
||||||
qa_usage() {
|
qa_usage() {
|
||||||
cat <<USAGE
|
cat <<USAGE
|
||||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local|r106-visual-local|smoke-live>
|
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local|r106-visual-local|r108-mobile-local|smoke-live>
|
||||||
|
|
||||||
可选环境变量:
|
可选环境变量:
|
||||||
QA_PORT / QA_HOST
|
QA_PORT / QA_HOST
|
||||||
@@ -454,6 +454,23 @@ qa_run_r106_visual_local() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qa_run_r108_mobile_local() {
|
||||||
|
qa_select_port
|
||||||
|
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1536}"
|
||||||
|
export JOBS_ENABLED="${JOBS_ENABLED:-true}"
|
||||||
|
qa_export_app_env
|
||||||
|
qa_prepare_db
|
||||||
|
qa_build_client
|
||||||
|
qa_start_server
|
||||||
|
|
||||||
|
echo ">>> 执行 R108 企业微信移动端视觉专项"
|
||||||
|
(
|
||||||
|
cd "$CLIENT_DIR"
|
||||||
|
rm -rf test-results playwright-report
|
||||||
|
E2E_MODE=local npm run test:e2e:r108-mobile-visual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
qa_run_live_smoke() {
|
qa_run_live_smoke() {
|
||||||
local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||||
echo ">>> 探活 live /ready: ${live_base_url}/ready"
|
echo ">>> 探活 live /ready: ${live_base_url}/ready"
|
||||||
@@ -500,6 +517,14 @@ case "$MODE" in
|
|||||||
trap qa_cleanup EXIT
|
trap qa_cleanup EXIT
|
||||||
qa_run_r106_visual_local
|
qa_run_r106_visual_local
|
||||||
;;
|
;;
|
||||||
|
r108-mobile-local)
|
||||||
|
qa_require_cmd curl
|
||||||
|
qa_require_db_client
|
||||||
|
qa_require_cmd node
|
||||||
|
qa_require_cmd npm
|
||||||
|
trap qa_cleanup EXIT
|
||||||
|
qa_run_r108_mobile_local
|
||||||
|
;;
|
||||||
smoke-live)
|
smoke-live)
|
||||||
qa_require_cmd curl
|
qa_require_cmd curl
|
||||||
qa_require_cmd node
|
qa_require_cmd node
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户