R103/A1-A6 收口跨页面设计一致性
这个提交包含在:
@@ -3,12 +3,19 @@ export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization",
|
|||||||
export const BACKUP_NAV_ORDER = ["/attendance", "/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
export const BACKUP_NAV_ORDER = ["/attendance", "/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||||
|
|
||||||
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||||
{ path: "/portal", title: "Portal", group: "工作台" },
|
{ path: "/portal", title: "工作台", group: "工作台" },
|
||||||
{ path: "/announcements", title: "公告", group: "主导航" },
|
{ path: "/announcements", title: "公告", group: "主导航" },
|
||||||
{ path: "/team", title: "团队", group: "主导航" },
|
{ path: "/team", title: "团队", group: "主导航" },
|
||||||
{ path: "/attendance", title: "考勤", group: "备用模块" },
|
{ path: "/attendance", title: "考勤", group: "备用模块" },
|
||||||
{ path: "/projects", title: "项目", group: "主导航" },
|
{ path: "/projects", title: "项目", group: "主导航" },
|
||||||
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
|
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
|
||||||
|
{ path: "/purchase-requests", title: "采购申请", group: "备用模块" },
|
||||||
|
{ path: "/travel-requests", title: "差旅申请", group: "备用模块" },
|
||||||
|
{ path: "/budgets", title: "预算管理", group: "备用模块" },
|
||||||
|
{ path: "/payment-batches", title: "付款批次", group: "备用模块" },
|
||||||
|
{ path: "/ap-operations", title: "应付作业", group: "备用模块" },
|
||||||
|
{ path: "/invoice-pool", title: "票据中心", group: "备用模块" },
|
||||||
|
{ path: "/inventory-checks", title: "盘点任务", group: "备用模块" },
|
||||||
{ path: "/weekly-reports", title: "周报", group: "主导航" },
|
{ path: "/weekly-reports", title: "周报", group: "主导航" },
|
||||||
{ path: "/todos", title: "待办", group: "主导航" },
|
{ path: "/todos", title: "待办", group: "主导航" },
|
||||||
{ path: "/reimbursements/", title: "报销详情", group: "主导航" },
|
{ path: "/reimbursements/", title: "报销详情", group: "主导航" },
|
||||||
@@ -29,6 +36,9 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
|||||||
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理入口" },
|
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理入口" },
|
||||||
{ path: "/system/settings", title: "系统设置", group: "管理入口" },
|
{ path: "/system/settings", title: "系统设置", group: "管理入口" },
|
||||||
{ path: "/system/feature-flags", title: "功能开关", group: "管理入口" },
|
{ path: "/system/feature-flags", title: "功能开关", group: "管理入口" },
|
||||||
|
{ path: "/system/cutover", title: "切换控制台", group: "管理入口" },
|
||||||
|
{ path: "/system/integrations/erp", title: "ERP 集成", group: "管理入口" },
|
||||||
|
{ path: "/dashboard", title: "数据看板", group: "备用模块" },
|
||||||
{ path: "/audit-logs", title: "审计日志", group: "管理入口" },
|
{ path: "/audit-logs", title: "审计日志", group: "管理入口" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -41,7 +51,7 @@ export function resolvePageMeta(pathname: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/") {
|
if (pathname === "/") {
|
||||||
return { title: "Portal", group: "工作台" };
|
return { title: "工作台", group: "工作台" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { title: pathname, group: "工作台" };
|
return { title: pathname, group: "工作台" };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -232,6 +232,8 @@ export function PurchaseRequestsPage() {
|
|||||||
const [items, setItems] = useState<DraftItem[]>([]);
|
const [items, setItems] = useState<DraftItem[]>([]);
|
||||||
const [archivePoNo, setArchivePoNo] = useState("");
|
const [archivePoNo, setArchivePoNo] = useState("");
|
||||||
const [archiveContractNo, setArchiveContractNo] = useState("");
|
const [archiveContractNo, setArchiveContractNo] = useState("");
|
||||||
|
const [selectedAttachmentName, setSelectedAttachmentName] = useState("");
|
||||||
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const suppliersQuery = useQuery({
|
const suppliersQuery = useQuery({
|
||||||
queryKey: ["purchase-requests", "suppliers"],
|
queryKey: ["purchase-requests", "suppliers"],
|
||||||
@@ -701,7 +703,7 @@ export function PurchaseRequestsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="采购申请"
|
title="采购申请"
|
||||||
subtitle="支持草稿、附件、提交、撤回与经理单步审批的轻采购流程。"
|
subtitle="草稿、附件、审批。"
|
||||||
actions={
|
actions={
|
||||||
canCreate ? (
|
canCreate ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1290,17 +1292,41 @@ export function PurchaseRequestsPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-base font-semibold text-zinc-900">附件</h4>
|
<h4 className="text-base font-semibold text-zinc-900">附件</h4>
|
||||||
<input
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
type="file"
|
<input
|
||||||
disabled={!canEditDraft || uploadMutation.isPending}
|
data-testid="purchase-attachment-file-input"
|
||||||
onChange={(event) => {
|
hidden
|
||||||
const file = event.target.files?.[0];
|
ref={attachmentInputRef}
|
||||||
if (file) {
|
type="file"
|
||||||
uploadMutation.mutate(file);
|
disabled={!canEditDraft || uploadMutation.isPending}
|
||||||
}
|
onChange={(event) => {
|
||||||
event.currentTarget.value = "";
|
const file = event.target.files?.[0];
|
||||||
}}
|
if (file) {
|
||||||
/>
|
setSelectedAttachmentName(file.name);
|
||||||
|
uploadMutation.mutate(file);
|
||||||
|
}
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="hidden max-w-[220px] truncate text-xs text-zinc-500 sm:inline"
|
||||||
|
data-testid="purchase-attachment-selected-name"
|
||||||
|
>
|
||||||
|
{uploadMutation.isPending
|
||||||
|
? `正在上传:${uploadMutation.variables?.name ?? selectedAttachmentName}`
|
||||||
|
: selectedAttachmentName || "未选择文件"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
data-testid="purchase-attachment-upload"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!canEditDraft || uploadMutation.isPending}
|
||||||
|
onClick={() => attachmentInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploadMutation.isPending ? "上传中" : "上传附件"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 md:hidden">
|
<div className="space-y-3 md:hidden">
|
||||||
{detail.attachments.length ? (
|
{detail.attachments.length ? (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { InvoicePoolListResponse } from "../../api/invoicePool";
|
|||||||
import type { ReimbursementListResponse } from "../../api/reimbursements";
|
import type { ReimbursementListResponse } from "../../api/reimbursements";
|
||||||
import type { TravelRequestDetail, TravelRequestListResponse } from "../../api/travelRequests";
|
import type { TravelRequestDetail, TravelRequestListResponse } from "../../api/travelRequests";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { resolveReimbursementDisplayCode } from "../../lib/reimbursementDisplay";
|
||||||
|
|
||||||
export type InvoiceBindMode = "reimbursement" | "travelOrder";
|
export type InvoiceBindMode = "reimbursement" | "travelOrder";
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export function InvoiceBindCard({
|
|||||||
<option value="">选择报销单</option>
|
<option value="">选择报销单</option>
|
||||||
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
{item.formNumber ?? item.id} ({item.status})
|
{resolveReimbursementDisplayCode(item)} ({item.status})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
function envelope(data: unknown) {
|
||||||
|
return { requestId: "mock-r103-ui-consistency", data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope(data)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103");
|
||||||
|
|
||||||
|
function screenshotPath(fileName: string) {
|
||||||
|
mkdirSync(screenshotDir, { recursive: true });
|
||||||
|
return path.join(screenshotDir, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = {
|
||||||
|
id: "employee-r103",
|
||||||
|
employeeNo: "E103",
|
||||||
|
name: "Employee",
|
||||||
|
phone: "13800000000",
|
||||||
|
email: "[email protected]",
|
||||||
|
departmentId: "dept-r103",
|
||||||
|
directManagerId: null,
|
||||||
|
status: "active",
|
||||||
|
notificationPushEnabled: true,
|
||||||
|
roleCodes: ["employee"],
|
||||||
|
profileCompleteness: {
|
||||||
|
hasEmergencyContact: true,
|
||||||
|
hasBankAccount: true,
|
||||||
|
hasAvatar: false,
|
||||||
|
hasIdNumber: false,
|
||||||
|
score: 50,
|
||||||
|
},
|
||||||
|
wecomBinding: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const purchaseId = "purchase-r103";
|
||||||
|
const reimbursementId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const invoiceId = "invoice-r103";
|
||||||
|
|
||||||
|
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||||||
|
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function purchaseSummary() {
|
||||||
|
return {
|
||||||
|
id: purchaseId,
|
||||||
|
requestNo: "CG-20260506-0001",
|
||||||
|
applicantId: me.id,
|
||||||
|
submitterId: me.id,
|
||||||
|
departmentId: me.departmentId,
|
||||||
|
legalEntityId: "legal-r103",
|
||||||
|
costCenterId: "cost-r103",
|
||||||
|
supplierId: "supplier-r103",
|
||||||
|
documentCategoryId: "category-r103",
|
||||||
|
budgetPoolId: null,
|
||||||
|
title: "办公用品采购",
|
||||||
|
reason: "日常办公",
|
||||||
|
currencyCode: "CNY",
|
||||||
|
totalAmount: "299.00",
|
||||||
|
expectedDeliveryDate: "2026-05-10",
|
||||||
|
status: "draft",
|
||||||
|
createdAt: "2026-05-06T09:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||||
|
applicant: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
department: { id: me.departmentId, name: "研发部" },
|
||||||
|
legalEntity: { id: "legal-r103", code: "HYD", name: "海音达" },
|
||||||
|
costCenter: { id: "cost-r103", code: "RD", name: "研发部", legalEntityId: "legal-r103" },
|
||||||
|
supplier: { id: "supplier-r103", code: "SUP", name: "测试供应商" },
|
||||||
|
documentCategory: { id: "category-r103", code: "CG", name: "采购申请", entityType: "purchase_request" },
|
||||||
|
budgetPool: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function purchaseDetail() {
|
||||||
|
return {
|
||||||
|
...purchaseSummary(),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "purchase-item-r103",
|
||||||
|
description: "键盘",
|
||||||
|
specification: "无线",
|
||||||
|
quantity: "1.00",
|
||||||
|
unitPrice: "299.00",
|
||||||
|
lineAmount: "299.00",
|
||||||
|
isAssetCandidate: false,
|
||||||
|
createdAt: "2026-05-06T09:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attachments: [],
|
||||||
|
pendingWorkflow: null,
|
||||||
|
archiveOrder: null,
|
||||||
|
permissions: {
|
||||||
|
canEditDraft: true,
|
||||||
|
canSubmit: true,
|
||||||
|
canWithdraw: false,
|
||||||
|
canArchiveOrder: false,
|
||||||
|
},
|
||||||
|
riskFlags: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoiceItem() {
|
||||||
|
return {
|
||||||
|
id: invoiceId,
|
||||||
|
status: "available",
|
||||||
|
ocrType: "mixed",
|
||||||
|
ocrProvider: "mock",
|
||||||
|
ocrAttempts: 1,
|
||||||
|
ocrLastError: null,
|
||||||
|
ocrCompletedAt: "2026-05-06T09:00:00.000Z",
|
||||||
|
invoiceCode: "044002600111",
|
||||||
|
invoiceNumber: "12345678",
|
||||||
|
invoiceDate: "2026-05-06",
|
||||||
|
amount: "100.00",
|
||||||
|
taxAmount: "6.00",
|
||||||
|
totalAmount: "106.00",
|
||||||
|
linkedReimbursementId: null,
|
||||||
|
linkedItemId: null,
|
||||||
|
linkedTravelOrderId: null,
|
||||||
|
originalName: "发票.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
fileSize: 1024,
|
||||||
|
verification: null,
|
||||||
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
createdAt: "2026-05-06T09:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reimbursementForBind() {
|
||||||
|
return {
|
||||||
|
id: reimbursementId,
|
||||||
|
formNumber: null,
|
||||||
|
status: "draft",
|
||||||
|
reimbursementDate: "2026-05-06",
|
||||||
|
totalAmount: "106.00",
|
||||||
|
description: "发票绑定测试",
|
||||||
|
payment: {
|
||||||
|
paymentVoucherNo: null,
|
||||||
|
paymentDate: null,
|
||||||
|
paymentChannel: null,
|
||||||
|
paidAt: null,
|
||||||
|
paidBy: null,
|
||||||
|
},
|
||||||
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
project: null,
|
||||||
|
pendingWorkflow: null,
|
||||||
|
createdAt: "2026-05-06T09:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockApis(page: Page) {
|
||||||
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const pathName = url.pathname;
|
||||||
|
|
||||||
|
if (pathName === "/api/auth/sso/providers") {
|
||||||
|
await fulfillJson(route, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/auth/wecom/config") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
enabled: false,
|
||||||
|
entryEnabled: false,
|
||||||
|
oauthEnabled: false,
|
||||||
|
corpId: "",
|
||||||
|
agentId: "",
|
||||||
|
loginUrl: null,
|
||||||
|
bindEnabled: false,
|
||||||
|
defaultReturnTo: "/portal",
|
||||||
|
status: "disabled",
|
||||||
|
statusReasons: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||||
|
body: JSON.stringify(envelope({ accessToken: "mock-r103-token", expiresIn: 900, mustChangePassword: false })),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/me") {
|
||||||
|
await fulfillJson(route, me);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/system/navigation") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workspace: {
|
||||||
|
id: "workspace-r103",
|
||||||
|
persisted: true,
|
||||||
|
profile: "small_team_internal",
|
||||||
|
orgMode: "single_company",
|
||||||
|
primaryEntryMode: "password",
|
||||||
|
notificationStrategy: "in_app",
|
||||||
|
devicePriority: "desktop",
|
||||||
|
moduleVisibility: {},
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null,
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
main: [
|
||||||
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
|
navigationItem("purchase", "/purchase-requests", "采购申请", "shopping_cart"),
|
||||||
|
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
|
||||||
|
],
|
||||||
|
admin: [],
|
||||||
|
backup: [
|
||||||
|
navigationItem("payment", "/payment-batches", "付款批次", "payments"),
|
||||||
|
navigationItem("dashboard", "/dashboard", "数据看板", "monitoring"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
recommendedRolePresets: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/notifications") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/portal/home") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workspace: { id: "workspace-r103", persisted: true, profile: "small_team_internal" },
|
||||||
|
greeting: { name: me.name },
|
||||||
|
cards: [],
|
||||||
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||||
|
myApplications: [],
|
||||||
|
riskSummary: null,
|
||||||
|
announcements: [],
|
||||||
|
quickLinks: [],
|
||||||
|
todayPresence: null,
|
||||||
|
teamPresence: null,
|
||||||
|
weeklyReport: null,
|
||||||
|
weeklySummary: null,
|
||||||
|
todoSummary: null,
|
||||||
|
projectSummary: null,
|
||||||
|
peopleLifecycle: null,
|
||||||
|
adoptionSummary: null,
|
||||||
|
recentActivities: [],
|
||||||
|
teamStatusBoard: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/master-data/legal-entities") {
|
||||||
|
await fulfillJson(route, { includeArchived: false, items: [{ id: "legal-r103", code: "HYD", name: "海音达", shortName: null, currencyCode: "CNY", taxNo: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/master-data/cost-centers") {
|
||||||
|
await fulfillJson(route, { includeArchived: false, items: [{ id: "cost-r103", legalEntityId: "legal-r103", code: "RD", name: "研发部", parentId: null, ownerEmployeeId: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/master-data/suppliers") {
|
||||||
|
await fulfillJson(route, { includeArchived: false, items: [{ id: "supplier-r103", code: "SUP", name: "测试供应商", taxNo: null, contactName: null, contactPhone: null, bankName: null, bankAccount: null, enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/master-data/document-categories") {
|
||||||
|
await fulfillJson(route, { includeArchived: false, items: [{ id: "category-r103", code: "CG", name: "采购申请", entityType: "purchase_request", sequencePrefix: "CG", numberLength: 4, resetPeriod: "daily", enabled: true, archivedAt: null, createdAt: null, updatedAt: null }] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/purchase-requests" && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, mine: true, items: [purchaseSummary()] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === `/api/purchase-requests/${purchaseId}`) {
|
||||||
|
await fulfillJson(route, purchaseDetail());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === `/api/purchase-requests/${purchaseId}/attachments` && request.method() === "POST") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
attachment: {
|
||||||
|
id: "attachment-r103",
|
||||||
|
originalName: "采购报价.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
fileSize: 4,
|
||||||
|
uploadedBy: me.id,
|
||||||
|
createdAt: "2026-05-06T09:20:00.000Z",
|
||||||
|
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/invoice-pool") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceItem()] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/reimbursements") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 50, total: 1, scope: "own", items: [reimbursementForBind()] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson(route, {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||||
|
await page.getByTestId("login-password").fill("EmployeePassword1234");
|
||||||
|
await page.getByTestId("login-submit").click();
|
||||||
|
await expect(page).toHaveURL(/\/portal/);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("R103 cross page UI consistency", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockApis(page);
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("采购页显示中文标题并使用中文附件上传控件", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
|
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
||||||
|
|
||||||
|
await expect(page.locator("header").filter({ hasText: "备用模块" }).getByText("采购申请")).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "采购申请" })).toBeVisible();
|
||||||
|
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
||||||
|
await expect(page.getByText("Choose File")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("purchase-attachment-file-input").setInputFiles({
|
||||||
|
name: "采购报价.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
buffer: Buffer.from("r103"),
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.pdf");
|
||||||
|
await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("票据中心绑定报销单不展示 UUID 兜底", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
|
await page.goto("/invoice-pool");
|
||||||
|
|
||||||
|
await expect(page.locator("header").filter({ hasText: "备用模块" }).getByText("票据中心")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06");
|
||||||
|
await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId);
|
||||||
|
await expect(page.getByText("/invoice-pool")).toHaveCount(0);
|
||||||
|
await page.screenshot({ path: screenshotPath("invoice-pool-desktop.png"), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("移动宽度下关键页面仍保持浅色中文工作台风格", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/invoice-pool");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "票据中心" })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("invoice-pool-mobile-list")).toBeVisible();
|
||||||
|
await expect(page.getByText("Choose File")).toHaveCount(0);
|
||||||
|
await page.screenshot({ path: screenshotPath("invoice-pool-mobile.png"), fullPage: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
A1,R103 跨页面设计一致性合同,"以 R100 设计规则和 R102 剩余诊断结果为输入,收口路由标题、采购上传控件、票据中心 UUID 展示和跨页面视觉验证。","本 CSV 校验通过;本批仅改前端展示和测试,不改后端 API、权限、状态机和数据结构。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_23-07-02-r103-cross-page-ui-consistency.csv;业务流程影响:固定跨页面 UI 一致性收尾范围。","python|manual",DONE,DONE,DONE,"issues/2026-05-06_23-07-02-r103-cross-page-ui-consistency.csv","[]","回滚路径:revert 本批提交即可;无 Prisma 迁移。"
|
||||||
|
A2,补齐导航中文标题,"为采购、差旅、预算、付款、应付、票据、盘点、系统切换、ERP 集成和 dashboard 路径补齐 PAGE_TITLES,避免顶栏显示路由路径。","node scripts/qa/check-ui-design-rules.mjs 不再报告 navigation.no_route_title_fallback;PC 顶栏显示中文业务标题。","node scripts/qa/check-ui-design-rules.mjs;cd client && npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts --reporter=list;业务流程:员工从导航进入采购/票据/付款等页面,顶部标题为中文。","node|playwright|typescript",DONE,DONE,DONE,"client/src/features/navigation/schema.ts","[""A1""]","只补展示映射,不重构导航 schema。"
|
||||||
|
A3,采购上传控件中文化,"采购申请页附件上传使用自定义中文按钮和文件名回显,不直接暴露浏览器原生文件输入。","静态诊断不再报告 PurchaseRequestsPage 原生上传控件;采购草稿页附件区域显示 上传附件 和已选文件名。","node scripts/qa/check-ui-design-rules.mjs;cd client && npm run typecheck;业务流程:采购申请草稿→选择附件→看到中文按钮和文件名回显。","node|typescript|playwright",DONE,DONE,DONE,"client/src/pages/PurchaseRequestsPage.tsx","[""A1""]","不改上传 API;原 input 保留为 visually hidden 触发器。"
|
||||||
|
A4,票据中心报销卡展示兜底,"票据中心绑定卡不再用报销 UUID 作为主展示;缺少业务编号时显示草稿单、报销单或日期摘要。","静态诊断不再报告 InvoiceBindCard 的 UUID 主展示;票据绑定列表主标题不展示 UUID。","node scripts/qa/check-ui-design-rules.mjs;cd client && npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts --reporter=list;业务流程:票据中心查看可绑定报销单,主标题为业务编号或摘要。","node|playwright|typescript",DONE,DONE,DONE,"client/src/pages/invoice-pool/InvoiceBindCard.tsx|client/src/lib/reimbursementDisplay.ts","[""A1""]","复用 R102 reimbursementDisplay,不新增重复兜底逻辑。"
|
||||||
|
A5,跨页面自动化视觉核验,"新增 R103 Playwright 用例,用 mock API 覆盖关键页面中文标题、票据绑定卡兜底和采购上传控件;保留桌面与移动视口截图用于回归定位。","R103 Playwright 通过;截图输出到 output/playwright/r103;页面不出现路由式标题、UUID 主展示或原生英文上传按钮。","cd client && npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts --reporter=list;业务流程:PC 访问 dashboard/采购/票据;移动视口访问采购或票据页面,关键文案和控件可见。","playwright",DONE,DONE,DONE,"client/tests/e2e/r103-cross-page-ui-consistency.spec.ts|output/playwright/r103","[""A2"",""A3"",""A4""]","测试使用 mock API,不依赖生产数据;截图目录不纳入 git。"
|
||||||
|
A6,验证回填与中文提交,"执行 CSV 校验、设计规则、前端 typecheck/build、R103 Playwright 和 git diff 检查,回填状态后中文提交。","A1-A5 状态 DONE;node scripts/qa/check-ui-design-rules.mjs 输出 0 findings;提交信息中文且引用 R103/A1-A6。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_23-07-02-r103-cross-page-ui-consistency.csv;node scripts/qa/check-ui-design-rules.mjs;cd client && npm run build;cd client && npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts --reporter=list;git diff --check;业务流程:跨页面 UI 一致性可构建可回归。","python|node|npm|playwright|git",DONE,DONE,DONE,"issues/2026-05-06_23-07-02-r103-cross-page-ui-consistency.csv|client","[""A1"",""A2"",""A3"",""A4"",""A5""]","若 build 仍有既有 Vite chunk warning,记录但不在本批处理。"
|
||||||
|
在新工单中引用
屏蔽一个用户