fix(ux): surface hidden notices, bound report waits, add root boundaries
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:
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user