RS6 修复移动响应式布局体系

这个提交包含在:
2026-05-11 12:52:30 +08:00
父节点 5dd4c96bec
当前提交 e399376e25
共修改 16 个文件,包含 841 行新增和 97 行删除
@@ -34,7 +34,7 @@ export function MobileBottomNav() {
return (
<nav
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"
className="mobile-bottom-nav-surface pointer-events-auto fixed inset-x-0 bottom-0 z-[70] transform-gpu border-t border-zinc-200 bg-white/95 shadow-[0_-6px_18px_rgba(15,23,42,0.08)] backdrop-blur md:hidden"
data-testid="mobile-bottom-nav"
>
<div className="relative z-[71] grid grid-cols-4 gap-1">
+1 -1
查看文件
@@ -65,7 +65,7 @@ export function AppShellLayout() {
>
{isMobileRoute ? null : <Topbar />}
<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={`flex-1 px-4 py-5 ${isMobileRoute ? `min-h-0 overscroll-contain overflow-y-auto px-[var(--mobile-page-x)] py-[var(--mobile-page-y)] ${showMobileBottomNav ? "pb-[calc(var(--mobile-bottom-nav-height,68px)+env(safe-area-inset-bottom,0px)+var(--mobile-page-y)+0.5rem)]" : "pb-5 sm:pb-6"}` : "sm:px-7 sm:py-7 lg:px-8"}`}>
<div className="layout-container max-w-none">
<ErrorBoundary>
<Outlet />
+1 -1
查看文件
@@ -235,7 +235,7 @@ export function MobileCapturePage() {
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="mobile-two-action-grid">
<Button
className="min-h-12 w-full"
data-testid="mobile-capture-upload"
+11 -11
查看文件
@@ -64,7 +64,7 @@ function LeaveRecordCard({
const canSubmit = item.status === "draft" || item.status === "rejected";
return (
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-record-card">
<article className="mobile-card" data-testid="mobile-leave-record-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.leaveType?.name ?? "请假"}</div>
@@ -73,7 +73,7 @@ function LeaveRecordCard({
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="mobile-info-grid mt-4 text-sm">
<div>
<div className="text-xs text-zinc-500">天数</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{item.requestedDays}</div>
@@ -87,7 +87,7 @@ function LeaveRecordCard({
{item.reason ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.reason}</p> : null}
{canEdit || canSubmit ? (
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="mobile-two-action-grid mt-4">
{canEdit ? (
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
编辑
@@ -251,8 +251,8 @@ export function MobileLeavePage() {
<PageScaffold className="space-y-3 pb-4">
<PageHeader title="请假" />
<section className="rounded-lg border border-zinc-200 bg-white p-3 shadow-sm" data-testid="mobile-leave-summary">
<div className="grid grid-cols-3 gap-3 text-sm">
<section className="mobile-card" data-testid="mobile-leave-summary">
<div className="mobile-metric-grid text-sm">
<div>
<div className="text-xs text-zinc-500">可用</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.available.toFixed(1)}</div>
@@ -268,7 +268,7 @@ export function MobileLeavePage() {
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-3 shadow-sm" data-testid="mobile-leave-form">
<section className="mobile-card" data-testid="mobile-leave-form">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
{editingItem ? (
@@ -291,7 +291,7 @@ export function MobileLeavePage() {
</select>
</label>
<div className="grid grid-cols-2 gap-3">
<div className="mobile-info-grid">
<label className="block space-y-2 text-sm text-zinc-700">
<span>开始日期</span>
<input className="input-std" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
@@ -302,7 +302,7 @@ export function MobileLeavePage() {
</label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="mobile-info-grid">
<label className="block space-y-2 text-sm text-zinc-700">
<span>开始时段</span>
<select className="select-std" value={startSession} onChange={(event) => setStartSession(event.target.value as LeaveSession)}>
@@ -370,7 +370,7 @@ export function MobileLeavePage() {
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-3 shadow-sm" data-testid="mobile-leave-balances">
<section className="mobile-card" data-testid="mobile-leave-balances">
<h3 className="text-base font-semibold text-zinc-900">额度</h3>
<div className="mt-3 space-y-2">
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
@@ -381,7 +381,7 @@ export function MobileLeavePage() {
<div className="font-medium text-zinc-900">{item.leaveType.name}</div>
<span className="badge-success">启用</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-3 text-sm">
<div className="mobile-metric-grid mt-3 text-sm">
<div>
<div className="text-xs text-zinc-500">可用</div>
<div className="font-semibold text-zinc-900">{item.available}</div>
@@ -400,7 +400,7 @@ export function MobileLeavePage() {
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-3 shadow-sm" data-testid="mobile-leave-records">
<section className="mobile-card" data-testid="mobile-leave-records">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as LeaveStatusFilter)}>
<TabsList className="mt-3 grid h-auto w-full grid-cols-4">
+2 -2
查看文件
@@ -109,7 +109,7 @@ export function MobileMePage() {
</SectionPanel>
<SectionPanel title="更多入口">
<div className="grid grid-cols-2 gap-2" data-testid="mobile-more-links">
<div className="mobile-adaptive-grid" data-testid="mobile-more-links">
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
@@ -121,7 +121,7 @@ export function MobileMePage() {
{canAccessAdmin ? (
<SectionPanel title="管理入口">
<div className="grid grid-cols-2 gap-2">
<div className="mobile-adaptive-grid">
<MobileActionLink to="/mobile/me" label="人员管理" icon="group" />
<MobileActionLink to="/mobile/me" label="组织架构" icon="account_tree" />
<MobileActionLink to="/mobile/me" label="系统设置" icon="settings" />
+1 -1
查看文件
@@ -188,7 +188,7 @@ export function MobileNotificationsPage() {
</div>
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mobile-two-action-grid mt-3">
{targetLink ? (
<Button asChild size="sm" variant="secondary" data-testid="mobile-notification-target-link">
<Link to={targetLink}>查看</Link>
+31 -32
查看文件
@@ -130,25 +130,26 @@ function ActionTile({ action }: { action: MobileAction }) {
to={action.to}
className={
isPrimary
? "relative flex min-h-24 flex-col justify-between rounded-lg border border-primary-100 bg-primary-50 p-3 pr-14 text-primary shadow-sm"
? "mobile-icon-action mobile-icon-action--primary"
: isWarning
? "relative flex min-h-24 flex-col justify-between rounded-lg border border-amber-200 bg-amber-50 p-3 pr-14 text-amber-900"
: "relative flex min-h-24 flex-col justify-between rounded-lg border border-zinc-200 bg-white p-3 pr-14 text-zinc-900 shadow-sm"
? "mobile-icon-action mobile-icon-action--warning"
: "mobile-icon-action"
}
data-testid={`mobile-action-${action.key}`}
title={`${action.label}:${action.meta}`}
aria-label={`${action.label},${action.meta}`}
>
<div className="min-w-0">
<span className="block min-w-0 whitespace-nowrap text-base font-semibold leading-6" data-testid={`mobile-action-${action.key}-label`}>
{action.label}
</span>
</div>
<span
className="material-symbols-outlined absolute right-3 top-3 flex size-8 items-center justify-center rounded-md bg-white/70 text-[21px] leading-none shadow-sm"
className="material-symbols-outlined mobile-icon-action__icon"
data-testid={`mobile-action-${action.key}-icon`}
aria-hidden="true"
>
{action.icon}
</span>
<div className={isPrimary ? "truncate text-xs text-primary/70" : "truncate text-xs text-zinc-500"}>{action.meta}</div>
<span className="mobile-icon-action__label" data-testid={`mobile-action-${action.key}-label`}>
{action.label}
</span>
<span className="sr-only">{action.meta}</span>
</Link>
);
}
@@ -217,16 +218,14 @@ export function MobilePortalPage() {
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
const actions: MobileAction[] = [
canAccessApprovals
? {
key: "approvals",
label: "审批",
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
icon: "fact_check",
to: "/mobile/approvals",
tone: pendingApprovalCount > 0 ? "warning" : "primary",
}
: null,
{
key: "approvals",
label: "审批",
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
icon: "fact_check",
to: "/mobile/approvals",
tone: pendingApprovalCount > 0 ? "warning" : "primary",
},
canAccessCapture
? {
key: "capture",
@@ -246,6 +245,13 @@ export function MobilePortalPage() {
to: "/mobile/reimbursements",
}
: null,
{
key: "me",
label: "我的",
meta: unreadNotificationCount > 0 ? `${unreadNotificationCount} 条新通知` : "资料和通知",
icon: "person",
to: "/mobile/me",
},
canAccessLeave
? {
key: "leave",
@@ -255,25 +261,18 @@ export function MobilePortalPage() {
to: "/mobile/leave",
}
: null,
{
key: "me",
label: "我的",
meta: unreadNotificationCount > 0 ? `${unreadNotificationCount} 条新通知` : "资料和通知",
icon: "person",
to: "/mobile/me",
},
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return (
<PageScaffold className="space-y-4">
<PageHeader title="今天" />
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-today-focus">
<section className="mobile-card" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待处理</div>
{pendingApprovalCount > 0 ? <span className="badge-warning">{pendingApprovalCount} 待批</span> : null}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
{actions.map((action) => (
<ActionTile key={action.key} action={action} />
))}
@@ -281,7 +280,7 @@ export function MobilePortalPage() {
</section>
{!attendanceFeatureDisabled ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-attendance-card">
<section className="mobile-card" data-testid="mobile-attendance-card">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">打卡</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
@@ -326,7 +325,7 @@ export function MobilePortalPage() {
) : null}
{canAccessApprovals ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-pending-tasks">
<section className="mobile-card" data-testid="mobile-pending-tasks">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待审批</div>
<Button asChild size="sm" type="button" variant="secondary">
@@ -359,7 +358,7 @@ export function MobilePortalPage() {
</section>
) : null}
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-notifications">
<section className="mobile-card" data-testid="mobile-notifications">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">通知</div>
<Button asChild size="sm" type="button" variant="secondary">
@@ -761,7 +761,7 @@ export function MobileReimbursementDetailPage() {
"space-y-4",
showStickyActions ? "mobile-fixed-action-page" : "",
showStickyActions
? "pb-[calc(var(--mobile-sticky-action-height,76px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
: "pb-6",
)}
data-testid="mobile-reimbursement-detail"
@@ -779,10 +779,10 @@ export function MobileReimbursementDetailPage() {
</span>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<section className="mobile-card">
<div className="text-xs text-zinc-500">合计金额</div>
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatCurrency(detail.totalAmount)}</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-sm">
<div className="mobile-metric-grid mt-4 text-sm">
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">明细</div>
<div className="mt-1 font-semibold text-zinc-900">{detail.items.length}</div>
@@ -799,7 +799,7 @@ export function MobileReimbursementDetailPage() {
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-items">
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
{canEdit ? (
@@ -815,7 +815,7 @@ export function MobileReimbursementDetailPage() {
{itemForm ? (
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
<div className="grid grid-cols-2 gap-3">
<div className="mobile-info-grid">
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
<span>费用类型</span>
<select
@@ -861,7 +861,7 @@ export function MobileReimbursementDetailPage() {
/>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="mobile-two-action-grid">
<Button
type="button"
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !itemForm.amount.trim() || itemSaveMutation.isPending}
@@ -889,7 +889,7 @@ export function MobileReimbursementDetailPage() {
</div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{canEdit ? (
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
编辑
</Button>
@@ -914,7 +914,7 @@ export function MobileReimbursementDetailPage() {
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid={TEST_IDS.reimbursement.invoices}>
<section className="mobile-card" data-testid={TEST_IDS.reimbursement.invoices}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">发票</h2>
{canOpenInvoiceDialog ? (
@@ -971,7 +971,7 @@ export function MobileReimbursementDetailPage() {
)}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-attachments">
<section className="mobile-card" data-testid="mobile-reimbursement-attachments">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">附件材料</h2>
@@ -1031,7 +1031,7 @@ export function MobileReimbursementDetailPage() {
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mobile-two-action-grid mt-3">
<Button
size="sm"
variant="secondary"
@@ -1064,7 +1064,7 @@ export function MobileReimbursementDetailPage() {
</section>
{showSecondaryActions ? (
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-actions">
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
{actionError ? (
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -1090,10 +1090,10 @@ export function MobileReimbursementDetailPage() {
{showStickyActions ? (
<div
className="fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
data-testid="mobile-reimbursement-sticky-actions"
>
<div className={cn("grid gap-2", canOpenInvoiceDialog && showSubmitAction ? "grid-cols-2" : "grid-cols-1")}>
<div className={cn(canOpenInvoiceDialog && showSubmitAction ? "mobile-two-action-grid" : "grid grid-cols-1 gap-2")}>
{canOpenInvoiceDialog ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
@@ -1132,7 +1132,7 @@ export function MobileReimbursementDetailPage() {
<DialogDescription>可一次上传多张,识别完成后会自动添加。</DialogDescription>
</DialogHeader>
<div className="mt-4 grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1">
<div className="mobile-two-action-grid mt-4 rounded-lg bg-zinc-100 p-1">
<button
type="button"
className={cn("rounded-md px-3 py-2 text-sm font-medium", invoicePanel === "upload" ? "bg-white text-zinc-900 shadow-sm" : "text-zinc-500")}
@@ -1348,7 +1348,7 @@ export function MobileReimbursementDetailPage() {
<div className="whitespace-pre-wrap rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700">
{buildPrecheckDialogMessage(submitConfirmPrecheck)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="mobile-two-action-grid">
<Button type="button" variant="secondary" onClick={() => setSubmitConfirmPrecheck(null)}>
取消
</Button>
@@ -65,7 +65,7 @@ function ReimbursementCard({
const displayCode = resolveReimbursementDisplayCode(item);
return (
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-card">
<article className="mobile-card" data-testid="mobile-reimbursement-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
@@ -74,7 +74,7 @@ function ReimbursementCard({
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="mobile-info-grid mt-4 text-sm">
<div>
<div className="text-xs text-zinc-500">金额</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{formatCurrency(item.totalAmount)}</div>
@@ -87,7 +87,7 @@ function ReimbursementCard({
{item.description ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
<div className={canDeleteDraft ? "mt-4 grid grid-cols-2 gap-2" : "mt-4"}>
<div className={canDeleteDraft ? "mobile-two-action-grid mt-4" : "mt-4"}>
<Button asChild className="w-full" type="button">
<Link to={`/mobile/reimbursements/${item.id}`}>{actionLabel}</Link>
</Button>
@@ -174,8 +174,8 @@ export function MobileReimbursementsPage() {
}
/>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<div className="grid grid-cols-2 gap-3">
<section className="mobile-card">
<div className="mobile-info-grid">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{total}</div>
@@ -187,7 +187,7 @@ export function MobileReimbursementsPage() {
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursements-list">
<section className="mobile-card" data-testid="mobile-reimbursements-list">
<Tabs value={status} onValueChange={(value) => setStatus(value as ReimbursementStatus)}>
<TabsList className="grid h-auto w-full grid-cols-4">
{STATUS_TABS.map((item) => (
+137 -2
查看文件
@@ -8,8 +8,13 @@
:root {
--mobile-viewport-height: 100vh;
--mobile-bottom-nav-height: 64px;
--mobile-sticky-action-height: 76px;
--mobile-page-x: clamp(10px, 3vw, 20px);
--mobile-page-y: clamp(10px, 2.8vw, 20px);
--mobile-card-gap: clamp(8px, 2.5vw, 14px);
--mobile-card-padding: clamp(12px, 3.4vw, 16px);
--mobile-bottom-nav-height: 68px;
--mobile-sticky-action-height: 78px;
--mobile-icon-action-min-height: 72px;
}
.page-scaffold.mobile-fixed-action-page {
@@ -22,3 +27,133 @@
--mobile-viewport-height: 100dvh;
}
}
.mobile-card {
border: 1px solid rgb(228 228 231);
border-radius: 12px;
background: rgb(255 255 255);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.06);
padding: var(--mobile-card-padding);
}
.mobile-icon-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--mobile-card-gap);
min-width: 0;
}
.mobile-icon-action {
min-width: 0;
min-height: var(--mobile-icon-action-min-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid rgb(228 228 231);
border-radius: 14px;
background: rgb(255 255 255);
color: rgb(24 24 27);
padding: 8px 4px;
text-align: center;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.05);
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.mobile-icon-action--primary {
border-color: rgb(191 219 254);
background: rgb(239 246 255);
color: rgb(0 57 138);
}
.mobile-icon-action--warning {
border-color: rgb(253 230 138);
background: rgb(255 251 235);
color: rgb(146 64 14);
}
.mobile-icon-action__icon {
width: 36px;
height: 36px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: rgb(255 255 255 / 0.86);
font-size: 22px;
line-height: 1;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.07);
}
.mobile-icon-action__label {
display: block;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
line-height: 18px;
}
.mobile-adaptive-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
gap: var(--mobile-card-gap);
min-width: 0;
}
.mobile-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 7.5rem), 1fr));
gap: var(--mobile-card-gap);
min-width: 0;
}
.mobile-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 5.75rem), 1fr));
gap: var(--mobile-card-gap);
min-width: 0;
}
.mobile-two-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
min-width: 0;
}
.mobile-bottom-nav-surface {
min-height: calc(var(--mobile-bottom-nav-height) + env(safe-area-inset-bottom, 0px));
padding: 0.375rem var(--mobile-page-x) calc(0.35rem + env(safe-area-inset-bottom, 0px));
}
.mobile-sticky-action-surface {
padding: 0.75rem var(--mobile-page-x) max(0.75rem, env(safe-area-inset-bottom, 0px));
}
@media (max-width: 359px) {
:root {
--mobile-page-x: 8px;
--mobile-card-gap: 6px;
--mobile-card-padding: 10px;
--mobile-icon-action-min-height: 68px;
}
.mobile-icon-action__icon {
width: 32px;
height: 32px;
border-radius: 10px;
font-size: 20px;
}
.mobile-icon-action__label {
font-size: 12px;
line-height: 17px;
}
}
@@ -263,25 +263,46 @@ async function collectMobileMetrics(page: Page) {
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
document.querySelector("[data-testid='reimbursement-submit']"),
].filter(Boolean).map((element) => element!.getBoundingClientRect());
const actionLabels = Array.from(document.querySelectorAll("[data-testid^='mobile-action-'][data-testid$='-label']")).map((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const key = element.getAttribute("data-testid")?.replace(/^mobile-action-/, "").replace(/-label$/, "") ?? "";
const actionItems = ["approvals", "capture", "reimbursements", "me", "leave"].map((key) => {
const element = document.querySelector(`[data-testid='mobile-action-${key}']`);
const label = document.querySelector(`[data-testid='mobile-action-${key}-label']`);
const icon = document.querySelector(`[data-testid='mobile-action-${key}-icon']`);
const rect = element?.getBoundingClientRect();
const labelRect = label?.getBoundingClientRect();
const iconRect = icon?.getBoundingClientRect();
const style = label ? window.getComputedStyle(label) : null;
const overlaps = Boolean(
labelRect &&
iconRect &&
!(labelRect.right <= iconRect.left || labelRect.left >= iconRect.right || labelRect.bottom <= iconRect.top || labelRect.top >= iconRect.bottom),
);
return {
text: element.textContent?.trim() ?? "",
width: rect.width,
height: rect.height,
right: rect.right,
lineHeight: Number.parseFloat(style.lineHeight) || 24,
whiteSpace: style.whiteSpace,
iconLeft: iconRect?.left ?? null,
key,
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
width: rect?.width ?? 0,
height: rect?.height ?? 0,
text: label?.textContent?.trim() ?? "",
labelWidth: labelRect?.width ?? 0,
labelHeight: labelRect?.height ?? 0,
labelLineHeight: style ? Number.parseFloat(style.lineHeight) || 18 : 18,
labelWhiteSpace: style?.whiteSpace ?? "",
iconWidth: iconRect?.width ?? 0,
iconHeight: iconRect?.height ?? 0,
gapToIcon: iconRect ? iconRect.left - rect.right : null,
overlaps,
labelWithin:
Boolean(rect && labelRect) &&
labelRect!.left >= rect!.left - 1 &&
labelRect!.right <= rect!.right + 1 &&
labelRect!.top >= rect!.top - 1 &&
labelRect!.bottom <= rect!.bottom + 1,
iconWithin:
Boolean(rect && iconRect) &&
iconRect!.left >= rect!.left - 1 &&
iconRect!.right <= rect!.right + 1 &&
iconRect!.top >= rect!.top - 1 &&
iconRect!.bottom <= rect!.bottom + 1,
};
});
}).filter((item) => item.visible);
return {
width: window.innerWidth,
height: window.innerHeight,
@@ -297,7 +318,7 @@ async function collectMobileMetrics(page: Page) {
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
stickyButtonMinWidth: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.width)) : 0,
actionLabels,
actionItems,
text: document.body?.innerText ?? "",
};
});
@@ -334,15 +355,17 @@ test.describe("R5 真机反馈移动交互", () => {
expect(homeMetrics.bottomNavBottomGap).not.toBeNull();
expect(homeMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2);
expect(homeMetrics.scrollWidth).toBeLessThanOrEqual(homeMetrics.width + 2);
expect(homeMetrics.actionLabels.length).toBeGreaterThanOrEqual(4);
for (const label of homeMetrics.actionLabels) {
expect(label.whiteSpace, `${device.label} ${label.text} action label no wrap`).toBe("nowrap");
expect(label.height, `${device.label} ${label.text} action label single line`).toBeLessThanOrEqual(label.lineHeight + 2);
expect(label.width, `${device.label} ${label.text} action label readable width`).toBeGreaterThanOrEqual(40);
expect(label.iconWidth, `${device.label} ${label.text} action icon width`).toBeGreaterThanOrEqual(28);
expect(label.iconHeight, `${device.label} ${label.text} action icon height`).toBeGreaterThanOrEqual(28);
expect(label.gapToIcon, `${device.label} ${label.text} action label/icon spacing`).not.toBeNull();
expect(label.gapToIcon!, `${device.label} ${label.text} action label/icon spacing`).toBeGreaterThanOrEqual(6);
expect(homeMetrics.actionItems.length).toBeGreaterThanOrEqual(4);
for (const action of homeMetrics.actionItems) {
expect(action.height, `${device.label} ${action.text} action touch height`).toBeGreaterThanOrEqual(44);
expect(action.labelWhiteSpace, `${device.label} ${action.text} action label no wrap`).toBe("nowrap");
expect(action.labelHeight, `${device.label} ${action.text} action label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2);
expect(action.labelWidth, `${device.label} ${action.text} action label readable width`).toBeGreaterThanOrEqual(20);
expect(action.iconWidth, `${device.label} ${action.text} action icon width`).toBeGreaterThanOrEqual(28);
expect(action.iconHeight, `${device.label} ${action.text} action icon height`).toBeGreaterThanOrEqual(28);
expect(action.overlaps, `${device.label} ${action.text} action label/icon overlap`).toBe(false);
expect(action.labelWithin, `${device.label} ${action.text} action label within tile`).toBe(true);
expect(action.iconWithin, `${device.label} ${action.text} action icon within tile`).toBe(true);
}
for (const item of homeMetrics.navItems) {
expect(item.visible, `${device.label} ${item.key} nav item visible`).toBe(true);
@@ -0,0 +1,575 @@
import { mkdirSync } from "node:fs";
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/r6-mobile-responsive-system-hotfix");
const IOS_WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 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 ANDROID_WECOM_UA =
"Mozilla/5.0 (Linux; Android 14; Redmi K60 Build/UKQ1.230804.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const MOBILE_BROWSER_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const VIEWPORT_CASES = [
{ key: "w320-ios-wecom", label: "320 iOS 企业微信", viewport: { width: 320, height: 568 }, userAgent: IOS_WECOM_UA },
{ key: "w360-android-wecom", label: "360 Android 企业微信", viewport: { width: 360, height: 740 }, userAgent: ANDROID_WECOM_UA },
{ key: "w375-browser", label: "375 普通移动浏览器", viewport: { width: 375, height: 667 }, userAgent: MOBILE_BROWSER_UA },
{ key: "w390-ios-wecom", label: "390 iOS 企业微信", viewport: { width: 390, height: 844 }, userAgent: IOS_WECOM_UA },
{ key: "w393-android-wecom", label: "393 Android 企业微信", viewport: { width: 393, height: 873 }, userAgent: ANDROID_WECOM_UA },
{ key: "w414-ios-wecom", label: "414 iOS 企业微信", viewport: { width: 414, height: 896 }, userAgent: IOS_WECOM_UA },
{ key: "w430-browser", label: "430 普通移动浏览器", viewport: { width: 430, height: 932 }, userAgent: MOBILE_BROWSER_UA },
] as const;
const employee = {
id: "r6-responsive-employee",
employeeNo: "SMOKE002",
name: "OA试运行测试01",
phone: "13800000000",
email: "[email protected]",
departmentId: "dept-r6",
directManagerId: "manager-r6",
status: "active",
notificationPushEnabled: true,
roleCodes: ["manager", "employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 60,
},
wecomBinding: {
externalUserId: "oa_smoke_01",
createdAt: "2026-05-11T08:00:00.000Z",
},
};
function envelope(data: unknown) {
return { requestId: "mock-r6-mobile-responsive", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function screenshotPath(fileName: string) {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
return path.join(SCREENSHOT_DIR, `${fileName}.png`);
}
function reimbursementListItem(status: "draft" | "reviewing" = "draft") {
return {
id: "r6-responsive-reimbursement",
formNumber: "BX20260511001",
status,
reimbursementDate: "2026-05-11",
totalAmount: "168.30",
description: "R6 移动响应式体系验证",
payment: {},
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
project: null,
pendingWorkflow: null,
createdAt: "2026-05-11T08:00:00.000Z",
updatedAt: "2026-05-11T08:00:00.000Z",
};
}
async function mockApis(page: Page) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathName = url.pathname;
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({ accessToken: "mock-r6-responsive-token", expiresIn: 900 })),
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
entryEnabled: true,
oauthEnabled: true,
corpId: "ww-r6-responsive",
agentId: "1000002",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
bindEnabled: true,
defaultReturnTo: "/mobile",
status: "ready",
statusReasons: [],
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, employee);
return;
}
if (pathName === `/api/employees/${employee.id}/profile`) {
await fulfillJson(route, {
employee: {
...employee,
title: "测试员工",
avatarUrl: null,
department: { id: "dept-r6", name: "试运行部" },
directManager: { id: "manager-r6", name: "OA试运行测试02" },
},
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
assignedAssets: [],
organization: { reportingChain: [], directReports: [] },
});
return;
}
if (pathName === "/api/attendance/me/today") {
await fulfillJson(route, { workDate: "2026-05-11", needClockIn: false, exemptionReason: null, item: null });
return;
}
if (pathName === "/api/notifications" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
unreadOnly: false,
unreadCount: 1,
items: [
{
id: "notification-r6",
type: "workflow.task_assigned",
title: "报销待审批",
body: "R6 移动响应式验证待处理",
targetType: "workflow_task",
targetId: "task-r6",
readAt: null,
createdAt: "2026-05-11T08:00:00.000Z",
},
],
});
return;
}
if (pathName === "/api/workflow/tasks" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 100,
total: 1,
items: [
{
id: "task-r6",
instanceId: "instance-r6",
stepId: "step-r6",
stepName: "主管审批",
assigneeId: employee.id,
assigneeRole: null,
status: "pending",
outcome: null,
comments: null,
createdAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
workflowInstance: {
id: "instance-r6",
entityType: "reimbursement",
entityId: "r6-responsive-reimbursement",
currentStatus: "reviewing",
startedAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
businessSummary: {
reference: "BX20260511001",
title: "报销待审批",
applicantName: employee.name,
amount: "168.30",
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
},
],
});
return;
}
if (pathName === "/api/workflow/tasks/task-r6" && request.method() === "GET") {
await fulfillJson(route, {
task: {
id: "task-r6",
instanceId: "instance-r6",
stepId: "step-r6",
stepName: "主管审批",
assigneeId: employee.id,
assigneeRole: null,
status: "pending",
outcome: null,
comment: null,
createdAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
workflowInstance: {
id: "instance-r6",
entityType: "reimbursement",
currentStatus: "reviewing",
currentStepId: "step-r6",
contextData: { standardViolations: [] },
startedAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
reimbursement: {
id: "r6-responsive-reimbursement",
formNumber: "BX20260511001",
status: "reviewing",
reimbursementDate: "2026-05-11",
totalAmount: "168.30",
description: "R6 移动响应式体系验证",
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
},
leaveRequest: null,
purchaseRequest: null,
travelRequest: null,
allowedActions: ["approve", "reject", "return"],
history: [],
});
return;
}
if (pathName === "/api/workflow/approvers" && request.method() === "GET") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/reimbursements" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
scope: "own",
items: [reimbursementListItem()],
});
return;
}
if (pathName === "/api/reimbursements/r6-responsive-reimbursement") {
await fulfillJson(route, {
...reimbursementListItem(),
submitterId: employee.id,
ownerId: employee.id,
departmentId: "dept-r6",
department: { id: "dept-r6", name: "试运行部" },
submittedAt: null,
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
project: null,
owner: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
invoices: [],
attachments: [],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
items: [
{
id: "item-r6",
expenseTypeId: "expense-type-r6",
expenseDate: "2026-05-11",
amount: "168.30",
description: "响应式验证",
expenseType: { id: "expense-type-r6", code: "office", name: "办公费", category: "office", enabled: true },
},
],
});
return;
}
if (pathName === "/api/expense-types") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
items: [{ id: "expense-type-r6", code: "office", name: "办公费", category: "office", enabled: true }],
});
return;
}
if (pathName === "/api/leave-types") {
await fulfillJson(route, {
items: [
{
id: "leave-type-r6",
code: "annual",
name: "年假",
description: null,
enabled: true,
requiresAttachment: false,
attachmentMimeTypes: [],
sortOrder: 1,
policy: { id: "leave-policy-r6", annualQuotaDays: 5, enabled: true, description: null },
createdAt: null,
updatedAt: null,
},
],
});
return;
}
if (pathName === "/api/leave-balances") {
await fulfillJson(route, {
employeeId: employee.id,
items: [
{
leaveType: {
id: "leave-type-r6",
code: "annual",
name: "年假",
description: null,
enabled: true,
requiresAttachment: false,
attachmentMimeTypes: [],
sortOrder: 1,
policy: null,
createdAt: null,
updatedAt: null,
},
policy: { id: "leave-policy-r6", annualQuotaDays: 5, enabled: true, description: null },
granted: 5,
adjusted: 0,
reserved: 0,
used: 1,
available: 4,
periodStart: "2026-01-01",
periodEnd: "2026-12-31",
},
],
});
return;
}
if (pathName === "/api/leave-requests") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
scope: "own",
items: [
{
id: "leave-r6",
ownerId: employee.id,
departmentId: "dept-r6",
status: "draft",
startDate: "2026-05-12",
endDate: "2026-05-12",
startSession: "am",
endSession: "pm",
requestedDays: 1,
reason: "响应式验证",
attachment: null,
leaveType: { id: "leave-type-r6", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [] },
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
createdAt: "2026-05-11T08:00:00.000Z",
updatedAt: "2026-05-11T08:00:00.000Z",
},
],
});
return;
}
await fulfillJson(route, {});
});
}
async function login(page: Page) {
await page.goto("/login");
const accountLogin = page.getByRole("button", { name: "使用账号密码登录" });
if (await accountLogin.isVisible().catch(() => false)) {
await accountLogin.click();
}
await page.getByTestId("login-identifier").fill(employee.email);
await page.getByTestId("login-password").fill("Password1234");
await page.getByTestId("login-submit").click();
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
}
async function collectMetrics(page: Page) {
return page.evaluate(() => {
const viewportWidth = window.innerWidth;
const bottomNav = document.querySelector("[data-testid='mobile-bottom-nav']");
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;
return {
key,
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
width: rect?.width ?? 0,
height: rect?.height ?? 0,
hitWithin: Boolean(element && hit instanceof Element && hit.closest(`[data-testid='mobile-nav-${key}']`) === element),
};
});
const actionItems = ["approvals", "capture", "reimbursements", "me", "leave"].map((key) => {
const element = document.querySelector(`[data-testid='mobile-action-${key}']`);
const label = document.querySelector(`[data-testid='mobile-action-${key}-label']`);
const icon = document.querySelector(`[data-testid='mobile-action-${key}-icon']`);
const rect = element?.getBoundingClientRect();
const labelRect = label?.getBoundingClientRect();
const iconRect = icon?.getBoundingClientRect();
const labelStyle = label ? window.getComputedStyle(label) : null;
const overlaps = Boolean(
labelRect &&
iconRect &&
!(labelRect.right <= iconRect.left || labelRect.left >= iconRect.right || labelRect.bottom <= iconRect.top || labelRect.top >= iconRect.bottom),
);
return {
key,
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
width: rect?.width ?? 0,
height: rect?.height ?? 0,
text: label?.textContent?.trim() ?? "",
labelHeight: labelRect?.height ?? 0,
labelLineHeight: labelStyle ? Number.parseFloat(labelStyle.lineHeight) || 18 : 18,
labelWhiteSpace: labelStyle?.whiteSpace ?? "",
iconWidth: iconRect?.width ?? 0,
iconHeight: iconRect?.height ?? 0,
overlaps,
labelWithin:
Boolean(rect && labelRect) &&
labelRect!.left >= rect!.left - 1 &&
labelRect!.right <= rect!.right + 1 &&
labelRect!.top >= rect!.top - 1 &&
labelRect!.bottom <= rect!.bottom + 1,
iconWithin:
Boolean(rect && iconRect) &&
iconRect!.left >= rect!.left - 1 &&
iconRect!.right <= rect!.right + 1 &&
iconRect!.top >= rect!.top - 1 &&
iconRect!.bottom <= rect!.bottom + 1,
};
}).filter((item) => item.visible);
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
const stickyRect = stickyActions?.getBoundingClientRect();
const stickyButtons = [
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
document.querySelector("[data-testid='reimbursement-submit']"),
].filter(Boolean).map((element) => element!.getBoundingClientRect());
return {
viewportWidth,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
bottomNavVisible: Boolean(bottomNav?.getBoundingClientRect().height),
navItems,
actionItems,
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
stickyButtonsSameRow:
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
};
});
}
async function expectMobilePageStable(page: Page, label: string, screenshotName: string) {
await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true });
const metrics = await collectMetrics(page);
expect(metrics.scrollWidth, `${label} no horizontal overflow`).toBeLessThanOrEqual(metrics.viewportWidth + 2);
expect(metrics.tableCount, `${label} no visible desktop table`).toBe(0);
if (metrics.bottomNavVisible) {
for (const item of metrics.navItems) {
expect(item.visible, `${label} ${item.key} nav visible`).toBe(true);
expect(item.width, `${label} ${item.key} nav width`).toBeGreaterThanOrEqual(44);
expect(item.height, `${label} ${item.key} nav height`).toBeGreaterThanOrEqual(44);
expect(item.hitWithin, `${label} ${item.key} nav hit`).toBe(true);
}
}
return metrics;
}
test.describe("R6 移动响应式体系", () => {
for (const device of VIEWPORT_CASES) {
test.describe(device.label, () => {
test.use({
viewport: device.viewport,
isMobile: true,
hasTouch: true,
userAgent: device.userAgent,
locale: "zh-CN",
serviceWorkers: "block",
});
test("关键移动页面跨视口自适应且首页宫格不挤压", async ({ page }) => {
await mockApis(page);
await login(page);
await page.goto("/mobile");
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${device.label} home action count`).toBeGreaterThanOrEqual(4);
for (const action of homeMetrics.actionItems) {
expect(action.width, `${device.label} ${action.text} action width`).toBeGreaterThanOrEqual(44);
expect(action.height, `${device.label} ${action.text} action height`).toBeGreaterThanOrEqual(44);
expect(action.labelWhiteSpace, `${device.label} ${action.text} label nowrap`).toBe("nowrap");
expect(action.labelHeight, `${device.label} ${action.text} label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2);
expect(action.iconWidth, `${device.label} ${action.text} icon width`).toBeGreaterThanOrEqual(28);
expect(action.iconHeight, `${device.label} ${action.text} icon height`).toBeGreaterThanOrEqual(28);
expect(action.overlaps, `${device.label} ${action.text} icon/label overlap`).toBe(false);
expect(action.labelWithin, `${device.label} ${action.text} label within tile`).toBe(true);
expect(action.iconWithin, `${device.label} ${action.text} icon within tile`).toBe(true);
}
await page.getByTestId("mobile-nav-approvals").click();
await expect(page).toHaveURL(/\/mobile\/approvals$/);
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
await expectMobilePageStable(page, device.label, `${device.key}-mobile-approvals`);
await page.goto("/mobile/reimbursements");
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
await expectMobilePageStable(page, device.label, `${device.key}-mobile-reimbursements`);
await page.goto("/mobile/me");
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
await expectMobilePageStable(page, device.label, `${device.key}-mobile-me`);
await page.goto("/mobile/notifications");
await expect(page.getByTestId("mobile-notifications-page")).toBeVisible();
await expectMobilePageStable(page, device.label, `${device.key}-mobile-notifications`);
await page.goto("/mobile/capture");
await expect(page.getByTestId("mobile-capture-file")).toBeVisible();
await expectMobilePageStable(page, device.label, `${device.key}-mobile-capture`);
await page.goto("/mobile/leave");
await expect(page.getByTestId("mobile-leave-form")).toBeVisible();
await expectMobilePageStable(page, device.label, `${device.key}-mobile-leave`);
await page.goto("/mobile/reimbursements/r6-responsive-reimbursement");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
const detailMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-reimbursement-detail`);
expect(detailMetrics.stickyActionsVisible, `${device.label} sticky actions visible`).toBe(true);
expect(detailMetrics.stickyButtonsSameRow, `${device.label} sticky buttons same row`).toBe(true);
expect(detailMetrics.stickyButtonMinHeight, `${device.label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
});
});
}
});
+4
查看文件
@@ -98,6 +98,10 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共
- 手机端默认隐藏 PC 侧边栏和顶栏,使用移动首页、底部导航和轻量卡片。
- 菜单不应占用大量空间;常用入口放底部导航,更多入口放“我的”。
- 移动底部导航固定为“首页、审批、报销、我的”;票据上传作为首页快捷入口和报销页内操作,不作为底部一级 Tab。
- 移动端必须按响应式规则自动适配,不按单个机型写补丁;验收至少覆盖 320、360、375、390、393、414、430px 宽度。
- 移动首页一级快捷入口使用图标宫格:图标在上、短标题在下,4 项等宽,辅助说明不在小屏常驻展示,避免图标和文字挤压。
- 移动端页面边距、卡片间距、底部导航高度和吸底操作高度使用统一 token;不通过 viewport 缩放字体解决拥挤问题。
- 次级入口和信息字段可按最小宽度自动从两列变一列;核心操作按钮必须保持明确可点,不得被内容、安全区或底部栏遮挡。
- 报销详情、审批详情等深层 Push 页隐藏底部导航,使用顶部返回和上下文吸底操作,避免底部 Tab 在深层页面假选中。
- 企业微信个人应用消息 deeplink 应直达移动待办或业务详情;首次进入如需点击进入工作台,应保持后续可直接回到目标页。
- 移动列表使用卡片和状态标签,首屏优先展示“谁提交、什么事项、金额/天数、当前状态和下一步”。
+1 -1
查看文件
@@ -46,7 +46,7 @@
| ID | 级别 | 角色 | 入口/设备 | 流程 | 复现步骤 | 当前处理 | 负责人 | 状态 | 关联 Issue |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| R6-ISSUE-001 | 阻断 | 审批人 / 员工 | iPhone 11 / 企业微信 WebView | 移动底部导航、审批入口、首页快捷卡 | iPhone 11 使用企业微信打开 OA,底部菜单第二项“审批”点不了,首页快捷卡图标和文字挤在一起 | 已部署并通过公网自动化视觉回归,待 iPhone 11 / Redmi K60 真机确认后关闭或降级 | Codex / 待指派 | open | `issues/2026-05-11_10-46-10-r6-iphone11-wecom-mobile-layout-hotfix.csv` |
| R6-ISSUE-001 | 阻断 | 审批人 / 员工 | iPhone 11 / 企业微信 WebView | 移动底部导航、审批入口、首页快捷卡 | iPhone 11 使用企业微信打开 OA,底部菜单第二项“审批”点不了,首页快捷卡图标和文字挤在一起;用户反馈单点适配不足,需要像主流移动网页/App 一样自动适配不同分辨率 | 已从单机型热修升级为移动响应式体系修复并部署;首页快捷入口改为图标宫格,公网 320/360/375/390/393/414/430 视觉矩阵通过;待 iPhone 11 / Redmi K60 真机确认后关闭或降级 | Codex / 待指派 | open | `issues/2026-05-11_12-17-07-r6-mobile-responsive-system-hotfix.csv` |
级别定义:
@@ -4,4 +4,4 @@ UI6-2,补 iPhone 11 企业微信命中测试,补强 Playwright 视觉和命中
UI6-3,修复底部 Tab 固定与点击命中,修复移动 Shell 在 iPhone 11 企业微信 WebView 下底部导航不在真实可点区域或被内容层遮挡的问题。,"1) 底部 Tab 固定在可见视口底部并预留 safe-area;2) 四个 Tab 等宽且触控高度不低于 44px;3) 第二项审批 Tab 可点击进入审批列表;4) 不影响 Redmi K60 和普通手机浏览器。","流程级验证:在 iPhone 11 企业微信 UA 下从 /mobile 点击底部审批,进入 /mobile/approvals 并显示 mobile-approvals-list;重复点击首页、报销、我的均可命中;无横向滚动。",playwright|manual,DONE,DONE,DONE,client/src/*,"[""UI6-2""]",移动审批一级 Tab 已取消前端权限禁用和路由 403;底部导航提升层级、触控区域和 safe-area 预留;iPhone 11 / Redmi K60 E2E 通过。
UI6-4,修复 iPhone 11 页面拥挤和控件挤压,调整移动首页、审批、报销和我的页面的小屏密度,避免文字和控件在 iPhone 11 企业微信 WebView 内相互挤压。,"1) iPhone 11 下核心卡片和按钮不重叠;2) 文字不挤出容器且不遮挡相邻控件;3) 保留报销详情吸底双按钮同行,不降级为单列;4) 移动端不出现 PC 表格或 PC 壳。","流程级验证:Playwright 截图检查 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/me、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2;人工视觉确认控件间距可读可点。",playwright|manual,DONE,DONE,DONE,client/src/*,"[""UI6-2""]",移动 Shell 改为可视视口高度、紧凑小屏 padding 和 overscroll 约束;E2E 断言无横向滚动、无 PC 表格、报销详情吸底双按钮仍同行。
UI6-5,本地构建与移动视觉回归,完成前端类型检查、构建和移动专项 E2E,覆盖 iPhone 11、Redmi K60、企业微信 UA、底部导航、审批入口、报销详情和 PDF 上传入口。,"1) cd client && npm run typecheck 通过;2) cd client && npm run build 通过;3) 移动视觉专项通过;4) 截图证据保存在 output/playwright/r6-iphone11-wecom-mobile-layout-hotfix。","流程级验证:cd client && npm run typecheck;cd client && npm run build;cd client && npm run test:e2e -- r5-real-device-feedback-mobile-ux.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",npm|playwright,DONE,DONE,DONE,client/tests/e2e/*|output/playwright/r6-iphone11-wecom-mobile-layout-hotfix,"[""UI6-3"",""UI6-4""]",已通过 npm run typecheck、npm run build、r5-real-device-feedback-mobile-ux.spec.ts、r124-mobile-entry-matrix.spec.ts;UI6-6 部署与真机复测仍待执行。
UI6-6,部署复测并恢复 R6 Gate,部署修复后做 R4 复检和企业微信真机复测,只有 iPhone 11 底部导航与拥挤问题关闭后才允许继续 R6-3。,"1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) iPhone 11 企业微信底部第二项可点;4) Redmi K60 不回归;5) R6-ISSUE-001 状态关闭或降级为非阻断。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测 iPhone 11 企业微信打开 OA 并点击底部审批;回填 docs/trialrun/manual-trialrun-report.md。",bash|playwright|manual,DONE,DONE,TODO,deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md,"[""UI6-5""]",已部署 47da709 到 https://oa.hyinda.com;/ready 正常;部署脚本公网 bundle 扫描 findings=0;live smoke readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260511-120413;公网 baseURL 下 iPhone 11 / Redmi K60 移动视觉专项通过,已覆盖首页快捷卡文字和图标间距。仍待用户真机确认 iPhone 11 企业微信底部审批可点、首页快捷卡不拥挤和 Redmi K60 不回归,确认后再置 Regression DONE。
UI6-6,部署复测并恢复 R6 Gate,部署修复后做 R4 复检和企业微信真机复测,只有 iPhone 11 底部导航与拥挤问题关闭后才允许继续 R6-3。,"1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) iPhone 11 企业微信底部第二项可点;4) Redmi K60 不回归;5) R6-ISSUE-001 状态关闭或降级为非阻断。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测 iPhone 11 企业微信打开 OA 并点击底部审批;回填 docs/trialrun/manual-trialrun-report.md。",bash|playwright|manual,DONE,DONE,TODO,deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md,"[""UI6-5""]",已升级到 RS6 响应式体系修复并部署到 https://oa.hyinda.com;/ready 正常;公网 bundle findings=0;live smoke readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260511-124651;公网移动视觉矩阵 320/360/375/390/393/414/430 全部通过。仍待用户真机确认 iPhone 11 企业微信底部审批可点、首页宫格不拥挤和 Redmi K60 不回归,确认后再置 Regression DONE。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
4 UI6-3 修复底部 Tab 固定与点击命中 修复移动 Shell 在 iPhone 11 企业微信 WebView 下底部导航不在真实可点区域或被内容层遮挡的问题。 1) 底部 Tab 固定在可见视口底部并预留 safe-area;2) 四个 Tab 等宽且触控高度不低于 44px;3) 第二项审批 Tab 可点击进入审批列表;4) 不影响 Redmi K60 和普通手机浏览器。 流程级验证:在 iPhone 11 企业微信 UA 下从 /mobile 点击底部审批,进入 /mobile/approvals 并显示 mobile-approvals-list;重复点击首页、报销、我的均可命中;无横向滚动。 playwright|manual DONE DONE DONE client/src/* ["UI6-2"] 移动审批一级 Tab 已取消前端权限禁用和路由 403;底部导航提升层级、触控区域和 safe-area 预留;iPhone 11 / Redmi K60 E2E 通过。
5 UI6-4 修复 iPhone 11 页面拥挤和控件挤压 调整移动首页、审批、报销和我的页面的小屏密度,避免文字和控件在 iPhone 11 企业微信 WebView 内相互挤压。 1) iPhone 11 下核心卡片和按钮不重叠;2) 文字不挤出容器且不遮挡相邻控件;3) 保留报销详情吸底双按钮同行,不降级为单列;4) 移动端不出现 PC 表格或 PC 壳。 流程级验证:Playwright 截图检查 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/me、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2;人工视觉确认控件间距可读可点。 playwright|manual DONE DONE DONE client/src/* ["UI6-2"] 移动 Shell 改为可视视口高度、紧凑小屏 padding 和 overscroll 约束;E2E 断言无横向滚动、无 PC 表格、报销详情吸底双按钮仍同行。
6 UI6-5 本地构建与移动视觉回归 完成前端类型检查、构建和移动专项 E2E,覆盖 iPhone 11、Redmi K60、企业微信 UA、底部导航、审批入口、报销详情和 PDF 上传入口。 1) cd client && npm run typecheck 通过;2) cd client && npm run build 通过;3) 移动视觉专项通过;4) 截图证据保存在 output/playwright/r6-iphone11-wecom-mobile-layout-hotfix。 流程级验证:cd client && npm run typecheck;cd client && npm run build;cd client && npm run test:e2e -- r5-real-device-feedback-mobile-ux.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。 npm|playwright DONE DONE DONE client/tests/e2e/*|output/playwright/r6-iphone11-wecom-mobile-layout-hotfix ["UI6-3","UI6-4"] 已通过 npm run typecheck、npm run build、r5-real-device-feedback-mobile-ux.spec.ts、r124-mobile-entry-matrix.spec.ts;UI6-6 部署与真机复测仍待执行。
7 UI6-6 部署复测并恢复 R6 Gate 部署修复后做 R4 复检和企业微信真机复测,只有 iPhone 11 底部导航与拥挤问题关闭后才允许继续 R6-3。 1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) iPhone 11 企业微信底部第二项可点;4) Redmi K60 不回归;5) R6-ISSUE-001 状态关闭或降级为非阻断。 流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测 iPhone 11 企业微信打开 OA 并点击底部审批;回填 docs/trialrun/manual-trialrun-report.md。 bash|playwright|manual DONE DONE TODO deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md ["UI6-5"] 已部署 47da709 到 https://oa.hyinda.com;/ready 正常;部署脚本公网 bundle 扫描 findings=0;live smoke readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260511-120413;公网 baseURL 下 iPhone 11 / Redmi K60 移动视觉专项通过,已覆盖首页快捷卡文字和图标间距。仍待用户真机确认 iPhone 11 企业微信底部审批可点、首页快捷卡不拥挤和 Redmi K60 不回归,确认后再置 Regression DONE。 已升级到 RS6 响应式体系修复并部署到 https://oa.hyinda.com;/ready 正常;公网 bundle findings=0;live smoke readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260511-124651;公网移动视觉矩阵 320/360/375/390/393/414/430 全部通过。仍待用户真机确认 iPhone 11 企业微信底部审批可点、首页宫格不拥挤和 Redmi K60 不回归,确认后再置 Regression DONE。
@@ -0,0 +1,8 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
RS6-1,建档 R6 移动响应式体系修复,记录 iPhone 11 企业微信真机仍存在控件拥挤和图标文字挤压;本轮改为系统性移动响应式修复,不再按单机型补丁处理。,"1) 本 CSV 存在且校验通过;2) UI6-6 继续保持未完成;3) R6-ISSUE-001 保持阻断或更新为响应式体系修复中;4) 不改变审批链、通知通道、PDF-only 发票或后端接口。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-11_12-17-07-r6-mobile-responsive-system-hotfix.csv;人工核对 UI6-6 未被误置 DONE;git diff --check。",python3|git|manual,DONE,DONE,DONE,issues/2026-05-11_12-17-07-r6-mobile-responsive-system-hotfix.csv|docs/trialrun/manual-trialrun-report.md,[],已建档并更新 R6-ISSUE-001 为响应式体系修复中;UI6-6 未置 DONE;回滚路径为还原本 Issue 涉及的 client/docs/tests 变更并重新部署上一版。
RS6-2,建立移动响应式设计基线和共享样式,在产品设计规则和移动全局样式中固定页面边距、卡片间距、底部导航高度、吸底操作高度、安全区和小屏排版策略。,"1) docs/product-design-rules.md 明确移动响应式基线;2) 移动样式使用宽度区间和最小尺寸自动适配,不依赖具体机型;3) 不使用 viewport 缩放字体;4) 触控目标不低于 44px。","流程级验证:阅读 docs/product-design-rules.md 中移动响应式基线;检查 client/src/styles/mobile-touch.css 中移动 token;在 320/360/375/390/393/414/430 宽度下验证无横向滚动和触控尺寸。",manual|playwright,DONE,DONE,DONE,docs/product-design-rules.md|client/src/styles/mobile-touch.css,["RS6-1"],已补移动响应式设计规则和 mobile-touch token;本地视觉矩阵覆盖 320-430 宽度。
RS6-3,重构移动首页快捷入口为图标宫格,将移动首页审批、传票据、报销、我的等快捷入口从刚性两列卡片改为图标宫格,图标在上、短标题在下,小屏不常驻展示长说明。,"1) 首页快捷入口 4 项等宽展示;2) 图标槽和标题槽固定,标题单行省略;3) 不出现图标压文字、文字逐字换行或卡片撑破;4) 每项触控高度不低于 44px。","流程级验证:在 /mobile 的 320/360/375/390/393/414/430 宽度下截图;断言 mobile-action-* 图标、标题、容器不重叠;点击审批/传票据/报销/我的入口能进入对应页面。",playwright|manual,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/styles/mobile-touch.css,["RS6-2"],首页快捷入口已改为用户确认的图标宫格方案,审批入口固定展示;不改成每行一个按钮。
RS6-4,统一底部导航滚动容器和吸底操作,统一移动 Shell、底部 Tab、内容滚动区和报销详情吸底操作的 safe-area 与高度预留策略。,"1) 底部导航固定为首页/审批/报销/我的;2) 四个 Tab 等宽且中心点 elementFromPoint 命中自身;3) 深层报销详情隐藏底部 Tab;4) 报销详情吸底双按钮保持同行且内容不被遮挡。","流程级验证:Playwright 访问 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2;断言底部 Tab 中心点可点;断言吸底双按钮同行且高度 >=44px。",playwright,DONE,DONE,DONE,client/src/layouts/AppShellLayout.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/pages/MobileReimbursementDetailPage.tsx,["RS6-2"],已统一移动 Shell、底部导航和报销详情吸底操作 safe-area;报销详情主按钮仍保持双按钮同行。
RS6-5,巡检 R6 相关移动页刚性布局,巡检移动审批、报销、通知、票据上传、请假、我的等页面,清理小屏刚性两列和文字挤压风险。,"1) R6 相关移动页在窄屏无 PC 壳、无桌面表格、无横向溢出;2) 信息字段可根据最小宽度从两列变一列;3) 核心操作按钮保持可点且无遮挡;4) 不扩大业务功能范围。","流程级验证:Playwright 覆盖 /mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id、/mobile/me、/mobile/notifications、/mobile/capture、/mobile/leave;人工检查截图中无挤压和遮挡。",playwright|manual,DONE,DONE,DONE,client/src/pages/Mobile*.tsx,"[""RS6-3"",""RS6-4""]",已巡检并清理 R6 关键移动页刚性布局;只修布局体系,不改审批链、组织关系、通知通道或上传业务规则。
RS6-6,补跨视口视觉自动化矩阵,新增或补强移动视觉 E2E,覆盖 320x568、360x740、375x667、390x844、393x873、414x896、430x932 和企业微信/普通移动 UA。,"1) 新视觉矩阵截图输出到 output/playwright/r6-mobile-responsive-system-hotfix;2) 断言无横向滚动;3) 断言底部 Tab 命中;4) 断言首页图标宫格不重叠;5) 断言报销详情吸底双按钮同行。","流程级验证:cd client && npm run test:e2e -- r6-mobile-responsive-system-hotfix.spec.ts r5-real-device-feedback-mobile-ux.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",playwright,DONE,DONE,DONE,client/tests/e2e/r6-mobile-responsive-system-hotfix.spec.ts|client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts,"[""RS6-3"",""RS6-4"",""RS6-5""]",本地 typecheck/build 通过;移动回归组合 10 passed,截图输出到 output/playwright/r6-mobile-responsive-system-hotfix。
RS6-7,部署公网复检并回填 R6 移动阻断,本地验证通过后部署公网,复检 /ready、bundle、live smoke、PDF OCR 和企微个人应用消息,等待真机确认后关闭或降级 R6-ISSUE-001。,"1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) live smoke readiness PASS;4) 公网移动视觉专项通过;5) 用户真机确认 iPhone 11 企业微信不拥挤、审批可点、Redmi K60 不回归后回填 UI6-6 和 R6 问题状态。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;E2E_MODE=live 或 BASE_URL=https://oa.hyinda.com 运行移动视觉专项;人工真机复测并回填 docs/trialrun/manual-trialrun-report.md。",bash|playwright|manual,DONE,DONE,TODO,deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md,["RS6-6"],已部署到 https://oa.hyinda.com;/ready 正常;公网 bundle findings=0;live smoke readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260511-124651;公网移动视觉专项 7 passed。仍待用户真机确认 iPhone 11 企业微信不拥挤、审批可点、Redmi K60 不回归后再置 Regression DONE。
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 756 列。