fix(web): contain aborted workspace loads

This commit is contained in:
2026-10-06 11:42:09 +08:00
parent 3775c9e98b
commit afe39f92de
8 changed files with 70 additions and 9 deletions
@@ -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",
@@ -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<string | null>(null);
const [capabilities, setCapabilities] = useState<IdentityCapabilities | null>(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 {
@@ -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 && (
<div className="empty-state empty-state-error" role="alert">
<strong>待办数据暂时无法加载</strong>
<span>工作台其他功能仍可使用,请稍后刷新重试。</span>
</div>
)}
{session && (
<OnboardingGuide
initialState={analyticsState}
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { authenticatedEntryRedirect } from "./auth-entry-routing.ts";
import { authenticatedEntryRedirect, completeAuthenticatedEntry } from "./auth-entry-routing.ts";
test("an authenticated login visit redirects to the workspace", () => {
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"]);
});
@@ -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");
}
@@ -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,
});
});
@@ -0,0 +1,14 @@
export const WORKSPACE_TASK_TIMEOUT_MS = 5_000;
export type WorkspaceTaskLoadState<T> = {
tasks: T[];
unavailable: boolean;
};
export async function settleWorkspaceTasks<T>(request: Promise<T[]>): Promise<WorkspaceTaskLoadState<T>> {
try {
return { tasks: await request, unavailable: false };
} catch {
return { tasks: [], unavailable: true };
}
}
@@ -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<WorkspaceTask[]> {
const payload = await fetchJson<WorkspaceSummary>(
"/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 ?? [];
}