fix(web): contain aborted workspace loads
This commit is contained in:
@@ -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 {
|
||||
|
||||
+10
-2
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user