修复: 08-07-business-routing-regression-repair 关闭资产创建跳转回归
这个提交包含在:
@@ -21,6 +21,7 @@ type GuardRegistration = {
|
||||
type UnsavedChangesContextValue = {
|
||||
register: (id: string, registration: GuardRegistration | null) => void;
|
||||
runGuarded: (action: () => void) => void;
|
||||
runAfterSuccessfulSave: (action: () => void) => void;
|
||||
};
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
@@ -76,6 +77,17 @@ export function UnsavedChangesProvider({ children }: PropsWithChildren) {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const runWithNextNavigationAllowed = useCallback((action: () => void) => {
|
||||
allowNextNavigationRef.current = true;
|
||||
try {
|
||||
action();
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
allowNextNavigationRef.current = false;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state === "blocked") {
|
||||
setDialogOpen(true);
|
||||
@@ -95,7 +107,10 @@ export function UnsavedChangesProvider({ children }: PropsWithChildren) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ register, runGuarded }), [register, runGuarded]);
|
||||
const value = useMemo(
|
||||
() => ({ register, runGuarded, runAfterSuccessfulSave: runWithNextNavigationAllowed }),
|
||||
[register, runGuarded, runWithNextNavigationAllowed],
|
||||
);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
@@ -129,14 +144,7 @@ export function UnsavedChangesProvider({ children }: PropsWithChildren) {
|
||||
return;
|
||||
}
|
||||
if (!action) return;
|
||||
allowNextNavigationRef.current = true;
|
||||
try {
|
||||
action();
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
allowNextNavigationRef.current = false;
|
||||
});
|
||||
}
|
||||
runWithNextNavigationAllowed(action);
|
||||
}}
|
||||
/>
|
||||
</UnsavedChangesContext.Provider>
|
||||
@@ -157,3 +165,12 @@ export function useUnsavedChangesGuard(dirty: boolean, label: string) {
|
||||
|
||||
return context.runGuarded;
|
||||
}
|
||||
|
||||
export function usePostSaveNavigation() {
|
||||
const context = useContext(UnsavedChangesContext);
|
||||
if (!context) {
|
||||
throw new Error("usePostSaveNavigation 必须在 UnsavedChangesProvider 内使用");
|
||||
}
|
||||
|
||||
return context.runAfterSuccessfulSave;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from ".
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
import {
|
||||
usePostSaveNavigation,
|
||||
useUnsavedChangesGuard,
|
||||
} from "../components/common/UnsavedChangesGuard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import {
|
||||
@@ -405,6 +408,7 @@ function AssetRegistryWorkspace({
|
||||
Boolean(isCreatingAsset || selectedAssetId) && Boolean(canEditCurrent) && JSON.stringify(assetForm) !== savedAssetFormSignature,
|
||||
"资产表单",
|
||||
);
|
||||
const runAfterSuccessfulSave = usePostSaveNavigation();
|
||||
const hasAdvancedAssetFilters = Boolean(
|
||||
sensitivityFilter ||
|
||||
categoryFilter ||
|
||||
@@ -575,19 +579,38 @@ function AssetRegistryWorkspace({
|
||||
return patchAsset(selectedAssetId, buildPatchAssetPayload());
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
if (isCreatingAsset) {
|
||||
const detailPath = buildAssetDetailPath(data.asset.id);
|
||||
if (detailPath === "/assets") {
|
||||
const message = "资产已创建,但未返回可用的资产标识,请返回资产列表确认。";
|
||||
setAssetFormError(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetFormError("");
|
||||
setSavedAssetFormSignature(JSON.stringify(assetForm));
|
||||
toast.success("资产卡片已创建");
|
||||
runAfterSuccessfulSave(() => {
|
||||
navigate(detailPath, {
|
||||
replace: true,
|
||||
state: { from: backSource },
|
||||
});
|
||||
});
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]);
|
||||
setAssetFormError("");
|
||||
setSavedAssetFormSignature(JSON.stringify(assetForm));
|
||||
toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新");
|
||||
if (isCreatingAsset) {
|
||||
navigate(buildAssetDetailPath(data.asset.id), {
|
||||
replace: true,
|
||||
state: { from: backSource },
|
||||
});
|
||||
}
|
||||
toast.success("资产卡片已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
|
||||
@@ -118,6 +118,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
};
|
||||
const assetId = createAssetPayload.data.asset.id;
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.goForward();
|
||||
@@ -161,6 +162,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
|
||||
await logout(page, ADMINISTRATION.name);
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await login(page, FINANCE_MGR);
|
||||
await (await openNavLink(page, "资产盘点")).click();
|
||||
await page.getByTestId("inventory-create").click();
|
||||
|
||||
@@ -90,7 +90,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await page.goto(`/workflow/tasks/${currentTaskId}`);
|
||||
await expect(page.getByTestId("workflow-task-overview")).toContainText("采购申请");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("申请人");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("Employee");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(EMPLOYEE.name);
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
@@ -101,8 +101,9 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
|
||||
await login(page, FINANCE_MGR);
|
||||
await (await openNavLink(page, "采购")).click();
|
||||
await page.getByRole("button", { name: "全范围" }).click();
|
||||
await page.getByRole("combobox").first().selectOption("approved");
|
||||
await page.getByTestId("purchase-advanced-filters").click();
|
||||
await page.getByTestId("purchase-scope-all").click();
|
||||
await page.getByTestId("purchase-tab-approved").click();
|
||||
const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first();
|
||||
await expect(purchaseRow).toBeVisible({ timeout: 30_000 });
|
||||
await purchaseRow.getByTestId("purchase-row-open").click();
|
||||
|
||||
@@ -492,6 +492,29 @@ async function mockApis(page: Page, options: { roleCodes?: string[] } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/assets" && request.method() === "POST") {
|
||||
const payload = request.postDataJSON() as { name?: string };
|
||||
if (payload.name === "合成失败资产") {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-r103-asset-create-failure",
|
||||
error: { code: "VALID_001", message: "资产保存失败,请检查填写内容。" },
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
asset: {
|
||||
...assetListItem(),
|
||||
...payload,
|
||||
id: assetId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/assets/${assetId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, { asset: assetListItem() });
|
||||
return;
|
||||
@@ -894,6 +917,45 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await page.getByTestId("unsaved-changes-discard").click();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await expect(page.getByTestId("asset-row")).toBeVisible();
|
||||
|
||||
await page.getByTestId("asset-create").click();
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await page.getByTestId("asset-name").fill("合成已保存资产");
|
||||
await page.getByTestId("asset-category").selectOption("asset-category-r103");
|
||||
await page.getByTestId("asset-legal-entity").selectOption("legal-r103");
|
||||
await page.getByTestId("asset-cost-center").selectOption("cost-r103");
|
||||
await page.getByTestId("asset-save").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "false");
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
|
||||
await page.goBack();
|
||||
await page.getByTestId("asset-create").click();
|
||||
await page.getByTestId("asset-name").fill("合成失败资产");
|
||||
await page.getByTestId("asset-category").selectOption("asset-category-r103");
|
||||
await page.getByTestId("asset-legal-entity").selectOption("legal-r103");
|
||||
await page.getByTestId("asset-cost-center").selectOption("cost-r103");
|
||||
const failedCreateResponse = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/assets",
|
||||
);
|
||||
await page.getByTestId("asset-save").click();
|
||||
const failedResponse = await failedCreateResponse;
|
||||
expect(failedResponse.ok()).toBe(false);
|
||||
expect(failedResponse.status()).toBe(400);
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("合成失败资产");
|
||||
await expect(page.getByRole("main").getByText("资产保存失败,请检查填写内容。", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("合成失败资产");
|
||||
});
|
||||
|
||||
test("共享未保存保护覆盖浏览器返回取消、确认与刷新拦截", async ({ page }) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户