feat(08-05-business-detail-routing): 完成采购与资产独立路由
这个提交包含在:
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { searchGlobal, type GlobalSearchHit } from "../api/search";
|
||||
import { isApiError } from "../api/types";
|
||||
import { buildAssetDetailPath, buildPurchaseRequestDetailPath } from "../lib/businessDetailRoutes";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
@@ -88,6 +89,16 @@ function resolveBrowseHref(groupKey: SearchGroupKey, items: GlobalSearchHit[]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSearchHitHref(item: GlobalSearchHit) {
|
||||
if (item.entityType === "purchase_request") {
|
||||
return buildPurchaseRequestDetailPath(item.entityId);
|
||||
}
|
||||
if (item.entityType === "asset") {
|
||||
return buildAssetDetailPath(item.entityId);
|
||||
}
|
||||
return item.href;
|
||||
}
|
||||
|
||||
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -158,7 +169,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
key={`${item.entityType}-${item.entityId}`}
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
|
||||
onClick={() => closeAndNavigate(item.href)}
|
||||
onClick={() => closeAndNavigate(resolveSearchHitHref(item))}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/wecom-approval-archive", title: "企微审批历史", group: "业务" },
|
||||
{ path: "/projects", title: "项目", group: "业务" },
|
||||
{ path: "/contracts-registry", title: "合同", group: "业务" },
|
||||
{ path: "/purchase-requests/", title: "采购详情", group: "业务" },
|
||||
{ path: "/purchase-requests", title: "采购", group: "业务" },
|
||||
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
|
||||
{ path: "/budgets", title: "预算管理", group: "财务" },
|
||||
@@ -27,6 +28,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/reimbursements/", title: "报销详情", group: "工作" },
|
||||
{ path: "/reimbursements", title: "报销", group: "工作" },
|
||||
{ path: "/leave-requests", title: "请假记录", group: "业务" },
|
||||
{ path: "/assets/new", title: "新增资产", group: "业务" },
|
||||
{ path: "/assets/", title: "资产详情", group: "业务" },
|
||||
{ path: "/assets", title: "资产", group: "业务" },
|
||||
{ path: "/workflow/tasks/", title: "审批详情", group: "工作" },
|
||||
{ path: "/workflow/tasks", title: "待我审批", group: "工作" },
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { PurchaseRequestStatus } from "../api/purchaseRequests";
|
||||
import type { AssetSensitivityLevel, AssetStatus, AssetType } from "../api/assets";
|
||||
|
||||
const PURCHASE_STATUS_VALUES = new Set<PurchaseRequestStatus>([
|
||||
"draft",
|
||||
"reviewing",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
]);
|
||||
const ASSET_STATUS_VALUES = new Set<AssetStatus>([
|
||||
"in_stock",
|
||||
"in_use",
|
||||
"under_repair",
|
||||
"retired",
|
||||
"archived",
|
||||
]);
|
||||
const ASSET_TYPE_VALUES = new Set<AssetType>([
|
||||
"fixed_asset",
|
||||
"electronic_document",
|
||||
"electronic_system",
|
||||
"code_repository",
|
||||
"backup_media",
|
||||
]);
|
||||
const ASSET_SENSITIVITY_VALUES = new Set<AssetSensitivityLevel>([
|
||||
"public_candidate",
|
||||
"internal",
|
||||
"confidential",
|
||||
"strictly_confidential",
|
||||
]);
|
||||
|
||||
export const ASSET_CREATE_PATH = "/assets/new";
|
||||
|
||||
function normalizedEntityId(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalSearchValue(searchParams: URLSearchParams, key: string) {
|
||||
return (searchParams.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
function positivePage(value: string | null) {
|
||||
if (!value || !/^[1-9]\d*$/.test(value)) {
|
||||
return 1;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : 1;
|
||||
}
|
||||
|
||||
function appendOptional(target: URLSearchParams, key: string, value: string) {
|
||||
if (value) {
|
||||
target.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPurchaseRequestDetailPath(purchaseRequestId: string) {
|
||||
const normalized = normalizedEntityId(purchaseRequestId);
|
||||
return normalized ? `/purchase-requests/${encodeURIComponent(normalized)}` : "/purchase-requests";
|
||||
}
|
||||
|
||||
export function buildAssetDetailPath(assetId: string) {
|
||||
const normalized = normalizedEntityId(assetId);
|
||||
return normalized ? `/assets/${encodeURIComponent(normalized)}` : "/assets";
|
||||
}
|
||||
|
||||
export function readPurchaseListFilters(
|
||||
searchParams: URLSearchParams,
|
||||
canViewAll: boolean,
|
||||
): {
|
||||
status: PurchaseRequestStatus | "";
|
||||
mine: boolean;
|
||||
supplierId: string;
|
||||
legalEntityId: string;
|
||||
costCenterId: string;
|
||||
page: number;
|
||||
} {
|
||||
const rawStatus = searchParams.get("status");
|
||||
const status = rawStatus === "all"
|
||||
? ""
|
||||
: rawStatus && PURCHASE_STATUS_VALUES.has(rawStatus as PurchaseRequestStatus)
|
||||
? rawStatus as PurchaseRequestStatus
|
||||
: "draft";
|
||||
const scope = searchParams.get("scope");
|
||||
|
||||
return {
|
||||
status,
|
||||
mine: !(canViewAll && scope === "all"),
|
||||
supplierId: optionalSearchValue(searchParams, "supplierId"),
|
||||
legalEntityId: optionalSearchValue(searchParams, "legalEntityId"),
|
||||
costCenterId: optionalSearchValue(searchParams, "costCenterId"),
|
||||
page: positivePage(searchParams.get("page")),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePurchaseListSearchParams(searchParams: URLSearchParams) {
|
||||
const normalized = new URLSearchParams();
|
||||
const rawStatus = searchParams.get("status");
|
||||
if (rawStatus === "all" || (rawStatus && PURCHASE_STATUS_VALUES.has(rawStatus as PurchaseRequestStatus))) {
|
||||
normalized.set("status", rawStatus);
|
||||
}
|
||||
const rawScope = searchParams.get("scope");
|
||||
if (rawScope === "own" || rawScope === "all") {
|
||||
normalized.set("scope", rawScope);
|
||||
}
|
||||
appendOptional(normalized, "supplierId", optionalSearchValue(searchParams, "supplierId"));
|
||||
appendOptional(normalized, "legalEntityId", optionalSearchValue(searchParams, "legalEntityId"));
|
||||
appendOptional(normalized, "costCenterId", optionalSearchValue(searchParams, "costCenterId"));
|
||||
const page = positivePage(searchParams.get("page"));
|
||||
if (page > 1) {
|
||||
normalized.set("page", String(page));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readAssetListFilters(
|
||||
searchParams: URLSearchParams,
|
||||
canViewAll: boolean,
|
||||
): {
|
||||
q: string;
|
||||
status: AssetStatus | "";
|
||||
assetType: AssetType | "";
|
||||
sensitivity: AssetSensitivityLevel | "";
|
||||
categoryId: string;
|
||||
legalEntityId: string;
|
||||
costCenterId: string;
|
||||
assigneeId: string;
|
||||
mine: boolean;
|
||||
} {
|
||||
const rawStatus = searchParams.get("status");
|
||||
const rawAssetType = searchParams.get("assetType");
|
||||
const rawSensitivity = searchParams.get("sensitivity");
|
||||
const scope = searchParams.get("scope");
|
||||
|
||||
return {
|
||||
q: searchParams.get("q") ?? "",
|
||||
status: rawStatus && ASSET_STATUS_VALUES.has(rawStatus as AssetStatus) ? rawStatus as AssetStatus : "",
|
||||
assetType: rawAssetType && ASSET_TYPE_VALUES.has(rawAssetType as AssetType) ? rawAssetType as AssetType : "",
|
||||
sensitivity: rawSensitivity && ASSET_SENSITIVITY_VALUES.has(rawSensitivity as AssetSensitivityLevel)
|
||||
? rawSensitivity as AssetSensitivityLevel
|
||||
: "",
|
||||
categoryId: optionalSearchValue(searchParams, "categoryId"),
|
||||
legalEntityId: optionalSearchValue(searchParams, "legalEntityId"),
|
||||
costCenterId: optionalSearchValue(searchParams, "costCenterId"),
|
||||
assigneeId: optionalSearchValue(searchParams, "assigneeId"),
|
||||
mine: scope === "own" || !(canViewAll && (scope === "all" || scope == null)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAssetListSearchParams(searchParams: URLSearchParams) {
|
||||
const normalized = new URLSearchParams();
|
||||
appendOptional(normalized, "q", optionalSearchValue(searchParams, "q"));
|
||||
const rawStatus = searchParams.get("status");
|
||||
if (rawStatus && ASSET_STATUS_VALUES.has(rawStatus as AssetStatus)) {
|
||||
normalized.set("status", rawStatus);
|
||||
}
|
||||
const rawAssetType = searchParams.get("assetType");
|
||||
if (rawAssetType && ASSET_TYPE_VALUES.has(rawAssetType as AssetType)) {
|
||||
normalized.set("assetType", rawAssetType);
|
||||
}
|
||||
const rawSensitivity = searchParams.get("sensitivity");
|
||||
if (rawSensitivity && ASSET_SENSITIVITY_VALUES.has(rawSensitivity as AssetSensitivityLevel)) {
|
||||
normalized.set("sensitivity", rawSensitivity);
|
||||
}
|
||||
appendOptional(normalized, "categoryId", optionalSearchValue(searchParams, "categoryId"));
|
||||
appendOptional(normalized, "legalEntityId", optionalSearchValue(searchParams, "legalEntityId"));
|
||||
appendOptional(normalized, "costCenterId", optionalSearchValue(searchParams, "costCenterId"));
|
||||
appendOptional(normalized, "assigneeId", optionalSearchValue(searchParams, "assigneeId"));
|
||||
const rawScope = searchParams.get("scope");
|
||||
if (rawScope === "own" || rawScope === "all") {
|
||||
normalized.set("scope", rawScope);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function listSource(pathname: string, searchParams: URLSearchParams) {
|
||||
const search = searchParams.toString();
|
||||
return search ? `${pathname}?${search}` : pathname;
|
||||
}
|
||||
|
||||
export function resolvePurchaseRequestLegacyRedirect(searchParams: URLSearchParams) {
|
||||
const purchaseRequestId = normalizedEntityId(searchParams.get("id") ?? "");
|
||||
if (!purchaseRequestId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: buildPurchaseRequestDetailPath(purchaseRequestId),
|
||||
from: listSource("/purchase-requests", normalizePurchaseListSearchParams(searchParams)),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAssetLegacyRedirect(searchParams: URLSearchParams) {
|
||||
const assetId = normalizedEntityId(searchParams.get("id") ?? "");
|
||||
if (!assetId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: buildAssetDetailPath(assetId),
|
||||
from: listSource("/assets", normalizeAssetListSearchParams(searchParams)),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { buildAssetDetailPath, buildPurchaseRequestDetailPath } from "./businessDetailRoutes";
|
||||
|
||||
type NotificationTarget = {
|
||||
targetType: string | null;
|
||||
targetId: string | null;
|
||||
@@ -31,7 +33,7 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
|
||||
}
|
||||
|
||||
if (item.targetType === "purchase_request") {
|
||||
return `/purchase-requests?highlight=${item.targetId}`;
|
||||
return buildPurchaseRequestDetailPath(item.targetId);
|
||||
}
|
||||
|
||||
if (item.targetType === "travel_request") {
|
||||
@@ -39,7 +41,7 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
|
||||
}
|
||||
|
||||
if (item.targetType === "asset" || item.targetType === "asset_card") {
|
||||
return `/assets?highlight=${item.targetId}`;
|
||||
return buildAssetDetailPath(item.targetId);
|
||||
}
|
||||
|
||||
if (item.targetType === "inventory_check") {
|
||||
|
||||
@@ -2,22 +2,34 @@ export type PcDetailNavigationState = {
|
||||
from?: string;
|
||||
};
|
||||
|
||||
export type PcDetailSourcePolicy = "reimbursement" | "purchase-request" | "asset";
|
||||
|
||||
export function buildPcDetailSource(pathname: string, search: string) {
|
||||
return `${pathname}${search}`;
|
||||
}
|
||||
|
||||
export function resolvePcDetailSource(value: unknown, currentPath: string) {
|
||||
export function resolvePcDetailSource(
|
||||
value: unknown,
|
||||
currentPath: string,
|
||||
policy: PcDetailSourcePolicy = "reimbursement",
|
||||
) {
|
||||
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.includes("#")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(value, "https://hyb-oa.invalid");
|
||||
const isReimbursementList = url.pathname === "/reimbursements";
|
||||
const isWorkflowTask = /^\/workflow\/tasks\/[^/]+$/.test(url.pathname);
|
||||
if (!isReimbursementList && !isWorkflowTask) {
|
||||
const baseUrl = new URL("https://hyb-oa.invalid");
|
||||
const url = new URL(value, baseUrl);
|
||||
if (url.origin !== baseUrl.origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowed = policy === "purchase-request"
|
||||
? url.pathname === "/purchase-requests" || /^\/projects\/[^/]+$/.test(url.pathname)
|
||||
: policy === "asset"
|
||||
? url.pathname === "/assets"
|
||||
: url.pathname === "/reimbursements" || /^\/workflow\/tasks\/[^/]+$/.test(url.pathname);
|
||||
if (!allowed) return null;
|
||||
|
||||
const source = `${url.pathname}${url.search}`;
|
||||
return source === currentPath ? null : source;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
return "/mobile/approvals";
|
||||
}
|
||||
|
||||
if (normalizedPath === "/assets" || normalizedPath === "/inventory-checks") {
|
||||
if (normalizedPath === "/assets" || normalizedPath.startsWith("/assets/") || normalizedPath === "/inventory-checks") {
|
||||
return "/mobile/assets/scan";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import {
|
||||
assignAsset,
|
||||
createAsset,
|
||||
@@ -48,6 +49,17 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
|
||||
import {
|
||||
ASSET_CREATE_PATH,
|
||||
buildAssetDetailPath,
|
||||
readAssetListFilters,
|
||||
resolveAssetLegacyRedirect,
|
||||
} from "../lib/businessDetailRoutes";
|
||||
import {
|
||||
buildPcDetailSource,
|
||||
resolvePcDetailSource,
|
||||
type PcDetailNavigationState,
|
||||
} from "../lib/pcDetailNavigation";
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
return resolveUserErrorMessage(error, "请求失败,请稍后重试。");
|
||||
@@ -199,8 +211,33 @@ function unsafeAssetTextReason(value: string) {
|
||||
}
|
||||
|
||||
export function AssetRegistryPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const legacyRedirect = resolveAssetLegacyRedirect(searchParams);
|
||||
if (legacyRedirect) {
|
||||
return <Navigate to={legacyRedirect.to} replace state={{ from: legacyRedirect.from }} />;
|
||||
}
|
||||
return <AssetRegistryWorkspace mode="list" />;
|
||||
}
|
||||
|
||||
export function AssetCreatePage() {
|
||||
return <AssetRegistryWorkspace mode="create" />;
|
||||
}
|
||||
|
||||
export function AssetDetailPage() {
|
||||
const { assetId = "" } = useParams();
|
||||
return <AssetRegistryWorkspace mode="detail" assetId={assetId} />;
|
||||
}
|
||||
|
||||
function AssetRegistryWorkspace({
|
||||
mode,
|
||||
assetId = "",
|
||||
}: {
|
||||
mode: "list" | "create" | "detail";
|
||||
assetId?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
|
||||
@@ -208,24 +245,43 @@ export function AssetRegistryPage() {
|
||||
const canTransfer = hasPermission(permissions, "asset.transfer");
|
||||
const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
||||
const isCreatingAsset = mode === "create";
|
||||
const isAssetListMode = mode === "list";
|
||||
const selectedAssetId = mode === "detail" ? assetId.trim() : "";
|
||||
const listFilters = useMemo(
|
||||
() => readAssetListFilters(searchParams, canViewAll),
|
||||
[canViewAll, searchParams],
|
||||
);
|
||||
const {
|
||||
q: assetSearch,
|
||||
status: statusFilter,
|
||||
assetType: assetTypeFilter,
|
||||
sensitivity: sensitivityFilter,
|
||||
categoryId: categoryFilter,
|
||||
legalEntityId: legalEntityFilter,
|
||||
costCenterId: costCenterFilter,
|
||||
assigneeId: assigneeFilter,
|
||||
mine,
|
||||
} = listFilters;
|
||||
const deferredAssetSearch = useDeferredValue(assetSearch.trim());
|
||||
const currentSource = buildPcDetailSource(location.pathname, location.search);
|
||||
const navigationState = location.state as PcDetailNavigationState | null;
|
||||
const backSource = resolvePcDetailSource(navigationState?.from, currentSource, "asset") ?? "/assets";
|
||||
const listSource = buildPcDetailSource(location.pathname, location.search);
|
||||
|
||||
function updateListSearch(updates: Record<string, string | null>) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
const [categoryForm, setCategoryForm] = useState(initialCategoryForm);
|
||||
const [editingCategoryId, setEditingCategoryId] = useState("");
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<AssetStatus | "">("");
|
||||
const [assetTypeFilter, setAssetTypeFilter] = useState<AssetType | "">("");
|
||||
const [sensitivityFilter, setSensitivityFilter] = useState<AssetSensitivityLevel | "">("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||
const [costCenterFilter, setCostCenterFilter] = useState("");
|
||||
const [assigneeFilter, setAssigneeFilter] = useState("");
|
||||
const [assetSearch, setAssetSearch] = useState("");
|
||||
const [mine, setMine] = useState(!canViewAll);
|
||||
const [showCategoryManager, setShowCategoryManager] = useState(false);
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
|
||||
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
|
||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState(initialAssetForm);
|
||||
const [savedAssetFormSignature, setSavedAssetFormSignature] = useState(() => JSON.stringify(initialAssetForm()));
|
||||
const [assetFormError, setAssetFormError] = useState("");
|
||||
@@ -240,7 +296,8 @@ export function AssetRegistryPage() {
|
||||
});
|
||||
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ["assets", "list", statusFilter, assetTypeFilter, sensitivityFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, assetSearch, mine, canViewAll],
|
||||
enabled: isAssetListMode,
|
||||
queryKey: ["assets", "list", statusFilter, assetTypeFilter, sensitivityFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, deferredAssetSearch, mine, canViewAll],
|
||||
queryFn: () =>
|
||||
fetchAssets({
|
||||
status: statusFilter || undefined,
|
||||
@@ -250,13 +307,13 @@ export function AssetRegistryPage() {
|
||||
legalEntityId: legalEntityFilter || undefined,
|
||||
costCenterId: costCenterFilter || undefined,
|
||||
assigneeId: assigneeFilter || undefined,
|
||||
q: assetSearch.trim() || undefined,
|
||||
q: deferredAssetSearch || undefined,
|
||||
mine: canViewAll ? mine : true,
|
||||
}),
|
||||
});
|
||||
|
||||
const assetDetailQuery = useQuery({
|
||||
enabled: Boolean(selectedAssetId) && !isCreatingAsset,
|
||||
enabled: mode === "detail" && Boolean(selectedAssetId),
|
||||
queryKey: ["assets", "detail", selectedAssetId],
|
||||
queryFn: () => fetchAssetDetail(selectedAssetId),
|
||||
});
|
||||
@@ -272,6 +329,7 @@ export function AssetRegistryPage() {
|
||||
});
|
||||
|
||||
const documentCategoriesQuery = useQuery({
|
||||
enabled: isAssetListMode && canManage,
|
||||
queryKey: ["assets", "document-categories"],
|
||||
queryFn: () => fetchDocumentCategories({ entityType: "asset_card", enabled: true }),
|
||||
});
|
||||
@@ -283,44 +341,11 @@ export function AssetRegistryPage() {
|
||||
});
|
||||
|
||||
const approvedPurchasesQuery = useQuery({
|
||||
enabled: canReadProcurementSources,
|
||||
enabled: isCreatingAsset && canReadProcurementSources,
|
||||
queryKey: ["assets", "approved-purchases"],
|
||||
queryFn: () => fetchPurchaseRequests({ status: "approved", mine: false, page: 1, pageSize: 100 }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const searchSelectedId = searchParams.get("id") ?? "";
|
||||
if (searchSelectedId && searchSelectedId !== selectedAssetId) {
|
||||
setSelectedAssetId(searchSelectedId);
|
||||
}
|
||||
}, [searchParams, selectedAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = searchParams.get("id") ?? "";
|
||||
if (isCreatingAsset) {
|
||||
if (!currentId) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
setSearchParams(next, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!selectedAssetId && !currentId) {
|
||||
return;
|
||||
}
|
||||
if (selectedAssetId === currentId) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (selectedAssetId) {
|
||||
next.set("id", selectedAssetId);
|
||||
} else {
|
||||
next.delete("id");
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [isCreatingAsset, searchParams, selectedAssetId, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreatingAsset || !assetDetailQuery.data?.asset) {
|
||||
return;
|
||||
@@ -406,12 +431,7 @@ export function AssetRegistryPage() {
|
||||
|
||||
function resetAssetEditor() {
|
||||
runGuarded(() => {
|
||||
const nextAssetForm = initialAssetForm();
|
||||
setIsCreatingAsset(true);
|
||||
setSelectedAssetId("");
|
||||
setAssetForm(nextAssetForm);
|
||||
setSavedAssetFormSignature(JSON.stringify(nextAssetForm));
|
||||
setAssetFormError("");
|
||||
navigate(ASSET_CREATE_PATH, { state: { from: listSource } });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -561,9 +581,13 @@ export function AssetRegistryPage() {
|
||||
]);
|
||||
setAssetFormError("");
|
||||
setSavedAssetFormSignature(JSON.stringify(assetForm));
|
||||
setSelectedAssetId(data.asset.id);
|
||||
setIsCreatingAsset(false);
|
||||
toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新");
|
||||
if (isCreatingAsset) {
|
||||
navigate(buildAssetDetailPath(data.asset.id), {
|
||||
replace: true,
|
||||
state: { from: backSource },
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
@@ -718,20 +742,16 @@ export function AssetRegistryPage() {
|
||||
}, [assetItems]);
|
||||
const selectAsset = (assetId: string) => {
|
||||
runGuarded(() => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId(assetId);
|
||||
navigate(buildAssetDetailPath(assetId), { state: { from: listSource } });
|
||||
});
|
||||
};
|
||||
const returnToAssetList = () => {
|
||||
runGuarded(() => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId("");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
setSearchParams(next, { replace: true });
|
||||
navigate(backSource, { replace: true });
|
||||
});
|
||||
};
|
||||
const isAssetDetailMode = isCreatingAsset || Boolean(selectedAssetId);
|
||||
const isAssetDetailMode = !isAssetListMode;
|
||||
const detailAccessDenied = assetDetailQuery.isError && isApiError(assetDetailQuery.error) && [403, 404].includes(assetDetailQuery.error.status);
|
||||
|
||||
return (
|
||||
<ListWorkspace>
|
||||
@@ -741,7 +761,7 @@ export function AssetRegistryPage() {
|
||||
<HeaderActionGroup>
|
||||
{isAssetDetailMode ? (
|
||||
<Button type="button" variant="secondary" onClick={returnToAssetList}>
|
||||
返回列表
|
||||
返回资产列表
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
@@ -888,10 +908,10 @@ export function AssetRegistryPage() {
|
||||
<>
|
||||
{canViewAll ? (
|
||||
<>
|
||||
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
|
||||
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => updateListSearch({ scope: "own" })}>
|
||||
我相关
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
|
||||
<Button type="button" size="sm" variant={!mine ? "default" : "secondary"} onClick={() => updateListSearch({ scope: "all" })}>
|
||||
全范围
|
||||
</Button>
|
||||
</>
|
||||
@@ -909,14 +929,14 @@ export function AssetRegistryPage() {
|
||||
)}
|
||||
advanced={showAssetAdvancedFilters ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
|
||||
<select className="select-std" value={sensitivityFilter} onChange={(event) => updateListSearch({ sensitivity: event.target.value || null })}>
|
||||
{sensitivityOptions().map((item) => (
|
||||
<option key={item.value || "all"} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
|
||||
<select className="select-std" value={categoryFilter} onChange={(event) => updateListSearch({ categoryId: event.target.value || null })}>
|
||||
<option value="">全部分类</option>
|
||||
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
@@ -925,8 +945,14 @@ export function AssetRegistryPage() {
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
|
||||
setLegalEntityFilter(event.target.value);
|
||||
setCostCenterFilter("");
|
||||
const nextLegalEntityId = event.target.value;
|
||||
const keepCostCenter = costCenterFilter && (costCentersQuery.data?.items ?? []).some(
|
||||
(item) => item.id === costCenterFilter && item.legalEntityId === nextLegalEntityId,
|
||||
);
|
||||
updateListSearch({
|
||||
legalEntityId: nextLegalEntityId || null,
|
||||
costCenterId: keepCostCenter ? costCenterFilter : null,
|
||||
});
|
||||
}}>
|
||||
<option value="">全部公司</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
@@ -935,7 +961,7 @@ export function AssetRegistryPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
|
||||
<select className="select-std" value={costCenterFilter} onChange={(event) => updateListSearch({ costCenterId: event.target.value || null })}>
|
||||
<option value="">全部成本中心</option>
|
||||
{filteredFilterCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
@@ -943,7 +969,7 @@ export function AssetRegistryPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
|
||||
<select className="select-std" value={assigneeFilter} onChange={(event) => updateListSearch({ assigneeId: event.target.value || null })} disabled={!canViewAll}>
|
||||
<option value="">全部归属人</option>
|
||||
{(assigneesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
@@ -957,11 +983,13 @@ export function AssetRegistryPage() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSensitivityFilter("");
|
||||
setCategoryFilter("");
|
||||
setLegalEntityFilter("");
|
||||
setCostCenterFilter("");
|
||||
setAssigneeFilter("");
|
||||
updateListSearch({
|
||||
sensitivity: null,
|
||||
categoryId: null,
|
||||
legalEntityId: null,
|
||||
costCenterId: null,
|
||||
assigneeId: null,
|
||||
});
|
||||
setShowAdvancedFilters(false);
|
||||
}}
|
||||
>
|
||||
@@ -975,16 +1003,16 @@ export function AssetRegistryPage() {
|
||||
<Input
|
||||
placeholder="搜索编号/名称/hash/VER"
|
||||
value={assetSearch}
|
||||
onChange={(event) => setAssetSearch(event.target.value)}
|
||||
onChange={(event) => updateListSearch({ q: event.target.value || null })}
|
||||
/>
|
||||
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as AssetStatus | "") }>
|
||||
<select className="select-std" value={statusFilter} onChange={(event) => updateListSearch({ status: event.target.value || null })}>
|
||||
{assetStatusOptions().map((item) => (
|
||||
<option key={item.value || "all"} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={assetTypeFilter} onChange={(event) => setAssetTypeFilter(event.target.value as AssetType | "") }>
|
||||
<select className="select-std" value={assetTypeFilter} onChange={(event) => updateListSearch({ assetType: event.target.value || null })}>
|
||||
{assetTypeOptions().map((item) => (
|
||||
<option key={item.value || "all"} value={item.value}>
|
||||
{item.label}
|
||||
@@ -1111,8 +1139,17 @@ export function AssetRegistryPage() {
|
||||
<BusinessDetailMain>
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
|
||||
{mode === "detail" && (!selectedAssetId || assetDetailQuery.isError) ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700">
|
||||
<p>
|
||||
{!selectedAssetId || detailAccessDenied
|
||||
? "无法查看该记录,记录可能不存在或你无权访问。"
|
||||
: resolveError(assetDetailQuery.error)}
|
||||
</p>
|
||||
<Button className="mt-3" type="button" variant="secondary" onClick={returnToAssetList}>
|
||||
返回资产列表
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchEmployees } from "../api/employees";
|
||||
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectDetail, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
|
||||
@@ -20,6 +20,8 @@ import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import { buildPurchaseRequestDetailPath } from "../lib/businessDetailRoutes";
|
||||
import { buildPcDetailSource } from "../lib/pcDetailNavigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const PROJECT_STATUS_OPTIONS: ProjectStatus[] = ["draft", "active", "paused", "completed", "archived"];
|
||||
@@ -167,7 +169,9 @@ function documentLocationSummary(item: { confidentialityLevel: ConfidentialityLe
|
||||
|
||||
export function ProjectDetailPage() {
|
||||
const { projectId = "" } = useParams();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const currentProjectSource = buildPcDetailSource(location.pathname, location.search);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(projectId),
|
||||
@@ -1128,7 +1132,12 @@ export function ProjectDetailPage() {
|
||||
<div className="text-zinc-500">暂无关联采购</div>
|
||||
) : null}
|
||||
{(purchaseQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<Link key={item.id} to={`/purchase-requests?id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||
<Link
|
||||
key={item.id}
|
||||
to={buildPurchaseRequestDetailPath(item.id)}
|
||||
state={{ from: currentProjectSource }}
|
||||
className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate">{item.requestNo || item.title}</span>
|
||||
<StatusBadge status={item.status} />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import {
|
||||
archivePurchaseOrder,
|
||||
createPurchaseRequest,
|
||||
@@ -57,6 +58,16 @@ import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
buildPurchaseRequestDetailPath,
|
||||
readPurchaseListFilters,
|
||||
resolvePurchaseRequestLegacyRedirect,
|
||||
} from "../lib/businessDetailRoutes";
|
||||
import {
|
||||
buildPcDetailSource,
|
||||
resolvePcDetailSource,
|
||||
type PcDetailNavigationState,
|
||||
} from "../lib/pcDetailNavigation";
|
||||
import {
|
||||
formatApprovalHistoryTime,
|
||||
resolvePurchaseBudgetHint,
|
||||
@@ -297,24 +308,52 @@ function CompactBusinessCode({ value, copyable = true }: { value: string; copyab
|
||||
}
|
||||
|
||||
export function PurchaseRequestsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const legacyRedirect = resolvePurchaseRequestLegacyRedirect(searchParams);
|
||||
if (legacyRedirect) {
|
||||
return <Navigate to={legacyRedirect.to} replace state={{ from: legacyRedirect.from }} />;
|
||||
}
|
||||
return <PurchaseRequestsWorkspace mode="list" />;
|
||||
}
|
||||
|
||||
export function PurchaseRequestDetailPage() {
|
||||
const { purchaseRequestId = "" } = useParams();
|
||||
return <PurchaseRequestsWorkspace mode="detail" purchaseRequestId={purchaseRequestId} />;
|
||||
}
|
||||
|
||||
function PurchaseRequestsWorkspace({
|
||||
mode,
|
||||
purchaseRequestId = "",
|
||||
}: {
|
||||
mode: "list" | "detail";
|
||||
purchaseRequestId?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const canCreate = hasPermission(permissions, "procurement.create");
|
||||
const canManage = hasPermission(permissions, "procurement.manage");
|
||||
const canViewAll =
|
||||
hasPermission(permissions, "procurement.view_all") ||
|
||||
canManage;
|
||||
|
||||
const [status, setStatus] = useState<PurchaseRequestStatus | "">("draft");
|
||||
const [mine, setMine] = useState(true);
|
||||
const [supplierFilter, setSupplierFilter] = useState("");
|
||||
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||
const [costCenterFilter, setCostCenterFilter] = useState("");
|
||||
const isPurchaseDetailMode = mode === "detail";
|
||||
const selectedId = isPurchaseDetailMode ? purchaseRequestId.trim() : "";
|
||||
const listFilters = useMemo(
|
||||
() => readPurchaseListFilters(searchParams, canViewAll),
|
||||
[canViewAll, searchParams],
|
||||
);
|
||||
const {
|
||||
status,
|
||||
mine,
|
||||
supplierId: supplierFilter,
|
||||
legalEntityId: legalEntityFilter,
|
||||
costCenterId: costCenterFilter,
|
||||
page,
|
||||
} = listFilters;
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
|
||||
const [hydratedDetailId, setHydratedDetailId] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
@@ -348,6 +387,26 @@ export function PurchaseRequestsPage() {
|
||||
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
|
||||
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const currentSource = buildPcDetailSource(location.pathname, location.search);
|
||||
const navigationState = location.state as PcDetailNavigationState | null;
|
||||
const backSource = resolvePcDetailSource(navigationState?.from, currentSource, "purchase-request") ?? "/purchase-requests";
|
||||
const backLabel = backSource.startsWith("/projects/") ? "返回项目详情" : "返回采购列表";
|
||||
const listSource = buildPcDetailSource(location.pathname, location.search);
|
||||
|
||||
function updateListSearch(
|
||||
updates: Record<string, string | null>,
|
||||
options: { resetPage?: boolean } = {},
|
||||
) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
if (options.resetPage) {
|
||||
next.delete("page");
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
const suppliersQuery = useQuery({
|
||||
queryKey: ["purchase-requests", "suppliers"],
|
||||
@@ -365,16 +424,19 @@ export function PurchaseRequestsPage() {
|
||||
});
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
enabled: isPurchaseDetailMode,
|
||||
queryKey: ["purchase-requests", "document-categories"],
|
||||
queryFn: () => fetchDocumentCategories({ entityType: "purchase_request", enabled: true }),
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
enabled: isPurchaseDetailMode,
|
||||
queryKey: ["projects", "purchase-request-link"],
|
||||
queryFn: () => fetchProjects({ mine: true }),
|
||||
});
|
||||
|
||||
const listQuery = useQuery({
|
||||
enabled: !isPurchaseDetailMode,
|
||||
queryKey: ["purchase-requests", "list", status, mine, supplierFilter, legalEntityFilter, costCenterFilter, page],
|
||||
queryFn: () =>
|
||||
fetchPurchaseRequests({
|
||||
@@ -389,41 +451,17 @@ export function PurchaseRequestsPage() {
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(selectedId),
|
||||
enabled: isPurchaseDetailMode && Boolean(selectedId),
|
||||
queryKey: ["purchase-requests", "detail", selectedId],
|
||||
queryFn: () => fetchPurchaseRequestDetail(selectedId),
|
||||
});
|
||||
|
||||
const archiveOrderQuery = useQuery({
|
||||
enabled: Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
|
||||
enabled: isPurchaseDetailMode && Boolean(selectedId) && (detailQuery.data?.status === "approved" || Boolean(detailQuery.data?.archiveOrder)),
|
||||
queryKey: ["purchase-requests", "archive-order", selectedId],
|
||||
queryFn: () => fetchPurchaseOrderArchive(selectedId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const searchSelectedId = searchParams.get("id") ?? "";
|
||||
if (searchSelectedId && searchSelectedId !== selectedId) {
|
||||
setSelectedId(searchSelectedId);
|
||||
}
|
||||
}, [searchParams, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = searchParams.get("id") ?? "";
|
||||
if (!selectedId && !currentId) {
|
||||
return;
|
||||
}
|
||||
if (selectedId === currentId) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (selectedId) {
|
||||
next.set("id", selectedId);
|
||||
} else {
|
||||
next.delete("id");
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, selectedId, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) {
|
||||
return;
|
||||
@@ -533,11 +571,12 @@ export function PurchaseRequestsPage() {
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createPurchaseRequest(),
|
||||
onSuccess: async (data) => {
|
||||
setSelectedId(data.purchaseRequest.id);
|
||||
setHydratedDetailId("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail"] });
|
||||
toast.success("采购草稿已创建");
|
||||
navigate(buildPurchaseRequestDetailPath(data.purchaseRequest.id), {
|
||||
state: { from: listSource },
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
@@ -881,22 +920,19 @@ export function PurchaseRequestsPage() {
|
||||
);
|
||||
const detailEvidenceHint = detail ? resolvePurchaseEvidenceHint(detail) : null;
|
||||
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
|
||||
const isPurchaseDetailMode = Boolean(selectedId);
|
||||
const openPurchaseDetail = (id: string) => {
|
||||
runGuarded(() => {
|
||||
setSelectedId(id);
|
||||
setHydratedDetailId("");
|
||||
navigate(buildPurchaseRequestDetailPath(id), {
|
||||
state: { from: listSource },
|
||||
});
|
||||
});
|
||||
};
|
||||
const returnToPurchaseList = () => {
|
||||
runGuarded(() => {
|
||||
setSelectedId("");
|
||||
setHydratedDetailId("");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
setSearchParams(next, { replace: true });
|
||||
navigate(backSource, { replace: true });
|
||||
});
|
||||
};
|
||||
const detailAccessDenied = detailQuery.isError && isApiError(detailQuery.error) && [403, 404].includes(detailQuery.error.status);
|
||||
|
||||
return (
|
||||
<ListWorkspace>
|
||||
@@ -907,7 +943,7 @@ export function PurchaseRequestsPage() {
|
||||
<HeaderActionGroup>
|
||||
{isPurchaseDetailMode ? (
|
||||
<Button type="button" variant="secondary" onClick={returnToPurchaseList}>
|
||||
返回列表
|
||||
{backLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
@@ -960,8 +996,7 @@ export function PurchaseRequestsPage() {
|
||||
className="select-std"
|
||||
value={supplierFilter}
|
||||
onChange={(event) => {
|
||||
setSupplierFilter(event.target.value);
|
||||
setPage(1);
|
||||
updateListSearch({ supplierId: event.target.value || null }, { resetPage: true });
|
||||
}}
|
||||
>
|
||||
<option value="">全部供应商</option>
|
||||
@@ -977,11 +1012,16 @@ export function PurchaseRequestsPage() {
|
||||
value={legalEntityFilter}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setLegalEntityFilter(nextValue);
|
||||
if (costCenterFilter && !filterCostCenters.some((item) => item.id === costCenterFilter && item.legalEntityId === nextValue)) {
|
||||
setCostCenterFilter("");
|
||||
}
|
||||
setPage(1);
|
||||
const keepCostCenter = costCenterFilter && (costCentersQuery.data?.items ?? []).some(
|
||||
(item) => item.id === costCenterFilter && item.legalEntityId === nextValue,
|
||||
);
|
||||
updateListSearch(
|
||||
{
|
||||
legalEntityId: nextValue || null,
|
||||
costCenterId: keepCostCenter ? costCenterFilter : null,
|
||||
},
|
||||
{ resetPage: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">全部公司</option>
|
||||
@@ -996,8 +1036,7 @@ export function PurchaseRequestsPage() {
|
||||
className="select-std"
|
||||
value={costCenterFilter}
|
||||
onChange={(event) => {
|
||||
setCostCenterFilter(event.target.value);
|
||||
setPage(1);
|
||||
updateListSearch({ costCenterId: event.target.value || null }, { resetPage: true });
|
||||
}}
|
||||
>
|
||||
<option value="">全部成本中心</option>
|
||||
@@ -1016,8 +1055,7 @@ export function PurchaseRequestsPage() {
|
||||
type="button"
|
||||
variant={mine ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setMine(true);
|
||||
setPage(1);
|
||||
updateListSearch({ scope: null }, { resetPage: true });
|
||||
}}
|
||||
>
|
||||
我相关
|
||||
@@ -1028,8 +1066,7 @@ export function PurchaseRequestsPage() {
|
||||
type="button"
|
||||
variant={!mine ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setMine(false);
|
||||
setPage(1);
|
||||
updateListSearch({ scope: "all" }, { resetPage: true });
|
||||
}}
|
||||
>
|
||||
全范围
|
||||
@@ -1043,8 +1080,10 @@ export function PurchaseRequestsPage() {
|
||||
data-testid="purchase-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
setStatus(value as PurchaseRequestStatus | "");
|
||||
setPage(1);
|
||||
updateListSearch(
|
||||
{ status: value === "draft" ? null : value || "all" },
|
||||
{ resetPage: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
@@ -1216,7 +1255,7 @@ export function PurchaseRequestsPage() {
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
onPageChange={(nextPage) => updateListSearch({ page: nextPage > 1 ? String(nextPage) : null })}
|
||||
testIdPrefix="purchase-requests"
|
||||
/>
|
||||
</>
|
||||
@@ -1224,10 +1263,17 @@ export function PurchaseRequestsPage() {
|
||||
<div className="card-std space-y-5 p-4 md:p-5">
|
||||
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={3} rows={8} /> : null}
|
||||
|
||||
{selectedId && detailQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(detailQuery.error)}
|
||||
</p>
|
||||
{isPurchaseDetailMode && (!selectedId || detailQuery.isError) ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700">
|
||||
<p>
|
||||
{!selectedId || detailAccessDenied
|
||||
? "无法查看该记录,记录可能不存在或你无权访问。"
|
||||
: resolveError(detailQuery.error)}
|
||||
</p>
|
||||
<Button className="mt-3" type="button" variant="secondary" onClick={returnToPurchaseList}>
|
||||
{backLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedId && detail ? (
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
ApprovalRulesPage,
|
||||
AnnouncementsPage,
|
||||
AttendancePage,
|
||||
AssetCreatePage,
|
||||
AssetDetailPage,
|
||||
AssetRegistryPage,
|
||||
AuditLogsPage,
|
||||
CutoverConsolePage,
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
TeamPresencePage,
|
||||
TodosPage,
|
||||
PurchaseRequestsPage,
|
||||
PurchaseRequestDetailPage,
|
||||
ReimbursementDetailPage,
|
||||
ReimbursementDashboardPage,
|
||||
ReimbursementPrintPage,
|
||||
@@ -254,6 +257,15 @@ const routeElements = (
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="purchase-requests/:purchaseRequestId"
|
||||
element={
|
||||
<RequirePermission anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}>
|
||||
{withRouteFallback(<PurchaseRequestDetailPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="travel-requests"
|
||||
element={
|
||||
@@ -301,6 +313,26 @@ const routeElements = (
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="assets/new"
|
||||
element={
|
||||
<RequirePermission anyOf={["asset.manage"]}>
|
||||
{withRouteFallback(<AssetCreatePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="assets/:assetId"
|
||||
element={
|
||||
<RequirePermission
|
||||
anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}
|
||||
>
|
||||
{withRouteFallback(<AssetDetailPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="inventory-checks"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AssetCreatePage as default } from "../../pages/AssetRegistryPage";
|
||||
@@ -0,0 +1 @@
|
||||
export { AssetDetailPage as default } from "../../pages/AssetRegistryPage";
|
||||
@@ -0,0 +1 @@
|
||||
export { PurchaseRequestDetailPage as default } from "../../pages/PurchaseRequestsPage";
|
||||
@@ -5,6 +5,8 @@ export const AttendancePage = lazy(() => import("./AttendancePage"));
|
||||
export const AuditLogsPage = lazy(() => import("./AuditLogsPage"));
|
||||
export const ApprovalRulesPage = lazy(() => import("./ApprovalRulesPage"));
|
||||
export const AssetRegistryPage = lazy(() => import("./AssetRegistryPage"));
|
||||
export const AssetCreatePage = lazy(() => import("./AssetCreatePage"));
|
||||
export const AssetDetailPage = lazy(() => import("./AssetDetailPage"));
|
||||
export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage"));
|
||||
export const DelegationsPage = lazy(() => import("./DelegationsPage"));
|
||||
export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
|
||||
@@ -49,6 +51,7 @@ export const ProjectsPage = lazy(() => import("./ProjectsPage"));
|
||||
export const ProjectDetailPage = lazy(() => import("./ProjectDetailPage"));
|
||||
export const ContractsRegistryPage = lazy(() => import("./ContractsRegistryPage"));
|
||||
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
|
||||
export const PurchaseRequestDetailPage = lazy(() => import("./PurchaseRequestDetailPage"));
|
||||
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
||||
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
|
||||
export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage"));
|
||||
|
||||
@@ -96,6 +96,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
await page.getByTestId("asset-category-save").click();
|
||||
|
||||
await page.getByTestId("asset-create").click();
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await expect(page.getByRole("heading", { name: "新建资产卡片" })).toBeVisible();
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("");
|
||||
await page.getByTestId("asset-name").fill(assetName);
|
||||
@@ -116,8 +117,12 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
data: { asset: { id: string } };
|
||||
};
|
||||
const assetId = createAssetPayload.data.asset.id;
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
|
||||
await expect(page.locator(".detail-action-panel")).toHaveCSS("position", "sticky");
|
||||
|
||||
@@ -129,7 +134,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
await logout(page, FINANCE_MGR.name);
|
||||
|
||||
await login(page, ADMINISTRATION);
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
await page.goto(`/assets/${assetId}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
|
||||
|
||||
await selectOptionByText(page.getByTestId("asset-movement-assignee"), /陈工程/);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
buildAssetDetailPath,
|
||||
buildPurchaseRequestDetailPath,
|
||||
readAssetListFilters,
|
||||
readPurchaseListFilters,
|
||||
resolveAssetLegacyRedirect,
|
||||
resolvePurchaseRequestLegacyRedirect,
|
||||
} from "../../src/lib/businessDetailRoutes";
|
||||
import { resolveNotificationTargetLink } from "../../src/lib/notificationLinks";
|
||||
import { resolvePcDetailSource } from "../../src/lib/pcDetailNavigation";
|
||||
import { resolveMobileEquivalentPath } from "../../src/lib/runtimeContext";
|
||||
import { resolvePageMeta } from "../../src/features/navigation/schema";
|
||||
|
||||
test("采购与资产 canonical 路径统一编码实体 ID,并对空 ID 回落列表", () => {
|
||||
expect(buildPurchaseRequestDetailPath("purchase/001")).toBe("/purchase-requests/purchase%2F001");
|
||||
expect(buildPurchaseRequestDetailPath(" ")).toBe("/purchase-requests");
|
||||
expect(buildAssetDetailPath("asset/001")).toBe("/assets/asset%2F001");
|
||||
expect(buildAssetDetailPath("")).toBe("/assets");
|
||||
|
||||
expect(resolveNotificationTargetLink({ targetType: "purchase_request", targetId: "purchase/001" })).toBe(
|
||||
"/purchase-requests/purchase%2F001",
|
||||
);
|
||||
expect(resolveNotificationTargetLink({ targetType: "asset_card", targetId: "asset/001" })).toBe(
|
||||
"/assets/asset%2F001",
|
||||
);
|
||||
});
|
||||
|
||||
test("旧查询链接只保留白名单筛选,并 replace 到 canonical 详情", () => {
|
||||
const purchaseRedirect = resolvePurchaseRequestLegacyRedirect(new URLSearchParams(
|
||||
"id=purchase%2F001&status=approved&scope=all&supplierId=supplier-1&page=2&unknown=secret",
|
||||
));
|
||||
expect(purchaseRedirect).toEqual({
|
||||
to: "/purchase-requests/purchase%2F001",
|
||||
from: "/purchase-requests?status=approved&scope=all&supplierId=supplier-1&page=2",
|
||||
});
|
||||
|
||||
const assetRedirect = resolveAssetLegacyRedirect(new URLSearchParams(
|
||||
"id=asset-1&q=MacBook&status=in_use&assetType=fixed_asset&sensitivity=internal&scope=own&unknown=secret",
|
||||
));
|
||||
expect(assetRedirect).toEqual({
|
||||
to: "/assets/asset-1",
|
||||
from: "/assets?q=MacBook&status=in_use&assetType=fixed_asset&sensitivity=internal&scope=own",
|
||||
});
|
||||
expect(resolvePurchaseRequestLegacyRedirect(new URLSearchParams("id=%20%20"))).toBeNull();
|
||||
expect(resolveAssetLegacyRedirect(new URLSearchParams("id="))).toBeNull();
|
||||
});
|
||||
|
||||
test("列表筛选读取会收紧非法枚举、页码和越权范围", () => {
|
||||
expect(readPurchaseListFilters(new URLSearchParams("status=unknown&scope=all&page=-2"), false)).toMatchObject({
|
||||
status: "draft",
|
||||
mine: true,
|
||||
page: 1,
|
||||
});
|
||||
expect(readPurchaseListFilters(new URLSearchParams("status=all&scope=all&page=3"), true)).toMatchObject({
|
||||
status: "",
|
||||
mine: false,
|
||||
page: 3,
|
||||
});
|
||||
|
||||
expect(readAssetListFilters(new URLSearchParams("status=unknown&assetType=unknown&sensitivity=unknown&scope=all"), false)).toMatchObject({
|
||||
status: "",
|
||||
assetType: "",
|
||||
sensitivity: "",
|
||||
mine: true,
|
||||
});
|
||||
expect(readAssetListFilters(new URLSearchParams(), true).mine).toBe(false);
|
||||
expect(readAssetListFilters(new URLSearchParams("scope=own"), true).mine).toBe(true);
|
||||
});
|
||||
|
||||
test("详情返回来源按模块白名单校验,拒绝外部、跨模块和当前页", () => {
|
||||
expect(resolvePcDetailSource(
|
||||
"/purchase-requests?status=approved&scope=own",
|
||||
"/purchase-requests/purchase-1",
|
||||
"purchase-request",
|
||||
)).toBe("/purchase-requests?status=approved&scope=own");
|
||||
expect(resolvePcDetailSource("/projects/project-1", "/purchase-requests/purchase-1", "purchase-request")).toBe(
|
||||
"/projects/project-1",
|
||||
);
|
||||
expect(resolvePcDetailSource("/assets?scope=own", "/assets/asset-1", "asset")).toBe("/assets?scope=own");
|
||||
expect(resolvePcDetailSource("/projects/project-1", "/assets/asset-1", "asset")).toBeNull();
|
||||
expect(resolvePcDetailSource("//example.com/assets", "/assets/asset-1", "asset")).toBeNull();
|
||||
expect(resolvePcDetailSource("/assets/asset-1", "/assets/asset-1", "asset")).toBeNull();
|
||||
});
|
||||
|
||||
test("页面元数据与移动工作台回落覆盖新路由", () => {
|
||||
expect(resolvePageMeta("/purchase-requests/purchase-1")).toMatchObject({ title: "采购详情", group: "业务" });
|
||||
expect(resolvePageMeta("/assets/new")).toMatchObject({ title: "新增资产", group: "业务" });
|
||||
expect(resolvePageMeta("/assets/asset-1")).toMatchObject({ title: "资产详情", group: "业务" });
|
||||
expect(resolveMobileEquivalentPath("/purchase-requests/purchase-1")).toBe("/mobile/purchase-requests/purchase-1");
|
||||
expect(resolveMobileEquivalentPath("/assets/new")).toBe("/mobile/assets/scan");
|
||||
expect(resolveMobileEquivalentPath("/assets/asset-1")).toBe("/mobile/assets/scan");
|
||||
});
|
||||
@@ -10,6 +10,146 @@ function envelope<T>(data: T) {
|
||||
}
|
||||
|
||||
async function mockSearchResults(page: Page) {
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
if (pathname === "/api/search") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/auth/sso/providers") {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope([])) });
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/auth/wecom/config") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
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-global-search-token", expiresIn: 900, mustChangePassword: false })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/me") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
id: "admin-global-search",
|
||||
employeeNo: ADMIN.employeeNo,
|
||||
name: ADMIN.name,
|
||||
phone: null,
|
||||
email: ADMIN.identifier,
|
||||
departmentId: "department-global-search",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["admin"],
|
||||
bankAccount: { bankName: null, bankInstitutionCode: null, accountNumberMasked: null },
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 50,
|
||||
},
|
||||
wecomBinding: null,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/system/navigation") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
workspace: {
|
||||
id: "workspace-global-search",
|
||||
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: [
|
||||
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "reimbursements", href: "/reimbursements", label: "报销", icon: "receipt_long", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "purchase", href: "/purchase-requests", label: "采购", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/notifications") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({ page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/portal/home") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
workspace: { id: "workspace-global-search", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: ADMIN.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;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({})) });
|
||||
});
|
||||
|
||||
await page.route("**/api/search?**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -20,11 +160,11 @@ async function mockSearchResults(page: Page) {
|
||||
{ entityType: "employee", entityId: "employee-1", title: "王小明", subtitle: "E001 · 财务", status: "active", href: "/people/employee-1", matchedBy: "prefix", updatedAt: null },
|
||||
{ entityType: "reimbursement", entityId: "reimbursement-1", title: "BX-001", subtitle: "办公室报销", status: "reviewing", href: "/reimbursements/reimbursement-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "leave", entityId: "leave-1", title: "请假 2026-03-24", subtitle: "年假", status: "approved", href: "/leave-requests", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "purchase_request", entityId: "purchase-1", title: "CG-001", subtitle: "办公椅采购", status: "reviewing", href: "/purchase-requests/purchase-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "purchase_request", entityId: "purchase-1", title: "CG-001", subtitle: "办公椅采购", status: "reviewing", href: "/purchase-requests?id=legacy-wrong", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "travel_request", entityId: "travel-1", title: "CL-001", subtitle: "上海出差", status: "draft", href: "/travel-requests/travel-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "project", entityId: "project-1", title: "海音达试点", subtitle: "实施项目", status: "active", href: "/projects/project-1", matchedBy: "exact", updatedAt: null },
|
||||
{ entityType: "announcement", entityId: "announcement-1", title: "本周通知", subtitle: "统一回填周报", status: null, href: "/announcements", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "asset", entityId: "asset-1", title: "ZC-001", subtitle: "MacBook Pro", status: "in_use", href: "/assets", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "asset", entityId: "asset-1", title: "ZC-001", subtitle: "MacBook Pro", status: "in_use", href: "/assets?highlight=legacy-wrong", matchedBy: "contains", updatedAt: null },
|
||||
],
|
||||
}),
|
||||
),
|
||||
@@ -80,7 +220,29 @@ test("E2E 全局搜索:查看更多结果会关闭弹窗并跳到分组列表
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("E2E 全局搜索:采购与资产忽略旧 href 并使用实体 canonical 路径", async ({ page }) => {
|
||||
await mockSearchResults(page);
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
await page.getByTestId("global-search-trigger").click();
|
||||
const purchaseSearchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
|
||||
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill("海音");
|
||||
await purchaseSearchResponse;
|
||||
await page.getByRole("button", { name: /CG-001/ }).click();
|
||||
await expect(page).toHaveURL(/\/purchase-requests\/purchase-1$/);
|
||||
|
||||
await page.goto("/portal");
|
||||
await page.getByTestId("global-search-trigger").click();
|
||||
const assetSearchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
|
||||
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill("海音");
|
||||
await assetSearchResponse;
|
||||
await page.getByRole("button", { name: /ZC-001/ }).click();
|
||||
await expect(page).toHaveURL(/\/assets\/asset-1$/);
|
||||
});
|
||||
|
||||
test("E2E 全局搜索:Ctrl+K 和 Cmd+K 可打开搜索弹窗", async ({ page }) => {
|
||||
await mockSearchResults(page);
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByTestId("global-search-trigger")).toBeVisible();
|
||||
|
||||
@@ -139,8 +139,10 @@ async function createAsset(page: Page, categoryName: string, suffix: string, ind
|
||||
const asset = payload.data?.asset;
|
||||
const assetNo = String(asset?.assetNo || "");
|
||||
expect(assetNo).toMatch(config.prefix);
|
||||
await page.goto(`/assets?id=${asset.id}`);
|
||||
await page.goto(`/assets/${asset.id}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(name, { timeout: 30_000 });
|
||||
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.getByPlaceholder("搜索编号/名称/hash/VER").fill(assetNo);
|
||||
await expect(page.getByTestId("asset-row").filter({ hasText: assetNo })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
|
||||
@@ -1016,7 +1016,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
|
||||
throw new Error(`创建采购申请后未返回 id: ${JSON.stringify(createPayload)}`);
|
||||
}
|
||||
state.purchaseRequestId = purchaseRequestId;
|
||||
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
|
||||
await page.goto(`/purchase-requests/${purchaseRequestId}`);
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
await page.getByTestId("purchase-title").fill(`${trialrunMarkerPrefix} 浏览器采购 ${marker}`);
|
||||
await selectFirstEnabledOption(page.getByTestId("purchase-legal-entity"));
|
||||
@@ -1075,7 +1075,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
|
||||
}
|
||||
|
||||
const employeeAgain = await loginRole(page, "employee");
|
||||
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
|
||||
await page.goto(`/purchase-requests/${purchaseRequestId}`);
|
||||
await expect(page.getByTestId("purchase-detail-status")).toContainText(/已通过|approved|已完成/, { timeout: 30_000 });
|
||||
await expect(page.getByTestId("purchase-approval-history")).toContainText(/通过|approve|approved/, { timeout: 30_000 });
|
||||
await expect(page.locator("tr").filter({ hasText: purchaseAttachmentName }).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -1232,7 +1232,7 @@ test("已完成审批只保留中文只读摘要", async ({ page }) => {
|
||||
|
||||
test("1280px 资产详情保持可用双栏和可点击操作区", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/assets?id=asset-wave3");
|
||||
await page.goto("/assets/asset-wave3");
|
||||
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetDetail.name);
|
||||
await expect(page.getByTestId("asset-detail-action-panel")).toBeVisible();
|
||||
|
||||
@@ -396,6 +396,7 @@ test.describe("R3 权限直连审计", () => {
|
||||
"/ap-operations",
|
||||
"/system/integrations/erp",
|
||||
"/payment-batches",
|
||||
"/assets/new",
|
||||
]);
|
||||
|
||||
await expectForbiddenRoutes(browser, MANAGER, [
|
||||
@@ -503,6 +504,10 @@ test.describe("R3 权限直连审计", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("资产创建路由只允许 asset.manage 角色直连", async ({ browser }) => {
|
||||
await expectDirectRoutesNotForbidden(browser, FINANCE_MGR, ["/assets/new"]);
|
||||
});
|
||||
|
||||
test("后端直连接口按权限拒绝或限制范围", async () => {
|
||||
const clients: AuthenticatedApiClient[] = [];
|
||||
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
|
||||
@@ -42,8 +42,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
data: { purchaseRequest: { id: string } };
|
||||
};
|
||||
const purchaseRequestId = createPayload.data.purchaseRequest.id;
|
||||
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`id=${purchaseRequestId}`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseRequestId}$`));
|
||||
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
await page.getByTestId("purchase-title").fill(title);
|
||||
@@ -107,6 +106,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first();
|
||||
await expect(purchaseRow).toBeVisible({ timeout: 30_000 });
|
||||
await purchaseRow.getByTestId("purchase-row-open").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseRequestId}$`));
|
||||
|
||||
await expect(page.getByTestId("purchase-archive-po-no")).toBeVisible();
|
||||
await expect(page.getByTestId("purchase-status-hint")).toContainText("已通过", { timeout: 30_000 });
|
||||
|
||||
@@ -15,21 +15,12 @@ async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
}
|
||||
|
||||
const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103");
|
||||
const taskScreenshotDir = path.resolve(
|
||||
process.cwd(),
|
||||
"../output/playwright/07-18-small-team-internal-readiness",
|
||||
);
|
||||
|
||||
function screenshotPath(fileName: string) {
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
return path.join(screenshotDir, fileName);
|
||||
}
|
||||
|
||||
function taskScreenshotPath(fileName: string) {
|
||||
mkdirSync(taskScreenshotDir, { recursive: true });
|
||||
return path.join(taskScreenshotDir, fileName);
|
||||
}
|
||||
|
||||
const me = {
|
||||
id: "employee-r103",
|
||||
employeeNo: "HYD000103",
|
||||
@@ -501,6 +492,11 @@ async function mockApis(page: Page, options: { roleCodes?: string[] } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/assets/${assetId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, { asset: assetListItem() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/purchase-requests/${purchaseId}` && request.method() === "PATCH") {
|
||||
await fulfillJson(route, { ...purchaseDetail(), ...request.postDataJSON() });
|
||||
return;
|
||||
@@ -622,7 +618,7 @@ async function expectResponsiveBusinessTable(
|
||||
const operation = table.getByRole("link").or(table.getByRole("button")).last();
|
||||
await expect(operation).toBeVisible();
|
||||
await operation.click({ trial: true });
|
||||
await page.screenshot({ path: taskScreenshotPath(target.screenshotName), fullPage: true });
|
||||
await page.screenshot({ path: screenshotPath(target.screenshotName), fullPage: true });
|
||||
}
|
||||
|
||||
async function expectLedgerHeaderActions(
|
||||
@@ -710,6 +706,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
|
||||
|
||||
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
||||
@@ -754,6 +751,72 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("采购与资产列表、详情各自只请求职责内数据并恢复 URL 筛选", async ({ page }) => {
|
||||
const requests: Array<{ method: string; pathname: string; search: string }> = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.startsWith("/api/purchase-requests") || url.pathname.startsWith("/api/assets")) {
|
||||
requests.push({ method: request.method(), pathname: url.pathname, search: url.search });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/purchase-requests?status=draft&scope=own&supplierId=supplier-r103");
|
||||
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
|
||||
expect(requests.some((item) => item.method === "GET" && item.pathname === `/api/purchase-requests/${purchaseId}`)).toBe(false);
|
||||
requests.length = 0;
|
||||
|
||||
await page.getByTestId("purchase-row-open").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
expect(requests.some((item) => item.method === "GET" && item.pathname === "/api/purchase-requests")).toBe(false);
|
||||
await page.getByRole("button", { name: "返回采购列表", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/purchase-requests\?status=draft&scope=own&supplierId=supplier-r103$/);
|
||||
|
||||
requests.length = 0;
|
||||
await page.goto("/assets?q=研发&status=in_use&scope=own");
|
||||
await expect(page.getByTestId("assets-table")).toBeVisible();
|
||||
expect(requests.some((item) => item.method === "GET" && item.pathname === `/api/assets/${assetId}`)).toBe(false);
|
||||
requests.length = 0;
|
||||
|
||||
await page.getByRole("button", { name: "查看详情", exact: true }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("研发测试笔记本电脑");
|
||||
expect(requests.some((item) => item.method === "GET" && item.pathname === "/api/assets")).toBe(false);
|
||||
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/assets\?/);
|
||||
const restoredAssetUrl = new URL(page.url());
|
||||
expect(restoredAssetUrl.pathname).toBe("/assets");
|
||||
expect(Object.fromEntries(restoredAssetUrl.searchParams)).toEqual({ q: "研发", status: "in_use", scope: "own" });
|
||||
});
|
||||
|
||||
test("采购与资产长表单详情对 403/404 使用同一安全错误态", async ({ page }) => {
|
||||
const safeError = (message: string) => JSON.stringify({
|
||||
requestId: "mock-business-detail-denied",
|
||||
error: { code: "NOT_FOUND", message },
|
||||
});
|
||||
await page.route("**/api/purchase-requests/missing-purchase", async (route) => {
|
||||
await route.fulfill({ status: 404, contentType: "application/json", body: safeError("内部采购对象不存在") });
|
||||
});
|
||||
await page.route("**/api/assets/missing-asset", async (route) => {
|
||||
await route.fulfill({ status: 403, contentType: "application/json", body: safeError("内部资产对象无权访问") });
|
||||
});
|
||||
|
||||
await page.goto("/purchase-requests/missing-purchase");
|
||||
await expect(page.getByText("无法查看该记录,记录可能不存在或你无权访问。", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("内部采购对象不存在")).toHaveCount(0);
|
||||
|
||||
await page.goto("/assets/missing-asset");
|
||||
await expect(page.getByText("无法查看该记录,记录可能不存在或你无权访问。", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("内部资产对象无权访问")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("资产创建路由不会把列表查看权限当成 asset.manage", async ({ page }) => {
|
||||
await page.goto("/assets");
|
||||
await expect(page).not.toHaveURL(/\/403$/);
|
||||
await page.goto("/assets/new");
|
||||
await expect(page).toHaveURL(/\/403$/);
|
||||
});
|
||||
|
||||
test("票据绑定报销单不展示 UUID 兜底", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto("/invoice-pool");
|
||||
@@ -782,16 +845,16 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
|
||||
test("采购长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
||||
await page.goto(`/purchase-requests/${purchaseId}`);
|
||||
const title = page.getByTestId("purchase-title");
|
||||
await expect(title).toBeVisible();
|
||||
await title.fill("办公用品采购(未保存)");
|
||||
|
||||
const returnToList = page.getByRole("button", { name: "返回列表", exact: true });
|
||||
const returnToList = page.getByRole("button", { name: "返回采购列表", exact: true });
|
||||
await returnToList.click();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(title).toHaveValue("办公用品采购(未保存)");
|
||||
|
||||
await returnToList.click();
|
||||
@@ -799,7 +862,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await expect(page).toHaveURL(/\/purchase-requests$/);
|
||||
await expect(page.getByTestId("purchase-row-open")).toBeVisible();
|
||||
|
||||
await page.goto(`/purchase-requests?id=${purchaseId}`);
|
||||
await page.goto(`/purchase-requests/${purchaseId}`);
|
||||
await page.getByTestId("purchase-title").fill("办公用品采购(已保存)");
|
||||
await page.getByTestId("purchase-save").click();
|
||||
await expect(page.getByText("采购草稿已保存")).toBeVisible();
|
||||
@@ -810,17 +873,21 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
|
||||
test("资产长表单切回记录列表时支持取消保持与确认放弃", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const requestedPaths: string[] = [];
|
||||
page.on("request", (request) => requestedPaths.push(new URL(request.url()).pathname));
|
||||
await page.goto("/assets");
|
||||
await page.getByTestId("asset-create").click();
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
expect(requestedPaths).not.toContain("/api/assets/new");
|
||||
const name = page.getByTestId("asset-name");
|
||||
await expect(name).toBeVisible();
|
||||
await name.fill("合成未保存资产");
|
||||
|
||||
const returnToList = page.getByRole("button", { name: "返回列表", exact: true });
|
||||
const returnToList = page.getByRole("button", { name: "返回资产列表", exact: true });
|
||||
await returnToList.click();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await expect(name).toHaveValue("合成未保存资产");
|
||||
|
||||
await returnToList.click();
|
||||
@@ -834,7 +901,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await page.getByRole("link", { name: "采购", exact: true }).first().click();
|
||||
await expect(page).toHaveURL(/\/purchase-requests$/);
|
||||
await page.getByTestId("purchase-row-open").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
const title = page.getByTestId("purchase-title");
|
||||
await title.fill("浏览器历史未保存验证");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "true");
|
||||
@@ -842,7 +909,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("采购表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(title).toHaveValue("浏览器历史未保存验证");
|
||||
|
||||
const beforeUnloadPromise = page.waitForEvent("dialog");
|
||||
@@ -851,7 +918,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
expect(beforeUnload.type()).toBe("beforeunload");
|
||||
await beforeUnload.dismiss();
|
||||
await reloadPromise;
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(title).toHaveValue("浏览器历史未保存验证");
|
||||
|
||||
const acceptedBeforeUnloadPromise = page.waitForEvent("dialog");
|
||||
@@ -860,15 +927,14 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
expect(acceptedBeforeUnload.type()).toBe("beforeunload");
|
||||
await acceptedBeforeUnload.accept();
|
||||
await acceptedReloadPromise;
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests\\?id=${purchaseId}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/purchase-requests/${purchaseId}$`));
|
||||
await expect(title).toHaveValue("办公用品采购");
|
||||
await title.fill("浏览器历史再次未保存验证");
|
||||
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toBeVisible();
|
||||
await page.getByTestId("unsaved-changes-discard").click();
|
||||
// 详情查询参数由列表页使用 replace 写入,因此它的真实上一项是进入采购页前的工作台。
|
||||
await expect(page).toHaveURL(/\/portal$/);
|
||||
await expect(page).toHaveURL(/\/purchase-requests$/);
|
||||
});
|
||||
|
||||
test("/projects /assets /reimbursements 在中等宽度下操作列可达且页面不横溢", async ({ page }) => {
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
{ "path": "/announcements", "classification": "supported-noncore" },
|
||||
{ "path": "/ap-operations", "classification": "core", "bundle": "payment-ap", "behaviorSpecs": ["client/tests/e2e/reimbursement-payment-batch-flow.spec.ts"] },
|
||||
{ "path": "/assets", "classification": "supported-noncore", "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/assets/:assetId", "classification": "supported-noncore", "behaviorSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
|
||||
{ "path": "/assets/new", "classification": "supported-noncore", "behaviorSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/attendance", "classification": "core", "bundle": "attendance-archive", "behaviorSpecs": ["client/tests/e2e/r75-automation-gap-fill.spec.ts"] },
|
||||
{ "path": "/audit-logs", "classification": "core", "bundle": "people-audit", "behaviorSpecs": ["client/tests/e2e/settings-governance.spec.ts"] },
|
||||
{ "path": "/budgets", "classification": "supported-noncore" },
|
||||
@@ -163,6 +165,7 @@
|
||||
{ "path": "/projects", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/project-workbench.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/projects/:projectId", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/project-workbench.spec.ts"] },
|
||||
{ "path": "/purchase-requests", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/procurement-flow.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/purchase-requests/:purchaseRequestId", "classification": "core", "bundle": "procurement", "behaviorSpecs": ["client/tests/e2e/procurement-flow.spec.ts", "client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/reimbursements", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/r103-cross-page-ui-consistency.spec.ts"] },
|
||||
{ "path": "/reimbursements/:reimbursementId", "classification": "core", "bundle": "reimbursement", "behaviorSpecs": ["client/tests/e2e/r102-reimbursement-main-flow.spec.ts"] },
|
||||
{ "path": "/reimbursements/:reimbursementId/print", "classification": "supported-noncore" },
|
||||
|
||||
在新工单中引用
屏蔽一个用户