diff --git a/yuqei-contract-platform/apps/contract-web/package.json b/yuqei-contract-platform/apps/contract-web/package.json index 70cc73d1..bcbb72f8 100644 --- a/yuqei-contract-platform/apps/contract-web/package.json +++ b/yuqei-contract-platform/apps/contract-web/package.json @@ -12,7 +12,7 @@ "test:agreement-consent-routing": "node --no-warnings --experimental-strip-types --test src/lib/agreement-consent-routing.test.mjs", "test:required-agreements": "node --no-warnings --experimental-strip-types --test src/lib/required-agreements.test.mjs", "test:public-policies": "node --no-warnings --experimental-strip-types --test src/lib/public-policies.test.mjs", - "test:workspace-access": "node --no-warnings --experimental-strip-types --test src/lib/workspace-access-state.test.mjs", + "test:workspace-access": "node --no-warnings --experimental-strip-types --test src/lib/workspace-access-state.test.mjs src/lib/workspace-task-state.test.mjs", "test:access-control": "node --no-warnings --experimental-strip-types --test src/lib/access-control.test.mjs", "test:platform-access-policy": "node --no-warnings --experimental-strip-types --test src/lib/platform-access-policy.test.mjs", "test:platform-protected-resource": "node --no-warnings --experimental-strip-types --test src/lib/platform-protected-resource.test.mjs", diff --git a/yuqei-contract-platform/apps/contract-web/src/app/login/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/login/page.tsx index 0778962a..ef7bf204 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/login/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/login/page.tsx @@ -1,14 +1,13 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; import Link from "next/link"; import { Building2, LogIn, UserPlus } from "lucide-react"; import { PasswordInput } from "@/components/password-input"; +import { completeAuthenticatedEntry } from "@/lib/auth-entry-routing"; import { getIdentityCapabilities, type IdentityCapabilities } from "@/lib/identity-capabilities"; export default function LoginPage() { - const router = useRouter(); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); const [capabilities, setCapabilities] = useState(null); @@ -37,8 +36,7 @@ export default function LoginPage() { if (!response.ok) { throw new Error(await readError(response)); } - router.push("/workspace"); - router.refresh(); + completeAuthenticatedEntry((pathname) => window.location.replace(pathname)); } catch (error) { setMessage(error instanceof Error ? error.message : "登录失败。"); } finally { diff --git a/yuqei-contract-platform/apps/contract-web/src/features/workspace/workspace-page.tsx b/yuqei-contract-platform/apps/contract-web/src/features/workspace/workspace-page.tsx index baf40489..dff7cbbd 100644 --- a/yuqei-contract-platform/apps/contract-web/src/features/workspace/workspace-page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/features/workspace/workspace-page.tsx @@ -6,6 +6,7 @@ import { OnboardingGuide } from "@/components/onboarding-guide"; import { getContractWebSession } from "@/lib/contract-session"; import { getProductAnalyticsUserState } from "@/services/product-analytics"; import { getRoleLabel, normalizeUserRole } from "@/lib/access-control"; +import { settleWorkspaceTasks } from "@/lib/workspace-task-state"; const text = { title: "工作台", @@ -52,11 +53,12 @@ function buildDateLine(now: Date) { } export async function WorkspacePage() { - const [tasks, session, analyticsState] = await Promise.all([ - getWorkspaceTasks(), + const [taskState, session, analyticsState] = await Promise.all([ + settleWorkspaceTasks(getWorkspaceTasks()), getContractWebSession(), getProductAnalyticsUserState(), ]); + const tasks = taskState.tasks; const groups = groupWorkspaceTasks(tasks); const approvalCount = groups.find((group) => group.key === "approval")?.tasks.length ?? 0; const draftCount = groups.find((group) => group.key === "drafts")?.tasks.length ?? 0; @@ -74,6 +76,12 @@ export async function WorkspacePage() { return ( <> + {taskState.unavailable && ( +
+ 待办数据暂时无法加载 + 工作台其他功能仍可使用,请稍后刷新重试。 +
+ )} {session && ( { assert.equal(authenticatedEntryRedirect("/login", true), "/workspace"); @@ -30,3 +30,11 @@ test("the redirect applies only to the exact login route", () => { assert.equal(authenticatedEntryRedirect("/login/help", true), null); assert.equal(authenticatedEntryRedirect("/workspace", true), null); }); + +test("successful login performs one replacement navigation to the workspace", () => { + const destinations = []; + + completeAuthenticatedEntry((pathname) => destinations.push(pathname)); + + assert.deepEqual(destinations, ["/workspace"]); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.ts b/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.ts index d63945ca..2d1cf93f 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.ts @@ -1,3 +1,7 @@ export function authenticatedEntryRedirect(pathname: string, authenticated: boolean): string | null { return authenticated && pathname === "/login" ? "/workspace" : null; } + +export function completeAuthenticatedEntry(replace: (pathname: string) => void): void { + replace("/workspace"); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/workspace-task-state.test.mjs b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-task-state.test.mjs new file mode 100644 index 00000000..adfbc5b3 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-task-state.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + settleWorkspaceTasks, + WORKSPACE_TASK_TIMEOUT_MS, +} from "./workspace-task-state.ts"; + +test("workspace task requests allow a normal cold-start window", () => { + assert.equal(WORKSPACE_TASK_TIMEOUT_MS, 5_000); +}); + +test("workspace task state preserves successful results", async () => { + const tasks = [{ id: "task-1" }]; + + assert.deepEqual(await settleWorkspaceTasks(Promise.resolve(tasks)), { + tasks, + unavailable: false, + }); +}); + +test("workspace task state contains aborted or failed requests", async () => { + const failure = Promise.reject(new DOMException("This operation was aborted", "AbortError")); + + assert.deepEqual(await settleWorkspaceTasks(failure), { + tasks: [], + unavailable: true, + }); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/workspace-task-state.ts b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-task-state.ts new file mode 100644 index 00000000..75ca1bf4 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-task-state.ts @@ -0,0 +1,14 @@ +export const WORKSPACE_TASK_TIMEOUT_MS = 5_000; + +export type WorkspaceTaskLoadState = { + tasks: T[]; + unavailable: boolean; +}; + +export async function settleWorkspaceTasks(request: Promise): Promise> { + try { + return { tasks: await request, unavailable: false }; + } catch { + return { tasks: [], unavailable: true }; + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts index 300fa8b4..42348acf 100644 --- a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts @@ -1,6 +1,7 @@ import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; import { buildAiPlatformApiUrl, fetchAiPlatform } from "@/lib/ai-platform-api"; import { getContractWebSession } from "@/lib/contract-session"; +import { WORKSPACE_TASK_TIMEOUT_MS } from "@/lib/workspace-task-state"; import type { UserRole } from "@/lib/access-control"; export type ContractListItem = { @@ -2103,7 +2104,7 @@ export async function getWorkspaceTasks(): Promise { const payload = await fetchJson( "/api/v1/workspace", "/api/contract-platform/workspace", - { timeoutMs: 1800, cacheTtlMs: 5000, throwOnError: true } + { timeoutMs: WORKSPACE_TASK_TIMEOUT_MS, cacheTtlMs: 5000, throwOnError: true } ); return payload?.pending_tasks ?? []; }