R7E 真机二次反馈移动修复
引用 R7E-16C BIZ8-12B
这个提交包含在:
@@ -44,16 +44,26 @@ export function MobileDetailHeader({ title, subtitle, status, fallbackTo, testId
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="mobile-detail-header flex min-h-12 items-center gap-3" data-testid={testId}>
|
||||
<Button className="min-h-11 shrink-0 gap-1 px-3" size="sm" type="button" variant="secondary" onClick={handleBack}>
|
||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||
<span>返回</span>
|
||||
<header
|
||||
className="mobile-detail-header grid min-h-12 grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3"
|
||||
data-testid={testId}
|
||||
>
|
||||
<Button
|
||||
aria-label="返回"
|
||||
className="h-11 w-11 shrink-0 rounded-xl p-0"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
<span className="sr-only">返回</span>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mobile-detail-header__title min-w-0 flex-1">
|
||||
<div className="truncate text-base font-semibold text-zinc-900">{title}</div>
|
||||
{subtitle ? <div className="mt-0.5 truncate text-xs text-zinc-500">{subtitle}</div> : null}
|
||||
</div>
|
||||
{status ? <div className="shrink-0">{status}</div> : null}
|
||||
{status ? <div className="min-w-max shrink-0 justify-self-end">{status}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1037,13 +1037,13 @@ export function MobileApprovalsPage() {
|
||||
to={item.to}
|
||||
state={{ from: currentMobileSource }}
|
||||
>
|
||||
<div className="mobile-list-card__header">
|
||||
<div className="mobile-list-card__header mobile-list-card__header--approval">
|
||||
<div className="min-w-0">
|
||||
<div className="mobile-list-card__title">{item.title}</div>
|
||||
{item.subtitle ? <div className="mobile-list-card__subtitle mt-1">{item.subtitle}</div> : null}
|
||||
</div>
|
||||
<span className="mobile-status-slot">
|
||||
<StatusBadge status={item.status} />
|
||||
<span className="mobile-status-slot mobile-status-slot--approval">
|
||||
<StatusBadge className="mobile-status-badge" status={item.status} />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -1223,10 +1223,14 @@ export function MobileApprovalsPage() {
|
||||
data-testid={`mobile-approvals-task-${task.id}`}
|
||||
onClick={() => toggleBatchTask(task.id)}
|
||||
>
|
||||
<div className="mobile-list-card__header">
|
||||
<div className="mobile-list-card__header mobile-list-card__header--approval">
|
||||
<div className="mobile-list-card__title">{task.title}</div>
|
||||
<span className="mobile-status-slot">
|
||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||
<span className="mobile-status-slot mobile-status-slot--approval">
|
||||
<StatusBadge
|
||||
className="mobile-status-badge"
|
||||
status={task.status}
|
||||
label={workflowStatusLabel(task.status)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
|
||||
@@ -1249,10 +1253,14 @@ export function MobileApprovalsPage() {
|
||||
onClick={() => handleTaskSelection(task.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="mobile-list-card__header">
|
||||
<div className="mobile-list-card__header mobile-list-card__header--approval">
|
||||
<div className="mobile-list-card__title">{task.title}</div>
|
||||
<span className="mobile-status-slot">
|
||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||
<span className="mobile-status-slot mobile-status-slot--approval">
|
||||
<StatusBadge
|
||||
className="mobile-status-badge"
|
||||
status={task.status}
|
||||
label={workflowStatusLabel(task.status)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
|
||||
@@ -1292,13 +1300,17 @@ export function MobileApprovalsPage() {
|
||||
{selectedTask ? (
|
||||
<>
|
||||
<div className="mobile-list-card text-sm">
|
||||
<div className="mobile-list-card__header">
|
||||
<div className="mobile-list-card__header mobile-list-card__header--approval">
|
||||
<div className="min-w-0">
|
||||
<div className="mobile-list-card__title">{selectedDetailTitle}</div>
|
||||
<div className="mobile-list-card__subtitle mt-1">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</div>
|
||||
</div>
|
||||
<span className="mobile-status-slot">
|
||||
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
|
||||
<span className="mobile-status-slot mobile-status-slot--approval">
|
||||
<StatusBadge
|
||||
className="mobile-status-badge"
|
||||
status={selectedTask.task.status}
|
||||
label={workflowStatusLabel(selectedTask.task.status)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mobile-field-list mt-3">
|
||||
@@ -1389,7 +1401,7 @@ export function MobileApprovalsPage() {
|
||||
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
|
||||
) : null}
|
||||
|
||||
<div className="mobile-sticky-action-grid">
|
||||
<div className={canUseRemote ? "mobile-sticky-action-grid mobile-approval-action-grid" : "mobile-sticky-action-grid"}>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
|
||||
@@ -129,7 +129,7 @@ export function MobileMePage() {
|
||||
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
||||
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
|
||||
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
|
||||
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与台账" icon="account_balance_wallet" /> : null}
|
||||
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与付款状态" icon="account_balance_wallet" /> : null}
|
||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -204,6 +205,20 @@ export function MobilePortalPage() {
|
||||
queryFn: () => fetchPortalHome(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash !== "#finance" || !canUseFinance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const financeSection = document.getElementById("finance");
|
||||
financeSection?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
financeSection?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [canUseFinance, location.hash, portalHomeQuery.isError, portalHomeQuery.isLoading]);
|
||||
|
||||
const markHomeNotificationReadMutation = useMutation({
|
||||
mutationFn: (notificationId: string) => markNotificationRead(notificationId),
|
||||
onSuccess: async () => {
|
||||
@@ -379,7 +394,7 @@ export function MobilePortalPage() {
|
||||
) : null}
|
||||
|
||||
{canUseFinance ? (
|
||||
<section id="finance" className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||||
<section id="finance" tabIndex={-1} className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">财务</div>
|
||||
<span className="badge-neutral">{paymentBatchEnabled ? "复核与付款" : "审批与台账"}</span>
|
||||
|
||||
@@ -259,6 +259,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-list-card__header--approval {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-list-card__title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -295,6 +300,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-status-slot--approval {
|
||||
min-width: 4.5rem;
|
||||
max-width: none;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mobile-status-slot--approval > *,
|
||||
.mobile-status-badge {
|
||||
min-width: 4.25rem;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mobile-subcard {
|
||||
min-width: 0;
|
||||
border: 1px solid rgb(228 228 231);
|
||||
@@ -383,6 +401,16 @@
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.mobile-approval-action-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mobile-approval-action-grid > * {
|
||||
min-width: 0;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
:root {
|
||||
--mobile-page-x: 8px;
|
||||
|
||||
@@ -778,6 +778,74 @@ async function expectNoMobileShellRegression(page: Page) {
|
||||
expect(metrics.clippedStatusSlots).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectMobileApprovalStatusSlots(page: Page) {
|
||||
const slots = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>(".mobile-status-slot--approval"))
|
||||
.filter((slot) => {
|
||||
const rect = slot.getBoundingClientRect();
|
||||
const style = getComputedStyle(slot);
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||||
})
|
||||
.map((slot) => {
|
||||
const rect = slot.getBoundingClientRect();
|
||||
const badge = slot.querySelector<HTMLElement>(".mobile-status-badge");
|
||||
const badgeRect = badge?.getBoundingClientRect();
|
||||
return {
|
||||
text: slot.innerText.trim(),
|
||||
width: rect.width,
|
||||
badgeWidth: badgeRect?.width ?? 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
for (const slot of slots) {
|
||||
expect(slot.text.length).toBeGreaterThanOrEqual(2);
|
||||
expect(slot.width).toBeGreaterThanOrEqual(54);
|
||||
expect(slot.badgeWidth).toBeGreaterThanOrEqual(54);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectMobileDetailHeaderSpacing(page: Page, testId: string) {
|
||||
const metrics = await page.getByTestId(testId).evaluate((header) => {
|
||||
const backButton = header.querySelector<HTMLElement>("button[aria-label='返回']");
|
||||
const title = header.querySelector<HTMLElement>(".mobile-detail-header__title");
|
||||
const backRect = backButton?.getBoundingClientRect();
|
||||
const titleRect = title?.getBoundingClientRect();
|
||||
return backRect && titleRect ? {
|
||||
backWidth: backRect.width,
|
||||
gap: titleRect.left - backRect.right,
|
||||
} : null;
|
||||
});
|
||||
|
||||
expect(metrics).not.toBeNull();
|
||||
expect(metrics!.backWidth).toBeGreaterThanOrEqual(44);
|
||||
expect(metrics!.gap).toBeGreaterThanOrEqual(12);
|
||||
}
|
||||
|
||||
async function expectApprovalActionsStable(page: Page) {
|
||||
const actions = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>(".mobile-approval-action-grid > button")).map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions).toHaveLength(3);
|
||||
const firstTop = actions[0].top;
|
||||
for (const action of actions) {
|
||||
expect(Math.abs(action.top - firstTop)).toBeLessThanOrEqual(2);
|
||||
expect(action.width).toBeGreaterThanOrEqual(64);
|
||||
}
|
||||
expect(actions[0].right).toBeLessThanOrEqual(actions[1].left + 2);
|
||||
expect(actions[1].right).toBeLessThanOrEqual(actions[2].left + 2);
|
||||
}
|
||||
|
||||
async function expectActionTileGapStable(page: Page) {
|
||||
const gaps = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>("[data-testid='mobile-action-grid'] .mobile-icon-action")).map((tile) => {
|
||||
@@ -843,6 +911,7 @@ test.describe("MIF7 移动信息架构整改", () => {
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
|
||||
await expectMobileApprovalStatusSlots(mobile.page);
|
||||
await expectNoMobileShellRegression(mobile.page);
|
||||
await mobile.page.screenshot({ path: screenshotPath(`employee-progress-w${width}`), fullPage: true });
|
||||
|
||||
@@ -908,6 +977,9 @@ test.describe("MIF7 移动信息架构整改", () => {
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-history")).toContainText("提交 · 测试员工");
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-history")).toContainText("转审 · 测试审批人");
|
||||
await expect(mobile.page.getByTestId("mobile-approvals-history")).not.toContainText("transferToEmployeeId");
|
||||
await expectMobileApprovalStatusSlots(mobile.page);
|
||||
await expectMobileDetailHeaderSpacing(mobile.page, "mobile-approvals-detail-header");
|
||||
await expectApprovalActionsStable(mobile.page);
|
||||
await expectNoMobileShellRegression(mobile.page);
|
||||
await mobile.page.screenshot({ path: screenshotPath("manager-approval-push-detail"), fullPage: true });
|
||||
|
||||
@@ -1050,6 +1122,10 @@ test.describe("MIF7 移动信息架构整改", () => {
|
||||
await financeMobile.page.screenshot({ path: screenshotPath("home-finance-summary"), fullPage: true });
|
||||
await financeMobile.page.goto("/mobile/me");
|
||||
await expect(financeMobile.page.getByRole("link", { name: /财务/ })).toBeVisible();
|
||||
await financeMobile.page.getByRole("link", { name: /财务/ }).click();
|
||||
await expect(financeMobile.page).toHaveURL(/\/mobile#finance$/);
|
||||
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toBeInViewport();
|
||||
await expect(financeMobile.page.getByTestId("mobile-finance-disabled-state")).toBeInViewport();
|
||||
await expectNoMobileShellRegression(financeMobile.page);
|
||||
} finally {
|
||||
await financeMobile.context.close();
|
||||
|
||||
@@ -55,7 +55,7 @@ function buildMe() {
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["manager"],
|
||||
roleCodes: ["manager", "finance"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
@@ -230,6 +230,13 @@ async function mockApis(page: Page, state: ReturnType<typeof buildState>) {
|
||||
weeklySummary: null,
|
||||
todoSummary: null,
|
||||
projectSummary: null,
|
||||
financeSummary: {
|
||||
paymentBatchEnabled: false,
|
||||
href: "/mobile#finance",
|
||||
pendingBatchCount: 0,
|
||||
failedItemCount: 0,
|
||||
riskCount: 0,
|
||||
},
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
recentActivities: [],
|
||||
@@ -518,6 +525,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
|
||||
await mobile.page.getByTestId("mobile-nav-approvals").click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
|
||||
await expect(mobile.page.getByText("审批").first()).toBeVisible();
|
||||
await expect(mobile.page.locator(".mobile-status-slot--approval").first()).toContainText("待处理");
|
||||
await expectMobilePageContract(mobile.page);
|
||||
|
||||
await mobile.page.getByTestId("mobile-nav-me").click();
|
||||
@@ -526,6 +534,14 @@ test.describe("R124 企业微信移动入口矩阵", () => {
|
||||
await mobile.page.screenshot({ path: screenshotPath("mobile-me") });
|
||||
await expectMobilePageContract(mobile.page);
|
||||
|
||||
await expect(mobile.page.getByRole("link", { name: /财务/ })).toBeVisible();
|
||||
await mobile.page.getByRole("link", { name: /财务/ }).click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile#finance$/);
|
||||
await expect(mobile.page.getByTestId("mobile-finance-summary")).toBeInViewport();
|
||||
await expect(mobile.page.getByTestId("mobile-finance-disabled-state")).toBeInViewport();
|
||||
await expectMobilePageContract(mobile.page);
|
||||
|
||||
await mobile.page.goto("/mobile/me");
|
||||
await mobile.page.getByRole("link", { name: /个人设置/ }).click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/profile$/);
|
||||
await expect(mobile.page.getByTestId("mobile-profile-page")).toBeVisible();
|
||||
|
||||
在新工单中引用
屏蔽一个用户