MUD6 推广移动统一设计
这个提交包含在:
@@ -183,7 +183,7 @@ export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeSca
|
||||
}, [startWithBarcodeDetector, startWithZxing, stopScanner]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-zinc-200 p-4" data-testid="barcode-scanner-panel">
|
||||
<div className="mobile-card space-y-3" data-testid="barcode-scanner-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">相机扫码</h3>
|
||||
|
||||
@@ -811,7 +811,7 @@ export function MobileApprovalsPage() {
|
||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
<section className="card-std space-y-4 p-4" data-testid="mobile-approvals-list">
|
||||
<section className="mobile-card space-y-4" data-testid="mobile-approvals-list">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{listTitle}</h3>
|
||||
@@ -835,7 +835,7 @@ export function MobileApprovalsPage() {
|
||||
placeholder="搜索待办"
|
||||
/>
|
||||
|
||||
<details className="rounded-xl border border-zinc-200 bg-zinc-50/70 px-3 py-2 text-sm">
|
||||
<details className="mobile-subcard bg-zinc-50/70 text-sm">
|
||||
<summary className="cursor-pointer select-none font-medium text-zinc-700">
|
||||
筛选与批量
|
||||
</summary>
|
||||
@@ -891,7 +891,7 @@ export function MobileApprovalsPage() {
|
||||
</div>
|
||||
|
||||
{batchMode ? (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||
<div className="mobile-subcard bg-zinc-50 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-zinc-700">已选 {selectedBatchTaskIds.length} 条</div>
|
||||
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
|
||||
@@ -964,7 +964,7 @@ export function MobileApprovalsPage() {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex gap-3 rounded-xl border px-3 py-3 ${
|
||||
className={`mobile-list-card flex gap-3 ${
|
||||
isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
@@ -999,7 +999,7 @@ export function MobileApprovalsPage() {
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
className={`min-h-20 w-full rounded-xl border px-3 py-3 text-left transition-colors ${
|
||||
className={`mobile-list-card min-h-20 w-full text-left transition-colors ${
|
||||
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
||||
}`}
|
||||
data-testid={`mobile-approvals-task-${task.id}`}
|
||||
@@ -1022,7 +1022,7 @@ export function MobileApprovalsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" data-testid="mobile-approvals-detail">
|
||||
<section ref={detailSectionRef} className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
|
||||
<h3 className="text-base font-semibold text-zinc-900">处理</h3>
|
||||
|
||||
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
||||
@@ -1045,7 +1045,7 @@ export function MobileApprovalsPage() {
|
||||
|
||||
{selectedTask ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="mobile-list-card text-sm">
|
||||
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
|
||||
<div>步骤:{selectedTask.task.stepName}</div>
|
||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||
@@ -1105,7 +1105,7 @@ export function MobileApprovalsPage() {
|
||||
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<div className="grid gap-2 min-[390px]:grid-cols-3">
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
@@ -1150,7 +1150,7 @@ export function MobileApprovalsPage() {
|
||||
</div>
|
||||
|
||||
{canTransfer ? (
|
||||
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3 text-sm">
|
||||
<div className="mobile-list-card space-y-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
@@ -1192,7 +1192,7 @@ export function MobileApprovalsPage() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-queue">
|
||||
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
|
||||
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span>
|
||||
@@ -1216,7 +1216,7 @@ export function MobileApprovalsPage() {
|
||||
{!currentQueueItems.length ? <p className="text-sm text-zinc-500">暂无待同步草稿。</p> : null}
|
||||
|
||||
{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="mobile-list-card text-sm">
|
||||
<div className="font-medium text-zinc-900">
|
||||
{publicReference(item.taskSnapshot.reference) ?? entityLabel(item.taskSnapshot.entityType)} · {item.action === "approve" ? "通过" : "驳回"}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { BarcodeScannerPanel } from "../components/mobile/BarcodeScannerPanel";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import {
|
||||
@@ -394,7 +395,7 @@ export function MobileAssetScanPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title={isInventoryMode ? "移动扫码盘点" : "移动资产扫码"}
|
||||
subtitle={isInventoryMode ? "在盘点任务上下文中扫码定位资产并快捷录入结果。" : "扫码或手工输入资产编号,快速查看资产卡片。"}
|
||||
@@ -410,7 +411,7 @@ export function MobileAssetScanPage() {
|
||||
) : null}
|
||||
|
||||
{isInventoryMode ? (
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-inventory-context">
|
||||
<div className="mobile-card space-y-3" data-testid="mobile-asset-scan-inventory-context">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">盘点任务上下文</h3>
|
||||
@@ -436,7 +437,7 @@ export function MobileAssetScanPage() {
|
||||
) : null}
|
||||
|
||||
{selectedInventoryCheck ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||
<div className="mobile-list-card flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{selectedInventoryCheck.checkNo}</div>
|
||||
<div className="text-zinc-500">状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{selectedInventoryCheck.scopeType} / {selectedInventoryCheck.scopeRefId ?? "全量"}</div>
|
||||
@@ -459,13 +460,13 @@ export function MobileAssetScanPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-search">
|
||||
<div className="mobile-card space-y-3" data-testid="mobile-asset-scan-search">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">手工输入 / 搜索</h3>
|
||||
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="grid gap-2 min-[390px]:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Input
|
||||
data-testid="mobile-asset-scan-input"
|
||||
value={searchInput}
|
||||
@@ -483,14 +484,14 @@ export function MobileAssetScanPage() {
|
||||
</div>
|
||||
|
||||
{filteredSearchResults.length > 1 ? (
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-candidates">
|
||||
<div className="mobile-card space-y-3" data-testid="mobile-asset-scan-candidates">
|
||||
<h3 className="text-base font-semibold text-zinc-900">候选资产</h3>
|
||||
<div className="space-y-2">
|
||||
{filteredSearchResults.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left text-sm ${selectedAssetId === item.id ? "border-primary bg-primary-50 text-primary" : "border-zinc-200 bg-white text-zinc-800"}`}
|
||||
className={`mobile-list-card w-full text-left text-sm ${selectedAssetId === item.id ? "border-primary bg-primary-50 text-primary" : "border-zinc-200 bg-white text-zinc-800"}`}
|
||||
onClick={() => setSelectedAssetId(item.id)}
|
||||
data-testid={`mobile-asset-scan-candidate-${item.id}`}
|
||||
>
|
||||
@@ -503,7 +504,7 @@ export function MobileAssetScanPage() {
|
||||
) : null}
|
||||
|
||||
{selectedAsset ? (
|
||||
<div className="card-std space-y-4 p-4" data-testid="mobile-asset-scan-detail">
|
||||
<div className="mobile-card space-y-4" data-testid="mobile-asset-scan-detail">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900">{selectedAsset.assetNo}</h3>
|
||||
@@ -514,22 +515,22 @@ export function MobileAssetScanPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">状态:{statusLabel(selectedAsset.status)}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">位置:{selectedAsset.location ?? "-"}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div>
|
||||
<div className="mobile-info-grid">
|
||||
<div className="mobile-subcard text-sm">状态:{statusLabel(selectedAsset.status)}</div>
|
||||
<div className="mobile-subcard text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
|
||||
<div className="mobile-subcard text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
|
||||
<div className="mobile-subcard text-sm">位置:{selectedAsset.location ?? "-"}</div>
|
||||
<div className="mobile-subcard text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
|
||||
<div className="mobile-subcard text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
{isInventoryMode && canRecordInventory && selectedInventoryCheck ? (
|
||||
<div className="space-y-3 rounded-xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div className="mobile-subcard space-y-3 bg-zinc-50">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">盘点录入</h4>
|
||||
<p className="text-xs text-zinc-500">仅在盘点中的任务可提交或离线缓存结果。</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="mobile-info-grid">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">盘点结果</label>
|
||||
<select className="select-std" value={recordResult} onChange={(event) => setRecordResult(event.target.value as InventoryCheckResult)}>
|
||||
@@ -564,7 +565,7 @@ export function MobileAssetScanPage() {
|
||||
) : null}
|
||||
|
||||
{isInventoryMode && canRecordInventory ? (
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-queue">
|
||||
<div className="mobile-card space-y-3" data-testid="mobile-asset-scan-queue">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离线盘点队列</h3>
|
||||
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length} 项` : "无待同步项"}</span>
|
||||
@@ -575,7 +576,7 @@ export function MobileAssetScanPage() {
|
||||
) : null}
|
||||
|
||||
{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="mobile-list-card text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.assetNo}</div>
|
||||
<div className="text-zinc-500">结果:{inventoryResultLabel(item.result)} · 创建时间:{formatDateTime(item.createdAt)}</div>
|
||||
<div className="text-zinc-500">备注:{item.note ?? "-"}</div>
|
||||
@@ -597,6 +598,6 @@ export function MobileAssetScanPage() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function MobileCapturePage() {
|
||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="card-std space-y-4 p-4">
|
||||
<div className="mobile-card space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">选择票据</h3>
|
||||
@@ -200,10 +200,10 @@ export function MobileCapturePage() {
|
||||
<div className="grid gap-2" data-testid="mobile-capture-file">
|
||||
<label
|
||||
htmlFor="mobile-capture-pdf-file"
|
||||
className="flex min-h-16 cursor-pointer items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:bg-zinc-50"
|
||||
className="mobile-action-link min-h-16 cursor-pointer"
|
||||
>
|
||||
<MobileIcon name="picture_as_pdf" className="size-10 rounded-lg bg-emerald-50 p-2 text-emerald-700" />
|
||||
<span className="min-w-0">
|
||||
<MobileIcon name="picture_as_pdf" className="mobile-action-icon bg-emerald-50 text-emerald-700" />
|
||||
<span className="mobile-action-copy">
|
||||
<span className="block text-sm font-semibold text-zinc-900">选择 PDF 发票</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">数电票或扫描件 PDF</span>
|
||||
</span>
|
||||
@@ -221,7 +221,7 @@ export function MobileCapturePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-600">
|
||||
<div className="mobile-subcard text-sm text-zinc-600">
|
||||
<span className="font-medium text-zinc-800">当前文件:</span>
|
||||
{selectedFiles.length ? `已选择 ${selectedFiles.length} 个` : "未选择"}
|
||||
{selectedFiles.length ? (
|
||||
@@ -282,7 +282,7 @@ export function MobileCapturePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-capture-queue">
|
||||
<div className="mobile-card space-y-3" data-testid="mobile-capture-queue">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离线上传队列</h3>
|
||||
<span className="shrink-0 text-sm text-zinc-500">{queuedCountLabel}</span>
|
||||
@@ -295,7 +295,7 @@ export function MobileCapturePage() {
|
||||
) : null}
|
||||
|
||||
{queuedItems.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm">
|
||||
<div key={item.id} className="mobile-list-card text-sm">
|
||||
<div className="font-medium text-zinc-800">{item.fileName}</div>
|
||||
<div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -376,7 +376,7 @@ export function MobileLeavePage() {
|
||||
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null}
|
||||
{balances.slice(0, 3).map((item) => (
|
||||
<div key={item.leaveType.id} className="rounded-lg border border-zinc-200 p-3">
|
||||
<div key={item.leaveType.id} className="mobile-list-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.leaveType.name}</div>
|
||||
<span className="badge-success">启用</span>
|
||||
@@ -413,14 +413,14 @@ export function MobileLeavePage() {
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{requestsQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null}
|
||||
{requestsQuery.isLoading ? <div className="mobile-empty-state">加载中...</div> : null}
|
||||
{requestsQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{resolveErrorMessage(requestsQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
{!requestsQuery.isLoading && !requestsQuery.isError && !records.length ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无请假记录</div>
|
||||
<div className="mobile-empty-state">暂无请假记录</div>
|
||||
) : null}
|
||||
{records.map((item) => (
|
||||
<LeaveRecordCard
|
||||
|
||||
@@ -38,11 +38,11 @@ function MobileActionLink({
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="mobile-me-action-link"
|
||||
className="mobile-action-link mobile-me-action-link"
|
||||
data-testid="mobile-me-action-link"
|
||||
>
|
||||
<MobileIcon name={icon} className="mobile-me-action-icon" />
|
||||
<span className="mobile-me-action-copy">
|
||||
<MobileIcon name={icon} className="mobile-action-icon mobile-me-action-icon" />
|
||||
<span className="mobile-action-copy mobile-me-action-copy">
|
||||
<span className="block text-sm font-semibold text-zinc-900">{label}</span>
|
||||
{description ? <span className="mt-0.5 block text-xs leading-5 text-zinc-500">{description}</span> : null}
|
||||
</span>
|
||||
@@ -82,11 +82,11 @@ export function MobileMePage() {
|
||||
<PageHeader title="我的" />
|
||||
|
||||
<SectionPanel title="个人信息">
|
||||
<div className="mobile-me-profile-row" data-testid="mobile-me-profile-row">
|
||||
<div className="mobile-me-avatar-slot" data-testid="mobile-me-avatar-slot">
|
||||
<div className="mobile-slot-row mobile-me-profile-row" data-testid="mobile-me-profile-row">
|
||||
<div className="mobile-slot-avatar mobile-me-avatar-slot" data-testid="mobile-me-avatar-slot">
|
||||
<EmployeeAvatar name={me?.name ?? "员工"} src={profile?.avatarUrl} size="md" />
|
||||
</div>
|
||||
<div className="mobile-me-profile-copy" data-testid="mobile-me-profile-copy">
|
||||
<div className="mobile-slot-copy mobile-me-profile-copy" data-testid="mobile-me-profile-copy">
|
||||
<div className="text-lg font-semibold leading-7 text-zinc-900" data-testid="mobile-me-profile-name">
|
||||
{me?.name ?? "-"}
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@ export function MobileMePage() {
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="更多入口">
|
||||
<div className="mobile-me-entry-grid" data-testid="mobile-more-links">
|
||||
<div className="mobile-entry-grid mobile-me-entry-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" />
|
||||
@@ -125,7 +125,7 @@ export function MobileMePage() {
|
||||
|
||||
{canAccessAdmin ? (
|
||||
<SectionPanel title="管理入口">
|
||||
<div className="mobile-me-entry-grid">
|
||||
<div className="mobile-entry-grid mobile-me-entry-grid">
|
||||
<MobileActionLink to="/mobile/me" label="人员管理" icon="group" />
|
||||
<MobileActionLink to="/mobile/me" label="组织架构" icon="account_tree" />
|
||||
<MobileActionLink to="/mobile/me" label="系统设置" icon="settings" />
|
||||
|
||||
@@ -115,7 +115,7 @@ export function MobileNotificationsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="card-std space-y-4 p-4" data-testid="mobile-notifications-list">
|
||||
<section className="mobile-card space-y-4" data-testid="mobile-notifications-list">
|
||||
<div className="space-y-3">
|
||||
<Tabs
|
||||
data-testid="mobile-notifications-filter-tabs"
|
||||
@@ -160,7 +160,7 @@ export function MobileNotificationsPage() {
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div>
|
||||
<div className="mobile-empty-state">加载中...</div>
|
||||
) : null}
|
||||
|
||||
{listQuery.isError ? (
|
||||
@@ -170,13 +170,13 @@ export function MobileNotificationsPage() {
|
||||
) : null}
|
||||
|
||||
{!listQuery.isLoading && !listQuery.isError && !notificationItems.length ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无通知</div>
|
||||
<div className="mobile-empty-state">暂无通知</div>
|
||||
) : null}
|
||||
|
||||
{notificationItems.map((item) => {
|
||||
const targetLink = resolveMobileNotificationTarget(item);
|
||||
return (
|
||||
<article data-testid="mobile-notification-card" key={item.id} className="rounded-lg border border-zinc-200 bg-white p-4">
|
||||
<article data-testid="mobile-notification-card" key={item.id} className="mobile-list-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
|
||||
|
||||
@@ -124,20 +124,20 @@ export function MobileProfilePage() {
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="基本信息">
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<dl className="mobile-info-grid text-sm">
|
||||
<div className="mobile-subcard">
|
||||
<dt className="text-xs text-zinc-500">姓名</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<div className="mobile-subcard">
|
||||
<dt className="text-xs text-zinc-500">员工号</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<div className="mobile-subcard">
|
||||
<dt className="text-xs text-zinc-500">邮箱</dt>
|
||||
<dd className="mt-1 break-words font-medium text-zinc-900">{me?.email ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<div className="mobile-subcard">
|
||||
<dt className="text-xs text-zinc-500">手机号</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ export function MobileProfilePage() {
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="外部通知推送">
|
||||
<label className="flex min-h-12 items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-800">
|
||||
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
|
||||
<span>接收外部渠道推送</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -159,13 +159,13 @@ export function MobileProfilePage() {
|
||||
|
||||
<SectionPanel title="企业微信绑定">
|
||||
<div className="space-y-3" data-testid="mobile-profile-wecom-card">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<div className="mobile-subcard">
|
||||
<div className="text-xs text-zinc-500">接入状态</div>
|
||||
<div className="mt-1 font-semibold text-zinc-900">{wecomStatus.label}</div>
|
||||
<div className="mt-1 text-sm leading-5 text-zinc-500">{wecomStatus.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<div className="mobile-subcard">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前绑定</div>
|
||||
@@ -181,7 +181,7 @@ export function MobileProfilePage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="mobile-two-action-grid">
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
|
||||
@@ -880,7 +880,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.items.length ? (
|
||||
detail.items.map((item) => (
|
||||
<article key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3">
|
||||
<article key={item.id} className="mobile-list-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.expenseType?.name ?? "费用"}</div>
|
||||
@@ -934,7 +934,7 @@ export function MobileReimbursementDetailPage() {
|
||||
{detail.invoices.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.invoices.map((invoice) => (
|
||||
<article key={invoice.id} className="rounded-lg border border-zinc-200 px-3 py-3">
|
||||
<article key={invoice.id} className="mobile-list-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">{formatInvoiceTitle(invoice)}</div>
|
||||
@@ -1025,7 +1025,7 @@ export function MobileReimbursementDetailPage() {
|
||||
{(detail.attachments ?? []).length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{(detail.attachments ?? []).map((attachment) => (
|
||||
<article key={attachment.id} className="rounded-lg border border-zinc-200 px-3 py-3">
|
||||
<article key={attachment.id} className="mobile-list-card">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
|
||||
@@ -199,14 +199,14 @@ export function MobileReimbursementsPage() {
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{listQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null}
|
||||
{listQuery.isLoading ? <div className="mobile-empty-state">加载中...</div> : null}
|
||||
{listQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{resolveErrorMessage(listQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无报销记录</div>
|
||||
<div className="mobile-empty-state">暂无报销记录</div>
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<ReimbursementCard
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
--mobile-bottom-nav-height: 68px;
|
||||
--mobile-sticky-action-height: 78px;
|
||||
--mobile-icon-action-min-height: 72px;
|
||||
--mobile-me-slot-gap: clamp(12px, 3.7vw, 16px);
|
||||
--mobile-me-action-icon-slot: 36px;
|
||||
--mobile-slot-gap: clamp(12px, 3.7vw, 16px);
|
||||
--mobile-action-icon-slot: 36px;
|
||||
--mobile-me-slot-gap: var(--mobile-slot-gap);
|
||||
--mobile-me-action-icon-slot: var(--mobile-action-icon-slot);
|
||||
}
|
||||
|
||||
.page-scaffold.mobile-fixed-action-page {
|
||||
@@ -119,6 +121,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-entry-grid,
|
||||
.mobile-me-entry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -126,14 +129,16 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-slot-row,
|
||||
.mobile-me-profile-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
column-gap: var(--mobile-me-slot-gap);
|
||||
column-gap: var(--mobile-slot-gap);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-slot-avatar,
|
||||
.mobile-me-avatar-slot {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
@@ -142,10 +147,12 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobile-slot-copy,
|
||||
.mobile-me-profile-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-slot-copy > *,
|
||||
.mobile-me-profile-copy > * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -153,10 +160,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-action-link,
|
||||
.mobile-me-action-link {
|
||||
display: grid;
|
||||
grid-template-columns: var(--mobile-me-action-icon-slot) minmax(0, 1fr);
|
||||
column-gap: var(--mobile-me-slot-gap);
|
||||
grid-template-columns: var(--mobile-action-icon-slot) minmax(0, 1fr);
|
||||
column-gap: var(--mobile-slot-gap);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
@@ -169,30 +177,35 @@
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.mobile-action-link:hover,
|
||||
.mobile-me-action-link:hover {
|
||||
background: rgb(250 250 250);
|
||||
}
|
||||
|
||||
.mobile-action-icon,
|
||||
.mobile-me-action-icon {
|
||||
width: var(--mobile-me-action-icon-slot);
|
||||
height: var(--mobile-me-action-icon-slot);
|
||||
width: var(--mobile-action-icon-slot);
|
||||
height: var(--mobile-action-icon-slot);
|
||||
border-radius: 10px;
|
||||
background: rgb(244 244 245);
|
||||
color: rgb(63 63 70);
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.mobile-action-icon svg,
|
||||
.mobile-me-action-icon svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mobile-action-copy,
|
||||
.mobile-me-action-copy {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-action-copy > *,
|
||||
.mobile-me-action-copy > * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -200,6 +213,34 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-list-card {
|
||||
min-width: 0;
|
||||
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.05);
|
||||
padding: var(--mobile-card-padding);
|
||||
}
|
||||
|
||||
.mobile-subcard {
|
||||
min-width: 0;
|
||||
border: 1px solid rgb(228 228 231);
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.mobile-empty-state {
|
||||
min-width: 0;
|
||||
border: 1px dashed rgb(228 228 231);
|
||||
border-radius: 12px;
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: rgb(113 113 122);
|
||||
}
|
||||
|
||||
.mobile-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 7.5rem), 1fr));
|
||||
@@ -238,8 +279,8 @@
|
||||
--mobile-card-gap: 6px;
|
||||
--mobile-card-padding: 10px;
|
||||
--mobile-icon-action-min-height: 68px;
|
||||
--mobile-me-slot-gap: 12px;
|
||||
--mobile-me-action-icon-slot: 34px;
|
||||
--mobile-slot-gap: 12px;
|
||||
--mobile-action-icon-slot: 34px;
|
||||
}
|
||||
|
||||
.mobile-icon-action__icon {
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
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-unified-design-rollout");
|
||||
|
||||
const IOS_WECOM_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_1 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 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 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 MOBILE_PAGES = [
|
||||
{ key: "home", path: "/mobile", marker: "mobile-action-grid", deep: false },
|
||||
{ key: "approvals", path: "/mobile/approvals", marker: "mobile-approvals-list", deep: false },
|
||||
{ key: "reimbursements", path: "/mobile/reimbursements", marker: "mobile-reimbursements-list", deep: false },
|
||||
{ key: "reimbursement-detail", path: "/mobile/reimbursements/mud6-reimbursement", marker: "mobile-reimbursement-detail", deep: true },
|
||||
{ key: "me", path: "/mobile/me", marker: "mobile-more-links", deep: false },
|
||||
{ key: "profile", path: "/mobile/profile", marker: "mobile-profile-page", deep: false },
|
||||
{ key: "notifications", path: "/mobile/notifications", marker: "mobile-notifications-page", deep: false },
|
||||
{ key: "capture", path: "/mobile/capture", marker: "mobile-capture-file", deep: false },
|
||||
{ key: "leave", path: "/mobile/leave", marker: "mobile-leave-form", deep: false },
|
||||
{ key: "asset-scan", path: "/mobile/assets/scan", marker: "barcode-scanner-panel", deep: false },
|
||||
] as const;
|
||||
|
||||
const employee = {
|
||||
id: "mud6-employee",
|
||||
employeeNo: "SMOKE002",
|
||||
name: "OA试运行测试01",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "mud6-dept",
|
||||
directManagerId: "mud6-manager",
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["manager", "finance_mgr", "employee"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 80,
|
||||
},
|
||||
wecomBinding: {
|
||||
externalUserId: "oa_smoke_01",
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-r6-mobile-unified", 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() {
|
||||
return {
|
||||
id: "mud6-reimbursement",
|
||||
formNumber: "BX20260511007",
|
||||
status: "draft",
|
||||
reimbursementDate: "2026-05-11",
|
||||
totalAmount: "168.30",
|
||||
description: "MUD6 移动统一设计验证",
|
||||
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-mud6-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-mud6",
|
||||
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: "mud6-dept", name: "试运行部" },
|
||||
directManager: { id: "mud6-manager", 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: "mud6-notification",
|
||||
type: "workflow.task_assigned",
|
||||
title: "报销待审批",
|
||||
body: "MUD6 移动统一设计待办",
|
||||
targetType: "workflow_task",
|
||||
targetId: "mud6-task",
|
||||
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: "mud6-task",
|
||||
instanceId: "mud6-instance",
|
||||
stepId: "mud6-step",
|
||||
stepName: "主管审批",
|
||||
assigneeId: employee.id,
|
||||
assigneeRole: null,
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
comments: null,
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
workflowInstance: {
|
||||
id: "mud6-instance",
|
||||
entityType: "reimbursement",
|
||||
entityId: "mud6-reimbursement",
|
||||
currentStatus: "reviewing",
|
||||
startedAt: "2026-05-11T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
businessSummary: {
|
||||
reference: "BX20260511007",
|
||||
title: "报销待审批",
|
||||
applicantName: employee.name,
|
||||
amount: "168.30",
|
||||
duration: null,
|
||||
businessStatus: "reviewing",
|
||||
},
|
||||
allowedActions: ["approve", "reject", "return"],
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks/mud6-task" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
task: {
|
||||
id: "mud6-task",
|
||||
instanceId: "mud6-instance",
|
||||
stepId: "mud6-step",
|
||||
stepName: "主管审批",
|
||||
assigneeId: employee.id,
|
||||
assigneeRole: null,
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
comment: null,
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
workflowInstance: {
|
||||
id: "mud6-instance",
|
||||
entityType: "reimbursement",
|
||||
currentStatus: "reviewing",
|
||||
currentStepId: "mud6-step",
|
||||
contextData: { standardViolations: [] },
|
||||
startedAt: "2026-05-11T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
reimbursement: reimbursementListItem(),
|
||||
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/mud6-reimbursement") {
|
||||
await fulfillJson(route, {
|
||||
...reimbursementListItem(),
|
||||
submitterId: employee.id,
|
||||
ownerId: employee.id,
|
||||
departmentId: "mud6-dept",
|
||||
department: { id: "mud6-dept", 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: "mud6-item",
|
||||
expenseTypeId: "mud6-expense-type",
|
||||
expenseDate: "2026-05-11",
|
||||
amount: "168.30",
|
||||
description: "统一设计验证",
|
||||
expenseType: { id: "mud6-expense-type", code: "office", name: "办公费", category: "office", enabled: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/expense-types") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
items: [{ id: "mud6-expense-type", code: "office", name: "办公费", category: "office", enabled: true }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/leave-types") {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "mud6-leave-type",
|
||||
code: "annual",
|
||||
name: "年假",
|
||||
description: null,
|
||||
enabled: true,
|
||||
requiresAttachment: false,
|
||||
attachmentMimeTypes: [],
|
||||
sortOrder: 1,
|
||||
policy: { id: "mud6-leave-policy", 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: "mud6-leave-type", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [] },
|
||||
policy: { id: "mud6-leave-policy", 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: "mud6-leave",
|
||||
ownerId: employee.id,
|
||||
departmentId: "mud6-dept",
|
||||
status: "draft",
|
||||
startDate: "2026-05-12",
|
||||
endDate: "2026-05-12",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
requestedDays: 1,
|
||||
reason: "统一设计验证",
|
||||
attachment: null,
|
||||
leaveType: { id: "mud6-leave-type", 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;
|
||||
}
|
||||
|
||||
if (pathName === "/api/inventory-checks") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/assets") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||||
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 visibleElements = (selector: string) =>
|
||||
Array.from(document.querySelectorAll(selector)).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||||
});
|
||||
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 actionSlotGaps = visibleElements(".mobile-action-link").map((element) => {
|
||||
const icon = element.querySelector(".mobile-action-icon");
|
||||
const copy = element.querySelector(".mobile-action-copy");
|
||||
const iconRect = icon?.getBoundingClientRect();
|
||||
const copyRect = copy?.getBoundingClientRect();
|
||||
return iconRect && copyRect ? copyRect.left - iconRect.right : null;
|
||||
}).filter((item): item is number => typeof item === "number");
|
||||
const mobileSectionHasDecorativeTopLine = visibleElements(".hyb-mobile-shell .section-panel").some((element) => {
|
||||
const before = window.getComputedStyle(element, "::before");
|
||||
const opacity = Number.parseFloat(before.opacity || "1");
|
||||
const height = Number.parseFloat(before.height || "0");
|
||||
return before.display !== "none" && before.visibility !== "hidden" && opacity > 0.01 && height > 0;
|
||||
});
|
||||
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),
|
||||
shellScoped: Boolean(document.querySelector(".hyb-mobile-shell")),
|
||||
desktopSidebarCount: visibleElements("aside, [data-testid='sidebar']").length,
|
||||
tableCount: visibleElements("table").length,
|
||||
desktopCardMainCount: visibleElements(".hyb-mobile-shell .card-std:not(.section-panel)").length,
|
||||
mobileCardCount: visibleElements(".mobile-card, .section-panel").length,
|
||||
mobileListCardCount: visibleElements(".mobile-list-card").length,
|
||||
hasDecorativeTopLine: mobileSectionHasDecorativeTopLine,
|
||||
bottomNavVisible: Boolean(bottomNav?.getBoundingClientRect().height),
|
||||
navItems,
|
||||
actionGapMin: actionSlotGaps.length ? Math.min(...actionSlotGaps) : null,
|
||||
actionGapMax: actionSlotGaps.length ? Math.max(...actionSlotGaps) : null,
|
||||
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 expectUnifiedMobilePage(page: Page, label: string, screenshotName: string, options: { deep: boolean }) {
|
||||
await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true });
|
||||
const metrics = await collectMetrics(page);
|
||||
expect(metrics.shellScoped, `${label} mobile shell scope`).toBe(true);
|
||||
expect(metrics.scrollWidth, `${label} no horizontal overflow`).toBeLessThanOrEqual(metrics.viewportWidth + 2);
|
||||
expect(metrics.desktopSidebarCount, `${label} no desktop sidebar`).toBe(0);
|
||||
expect(metrics.tableCount, `${label} no visible desktop table`).toBe(0);
|
||||
expect(metrics.desktopCardMainCount, `${label} no desktop card main container`).toBe(0);
|
||||
expect(metrics.mobileCardCount, `${label} has mobile card system`).toBeGreaterThan(0);
|
||||
expect(metrics.hasDecorativeTopLine, `${label} mobile card top line hidden`).toBe(false);
|
||||
|
||||
if (metrics.actionGapMin !== null) {
|
||||
const minSlotGap = metrics.viewportWidth >= 390 ? 14 : 12;
|
||||
expect(metrics.actionGapMin, `${label} action slot gap min`).toBeGreaterThanOrEqual(minSlotGap);
|
||||
expect(metrics.actionGapMax ?? 0, `${label} action slot gap max`).toBeLessThanOrEqual(18);
|
||||
}
|
||||
|
||||
if (options.deep) {
|
||||
expect(metrics.bottomNavVisible, `${label} deep page hides bottom nav`).toBe(false);
|
||||
expect(metrics.stickyButtonsSameRow, `${label} sticky buttons same row`).toBe(true);
|
||||
expect(metrics.stickyButtonMinHeight, `${label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
|
||||
} else {
|
||||
expect(metrics.bottomNavVisible, `${label} bottom nav visible`).toBe(true);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("MUD6 专用移动页面统一设计", () => {
|
||||
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("所有专用 /mobile 页面复用统一槽位、卡片和底部导航体系", async ({ page }) => {
|
||||
await mockApis(page);
|
||||
await login(page);
|
||||
|
||||
for (const mobilePage of MOBILE_PAGES) {
|
||||
await page.goto(mobilePage.path);
|
||||
await expect(page.getByTestId(mobilePage.marker)).toBeVisible();
|
||||
await expectUnifiedMobilePage(page, `${device.label} ${mobilePage.key}`, `${device.key}-${mobilePage.key}`, {
|
||||
deep: mobilePage.deep,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户