R69 完成业务验收与会话稳定性修复
这个提交包含在:
@@ -24,6 +24,7 @@ import {
|
||||
loginWithPassword,
|
||||
logoutSession,
|
||||
refreshSession,
|
||||
resetRefreshSessionCache,
|
||||
} from "./authService";
|
||||
import { resolvePermissionsForRoles } from "./permissions";
|
||||
|
||||
@@ -246,6 +247,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
}, [clearProactiveRefreshTimer]);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
resetRefreshSessionCache();
|
||||
clearSessionHint();
|
||||
markUnauthenticatedReady();
|
||||
void clearPwaRuntimeCaches();
|
||||
@@ -427,6 +429,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const changePassword = useCallback(
|
||||
async (oldPassword: string, newPassword: string) => {
|
||||
const result = await changePasswordApi({ oldPassword, newPassword });
|
||||
resetRefreshSessionCache();
|
||||
const me = await fetchCurrentEmployee(result.accessToken);
|
||||
commitAuthenticatedUser(result.accessToken, me, false);
|
||||
scheduleProactiveRefresh(result.expiresIn);
|
||||
|
||||
@@ -6,7 +6,20 @@ const JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
const REFRESH_SUCCESS_REPLAY_MS = 1000;
|
||||
|
||||
let refreshSessionPromise: Promise<LoginResponse> | null = null;
|
||||
let refreshSessionReplay: { data: LoginResponse; expiresAt: number } | null = null;
|
||||
let refreshSessionGeneration = 0;
|
||||
|
||||
export function resetRefreshSessionCache() {
|
||||
refreshSessionGeneration += 1;
|
||||
refreshSessionPromise = null;
|
||||
refreshSessionReplay = null;
|
||||
}
|
||||
|
||||
export async function loginWithPassword(payload: LoginRequest) {
|
||||
resetRefreshSessionCache();
|
||||
return apiRequestData<LoginResponse>("/api/auth/login", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
@@ -19,7 +32,7 @@ export async function loginWithPassword(payload: LoginRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshSession() {
|
||||
async function requestRefreshSession() {
|
||||
return apiRequestData<LoginResponse>("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
@@ -30,12 +43,54 @@ export async function refreshSession() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshSession() {
|
||||
const now = Date.now();
|
||||
if (refreshSessionReplay && refreshSessionReplay.expiresAt > now) {
|
||||
return refreshSessionReplay.data;
|
||||
}
|
||||
|
||||
if (!refreshSessionPromise) {
|
||||
const generation = refreshSessionGeneration;
|
||||
const requestPromise = requestRefreshSession()
|
||||
.then((data) => {
|
||||
if (generation === refreshSessionGeneration) {
|
||||
refreshSessionReplay = {
|
||||
data,
|
||||
expiresAt: Date.now() + REFRESH_SUCCESS_REPLAY_MS,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation === refreshSessionGeneration) {
|
||||
refreshSessionReplay = null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
refreshSessionPromise = requestPromise;
|
||||
void requestPromise
|
||||
.finally(() => {
|
||||
if (refreshSessionPromise === requestPromise) {
|
||||
refreshSessionPromise = null;
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return refreshSessionPromise;
|
||||
}
|
||||
|
||||
export async function logoutSession() {
|
||||
return apiRequestData<Record<string, never>>("/api/auth/logout", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
retryOn401: false,
|
||||
});
|
||||
try {
|
||||
return await apiRequestData<Record<string, never>>("/api/auth/logout", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
retryOn401: false,
|
||||
});
|
||||
} finally {
|
||||
resetRefreshSessionCache();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCurrentEmployee(accessToken: string) {
|
||||
|
||||
@@ -253,7 +253,7 @@ export function AssetRegistryPage() {
|
||||
}, [isCreatingAsset, searchParams, selectedAssetId, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetDetailQuery.data?.asset) {
|
||||
if (isCreatingAsset || !assetDetailQuery.data?.asset) {
|
||||
return;
|
||||
}
|
||||
const asset = assetDetailQuery.data.asset;
|
||||
@@ -275,7 +275,7 @@ export function AssetRegistryPage() {
|
||||
setMovementNote("");
|
||||
setMovementError("");
|
||||
setAssetFormError("");
|
||||
}, [assetDetailQuery.data]);
|
||||
}, [assetDetailQuery.data, isCreatingAsset]);
|
||||
|
||||
const filteredAssetCostCenters = useMemo(() => {
|
||||
if (!assetForm.legalEntityId) {
|
||||
|
||||
@@ -228,7 +228,7 @@ export function MobileApprovalsPage() {
|
||||
const [transferToApproverId, setTransferToApproverId] = useState("");
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState<boolean>(typeof navigator === "undefined" ? true : navigator.onLine);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
|
||||
const ownerEmployeeId = me?.id ?? null;
|
||||
@@ -673,6 +673,12 @@ export function MobileApprovalsPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
setIsOnline(true);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSyncRequestedRef.current || !canUseRemote || syncMutation.isPending) {
|
||||
return;
|
||||
|
||||
@@ -36,6 +36,7 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
|
||||
await page.getByRole("button", { name: "保存状态" }).click();
|
||||
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
||||
await page.goto("/portal");
|
||||
await page.getByRole("tab", { name: "公告" }).click();
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
||||
|
||||
@@ -48,6 +49,7 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
|
||||
await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible();
|
||||
|
||||
await page.goto("/portal");
|
||||
await page.getByRole("tab", { name: "公告" }).click();
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible();
|
||||
} finally {
|
||||
|
||||
@@ -83,6 +83,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
|
||||
await login(page, FINANCE_MGR);
|
||||
await page.getByRole("link", { name: "资产台账" }).click();
|
||||
await expect(page.getByTestId("asset-name")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("asset-category-reset").click();
|
||||
await page.getByTestId("asset-category-code").fill(categoryCode);
|
||||
@@ -90,25 +91,48 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
await page.getByTestId("asset-category-save").click();
|
||||
|
||||
await page.getByTestId("asset-create").click();
|
||||
await expect(page.getByRole("heading", { name: "新建资产卡片" })).toBeVisible();
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("");
|
||||
await page.getByTestId("asset-name").fill(assetName);
|
||||
await page.getByTestId("asset-category").selectOption({ label: categoryName });
|
||||
await selectFirstBusinessOption(page.getByTestId("asset-legal-entity"));
|
||||
await selectFirstBusinessOption(page.getByTestId("asset-cost-center"));
|
||||
await page.getByTestId("asset-location").fill("深圳办公室 A-01");
|
||||
const createAssetResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/assets",
|
||||
);
|
||||
await page.getByTestId("asset-save").click();
|
||||
const createAssetResponse = await createAssetResponsePromise;
|
||||
if (!createAssetResponse.ok()) {
|
||||
const failureText = await createAssetResponse.text();
|
||||
throw new Error(`asset create failed: ${createAssetResponse.status()} ${failureText}`);
|
||||
}
|
||||
const createAssetPayload = (await createAssetResponse.json()) as {
|
||||
data: { asset: { id: string } };
|
||||
};
|
||||
const assetId = createAssetPayload.data.asset.id;
|
||||
await page.getByRole("button", { name: "全范围" }).click();
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
|
||||
|
||||
const assetRow = page.getByTestId("asset-row").filter({ hasText: assetName }).first();
|
||||
await expect(assetRow).toBeVisible({ timeout: 30_000 });
|
||||
if (await assetRow.isVisible().catch(() => false)) {
|
||||
await assetRow.click();
|
||||
}
|
||||
|
||||
await logout(page, FINANCE_MGR.name);
|
||||
|
||||
await login(page, ADMINISTRATION);
|
||||
await page.getByRole("link", { name: "资产台账" }).click();
|
||||
await page.getByTestId("asset-row").filter({ hasText: assetName }).first().click();
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
|
||||
|
||||
await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Employee/);
|
||||
await page.getByTestId("asset-movement-note").fill("Playwright 首次领用");
|
||||
await page.getByTestId("asset-assign-submit").click();
|
||||
if (await page.getByText(/当前归属人:未分配/).isVisible().catch(() => false)) {
|
||||
await page.getByTestId("asset-assign-submit").click();
|
||||
} else {
|
||||
await page.getByTestId("asset-transfer-submit").click();
|
||||
}
|
||||
await expect(page.getByText(/当前归属人:Employee/)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("asset-return-submit").click();
|
||||
|
||||
@@ -75,6 +75,7 @@ test("E2E 涉密边界:敏感摘要不可搜索,编号可命中", async ({ p
|
||||
const contract = await apiPostData<{ item: { id: string } }>(adminClient.api, "/api/contracts-registry", {
|
||||
contractNo,
|
||||
projectId: project.item.id,
|
||||
contractType: "service",
|
||||
counterpartyName: `客户 ${suffix}`,
|
||||
summary: sensitivePhrase,
|
||||
confidentialityLevel: "restricted",
|
||||
|
||||
@@ -26,6 +26,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
||||
});
|
||||
await page.getByTestId("reimbursements-create").click();
|
||||
await page.getByRole("menuitem", { name: "空白报销" }).click();
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const reimbursementId = createPayload.data?.id;
|
||||
if (!reimbursementId) {
|
||||
|
||||
@@ -71,6 +71,7 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("通过");
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("无待处理项", { timeout: 30_000 });
|
||||
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
@@ -108,6 +109,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await approveWorkflowTaskViaApi(managerApi.api, task.id);
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("存在冲突", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("最近错误:", { timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "查看任务" })).toBeVisible();
|
||||
|
||||
@@ -41,9 +41,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
data: { purchaseRequest: { id: string } };
|
||||
};
|
||||
const purchaseRequestId = createPayload.data.purchaseRequest.id;
|
||||
await page.waitForResponse(
|
||||
(response) => response.request().method() === "GET" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}`,
|
||||
);
|
||||
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`id=${purchaseRequestId}`));
|
||||
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
await page.getByTestId("purchase-title").fill(title);
|
||||
@@ -51,14 +50,21 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-cost-center"));
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-supplier"));
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-document-category"));
|
||||
await page.getByTestId("purchase-add-item").click();
|
||||
|
||||
await page.locator('[data-testid^="purchase-item-description-"]').first().fill("Playwright Laptop");
|
||||
const itemDescriptionInputs = page.locator('[data-testid^="purchase-item-description-"]');
|
||||
if (!(await itemDescriptionInputs.first().isVisible().catch(() => false))) {
|
||||
await page.getByTestId("purchase-add-item").click();
|
||||
}
|
||||
|
||||
await expect(itemDescriptionInputs.first()).toBeVisible({ timeout: 30_000 });
|
||||
await itemDescriptionInputs.first().fill("Playwright Laptop");
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]').first().fill("2");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]').first().fill("100");
|
||||
|
||||
const submitResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && /\/api\/purchase-requests\/[^/]+\/submit$/.test(new URL(response.url()).pathname),
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}/submit`,
|
||||
);
|
||||
await page.getByTestId("purchase-submit").click();
|
||||
const submitResponse = await submitResponsePromise;
|
||||
|
||||
@@ -99,7 +99,8 @@ test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async
|
||||
await expect(page.getByTestId("project-closure-panel")).toContainText("确认交付/验收记录齐全");
|
||||
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByRole("heading", { name: "今日管理重点" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "你好,Admin" })).toBeVisible();
|
||||
await page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" }).click();
|
||||
await expect(page.getByRole("heading", { name: "项目跟进与组织协同" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /项目风险/ })).toBeVisible();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
|
||||
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
||||
});
|
||||
await page.getByTestId("reimbursements-create").click();
|
||||
await page.getByRole("menuitem", { name: "空白报销" }).click();
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const reimbursementId = createPayload.data?.id;
|
||||
if (!reimbursementId) {
|
||||
|
||||
@@ -68,7 +68,7 @@ async function submitLeave(api: Awaited<ReturnType<typeof createAuthenticatedApi
|
||||
|
||||
test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过请假申请", async ({ page }) => {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const startDate = formatDate(45 + Number(String(Date.now()).slice(-2)));
|
||||
const startDate = formatDate(190 + (Date.now() % 40));
|
||||
const reason = `Playwright 快捷通过 ${Date.now()}`;
|
||||
|
||||
try {
|
||||
|
||||
在新工单中引用
屏蔽一个用户