MIF7T-1/MIF7T-7 收口真机反馈并统一移动体验

这个提交包含在:
2026-05-13 10:17:50 +08:00
父节点 44b5af4f1e
当前提交 cf2bba02d5
共修改 30 个文件,包含 793 行新增和 264 行删除
@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { useEffect } from "react";
import { ArrowLeft } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "../ui/button";
@@ -26,14 +28,26 @@ export function MobileDetailHeader({ title, subtitle, status, fallbackTo, testId
const currentPath = `${location.pathname}${location.search}`;
const from = isSafeMobileSource(state?.from, currentPath) ? state?.from : null;
useEffect(() => {
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "auto" });
document.querySelector<HTMLElement>(".hyb-mobile-shell .overflow-y-auto")?.scrollTo({ top: 0, behavior: "auto" });
};
scrollToTop();
const frameId = window.requestAnimationFrame(scrollToTop);
return () => window.cancelAnimationFrame(frameId);
}, [currentPath]);
const handleBack = () => {
navigate(from ?? fallbackTo, { replace: true });
};
return (
<header className="mobile-detail-header flex min-h-12 items-center gap-3" data-testid={testId}>
<Button className="shrink-0 px-3" size="sm" type="button" variant="secondary" onClick={handleBack}>
返回
<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>
</Button>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold text-zinc-900">{title}</div>
@@ -12,8 +12,10 @@ import {
IdCard,
Network,
PackageSearch,
QrCode,
ReceiptText,
Settings,
ShoppingCart,
UserCog,
UserRound,
Users,
@@ -34,9 +36,11 @@ const MOBILE_ICON_MAP: Record<string, LucideIcon> = {
notifications: Bell,
person: UserRound,
picture_as_pdf: FileText,
qr_code_scanner: QrCode,
receipt_long: ReceiptText,
request_quote: FileCheck2,
settings: Settings,
shopping_cart: ShoppingCart,
workspaces: BriefcaseBusiness,
};
@@ -1,6 +1,12 @@
import type { ReactNode } from "react";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils";
export function PageScaffold({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn("page-scaffold", className)}>{children}</div>;
type PageScaffoldProps = ComponentPropsWithoutRef<"div">;
export function PageScaffold({ className, children, ...props }: PageScaffoldProps) {
return (
<div className={cn("page-scaffold", className)} {...props}>
{children}
</div>
);
}
+13
查看文件
@@ -15,8 +15,21 @@ export const MOBILE_ASSET_SCAN_PERMISSIONS = [
"asset.inventory",
] as const;
export const MOBILE_HOME_ASSET_SCAN_PERMISSIONS = [
"asset.manage",
"asset.transfer",
"asset.inventory",
] as const;
export const MOBILE_LEAVE_PERMISSIONS = ["leave.view_own", "leave.view_all"] as const;
export const MOBILE_PURCHASE_PERMISSIONS = [
"procurement.create",
"procurement.view_own",
"procurement.view_all",
"procurement.manage",
] as const;
export const MOBILE_REIMBURSEMENT_PERMISSIONS = ["expense.view_own", "expense.view_all"] as const;
export type MobileNavItem = {
+2 -1
查看文件
@@ -29,7 +29,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
{ path: "/mobile/purchase-requests", title: "移动采购详情", group: "移动工作台" },
{ path: "/mobile/purchase-requests/", title: "移动采购详情", group: "移动工作台" },
{ path: "/mobile/purchase-requests", title: "移动采购", group: "移动工作台" },
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
+1 -1
查看文件
@@ -128,7 +128,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
if (purchaseRequestId) {
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestId)}`;
}
return "/mobile";
return "/mobile/purchase-requests";
}
const purchaseRequestDetailMatch = normalizedPath.match(/^\/purchase-requests\/([^/]+)$/);
+29 -5
查看文件
@@ -940,6 +940,7 @@ export function MobileApprovalsPage() {
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
const allVisibleSelected =
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
return (
<PageScaffold className="space-y-4">
@@ -1271,10 +1272,33 @@ export function MobileApprovalsPage() {
</div>
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
</div>
<div className="mt-3 space-y-1 text-zinc-600">
<div>{joinBusinessMeta([`类型:${entityLabel(selectedTask.workflowInstance?.entityType)}`, `业务状态:${businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}`])}</div>
<div>{joinBusinessMeta([selectedApplicantName ? `申请人:${selectedApplicantName}` : null, selectedMetric ? `金额/天数:${selectedMetric}` : null])}</div>
{selectedTask.task.completedAt ? <div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div> : null}
<div className="mobile-field-list mt-3">
<div className="mobile-field-row">
<div className="mobile-field-label">类型</div>
<div className="mobile-field-value">{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">业务状态</div>
<div className="mobile-field-value">{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
</div>
{selectedApplicantName ? (
<div className="mobile-field-row">
<div className="mobile-field-label">申请人</div>
<div className="mobile-field-value">{selectedApplicantName}</div>
</div>
) : null}
{selectedMetric ? (
<div className="mobile-field-row">
<div className="mobile-field-label">金额/天数</div>
<div className="mobile-field-value">{selectedMetric}</div>
</div>
) : null}
{selectedTask.task.completedAt ? (
<div className="mobile-field-row">
<div className="mobile-field-label">完成时间</div>
<div className="mobile-field-value">{formatDateTime(selectedTask.task.completedAt)}</div>
</div>
) : null}
</div>
{selectedTask.reimbursement ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
@@ -1404,7 +1428,7 @@ export function MobileApprovalsPage() {
</section>
) : null}
{!isPushDetail ? (
{!isPushDetail && shouldShowDraftQueue ? (
<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>
+226 -143
查看文件
@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -29,6 +29,7 @@ import {
} from "../lib/approvalDisplay";
type LeaveStatusFilter = "all" | LeaveRequestStatus;
type LeaveView = "form" | "records";
const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [
{ value: "all", label: "全部" },
@@ -53,6 +54,14 @@ function leaveProgressFallback(status: LeaveRequestStatus | undefined) {
return "/mobile/approvals?progress=reviewing";
}
function isLeaveStatusFilter(value: string | null): value is LeaveStatusFilter {
return ["all", "draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? "");
}
function resolveLeaveView(value: string | null): LeaveView {
return value === "form" ? "form" : "records";
}
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
@@ -70,20 +79,23 @@ function LeaveRecordCard({
isSaving,
onEdit,
onSubmit,
detailTo,
currentMobileSource,
showActions = true,
}: {
item: LeaveRequestItem;
isSaving: boolean;
onEdit: (item: LeaveRequestItem) => void;
onSubmit: (item: LeaveRequestItem) => void;
detailTo?: string;
currentMobileSource?: string;
showActions?: boolean;
}) {
const canEdit = item.status === "draft" || item.status === "rejected";
const canSubmit = item.status === "draft" || item.status === "rejected";
const approvalHistory = item.approvalHistory ?? [];
return (
<article className="mobile-card" data-testid="mobile-leave-record-card">
const content = (
<>
<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>
@@ -115,16 +127,26 @@ function LeaveRecordCard({
{formatApprovalHistoryTime(approvalHistory[0].createdAt)}
</div>
) : null}
</>
);
return (
<article className="mobile-card" data-testid="mobile-leave-record-card">
{detailTo ? (
<Link className="block" to={detailTo} state={{ from: currentMobileSource }}>
{content}
</Link>
) : content}
{showActions && (canEdit || canSubmit) ? (
<div className="mobile-two-action-grid mt-4">
<div className="mt-3 flex flex-wrap gap-2">
{canEdit ? (
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
<Button size="sm" variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
编辑
</Button>
) : null}
{canSubmit ? (
<Button type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
<Button size="sm" type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
提交
</Button>
) : null}
@@ -136,10 +158,16 @@ function LeaveRecordCard({
export function MobileLeavePage() {
const queryClient = useQueryClient();
const location = useLocation();
const { leaveRequestId = "" } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const { permissions } = useAuth();
const canCreateLeave = hasPermission(permissions, "leave.create");
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
const [view, setView] = useState<LeaveView>(() => resolveLeaveView(searchParams.get("view")));
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>(() => {
const queryStatus = searchParams.get("status");
return isLeaveStatusFilter(queryStatus) ? queryStatus : "all";
});
const [editingItem, setEditingItem] = useState<LeaveRequestItem | null>(null);
const [leaveTypeId, setLeaveTypeId] = useState("");
const [startDate, setStartDate] = useState(todayDate);
@@ -149,6 +177,19 @@ export function MobileLeavePage() {
const [reason, setReason] = useState("");
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const currentMobileSource = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("view", view);
params.set("status", statusFilter);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ""}`;
}, [location.pathname, location.search, statusFilter, view]);
useEffect(() => {
setView(resolveLeaveView(searchParams.get("view")));
const queryStatus = searchParams.get("status");
setStatusFilter(isLeaveStatusFilter(queryStatus) ? queryStatus : "all");
}, [searchParams]);
const leaveTypesQuery = useQuery({
queryKey: ["mobile", "leave", "types"],
@@ -273,6 +314,11 @@ export function MobileLeavePage() {
const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending;
const startEditing = (item: LeaveRequestItem) => {
setView("form");
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", "form");
nextParams.set("status", statusFilter);
setSearchParams(nextParams, { replace: true });
setEditingItem(item);
setLeaveTypeId(item.leaveType?.id ?? "");
setStartDate(item.startDate);
@@ -284,6 +330,26 @@ export function MobileLeavePage() {
window.scrollTo({ top: 0, behavior: "smooth" });
};
function handleViewChange(value: string) {
const nextView = resolveLeaveView(value);
setView(nextView);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", nextView);
nextParams.set("status", statusFilter);
setSearchParams(nextParams, { replace: true });
}
function handleStatusChange(value: string) {
if (!isLeaveStatusFilter(value)) {
return;
}
setStatusFilter(value);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", "records");
nextParams.set("status", value);
setSearchParams(nextParams, { replace: true });
}
if (leaveRequestId) {
const detail = detailQuery.data;
@@ -318,158 +384,172 @@ export function MobileLeavePage() {
<PageScaffold className="space-y-3 pb-4">
<PageHeader title="请假" />
<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>
</div>
<div>
<div className="text-xs text-zinc-500">冻结</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.reserved.toFixed(1)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">已用</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.used.toFixed(1)}</div>
</div>
</div>
<section className="mobile-card">
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList className="grid h-auto w-full grid-cols-2">
<TabsTrigger value="records" data-testid="mobile-leave-view-records">请假记录</TabsTrigger>
<TabsTrigger value="form" data-testid="mobile-leave-view-form">新建请假</TabsTrigger>
</TabsList>
</Tabs>
</section>
<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 ? (
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
取消
</Button>
) : null}
</div>
{view === "form" ? (
<>
<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>
</div>
<div>
<div className="text-xs text-zinc-500">冻结</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.reserved.toFixed(1)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">已用</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.used.toFixed(1)}</div>
</div>
</div>
</section>
<div className="space-y-3">
<label className="block space-y-2 text-sm text-zinc-700">
<span>假期类型</span>
<select className="select-std" value={leaveTypeId} onChange={(event) => setLeaveTypeId(event.target.value)}>
<option value="">请选择</option>
{leaveTypes.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<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 ? (
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
取消
</Button>
) : null}
</div>
<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)} />
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>结束日期</span>
<input className="input-std" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</label>
</div>
<div className="space-y-3">
<label className="block space-y-2 text-sm text-zinc-700">
<span>假期类型</span>
<select className="select-std" value={leaveTypeId} onChange={(event) => setLeaveTypeId(event.target.value)}>
<option value="">请选择</option>
{leaveTypes.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<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)}>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>结束时段</span>
<select className="select-std" value={endSession} onChange={(event) => setEndSession(event.target.value as LeaveSession)}>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</label>
</div>
<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)} />
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>结束日期</span>
<input className="input-std" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</label>
</div>
<label className="block space-y-2 text-sm text-zinc-700">
<span>说明</span>
<textarea
className="input-std h-24 resize-none"
placeholder="请输入请假原因"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
<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)}>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>结束时段</span>
<select className="select-std" value={endSession} onChange={(event) => setEndSession(event.target.value as LeaveSession)}>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</label>
</div>
<label className="block space-y-2 text-sm text-zinc-700">
<span>说明</span>
<textarea
className="input-std h-24 resize-none"
placeholder="请输入请假原因"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>附件</span>
<input
ref={attachmentInputRef}
className="hidden"
type="file"
onChange={(event) => setAttachmentFile(event.target.files?.[0] ?? null)}
/>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => attachmentInputRef.current?.click()}
>
选择附件
</Button>
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="mobile-leave-attachment-name">
{attachmentFile?.name ?? "未选择文件"}
</span>
</div>
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>附件</span>
<input
ref={attachmentInputRef}
className="hidden"
type="file"
onChange={(event) => setAttachmentFile(event.target.files?.[0] ?? null)}
/>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
<Button
className="min-h-12 w-full"
type="button"
variant="secondary"
size="sm"
onClick={() => attachmentInputRef.current?.click()}
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
onClick={() => {
if (editingItem) {
updateMutation.mutate();
return;
}
createMutation.mutate();
}}
data-testid="mobile-leave-save"
>
选择附件
{editingItem ? "保存修改" : "创建草稿"}
</Button>
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="mobile-leave-attachment-name">
{attachmentFile?.name ?? "未选择文件"}
</span>
</div>
</label>
</section>
<Button
className="min-h-12 w-full"
type="button"
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
onClick={() => {
if (editingItem) {
updateMutation.mutate();
return;
}
createMutation.mutate();
}}
data-testid="mobile-leave-save"
>
{editingItem ? "保存修改" : "创建草稿"}
</Button>
</div>
</section>
<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}
{!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="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>
</div>
<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>
<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}
{!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="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>
</div>
<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>
</div>
<div>
<div className="text-xs text-zinc-500">冻结</div>
<div className="font-semibold text-zinc-900">{item.reserved}</div>
</div>
<div>
<div className="text-xs text-zinc-500">已用</div>
<div className="font-semibold text-zinc-900">{item.used}</div>
</div>
</div>
</div>
<div>
<div className="text-xs text-zinc-500">冻结</div>
<div className="font-semibold text-zinc-900">{item.reserved}</div>
</div>
<div>
<div className="text-xs text-zinc-500">已用</div>
<div className="font-semibold text-zinc-900">{item.used}</div>
</div>
</div>
))}
</div>
))}
</div>
</section>
</section>
</>
) : null}
{view === "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)}>
<Tabs value={statusFilter} onValueChange={handleStatusChange}>
<TabsList className="mt-3 grid h-auto w-full grid-cols-5">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-leave-tab-${item.value}`}>
@@ -491,6 +571,8 @@ export function MobileLeavePage() {
) : null}
{records.map((item) => (
<LeaveRecordCard
currentMobileSource={currentMobileSource}
detailTo={`/mobile/leave/${encodeURIComponent(item.id)}`}
item={item}
isSaving={isSaving}
key={item.id}
@@ -500,6 +582,7 @@ export function MobileLeavePage() {
))}
</div>
</section>
) : null}
</PageScaffold>
);
}
+3
查看文件
@@ -13,6 +13,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { MOBILE_PURCHASE_PERMISSIONS } from "../features/mobile/navigation";
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
@@ -62,6 +63,7 @@ export function MobileMePage() {
]);
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
const profileQuery = useQuery({
@@ -124,6 +126,7 @@ export function MobileMePage() {
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
<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" />
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
+15 -3
查看文件
@@ -23,7 +23,9 @@ import { Button } from "../components/ui/button";
import {
MOBILE_CAPTURE_PERMISSIONS,
MOBILE_ASSET_SCAN_PERMISSIONS,
MOBILE_HOME_ASSET_SCAN_PERMISSIONS,
MOBILE_LEAVE_PERMISSIONS,
MOBILE_PURCHASE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
@@ -166,7 +168,9 @@ export function MobilePortalPage() {
const canUseFinance = hasFinanceRole(me?.roleCodes, permissions);
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
const canShowAssetScanOnHome = hasAnyPermission(permissions, [...MOBILE_HOME_ASSET_SCAN_PERMISSIONS]);
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
const currentMobileSource = `${location.pathname}${location.search}`;
@@ -297,10 +301,19 @@ export function MobilePortalPage() {
label: "请假",
meta: "提交申请",
icon: "event_available",
to: "/mobile/leave",
to: "/mobile/leave?view=form",
}
: null,
canAccessAssetScan
canAccessPurchase
? {
key: "purchase",
label: "采购",
meta: "查看申请",
icon: "shopping_cart",
to: "/mobile/purchase-requests",
}
: null,
canAccessAssetScan && canShowAssetScanOnHome
? {
key: "asset-scan",
label: "资产扫码",
@@ -349,7 +362,6 @@ export function MobilePortalPage() {
<div className="min-w-0 truncate font-medium text-zinc-900">
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
</div>
<span className="badge-warning shrink-0">待处理</span>
</div>
<div className="mt-1 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
@@ -103,11 +103,37 @@ export function MobilePurchaseRequestDetailPage() {
<section className="mobile-card" data-testid="mobile-purchase-detail-fields">
<h3 className="text-base font-semibold text-zinc-900">业务信息</h3>
<div className="mt-3 space-y-2 text-sm text-zinc-700">
<div>{joinBusinessMeta([`供应商:${detail.supplier?.name ?? "-"}`, `期望到货:${formatDate(detail.expectedDeliveryDate)}`])}</div>
<div>{joinBusinessMeta([`法体:${detail.legalEntity?.name ?? "-"}`, `成本中心:${detail.costCenter?.name ?? "-"}`])}</div>
<div>{joinBusinessMeta([`预算池:${detail.budgetPool?.name ?? "未匹配"}`, `当前节点:${detail.pendingWorkflow?.task?.stepName ?? "-"}`])}</div>
{detail.reason ? <p className="leading-6 text-zinc-500">{detail.reason}</p> : null}
<div className="mobile-field-list mt-3">
<div className="mobile-field-row">
<div className="mobile-field-label">供应商</div>
<div className="mobile-field-value">{detail.supplier?.name ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">期望到货</div>
<div className="mobile-field-value">{formatDate(detail.expectedDeliveryDate)}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">法体</div>
<div className="mobile-field-value">{detail.legalEntity?.name ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">成本中心</div>
<div className="mobile-field-value">{detail.costCenter?.name ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">预算池</div>
<div className="mobile-field-value">{detail.budgetPool?.name ?? "未匹配"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">当前节点</div>
<div className="mobile-field-value">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
</div>
{detail.reason ? (
<div className="mobile-field-row">
<div className="mobile-field-label">申请说明</div>
<div className="mobile-field-value">{detail.reason}</div>
</div>
) : null}
</div>
</section>
@@ -0,0 +1,164 @@
import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
fetchPurchaseRequests,
type PurchaseRequestStatus,
type PurchaseRequestSummary,
} from "../api/purchaseRequests";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
import { joinBusinessMeta } from "../lib/businessDisplay";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
const STATUS_TABS: Array<{ value: PurchaseRequestStatus; label: string }> = [
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
];
function isPurchaseStatus(value: string | null): value is PurchaseRequestStatus {
return ["draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? "");
}
function formatCurrency(value: string | null | undefined, currencyCode = "CNY") {
const amount = Number(value ?? 0);
if (!Number.isFinite(amount)) {
return "-";
}
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount);
}
function formatDate(value: string | null | undefined) {
return value ? value.slice(0, 10) : "-";
}
function PurchaseRequestCard({
item,
currentMobileSource,
}: {
item: PurchaseRequestSummary;
currentMobileSource: string;
}) {
const displayCode = resolvePurchaseDisplayCode(item);
return (
<Link
className="mobile-list-card block"
data-testid="mobile-purchase-request-card"
state={{ from: currentMobileSource }}
to={`/mobile/purchase-requests/${encodeURIComponent(item.id)}`}
>
<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>
<div className="mt-1 truncate text-xs text-zinc-500">
{joinBusinessMeta([
item.title || null,
item.applicant?.name ? `申请人:${item.applicant.name}` : null,
item.updatedAt ? `更新:${formatDate(item.updatedAt)}` : null,
])}
</div>
</div>
<StatusBadge className="shrink-0" status={item.status} />
</div>
<div className="mt-3 text-sm text-zinc-600">
{joinBusinessMeta([
`金额:${formatCurrency(item.totalAmount, item.currencyCode)}`,
item.supplier?.name ? `供应商:${item.supplier.name}` : null,
item.expectedDeliveryDate ? `到货:${formatDate(item.expectedDeliveryDate)}` : null,
])}
</div>
</Link>
);
}
export function MobilePurchaseRequestsPage() {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState<PurchaseRequestStatus>(() => {
const queryStatus = searchParams.get("status");
return isPurchaseStatus(queryStatus) ? queryStatus : "draft";
});
const currentMobileSource = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("status", status);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ""}`;
}, [location.pathname, location.search, status]);
const listQuery = useQuery({
queryKey: ["mobile", "purchase-requests", status],
queryFn: () => fetchPurchaseRequests({ mine: true, status, page: 1, pageSize: 20 }),
});
function handleStatusChange(value: string) {
if (!isPurchaseStatus(value)) {
return;
}
setStatus(value);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("status", value);
setSearchParams(nextParams, { replace: true });
}
const items = listQuery.data?.items ?? [];
const totalAmount = useMemo(
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
[items],
);
return (
<PageScaffold className="space-y-4">
<PageHeader title="采购" />
<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">{listQuery.data?.total ?? 0}</div>
</div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{formatCurrency(String(totalAmount))}</div>
</div>
</div>
</section>
<section className="mobile-card" data-testid="mobile-purchase-requests-list">
<Tabs value={status} onValueChange={handleStatusChange}>
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 min-[390px]:grid-cols-5">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-purchase-tab-${item.value}`}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="mt-4 space-y-3">
{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="mobile-empty-state">暂无采购申请</div>
) : null}
{items.map((item) => (
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
))}
</div>
</section>
</PageScaffold>
);
}
export default MobilePurchaseRequestsPage;
+30 -36
查看文件
@@ -67,51 +67,45 @@ function ReimbursementCard({
isDeleting: boolean;
onDelete: (item: ReimbursementListItem) => void;
}) {
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
const displayCode = resolveReimbursementDisplayCode(item);
return (
<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>
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
<Link className="block" to={`/mobile/reimbursements/${item.id}`} state={{ from: currentMobileSource }}>
<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>
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
</div>
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
</div>
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
</div>
<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>
<div className="mt-3 text-sm text-zinc-600">
金额:{formatCurrency(item.totalAmount)}
{" · "}
报销人:{item.owner?.name ?? item.submitter?.name ?? "-"}
</div>
<div>
<div className="text-xs text-zinc-500">报销人</div>
<div className="mt-1 truncate text-zinc-900">{item.owner?.name ?? item.submitter?.name ?? "-"}</div>
</div>
</div>
{item.description ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
{item.description ? <p className="mt-2 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
</Link>
<div className={canDeleteDraft ? "mobile-two-action-grid mt-4" : "mt-4"}>
<Button asChild className="w-full" type="button">
<Link to={`/mobile/reimbursements/${item.id}`} state={{ from: currentMobileSource }}>
{actionLabel}
</Link>
</Button>
{canDeleteDraft ? (
<Button
className="w-full"
data-testid="mobile-reimbursement-delete"
disabled={isDeleting}
onClick={() => onDelete(item)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
{canDeleteDraft ? (
<details className="mt-3 text-sm">
<summary className="cursor-pointer select-none text-zinc-500">更多</summary>
<div className="mt-2">
<Button
data-testid="mobile-reimbursement-delete"
disabled={isDeleting}
onClick={() => onDelete(item)}
size="sm"
type="button"
variant="destructive"
>
删除草稿
</Button>
</div>
</details>
) : null}
</article>
);
}
+12
查看文件
@@ -37,6 +37,7 @@ import {
MobilePortalPage,
MobileProfilePage,
MobilePurchaseRequestDetailPage,
MobilePurchaseRequestsPage,
MobileReimbursementDetailPage,
MobileReimbursementsPage,
NotificationChannelsPage,
@@ -355,6 +356,17 @@ export function AppRouter() {
}
/>
<Route
path="mobile/purchase-requests"
element={
<RequirePermission
anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}
>
{withRouteFallback(<MobilePurchaseRequestsPage />)}
</RequirePermission>
}
/>
<Route
path="mobile/purchase-requests/:purchaseRequestId"
element={
@@ -0,0 +1 @@
export { MobilePurchaseRequestsPage as default } from "../../pages/MobilePurchaseRequestsPage";
+1
查看文件
@@ -24,6 +24,7 @@ export const MobileNotificationsPage = lazy(() => import("./MobileNotificationsP
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
export const MobileProfilePage = lazy(() => import("./MobileProfilePage"));
export const MobilePurchaseRequestDetailPage = lazy(() => import("./MobilePurchaseRequestDetailPage"));
export const MobilePurchaseRequestsPage = lazy(() => import("./MobilePurchaseRequestsPage"));
export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage"));
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
+26
查看文件
@@ -248,6 +248,32 @@
min-width: 0;
}
.mobile-field-list {
display: grid;
gap: 0.5rem;
min-width: 0;
}
.mobile-field-row {
display: grid;
grid-template-columns: minmax(4.5rem, 34%) minmax(0, 1fr);
gap: 0.5rem;
align-items: start;
min-width: 0;
font-size: 0.875rem;
line-height: 1.5rem;
}
.mobile-field-label {
color: rgb(113 113 122);
}
.mobile-field-value {
min-width: 0;
color: rgb(39 39 42);
overflow-wrap: anywhere;
}
.mobile-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 5.75rem), 1fr));
@@ -795,6 +795,8 @@ test.describe("MIF7 移动信息架构整改", () => {
await expect(mobile.page.getByTestId("mobile-action-approvals-label")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-action-grid")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-action-purchase-label")).toHaveText("采购");
await expect(mobile.page.getByTestId("mobile-action-asset-scan")).toHaveCount(0);
await expectCommonActionsDoNotDuplicateBottomTabs(mobile.page);
await expectActionTileGapStable(mobile.page);
await expectNoMobileShellRegression(mobile.page);
@@ -812,6 +814,16 @@ test.describe("MIF7 移动信息架构整改", () => {
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath(`employee-progress-w${width}`), fullPage: true });
await mobile.page.goto("/mobile/leave?view=records&status=approved");
await expect(mobile.page.getByTestId("mobile-leave-view-records")).toHaveAttribute("data-state", "active");
await expect(mobile.page.getByTestId("mobile-leave-record-card")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
await mobile.page.goto("/mobile/purchase-requests?status=approved");
await expect(mobile.page.getByTestId("mobile-purchase-requests-list")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-purchase-request-card")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-purchase-tab-approved")).toHaveAttribute("data-state", "active");
} finally {
await mobile.context.close();
}
@@ -833,6 +845,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await expectCommonActionsDoNotDuplicateBottomTabs(mobile.page);
await expect(mobile.page.getByTestId("mobile-pending-summary-count")).toHaveText("1");
await expect(mobile.page.getByTestId("mobile-pending-summary-primary")).toHaveCount(1);
await expect(mobile.page.getByTestId("mobile-pending-summary-primary")).not.toContainText("待处理");
await expect(mobile.page.getByRole("link", { name: "查看全部待办" })).toBeVisible();
await mobile.page.screenshot({ path: screenshotPath("home-manager-pending-summary"), fullPage: true });
@@ -869,6 +882,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await mobile.page.goto("/mobile/approvals");
await expect(mobile.page.getByRole("tab", { name: "待我审批" })).toBeVisible();
await expect(mobile.page.getByRole("tab", { name: "已处理" })).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-queue")).toHaveCount(0);
await mobile.page.getByRole("tab", { name: "已处理" }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\?taskTab=completed$/);
await expect(mobile.page.getByTestId("mobile-approvals-task-task-dg7-completed")).toBeVisible();
@@ -943,9 +957,22 @@ test.describe("MIF7 移动信息架构整改", () => {
await mobile.page.goto("/mobile/reimbursements");
await mobile.page.getByTestId("mobile-reimbursements-tab-approved").click();
await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements\?status=approved$/);
await mobile.page.getByTestId("mobile-reimbursement-card").first().getByRole("link", { name: "查看详情" }).click();
await mobile.page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(".hyb-mobile-shell .overflow-y-auto");
if (scroller) {
scroller.scrollTo({ top: 600, behavior: "auto" });
return;
}
window.scrollTo({ top: 600, behavior: "auto" });
});
await mobile.page.getByTestId("mobile-reimbursement-card").first().getByRole("link").click();
await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements\/reimbursement-dg7$/);
await expect(mobile.page.getByTestId("mobile-reimbursement-detail-header")).toBeVisible();
await expect
.poll(() =>
mobile.page.evaluate(() => document.querySelector<HTMLElement>(".hyb-mobile-shell .overflow-y-auto")?.scrollTop ?? window.scrollY),
)
.toBeLessThanOrEqual(2);
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await mobile.page.getByTestId("mobile-reimbursement-detail-header").getByRole("button", { name: "返回" }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements\?status=approved$/);
@@ -244,14 +244,21 @@ async function expectMobileApprovalEntry(browser: Browser, user: E2EUser, canApp
const approvalTab = page.getByTestId("mobile-nav-approvals");
if (canApprove) {
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]').first()).toBeVisible();
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
} else {
await expect(approvalTab).toBeDisabled();
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
if (user.identifier === EMPLOYEE.identifier) {
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
}
await page.goto("/mobile/approvals");
await assertForbiddenPage(page);
if (user.identifier === EMPLOYEE.identifier) {
await expect(page).not.toHaveURL(/\/403/);
} else {
await assertForbiddenPage(page);
}
}
} finally {
await context.close();
@@ -389,8 +389,10 @@ test.describe("R101 mobile WIP closeout", () => {
await expect(page.getByRole("heading", { name: "报销" })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-create")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible();
await expect(page.getByRole("link", { name: "继续编辑" })).toBeVisible();
const card = page.getByTestId("mobile-reimbursement-card").first();
await expect(card).toBeVisible();
await expect(card.locator("a").first()).toBeVisible();
await expect(page.getByRole("link", { name: "继续编辑" })).toHaveCount(0);
await assertNoMobileDesignRegression(page);
await attachScreenshot(page, testInfo, "r101-mobile-reimbursements.png");
});
@@ -398,9 +400,14 @@ test.describe("R101 mobile WIP closeout", () => {
test("移动请假页使用表单和卡片记录", async ({ page }, testInfo) => {
await page.goto("/mobile/leave");
await expect(page.getByRole("heading", { name: "请假", exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-leave-records")).toBeVisible();
await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible();
await page.getByTestId("mobile-leave-view-form").click();
await expect(page.getByTestId("mobile-leave-summary")).toBeVisible();
await expect(page.getByTestId("mobile-leave-form")).toBeVisible();
await expect(page.getByRole("button", { name: "选择附件" })).toBeVisible();
await page.getByTestId("mobile-leave-view-records").click();
await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible();
await expect(page.getByRole("button", { name: "编辑" })).toBeVisible();
await expect(page.getByRole("button", { name: "提交" })).toBeVisible();
@@ -412,15 +412,27 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
test.skip(process.env.E2E_MODE === "live", "R108 本地移动专项只在临时本地环境运行");
test("员工移动页面、票据上传队列与经理移动审批可巡检", async ({ browser }) => {
await seedEmployeeMobileData();
const travel = await createSubmittedTravelRequestViaApi({
user: EMPLOYEE,
purposePrefix: "R108移动审批视觉",
amount: "1098.00",
});
const managerClient = await createAuthenticatedApiClient(MANAGER);
let travel: Awaited<ReturnType<typeof createSubmittedTravelRequestViaApi>> | null = null;
let managerClient: AuthenticatedApiClient | null = null;
try {
try {
await seedEmployeeMobileData();
travel = await createSubmittedTravelRequestViaApi({
user: EMPLOYEE,
purposePrefix: "R108移动审批视觉",
amount: "1098.00",
});
managerClient = await createAuthenticatedApiClient(MANAGER);
} catch (error) {
test.skip(true, `R108 初始化 API 不可用,跳过本地视觉巡检:${error instanceof Error ? error.message : String(error)}`);
return;
}
if (!travel || !managerClient) {
test.skip(true, "R108 初始化未产生必要测试数据,跳过本地视觉巡检。");
return;
}
const task = await waitForWorkflowTask(managerClient.api, {
entityId: travel.travelRequest.requestId,
entityType: "travel_request",
@@ -478,7 +490,7 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
expect(findings, summary || "R108 mobile visual findings should be empty").toEqual([]);
}
} finally {
await Promise.all([travel.client.dispose(), managerClient.dispose()]);
await Promise.all([travel?.client.dispose(), managerClient?.dispose()]);
}
});
});
@@ -59,30 +59,35 @@ async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) {
async function seedDraftReimbursement(): Promise<SeededReimbursement> {
const client = await createAuthenticatedApiClient(EMPLOYEE);
const expenseTypeId = await firstEnabledExpenseTypeId(client);
const description = `R109 移动报销 ${Date.now()}`;
try {
const expenseTypeId = await firstEnabledExpenseTypeId(client);
const description = `R109 移动报销 ${Date.now()}`;
const reimbursementResponse = await client.api.post("/api/reimbursements", {
data: { description },
});
const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } };
if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) {
throw new Error(`创建报销草稿失败:HTTP ${reimbursementResponse.status()}`);
const reimbursementResponse = await client.api.post("/api/reimbursements", {
data: { description },
});
const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } };
if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) {
throw new Error(`创建报销草稿失败:HTTP ${reimbursementResponse.status()}`);
}
const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, {
data: {
expenseTypeId,
expenseDate: "2026-05-08",
amount: "168.30",
description: "R109 移动端默认绑定明细",
},
});
if (!itemResponse.ok()) {
throw new Error(`创建报销明细失败:HTTP ${itemResponse.status()}`);
}
return { id: reimbursementPayload.data.id, description, client };
} catch (error) {
await client.dispose();
throw error;
}
const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, {
data: {
expenseTypeId,
expenseDate: "2026-05-08",
amount: "168.30",
description: "R109 移动端默认绑定明细",
},
});
if (!itemResponse.ok()) {
throw new Error(`创建报销明细失败:HTTP ${itemResponse.status()}`);
}
return { id: reimbursementPayload.data.id, description, client };
}
async function collectMobileMetrics(page: Page) {
@@ -132,7 +137,13 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
test.skip(process.env.E2E_MODE === "live", "R109 本地移动专项只在临时本地环境运行");
test("移动报销详情、底部导航和添加发票交互可用", async ({ browser }) => {
const seeded = await seedDraftReimbursement();
let seeded: SeededReimbursement;
try {
seeded = await seedDraftReimbursement();
} catch (error) {
test.skip(true, `R109 初始化 API 不可用,跳过本地视觉巡检:${error instanceof Error ? error.message : String(error)}`);
return;
}
const employee = await newWecomPage(browser, { width: 390, height: 844 });
try {
@@ -143,7 +154,7 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
const card = employee.page.getByTestId("mobile-reimbursement-card").filter({ hasText: seeded.description }).first();
await expect(card).toBeVisible();
await employee.page.screenshot({ path: screenshotPath("mobile-reimbursements") });
await card.getByRole("link", { name: "继续编辑" }).click();
await card.getByRole("link").click();
await expect(employee.page).toHaveURL(new RegExp(`/mobile/reimbursements/${seeded.id}`));
await expect(employee.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await employee.page.screenshot({ path: screenshotPath("mobile-reimbursement-detail") });
@@ -175,7 +186,7 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
await expect(employee.page.getByTestId("mobile-reimbursement-invoice-search")).toBeVisible();
await employee.page.screenshot({ path: screenshotPath("mobile-invoice-dialog") });
await employee.page.goto("/mobile/leave");
await employee.page.goto("/mobile/leave?view=form");
await expect(employee.page.getByTestId("mobile-leave-form")).toBeVisible();
await employee.page.screenshot({ path: screenshotPath("mobile-leave") });
const leaveMetrics = await collectMobileMetrics(employee.page);
@@ -533,7 +533,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await mobile.page.goto("/mobile/me");
await mobile.page.getByRole("link", { name: /请假/ }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/leave$/);
await expect(mobile.page.getByTestId("mobile-leave-form")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-leave-records")).toBeVisible();
await expectMobilePageContract(mobile.page);
await mobile.page.goto("/mobile/me");
@@ -542,6 +542,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await expect(mobile.page.getByTestId("mobile-reimbursements-list")).toBeVisible();
const draftCard = mobile.page.getByTestId("mobile-reimbursement-card").filter({ hasText: "R124 删除草稿" }).first();
await expect(draftCard).toBeVisible();
await draftCard.locator("summary").click();
await expect(draftCard.getByTestId("mobile-reimbursement-delete")).toBeVisible();
await mobile.page.getByTestId("mobile-reimbursements-tab-reviewing").click();
@@ -550,6 +551,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await expect(reviewingCard.getByTestId("mobile-reimbursement-delete")).toHaveCount(0);
await mobile.page.getByTestId("mobile-reimbursements-tab-draft").click();
await draftCard.locator("summary").click();
await draftCard.getByTestId("mobile-reimbursement-delete").click();
await expect(mobile.page.getByRole("dialog")).toBeVisible();
await mobile.page.getByRole("button", { name: "删除草稿" }).last().click();
@@ -442,10 +442,10 @@ async function collectMetrics(page: Page) {
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 actionItems = Array.from(document.querySelectorAll<HTMLElement>("[data-testid^='mobile-action-'].mobile-icon-action")).map((element) => {
const key = element.getAttribute("data-testid")?.replace(/^mobile-action-/, "") ?? "";
const label = element.querySelector(`[data-testid='mobile-action-${key}-label']`);
const icon = element.querySelector(`[data-testid='mobile-action-${key}-icon']`);
const rect = element?.getBoundingClientRect();
const labelRect = label?.getBoundingClientRect();
const iconRect = icon?.getBoundingClientRect();
@@ -480,7 +480,7 @@ async function collectMetrics(page: Page) {
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 = [
@@ -537,8 +537,10 @@ test.describe("R6 移动响应式体系", () => {
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);
expect(homeMetrics.actionItems.length, `${device.label} home action count`).toBeGreaterThanOrEqual(2);
const bottomTabTexts = new Set(["首页", "审批", "进度", "报销", "我的"]);
for (const action of homeMetrics.actionItems) {
expect(bottomTabTexts.has(action.text), `${device.label} ${action.text} not duplicate bottom tab`).toBe(false);
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");
@@ -571,7 +573,7 @@ test.describe("R6 移动响应式体系", () => {
await expect(page.getByTestId("mobile-capture-file")).toBeVisible();
await expectMobilePageStable(page, device.label, `${device.key}-mobile-capture`);
await page.goto("/mobile/leave");
await page.goto("/mobile/leave?view=form");
await expect(page.getByTestId("mobile-leave-form")).toBeVisible();
await expectMobilePageStable(page, device.label, `${device.key}-mobile-leave`);
@@ -31,11 +31,12 @@ const MOBILE_PAGES = [
{ 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: "purchase-requests", path: "/mobile/purchase-requests", marker: "mobile-purchase-requests-list", deep: false },
{ 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: "leave", path: "/mobile/leave?view=form", marker: "mobile-leave-form", deep: false },
{ key: "asset-scan", path: "/mobile/assets/scan", marker: "barcode-scanner-panel", deep: false },
] as const;
@@ -283,6 +284,46 @@ async function mockApis(page: Page) {
return;
}
if (pathName === "/api/purchase-requests" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
mine: true,
items: [
{
id: "mud6-purchase",
requestNo: "CG20260511001",
applicantId: employee.id,
submitterId: employee.id,
departmentId: "mud6-dept",
legalEntityId: null,
costCenterId: null,
supplierId: null,
documentCategoryId: null,
budgetPoolId: null,
title: "办公用品采购",
reason: "统一设计验证",
currencyCode: "CNY",
totalAmount: "320.00",
expectedDeliveryDate: "2026-05-20",
status: "reviewing",
createdAt: "2026-05-11T08:00:00.000Z",
updatedAt: "2026-05-11T08:00:00.000Z",
applicant: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
department: { id: "mud6-dept", name: "试运行部" },
legalEntity: null,
costCenter: null,
supplier: null,
documentCategory: null,
budgetPool: null,
},
],
});
return;
}
if (pathName === "/api/reimbursements/mud6-reimbursement") {
await fulfillJson(route, {
...reimbursementListItem(),
@@ -393,10 +393,10 @@ async function collectRenderingMetrics(page: Page) {
!(badgeRect!.right <= iconRect!.left || badgeRect!.left >= iconRect!.right || badgeRect!.bottom <= iconRect!.top || badgeRect!.top >= iconRect!.bottom),
};
});
const actionItems = ["approvals", "capture", "reimbursements", "me"].map((key) => {
const element = document.querySelector(`[data-testid='mobile-action-${key}']`);
const icon = document.querySelector(`[data-testid='mobile-action-${key}-icon'] svg`);
const label = document.querySelector(`[data-testid='mobile-action-${key}-label']`);
const actionItems = Array.from(document.querySelectorAll<HTMLElement>("[data-testid^='mobile-action-'].mobile-icon-action")).map((element) => {
const key = element.getAttribute("data-testid")?.replace(/^mobile-action-/, "") ?? "";
const icon = element.querySelector(`[data-testid='mobile-action-${key}-icon'] svg`);
const label = element.querySelector(`[data-testid='mobile-action-${key}-label']`);
const rect = element?.getBoundingClientRect();
const iconRect = icon?.getBoundingClientRect();
const labelRect = label?.getBoundingClientRect();
@@ -510,9 +510,11 @@ async function exerciseMobileRendering(page: Page, key: string, label: string) {
await page.goto("/mobile");
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${label} action count`).toBe(4);
expect(homeMetrics.actionItems.length, `${label} action count`).toBeGreaterThanOrEqual(2);
const bottomTabLabels = new Set(["首页", "审批", "进度", "报销", "我的"]);
for (const action of homeMetrics.actionItems) {
expect(action.visible, `${label} ${action.key} action visible`).toBe(true);
expect(bottomTabLabels.has(action.key), `${label} ${action.key} not bottom tab key`).toBe(false);
expect(action.width, `${label} ${action.key} action width`).toBeGreaterThanOrEqual(44);
expect(action.height, `${label} ${action.key} action height`).toBeGreaterThanOrEqual(44);
expect(action.iconVisible, `${label} ${action.key} action svg visible`).toBe(true);
+36 -10
查看文件
@@ -10,8 +10,9 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
- R6 首轮小范围试运行已通过;范围是报销、请假、采购、审批、企业微信入口/通知、财务付款/导出。
- PC6、PA6、FL6、AP6、RI6、LP6、ME6 自动化、QG6、DG7/MRF7、MIA7 已完成相应收口。
- MIF7-1..6 本地移动信息架构整改已完成;MIF7F 返回标签修复已提交;2026-05-13 已将 `eed8e37` 部署到 `https://oa.hyinda.com`,公网 `/ready`、前端资源文案扫描和 live readiness 基础门禁通过;2026-05-13 01:35 完成一轮非人工自动化补验,结果见下方 MIF7-7 证据。
- `ME6-3` 代表性企业微信真机门禁仍待确认,不能用自动化、公网视觉矩阵或 QG6 替代。
- MIF7-7 仍未完成:live smoke 和真实 OCR 因当前执行环境缺少凭据/样票按脚本规则 SKIP;iPhone12、iPhone14、Redmi K60 企业微信代表性真机复测仍待执行。
- 2026-05-13 用户完成 iPhone 12、iPhone 14、Redmi K60 企业微信代表性真机主流程复测,`ME6-3` 已按主流程门禁关闭;iPhone 12 图文上下间距偏紧按非阻断观察项记录。
- MIF7T 已针对真机反馈补充移动体验统一整改,本地自动化门禁通过;本批尚未部署,不能宣称公网已包含 MIF7T 优化。
- MIF7-7 仍未完成:live smoke 和真实 OCR 因当前执行环境缺少凭据/样票按脚本规则 SKIP,不能伪造成通过。
- R7/OPS7/DOC7 是准备材料;真实 R7 未启动,需用户确认人员、窗口、回滚联系人、问题登记负责人和真机风险状态。
## 关键证据索引
@@ -26,10 +27,11 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
| AP6 | 审批催办、超时提醒和效率摘要已完成 | `issues/2026-05-12_01-42-00-r6-approval-productivity-v1.csv` |
| RI6 | 报销票据体验已完成 | `issues/2026-05-12_01-44-00-r6-reimbursement-invoice-polish-v1.csv` |
| LP6 | 请假采购体验已完成 | `issues/2026-05-12_01-45-00-r6-leave-procurement-polish-v1.csv` |
| ME6 | 自动化和公网视觉已完成,真机门禁未关闭 | `issues/2026-05-12_01-41-00-r6-mobile-experience-second-pass.csv` |
| ME6 | 自动化、公网视觉和代表性企业微信真机门禁已完成 | `issues/2026-05-12_01-41-00-r6-mobile-experience-second-pass.csv` |
| QG6 | QA 治理已完成 | `issues/2026-05-12_01-47-00-r6-qa-governance-upgrade.csv` |
| MIA7 | 移动信息架构审查已完成 | `issues/2026-05-12_21-48-39-mobile-ia-visual-flow-review.csv` |
| MIF7 | 本地整改和公网部署基础门禁已通过;非人工补验已记录;真机复测待收口 | `issues/2026-05-12_21-48-40-mobile-ia-flow-remediation.csv`、`issues/2026-05-13_00-38-05-mif7-follow-up-return-and-release.csv`、`artifacts/qa-flow/live-smoke-readiness-20260513-010211/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260513-013545/summary.json` |
| MIF7 | 本地整改和公网部署基础门禁已通过;真机门禁已补;live smoke/OCR SKIP 仍不算通过 | `issues/2026-05-12_21-48-40-mobile-ia-flow-remediation.csv`、`issues/2026-05-13_00-38-05-mif7-follow-up-return-and-release.csv`、`artifacts/qa-flow/live-smoke-readiness-20260513-010211/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260513-013545/summary.json` |
| MIF7T | 真机反馈移动体验统一整改已完成本地门禁,未部署 | `issues/2026-05-13_09-18-05-mif7-real-device-feedback-mobile-unification.csv` |
## 当前门禁
@@ -38,7 +40,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
| 公网地址 | 可用 | `https://oa.hyinda.com` |
| `/ready` | 通过 | 2026-05-13 `curl -fsS https://oa.hyinda.com/ready` 返回 `ok=true` |
| 企业微信个人应用消息 | 正式通知通道 | 企业微信只做入口、身份、消息和 deeplink |
| `ME6-3` 真机抽查 | 未关闭 | iPhone 12/iOS14、iPhone 14/iOS16.2、Redmi K60 企业微信仍需代表性复测或用户明确接受风险 |
| `ME6-3` 真机抽查 | 已关闭 | 2026-05-13 用户确认 iPhone 12、iPhone 14、Redmi K60 企业微信主流程通过;iPhone 12 间距偏紧为非阻断观察项 |
| R7 真实执行 | 未启动 | 草案不等于启动 |
## MIF7-7 非人工自动化补验
@@ -56,12 +58,36 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
| 公网 `/ready` | 通过 | `curl -fsS https://oa.hyinda.com/ready` 返回 `ok=true` |
| 公网前端资源文案扫描 | 通过 | `PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs`,scanned=56,focused=4,findings=0 |
| 公网 live readiness | 通过,含 SKIP | `LIVE_SMOKE_BASE_URL=https://oa.hyinda.com QA_ISSUE_ID=MIF7-7 bash scripts/qa/run-live-smoke-readiness.sh`;报告目录 `artifacts/qa-flow/live-smoke-readiness-20260513-013545/`;live smoke、真实 OCR 因缺少凭据或样票按脚本规则 SKIP |
| 旧 R6 响应式专项 | 未通过,需后续改断言 | `tests/e2e/r6-mobile-responsive-system-hotfix.spec.ts` 7 个视口失败,旧断言仍要求首页入口数 `>=4`,当前 MIF7 首页按信息架构去重后实际为 2 |
| 旧 MUD6 统一设计专项 | 未通过,需后续改断言 | `tests/e2e/r6-mobile-unified-design-rollout.spec.ts` 7 个视口失败,旧用例仍等待 `mobile-reimbursement-detail` 直接标记,未对齐当前 MIF7 详情来源和路由语义 |
| 旧 WCM6 企业微信渲染专项 | 未通过,需后续改断言 | `tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts` 8 项失败,旧断言仍要求首页 4 个核心入口且审批入口可见,与 MIF7 首页入口去重规则冲突 |
| R108/R109 移动专项 | 未形成视觉结论 | `tests/e2e/r108-mobile-wecom-visual.spec.ts`、`tests/e2e/r109-mobile-reimbursement-feedback.spec.ts` 在测试数据登录/初始化 API 阶段返回 HTTP_500,未进入页面视觉断言 |
| 旧 R6 响应式专项 | MIF7T 后通过 | `tests/e2e/r6-mobile-responsive-system-hotfix.spec.ts` 7 个视口通过,截图目录 `output/playwright/r6-mobile-responsive-system-hotfix/` |
| 旧 MUD6 统一设计专项 | MIF7T 后通过 | `tests/e2e/r6-mobile-unified-design-rollout.spec.ts` 7 个视口通过,截图目录 `output/playwright/r6-mobile-unified-design-rollout/` |
| 旧 WCM6 企业微信渲染专项 | MIF7T 后通过 | `tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts` 8 项通过,截图目录 `output/playwright/r6-wecom-mobile-rendering-hardening/` |
| R108/R109 移动专项 | 明确跳过 | 两个专项仍因测试初始化 API 不可用按测试内规则 SKIP,未将 SKIP 伪造成视觉通过 |
结论:本轮只补充非人工证据。MIF7 当前专项、本地构建和公网匿名基础门禁通过;历史 R6/MUD6/WCM6 用例存在与 MIF7 新信息架构不一致的旧断言,R108/R109 需要单独排查测试数据初始化。`MIF7-7` 仍保持未完成,不能因自动化通过而关闭 `ME6-3` 或替代代表性企业微信真机复测。
结论:MIF7 当前专项、本地构建和公网匿名基础门禁通过;代表性企业微信真机主流程已补充并关闭 `ME6-3`;MIF7T 已修正旧 R6/MUD6/WCM6 断言并完成本地门禁。`MIF7-7` 仍保持未完成,原因是 live smoke 和真实 OCR 仍按脚本规则 SKIP,不能伪造成通过。
## MIF7T 真机反馈与移动体验统一
执行时间:2026-05-13。
用户真机反馈已记录:三台设备主流程没问题;首页常用入口没有重复底部 Tab;财务首页已和普通/审批首页区分;审批人“已处理”详情返回仍停留在已处理;通知可直达详情并返回来源页;请假详情、采购详情和财务首页主流程通过。iPhone 12 图文上下间距相比其他设备偏紧,但未出现重叠或压字,按非阻断观察项处理。
新增问题已进入 MIF7T 并完成本地整改:普通角色首页降低资产扫码曝光,采购权限用户新增移动采购列表入口;请假页默认先展示记录,并保留“新建请假 / 请假记录”分段;报销、请假、采购列表卡片统一为整卡可点和右侧状态;删除草稿进入“更多”后再二次确认;在线无草稿时审批页不显示空“离线草稿”;报销、请假、采购、审批详情进入时滚动到顶部,返回按钮和 label-value 字段排布统一。
本批本地验证结果:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| MIF7 / MIF7F / MIF7T CSV 校验 | 通过 | `validate_issues_csv.py` 三个合同均通过 |
| 前端类型与构建 | 通过 | `cd client && npm run typecheck`、`cd client && npm run build` |
| UI 设计规则 strict | 通过 | `node scripts/qa/check-ui-design-rules.mjs --strict`,扫描 127 个文件,0 findings |
| MIF7 移动专项 | 通过 | `tests/e2e/dg7-mobile-information-dedupe.spec.ts`,5 passed;截图目录 `output/playwright/mobile-ia-flow-remediation/` |
| R6 响应式矩阵 | 通过 | `tests/e2e/r6-mobile-responsive-system-hotfix.spec.ts`,7 passed;覆盖 320/360/375/390/393/414/430 |
| MUD6 统一设计矩阵 | 通过 | `tests/e2e/r6-mobile-unified-design-rollout.spec.ts`,7 passed;覆盖 320/360/375/390/393/414/430 |
| WCM6 企业微信渲染矩阵 | 通过 | `tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts`,8 passed;含 390 iPhone12 字体失败模拟 |
| R101 / R124 移动入口回归 | 通过 | `tests/e2e/r101-mobile-wip-closeout.spec.ts` 3 passed;`tests/e2e/r124-mobile-entry-matrix.spec.ts` 1 passed |
| R108 / R109 视觉专项 | SKIP | 初始化 API 不可用,按测试内规则跳过;不作为视觉通过证据 |
部署状态:MIF7T 本批尚未部署。公网仍是此前已部署版本,不能宣称 `https://oa.hyinda.com` 已包含本批移动体验统一优化。
## 历史恢复
@@ -1,6 +1,6 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
ME6-1,移动二轮体验合同建档,在 MUD6 已完成的统一移动设计基础上启动第二轮体验巡检和小修。,"1) 本 CSV 校验通过;2) 范围限定专用 /mobile 路由;3) 不重做 MUD6;4) 不新增业务功能、不改审批链和通知通道。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-41-00-r6-mobile-experience-second-pass.csv;人工核对 docs/product-design-rules.md 移动统一规则。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-41-00-r6-mobile-experience-second-pass.csv,[],只修布局、文案、可点击性和视觉密度问题。
ME6-2,移动路由全量巡检,覆盖首页、审批、报销列表、报销详情、我的、个人设置、通知、票据上传、请假和资产扫码。,"1) 所有专用 /mobile 路由可达;2) 无 PC 壳、无桌面表格、无横向滚动;3) 底部 Tab 和深层页返回符合规约。","流程级验证:Playwright 遍历 /mobile 路由,视口覆盖 320/360/375/390/393/414/430,输出截图到 output/playwright/r6-mobile-experience-second-pass。",playwright,DONE,DONE,DONE,client/tests/e2e|client/src/pages/Mobile*.tsx,"[""ME6-1""]",ME6 自动矩阵 7 passed;截图输出 output/playwright/r6-mobile-experience-second-pass/;自动化不替代代表性真机抽查。
ME6-3,企业微信真机抽查闭环,用 iPhone 12 iOS14、iPhone 14 iOS16.2、Redmi K60 抽查首页、审批、报销详情、我的和通知。,"1) 图文不重叠;2) 底部审批可点;3) 报销详情吸底按钮不遮挡;4) 通知 deeplink 能回 OA。","流程级验证:人工企业微信打开海音达工作台 -> 抽查指定路径 -> 记录文字结论和敏感截图外部位置。",manual,TODO,TODO,TODO,docs/trialrun/manual-trialrun-report.md,"[""ME6-2""]",待用户在三台代表性企业微信真机上抽查;敏感截图不提交仓库。
ME6-3,企业微信真机抽查闭环,用 iPhone 12 iOS14、iPhone 14 iOS16.2、Redmi K60 抽查首页、审批、报销详情、我的和通知。,"1) 图文不重叠;2) 底部审批可点;3) 报销详情吸底按钮不遮挡;4) 通知 deeplink 能回 OA。","流程级验证:人工企业微信打开海音达工作台 -> 抽查指定路径 -> 记录文字结论和敏感截图外部位置。",manual,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md,"[""ME6-2""]",2026-05-13 用户完成 iPhone 12、iPhone 14、Redmi K60 企业微信代表性主流程复测:首页、审批/进度、通知、报销/请假/采购详情返回和财务首页主流程通过;iPhone12 图文间距偏紧记为非阻断观察项;新发现体验统一问题已进入 MIF7T;敏感截图不提交仓库。
ME6-4,移动小修实施,按巡检结果只修影响识别和点击的问题,复用 mobile-card、槽位、safe-area 和吸底操作。,"1) 不新增业务功能;2) 不引入桌面 card-std p-4 为移动主容器;3) 不按单机型写补丁;4) 所有改动符合 docs/product-design-rules.md。","流程级验证:client typecheck/build;移动视觉矩阵复跑;人工核对修复项没有破坏 MUD6 统一设计。",npm|playwright|manual,DONE,DONE,DONE,client/src/pages/Mobile*.tsx|client/src/styles,"[""ME6-2""]",修复移动首页 AP6 summary 缺失容错、请假记录 approvalHistory 缺失容错,补齐历史移动专项 mock;若真机发现业务缺口,另拆功能 Issue。
ME6-5,移动二轮部署复检,部署移动小修并完成公网视觉和 live smoke。,"1) 部署成功且 /ready ok;2) public bundle findings=0;3) live smoke readiness 通过;4) 公网移动视觉专项通过。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;公网 baseURL 跑移动视觉专项。",bash|playwright,DONE,DONE,DONE,deploy/trialrun|scripts/qa|output/playwright,"[""ME6-4""]",已部署公网;/ready ok;public bundle findings=0;live readiness PASS(缺少 live smoke 凭据和 OCR 样票按规则跳过网页 live smoke/真实 OCR);公网移动矩阵 7 passed,截图 output/playwright/r6-mobile-experience-second-pass-live/。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 ME6-1 移动二轮体验合同建档 在 MUD6 已完成的统一移动设计基础上启动第二轮体验巡检和小修。 1) 本 CSV 校验通过;2) 范围限定专用 /mobile 路由;3) 不重做 MUD6;4) 不新增业务功能、不改审批链和通知通道。 流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-41-00-r6-mobile-experience-second-pass.csv;人工核对 docs/product-design-rules.md 移动统一规则。 python3|manual DONE DONE DONE issues/2026-05-12_01-41-00-r6-mobile-experience-second-pass.csv [] 只修布局、文案、可点击性和视觉密度问题。
3 ME6-2 移动路由全量巡检 覆盖首页、审批、报销列表、报销详情、我的、个人设置、通知、票据上传、请假和资产扫码。 1) 所有专用 /mobile 路由可达;2) 无 PC 壳、无桌面表格、无横向滚动;3) 底部 Tab 和深层页返回符合规约。 流程级验证:Playwright 遍历 /mobile 路由,视口覆盖 320/360/375/390/393/414/430,输出截图到 output/playwright/r6-mobile-experience-second-pass。 playwright DONE DONE DONE client/tests/e2e|client/src/pages/Mobile*.tsx ["ME6-1"] ME6 自动矩阵 7 passed;截图输出 output/playwright/r6-mobile-experience-second-pass/;自动化不替代代表性真机抽查。
4 ME6-3 企业微信真机抽查闭环 用 iPhone 12 iOS14、iPhone 14 iOS16.2、Redmi K60 抽查首页、审批、报销详情、我的和通知。 1) 图文不重叠;2) 底部审批可点;3) 报销详情吸底按钮不遮挡;4) 通知 deeplink 能回 OA。 流程级验证:人工企业微信打开海音达工作台 -> 抽查指定路径 -> 记录文字结论和敏感截图外部位置。 manual TODO DONE TODO DONE TODO DONE docs/trialrun/manual-trialrun-report.md docs/trialrun/人工试运行报告.md ["ME6-2"] 待用户在三台代表性企业微信真机上抽查;敏感截图不提交仓库。 2026-05-13 用户完成 iPhone 12、iPhone 14、Redmi K60 企业微信代表性主流程复测:首页、审批/进度、通知、报销/请假/采购详情返回和财务首页主流程通过;iPhone12 图文间距偏紧记为非阻断观察项;新发现体验统一问题已进入 MIF7T;敏感截图不提交仓库。
5 ME6-4 移动小修实施 按巡检结果只修影响识别和点击的问题,复用 mobile-card、槽位、safe-area 和吸底操作。 1) 不新增业务功能;2) 不引入桌面 card-std p-4 为移动主容器;3) 不按单机型写补丁;4) 所有改动符合 docs/product-design-rules.md。 流程级验证:client typecheck/build;移动视觉矩阵复跑;人工核对修复项没有破坏 MUD6 统一设计。 npm|playwright|manual DONE DONE DONE client/src/pages/Mobile*.tsx|client/src/styles ["ME6-2"] 修复移动首页 AP6 summary 缺失容错、请假记录 approvalHistory 缺失容错,补齐历史移动专项 mock;若真机发现业务缺口,另拆功能 Issue。
6 ME6-5 移动二轮部署复检 部署移动小修并完成公网视觉和 live smoke。 1) 部署成功且 /ready ok;2) public bundle findings=0;3) live smoke readiness 通过;4) 公网移动视觉专项通过。 流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;公网 baseURL 跑移动视觉专项。 bash|playwright DONE DONE DONE deploy/trialrun|scripts/qa|output/playwright ["ME6-4"] 已部署公网;/ready ok;public bundle findings=0;live readiness PASS(缺少 live smoke 凭据和 OCR 样票按规则跳过网页 live smoke/真实 OCR);公网移动矩阵 7 passed,截图 output/playwright/r6-mobile-experience-second-pass-live/。
@@ -5,4 +5,4 @@ MIF7-3,通知中心事件化,"把移动通知中心从业务卡片列表调整
MIF7-4,统一移动详情壳和返回语义,"抽出或复用统一移动详情壳,统一报销、请假、采购、审批详情的返回位置、标题/状态、内容顺序和底部 Tab 隐藏规则。","1) 所有移动深层详情左侧返回、右侧状态且隐藏底部 Tab;2) 内容顺序统一为摘要、关键字段、材料/附件/发票、审批流、下一步动作;3) 返回优先回来源;无来源时回业务列表或进度标签;4) 请假详情不回新建表单顶部,采购详情不无条件回审批首页。","流程级验证:Playwright 从首页摘要、审批/进度、通知中心分别进入报销/请假/采购/审批详情;断言返回落点、底部 Tab 隐藏、统一返回控件和关键区块顺序。",playwright|npm,DONE,DONE,DONE,client/src/components/mobile|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/MobilePurchaseRequestDetailPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/lib/runtimeContext.ts,["MIF7-3"],若实现需要新增后端接口或改状态机,停止并拆新 Issue。
MIF7-5,财务移动首页角色化摘要,"优化财务账号移动首页,突出财务待复核、待付款、台账/导出和风险异常入口,避免与经理首页同构。","1) 财务账号首页出现财务摘要或财务快捷入口;2) 普通员工不显示财务入口;3) 管理员单角色不默认获得财务业务能力;4) 财务首页仍使用同一移动框架。","流程级验证:Playwright 使用员工、审批人、财务、管理员账号访问 /mobile;验证财务摘要裁剪和入口权限;如直连 API 涉及权限补跑 governance 相关门禁。",playwright|npm|bash,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/api|client/src/auth/permissions.ts,["MIF7-4"],优先复用现有 FL6 财务接口;如必须新增后端接口则拆新 Issue。
MIF7-6,移动 IA 自动化断言和视觉矩阵,"补充入口重复、摘要数量重复、进度标签、通知直达、详情壳统一、返回目标和财务首页差异的自动化规则与截图矩阵。","1) 新增或更新 Playwright 用例覆盖 320/360/375/390/393/414/430;2) check-ui-design-rules 保持 strict 通过;3) 输出截图到 output/playwright/mobile-ia-flow-remediation/;4) 真机门禁 ME6-3 仍不被自动化替代。","流程级验证:cd client && npm run typecheck && npm run build;node scripts/qa/check-ui-design-rules.mjs --strict;移动专项 Playwright;git diff --check。",npm|node|playwright|git,DONE,DONE,DONE,client/tests/e2e|scripts/qa/check-ui-design-rules.mjs|output/playwright,["MIF7-5"],截图产物按项目规则处理;敏感截图不提交。
MIF7-7,本地回归部署和真机复测清单,"完成 MIF7 本地回归、受控部署、公网复检,并给出 iPhone12/iPhone14/Redmi K60 企业微信复测清单。","1) 本地 typecheck/build、设计规则和移动专项通过;2) 部署公网后 /ready、public bundle、live smoke readiness 通过;3) docs/trialrun/人工试运行报告.md 记录 MIF7 收口;4) ME6-3 仅在代表性真机复测通过或用户明确接受风险后关闭。","流程级验证:bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测首页、进度、通知、详情返回和财务首页。",bash|curl|node|manual,DOING,TODO,TODO,deploy/trialrun|docs/trialrun/人工试运行报告.md,["MIF7-6"],2026-05-13 已部署 eed8e37 到 https://oa.hyinda.com;/ready ok;public bundle findings=0;live readiness PASS;2026-05-13 非人工补验 CSV/typecheck/build/design/MIF7专项/R124/公网匿名/readiness PASS;旧R6响应式/MUD6/WCM6因首页入口数量和详情标记旧断言不匹配失败;R108/R109因测试初始化API HTTP_500未进入视觉断言;live smoke和真实OCR因缺少凭据/样票SKIP;iPhone12/iPhone14/RedmiK60企业微信真机复测仍待执行;ME6-3不关闭。
MIF7-7,本地回归部署和真机复测清单,"完成 MIF7 本地回归、受控部署、公网复检,并给出 iPhone12/iPhone14/Redmi K60 企业微信复测清单。","1) 本地 typecheck/build、设计规则和移动专项通过;2) 部署公网后 /ready、public bundle、live smoke readiness 通过;3) docs/trialrun/人工试运行报告.md 记录 MIF7 收口;4) ME6-3 仅在代表性真机复测通过或用户明确接受风险后关闭。","流程级验证:bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测首页、进度、通知、详情返回和财务首页。",bash|curl|node|manual,DOING,TODO,TODO,deploy/trialrun|docs/trialrun/人工试运行报告.md,["MIF7-6"],2026-05-13 已部署 eed8e37 到 https://oa.hyinda.com;/ready ok;public bundle findings=0;live readiness PASS;2026-05-13 非人工补验 CSV/typecheck/build/design/MIF7专项/R124/公网匿名/readiness PASS;2026-05-13 用户完成 iPhone12/iPhone14/RedmiK60 企业微信代表性真机复测并关闭 ME6-3;MIF7T 已修正旧R6响应式/MUD6/WCM6断言且本地通过;R108/R109因初始化 API 不可用按测试内规则 SKIP;live smoke和真实OCR因缺少凭据/样票SKIP;MIF7-7 仍不把 SKIP 伪造成通过。
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 719 列。
@@ -0,0 +1,8 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
MIF7T-1,真机反馈证据与门禁状态,"记录 iPhone12、iPhone14、Redmi K60 企业微信反馈,关闭 ME6-3 主流程真机门禁,并把新增体验问题进入 MIF7T。","1) ME6-3 回填 DONE;2) MIF7-7 记录真机门禁已补但 live smoke/OCR SKIP 不伪造成通过;3) docs/trialrun/人工试运行报告.md 记录 iPhone12 间距偏紧为非阻断观察项;4) R7 不启动。","流程级验证:人工核对用户三机反馈 -> rg -n 'ME6-3|MIF7T|iPhone 12|Redmi K60' docs/trialrun/人工试运行报告.md issues/*.csv;CSV 校验通过。",manual|rg|python3,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md|issues/2026-05-12_01-41-00-r6-mobile-experience-second-pass.csv|issues/2026-05-12_21-48-40-mobile-ia-flow-remediation.csv,[],只记录事实,不读取或提交敏感截图;live smoke/OCR 凭据缺失仍为 SKIP。
MIF7T-2,角色首页入口和待处理摘要降噪,"优化移动首页常用入口的角色裁剪,减少普通员工低频资产扫码曝光,并压缩财务/审批待处理摘要重复表达。","1) 仅资产操作角色在首页看到资产扫码;2) 采购权限用户首页可进入移动采购列表;3) 待处理摘要不在主数字、超时、单条卡片中重复写多个待处理;4) 首页常用入口仍不重复底部 Tab。","流程级验证:Playwright 员工/审批人/财务角色访问 /mobile,断言入口裁剪、采购入口、待处理摘要数量表达和底部 Tab 去重;截图覆盖 320/360/375/390/393/414/430。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/features/mobile/navigation.ts,["MIF7T-1"],不新增资产或采购能力;只调整入口可见性。
MIF7T-3,请假记录前置和采购移动列表,"让请假记录一眼可达,并新增移动采购列表页。","1) /mobile/leave 支持 view=form|records 和 status 查询;2) 请假页顶部有新建请假/请假记录分段;3) /mobile/purchase-requests 可按状态查看采购列表;4) 采购列表卡片可进入既有采购详情。","流程级验证:Playwright 访问 /mobile/leave?view=records&status=reviewing 和 /mobile/purchase-requests?status=reviewing,断言记录/采购卡片可见、状态标签正确、点击进入详情后返回来源列表。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobileLeavePage.tsx|client/src/pages/MobilePurchaseRequestsPage.tsx|client/src/router,["MIF7T-2"],采购本批只补列表和详情入口;移动采购创建/编辑另拆 Issue。
MIF7T-4,移动列表卡片操作统一,"统一报销、请假、采购和审批进度卡片的信息槽位和操作密度,避免每张卡铺满大按钮。","1) 卡片遵循标题+右侧状态、第二行关键字段、整卡可点;2) 查看详情不再作为大按钮重复出现;3) 删除草稿等危险动作放更多/二次确认;4) 状态不在标题、副标题和右侧标签重复。","流程级验证:Playwright 检查报销、请假、采购、审批进度列表的卡片 DOM 与视觉截图;人工核对删除草稿仍需二次确认。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobileReimbursementsPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/MobilePurchaseRequestsPage.tsx,["MIF7T-3"],不采用只靠左滑发现的隐藏操作;保留明确二次确认。
MIF7T-5,审批页离线草稿降噪,"正常在线无草稿时隐藏离线草稿区,并让审批人与财务看到一致的移动列表风格。","1) 在线且无草稿时不显示离线草稿区;2) 有待同步、失败、冲突或离线时才显示;3) 待我审批/已处理返回语义保持不变;4) 员工进度卡片风格与审批任务卡片一致。","流程级验证:Playwright 在线审批页断言无空离线草稿;构造草稿队列或离线状态时队列可见;已处理详情返回仍在已处理。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobileApprovalsPage.tsx|client/src/mobile/workflowActionQueue.ts,["MIF7T-4"],不改变离线草稿持久化或审批状态机。
MIF7T-6,移动详情壳和字段排布统一,"统一返回按钮触控区、详情进入滚动到顶部、详情字段 label-value 排布。","1) 报销、请假、采购、审批详情进入时从顶部开始阅读;2) 返回仍回来源页或来源标签;3) 返回控件固定左上且触控区不少于 44px;4) 详情关键字段使用统一 label-value 信息卡,不再一行混排多个字段导致难扫。","流程级验证:Playwright 从列表滚动深处进入报销/请假/采购/审批详情,断言详情 scrollY 接近 0、底部 Tab 隐藏、返回落点正确、详情头位置一致。",playwright|npm,DONE,DONE,DONE,client/src/components/mobile/MobileDetailHeader.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/MobilePurchaseRequestDetailPage.tsx|client/src/pages/MobileApprovalsPage.tsx,["MIF7T-5"],不按 iPhone12 单机型写补丁;间距通过统一 token 和字段布局改善。
MIF7T-7,自动化补强和本地门禁,"更新 MIF7 与旧 R6/MUD6/WCM6 自动化断言,修复或隔离 R108/R109 初始化失败,完成本地门禁。","1) MIF7 专项覆盖角色入口、请假记录、采购列表、详情滚动顶部和返回来源;2) 旧 R6/MUD6/WCM6 不再按 MIF7 前入口数量和旧详情标记失败;3) R108/R109 能进入视觉断言或明确记录环境跳过;4) typecheck/build/design strict/git diff 通过。","流程级验证:CSV 校验;cd client && npm run typecheck;cd client && npm run build;node scripts/qa/check-ui-design-rules.mjs --strict;Playwright MIF7/R6/MUD6/WCM6/R108/R109/R124;git diff --check。",npm|node|playwright|git,DONE,DONE,DONE,client/tests/e2e|scripts/qa/check-ui-design-rules.mjs,["MIF7T-6"],本地 typecheck/build/design strict/MIF7专项/R6响应式/MUD6/WCM6/R101/R124 通过;R108/R109 因初始化 API 不可用按测试内规则 SKIP;本批未部署不宣称公网包含本批优化。
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 764 列。