fix(ux): surface hidden notices, bound report waits, add root boundaries
Independent Staging Quality Gate / validate (push) Successful in 12m8s
Independent Staging Quality Gate / publish (push) Successful in 14m29s

Framework-level UX fixes found while auditing staging (BUG-216..220).

- chat: route 44 previously discarded composer notices to sonner with
  dedupe, so recovery, cancel and archive feedback is actually visible
  (BUG-216)
- chat: anchor stream auto-scroll to bottom proximity and add a
  jump-to-latest control, so reading history is no longer interrupted
  on every token (BUG-218)
- reports: replace the silent 120s poll cutoff with an explicit
  timed-out state, an 8m budget, stepped backoff and an elapsed
  counter (BUG-217)
- reports: pause polling while the tab is hidden, via a shared hook
- app: add root error, global-error and not-found boundaries (BUG-219)
- admin: add antd SSR style extraction and the React 19 render adapter,
  and move admin-only css out of the global stylesheet (BUG-220)
- membership: run bootstrap fetches concurrently and pause payment
  polling while hidden
- build: configure optimizePackageImports

Verified on top of 2d370f2e: tsc, eslint, next build, and the related
frontend contract suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 12:52:24 +08:00
parent 2d370f2e9d
commit 9c296f1e3f
23 changed files with 1144 additions and 88 deletions
+35
View File
@@ -0,0 +1,35 @@
.admin-app-shell {
min-height: 100dvh;
background: #f3f2ee;
color: #1d1d1f;
font-family: var(--font-body);
}
.admin-app-shell .ant-layout { background: #f3f2ee; }
.admin-app-shell .ant-layout-sider { border-right: 1px solid #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-layout-header { border-bottom: 1px solid #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-layout-content { padding: 22px; }
.admin-app-shell .ant-menu { border-inline-end: 0 !important; background: transparent !important; }
.admin-app-shell .ant-menu-item, .admin-app-shell .ant-menu-submenu-title { margin-inline: 8px; width: calc(100% - 16px); }
.admin-app-shell .ant-card { border-color: #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-card-head { border-bottom-color: #d8d6cf; }
.admin-app-shell .ant-card .ant-card { border-radius: 0; }
.admin-app-shell .ant-card .ant-card:not(:last-child) { border-bottom: 0; }
.admin-app-shell .ant-list-bordered { border-color: #d8d6cf; border-radius: 6px; }
.admin-app-shell .ant-table-wrapper .ant-table { background: #fbfaf7; }
.admin-app-shell .ant-table-wrapper .ant-table-container { border-radius: 6px; }
.admin-app-shell .ant-table-wrapper .ant-table-thead > tr > th { font-size: 12px; font-weight: 650; }
.admin-app-shell .ant-table-wrapper .ant-table-cell { line-height: 1.45; }
.admin-app-shell .ant-tag { margin-inline-end: 4px; border-radius: 4px; font-weight: 550; }
.admin-app-shell .ant-alert { border-radius: 6px; box-shadow: none; }
.admin-app-shell .ant-statistic-title { color: #5f5f59; font-size: 12px; }
.admin-app-shell .ant-statistic-content { font-size: 22px; font-weight: 650; }
.admin-app-shell .ant-typography h1, .admin-app-shell h1.ant-typography { font-family: var(--font-body); font-size: 26px; font-weight: 680; letter-spacing: -.025em; }
.admin-app-shell .ant-typography h2, .admin-app-shell h2.ant-typography { font-family: var(--font-body); font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.admin-app-shell .ant-typography h3, .admin-app-shell h3.ant-typography { font-family: var(--font-body); font-size: 17px; font-weight: 650; }
.admin-app-shell code { font-family: var(--font-mono); }
.admin-text-list { max-width: 680px; color: #5f5f59; line-height: 1.65; white-space: normal; overflow-wrap: anywhere; }
.admin-loading { min-height: 100dvh; display: grid; place-content: center; justify-items: center; gap: 12px; background: #f3f2ee; color: #5f5f59; }
@media (max-width: 767px) {
.admin-app-shell .ant-layout-content { padding: 14px; }
}
+7 -2
View File
@@ -1,8 +1,9 @@
import "@refinedev/antd/dist/reset.css";
import "antd/dist/reset.css";
import "./admin.css";
import type { ReactNode } from "react";
import { forbidden, redirect } from "next/navigation";
import { AdminAntdRegistry } from "@/components/admin/admin-antd-registry";
import { AdminApp } from "@/components/admin/admin-app";
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
import { resolveAdminPageAccessFailure } from "@/lib/admin/page-access";
@@ -21,5 +22,9 @@ export default async function AdminLayout({ children }: { children: ReactNode })
}
throw error;
}
return <AdminApp>{children}</AdminApp>;
return (
<AdminAntdRegistry>
<AdminApp>{children}</AdminApp>
</AdminAntdRegistry>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function RootError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("app root error", error.digest ?? error.message);
}, [error]);
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<TriangleAlert aria-hidden="true" className="size-8 text-destructive" />
<div className="flex flex-col items-center gap-3" role="alert">
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="max-w-md text-sm text-muted-foreground">
</p>
</div>
{error.digest ? (
<p className="max-w-md text-xs text-muted-foreground">
<code className="font-mono">{error.digest}</code>
</p>
) : null}
<div className="mt-2 flex flex-wrap items-center justify-center gap-3">
<Button type="button" onClick={() => reset()}>
</Button>
<Button render={<Link href="/" />} nativeButton={false} variant="outline">
</Button>
</div>
</main>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
const canvas = "var(--color-canvas, #fbfaf7)";
const ink = "var(--color-ink, #1d1d1f)";
const inkSecondary = "var(--color-ink-secondary, #5f5f59)";
const danger = "var(--color-danger, #9a2f2f)";
const border = "var(--color-border, #d8d6cf)";
const action = "var(--color-action, #85432f)";
const focus = "var(--color-focus, #85432f)";
const actionStyle = {
alignItems: "center",
borderRadius: "12px",
display: "inline-flex",
fontSize: "14px",
fontWeight: 500,
justifyContent: "center",
minHeight: "44px",
minWidth: "88px",
padding: "0 20px",
} as const;
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="zh-CN">
<body
style={{
alignItems: "center",
background: canvas,
color: ink,
display: "flex",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
justifyContent: "center",
margin: 0,
minHeight: "100vh",
padding: "24px",
}}
>
<style>{`
.global-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
`}</style>
<main style={{ maxWidth: "32rem", textAlign: "center" }}>
<div role="alert">
<h1 style={{ color: danger, fontSize: "20px", fontWeight: 600, margin: "0 0 12px" }}>
</h1>
<p style={{ color: inkSecondary, fontSize: "14px", lineHeight: 1.7, margin: 0 }}>
</p>
</div>
{error.digest ? (
<p style={{ color: inkSecondary, fontSize: "12px", lineHeight: 1.7, margin: "12px 0 0" }}>
{error.digest}
</p>
) : null}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "12px",
justifyContent: "center",
marginTop: "24px",
}}
>
<button
className="global-error-action"
onClick={() => reset()}
style={{
...actionStyle,
background: action,
border: "1px solid transparent",
color: canvas,
cursor: "pointer",
}}
type="button"
>
</button>
<button
className="global-error-action"
onClick={() => window.location.assign("/")}
style={{
...actionStyle,
background: canvas,
border: `1px solid ${border}`,
color: ink,
cursor: "pointer",
}}
type="button"
>
</button>
</div>
</main>
</body>
</html>
);
}
-37
View File
@@ -2175,40 +2175,3 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.rectification-activity-receipt__toggle svg { transition: none; }
}
/* Admin: quiet operational surface. */
.admin-app-shell {
min-height: 100dvh;
background: #f3f2ee;
color: #1d1d1f;
font-family: var(--font-body);
}
.admin-app-shell .ant-layout { background: #f3f2ee; }
.admin-app-shell .ant-layout-sider { border-right: 1px solid #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-layout-header { border-bottom: 1px solid #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-layout-content { padding: 22px; }
.admin-app-shell .ant-menu { border-inline-end: 0 !important; background: transparent !important; }
.admin-app-shell .ant-menu-item, .admin-app-shell .ant-menu-submenu-title { margin-inline: 8px; width: calc(100% - 16px); }
.admin-app-shell .ant-card { border-color: #d8d6cf; box-shadow: none !important; }
.admin-app-shell .ant-card-head { border-bottom-color: #d8d6cf; }
.admin-app-shell .ant-card .ant-card { border-radius: 0; }
.admin-app-shell .ant-card .ant-card:not(:last-child) { border-bottom: 0; }
.admin-app-shell .ant-list-bordered { border-color: #d8d6cf; border-radius: 6px; }
.admin-app-shell .ant-table-wrapper .ant-table { background: #fbfaf7; }
.admin-app-shell .ant-table-wrapper .ant-table-container { border-radius: 6px; }
.admin-app-shell .ant-table-wrapper .ant-table-thead > tr > th { font-size: 12px; font-weight: 650; }
.admin-app-shell .ant-table-wrapper .ant-table-cell { line-height: 1.45; }
.admin-app-shell .ant-tag { margin-inline-end: 4px; border-radius: 4px; font-weight: 550; }
.admin-app-shell .ant-alert { border-radius: 6px; box-shadow: none; }
.admin-app-shell .ant-statistic-title { color: #5f5f59; font-size: 12px; }
.admin-app-shell .ant-statistic-content { font-size: 22px; font-weight: 650; }
.admin-app-shell .ant-typography h1, .admin-app-shell h1.ant-typography { font-family: var(--font-body); font-size: 26px; font-weight: 680; letter-spacing: -.025em; }
.admin-app-shell .ant-typography h2, .admin-app-shell h2.ant-typography { font-family: var(--font-body); font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.admin-app-shell .ant-typography h3, .admin-app-shell h3.ant-typography { font-family: var(--font-body); font-size: 17px; font-weight: 650; }
.admin-app-shell code { font-family: var(--font-mono); }
.admin-text-list { max-width: 680px; color: #5f5f59; line-height: 1.65; white-space: normal; overflow-wrap: anywhere; }
.admin-loading { min-height: 100dvh; display: grid; place-content: center; justify-items: center; gap: 12px; background: #f3f2ee; color: #5f5f59; }
@media (max-width: 767px) {
.admin-app-shell .ant-layout-content { padding: 14px; }
}
+41 -21
View File
@@ -130,8 +130,7 @@ function MembershipContent() {
useEffect(() => {
void (async () => {
await fetchAccountData();
await fetchPackages();
await Promise.allSettled([fetchAccountData(), fetchPackages()]);
})();
}, [fetchAccountData, fetchPackages]);
@@ -246,25 +245,46 @@ function MembershipContent() {
useEffect(() => {
if (!paymentOrder || paymentOrder.status !== "pending") return;
const timer = window.setInterval(() => {
void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok) return;
const failed = (typeof payload.status === "string"
&& ["failed", "closed", "cancelled"].includes(payload.status))
|| payload.grantStatus === "failed";
const paid = payload.status === "paid" && !failed;
setPaymentOrder((current) => current ? {
...current,
status: paid ? "paid" : failed ? "failed" : "pending",
} : current);
if (paid) {
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? 0);
}
});
}, 3000);
return () => window.clearInterval(timer);
const checkPaymentStatus = async () => {
const response = await fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" });
const payload = await response.json().catch(() => null);
if (!response.ok) return;
const failed = (typeof payload.status === "string"
&& ["failed", "closed", "cancelled"].includes(payload.status))
|| payload.grantStatus === "failed";
const paid = payload.status === "paid" && !failed;
setPaymentOrder((current) => current ? {
...current,
status: paid ? "paid" : failed ? "failed" : "pending",
} : current);
if (paid) {
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? 0);
}
};
let timer = 0;
const stopPolling = () => {
if (timer) window.clearInterval(timer);
timer = 0;
};
const startPolling = () => {
stopPolling();
timer = window.setInterval(() => void checkPaymentStatus(), 3000);
};
const onVisibilityChange = () => {
if (document.hidden) {
stopPolling();
return;
}
void checkPaymentStatus();
startPolling();
};
if (!document.hidden) startPolling();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
stopPolling();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [fetchAccountData, paymentOrder]);
const plans = selectMembershipPlans(paymentPackages);
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function RootNotFound() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<p className="text-sm font-medium tracking-[0.12em] text-muted-foreground">404</p>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="max-w-md text-sm text-muted-foreground">
线
</p>
<div className="mt-2">
<Button render={<Link href="/" />} nativeButton={false} variant="outline">
</Button>
</div>
</main>
);
}
+28 -3
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { ArrowDown, ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AppSidebar } from "@/components/app-sidebar";
@@ -68,6 +68,8 @@ import {
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import {
requestBirthTimeAssessment,
type JourneyClientResponse,
@@ -1020,7 +1022,6 @@ export default function Home() {
const [draft, setDraft] = useState("");
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(null);
const [, setComposerNotice] = useState("");
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
@@ -1259,6 +1260,15 @@ export default function Home() {
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const daypartGreeting = greetingForHour(new Date().getHours());
const conversationAnchor = useConversationScrollAnchor(
conversation,
!rectificationSurfaceOpen && !starterHomeVisible,
activeSessionId,
);
const jumpToLatestVisible = !rectificationSurfaceOpen
&& !starterHomeVisible
&& !conversationAnchor.anchored
&& Boolean(activeSession?.messages.length);
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
if (pendingConsultation.current) return;
@@ -1725,9 +1735,10 @@ export default function Home() {
if (starterHomeVisible) return;
const container = conversation.current;
if (!container) return;
if (!conversationAnchor.anchored) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
container.scrollTo({ top: container.scrollHeight, behavior: isLoading || reduceMotion ? "auto" : "smooth" });
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, starterHomeVisible]);
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, conversationAnchor.anchored, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, starterHomeVisible]);
useEffect(() => {
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
@@ -2875,6 +2886,7 @@ export default function Home() {
};
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
conversationAnchor.anchorToLatest();
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
@@ -3440,6 +3452,19 @@ export default function Home() {
{activeError && <p className="error-message" role="alert">{activeError}</p>}
</div>
)}
{jumpToLatestVisible && (
<div className="pointer-events-none sticky bottom-3 z-10 flex h-0 items-end justify-center">
<button
className="pointer-events-auto inline-flex min-h-11 min-w-11 items-center gap-1.5 rounded-full border border-border bg-canvas px-4 text-sm text-ink shadow-md transition-colors outline-none hover:bg-canvas-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
type="button"
aria-label="跳到最新"
onClick={conversationAnchor.anchorToLatest}
>
<ArrowDown aria-hidden="true" className="size-4" />
</button>
</div>
)}
</div>
)}