diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ca7265f1..91fe209b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3618,3 +3618,73 @@ - 防复发:被多处断言复用的测试辅助函数必须有自己的回归,不得以副本形式散落在各测试文件。以字符串匹配近似 CSS 语义时,必须按规则解析并覆盖同名选择器的全部声明;`indexOf` 式首个匹配不可用于可能重复出现的选择器。 - 相关记录:BUG-214 - 修复版本:本地未提交候选 + +## BUG-216 | 咨询中断、取消、归档等 44 条提示文案计算后从未显示 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-17 +- 最近更新:2026-08-17 +- 影响面:`/` 主对话页的断线恢复、取消回答、模型下线切换、会话重命名与删除失败等全部提示通路。 +- 用户现象:网络中断、刷新后恢复、点击停止、删除会话失败时,界面只有一个转圈或静默无反应,用户无法知道回答仍在后台生成、是否已取消、失败原因是什么。 +- 根因:`frontend/src/app/page.tsx` 将提示状态声明为 `const [, setComposerNotice] = useState("")`,解构时丢弃了状态值,JSX 中也没有任何渲染点;44 处 `setComposerNotice(...)` 调用的文案全部写入一个永不读取的 state。恢复轮询逻辑本身正确,缺的只是出口。 +- 修复:新增 `frontend/src/lib/chat-notice.ts`,把提示按语义分派到既有 sonner `toast.success` / `toast.error` / `toast`;根布局早已挂载 ``,无需改动。以固定 `id` 复用同一条 toast,避免 1750ms 恢复轮询把同一句话堆成几十条;空字符串走 dismiss 而不弹空 toast。44 处调用点与全部中文文案逐字未改。 +- 验证:新增 `frontend/tests/chat-notice-and-scroll-contract.test.ts` 锁定状态不再被丢弃、提示进入 toast、空串不弹窗、轮询防刷屏与分级语义;`tsc --noEmit`、`eslint` 清洁;与改动文件相关的 43 个测试文件共 386 条断言全绿。 +- 防复发:禁止以 `const [, setX]` 形式声明用户可见文案状态;任何面向用户的提示必须有可断言的渲染出口,合同测试需覆盖“文案确实可达 UI”而不仅是“文案存在”。 +- 相关记录:BUG-211 +- 修复版本:本地未提交候选 + +## BUG-217 | 个人报告页轮询 120 秒后静默停止,界面仍显示“生成完成后页面会自动显示” + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-17 +- 最近更新:2026-08-17 +- 影响面:`/reports/[reportId]` 报告详情页的生成等待态。 +- 用户现象:报告生成超过两分钟后,页面永远停在“报告正在生成中,请稍候…”的转圈上,即使报告已经在后台完成也不会刷新;页面同时声称“生成完成后页面会自动显示”,与实际行为矛盾。 +- 根因:`personal-report-page.tsx` 以 `MAX_POLLS = 40` × `POLL_INTERVAL_MS = 3000` 限制轮询次数,达到上限后 effect 直接 `return` 停止轮询,但 `state.phase` 仍保持 `"generating"`,UI 继续渲染等待分支,没有任何超时态或失败态承接。 +- 修复:在 `ReportLoadState` 中新增客户端专用的 `timed-out` 相位(`classifyReportEnvelope` 不产出该值,服务端语义未变)。轮询改为 8 分钟墙钟预算配阶梯退避(首分钟 3s,之后 6s/10s/15s,总请求数由 160 降到约 48)。预算耗尽后进入 `timed-out` 屏:说明生成仍在后台继续,提供“继续等待”重置时钟并立即重取,保留“返回报告中心”。等待中与超时后均显示“已等待 X 分 Y 秒”。 +- 验证:新增 `frontend/tests/report-polling-contract.test.ts`,除文本合同外还对导出的 `pollIntervalForElapsed` / `formatWaitedDuration` 做真实单元断言(退避边界、单调性、请求数上限);既有 `personal-report-*` 测试全部通过。 +- 防复发:任何有次数或时间上限的轮询,达到上限时必须切换到显式终态并给出用户可执行的下一步;等待文案承诺“自动刷新”时,必须由测试保证该承诺在整个等待窗口内成立。 +- 相关记录:BUG-217 无前序同类记录 +- 修复版本:本地未提交候选 + +## BUG-218 | 流式回答期间强制滚到底部,用户无法向上翻阅历史 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-17 +- 最近更新:2026-08-17 +- 影响面:`/` 主对话页的会话滚动容器。 +- 用户现象:长回答生成过程中向上滚动查看此前内容,会被立即拽回底部,无法停留;也没有任何“回到最新”的入口。 +- 根因:`page.tsx` 的自动滚动 effect 依赖数组包含 `activeStreamingText`,每个流式 token 都会触发一次无条件 `scrollTo(scrollHeight)`,未判断用户当前是否已在底部附近。 +- 修复:新增 `frontend/src/hooks/use-conversation-scroll-anchor.ts`,以 rAF 合并的 passive 滚动监听维护锚定状态:用户向上越过约 96px 阈值即解除锚定,回到阈值内自动恢复;仅在锚定时执行自动滚动。切换会话与用户自己发送消息仍强制滚到底部。解除锚定时显示“跳到最新”按钮(真实 ` + + + + + + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e606e9d1..017a412e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; } -} diff --git a/frontend/src/app/membership/page.tsx b/frontend/src/app/membership/page.tsx index 4a75e776..f98360de 100644 --- a/frontend/src/app/membership/page.tsx +++ b/frontend/src/app/membership/page.tsx @@ -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); diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx new file mode 100644 index 00000000..0a493cfd --- /dev/null +++ b/frontend/src/app/not-found.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; + +export default function RootNotFound() { + return ( +
+

404

+

页面不存在

+

+ 该地址不存在、已下线,或在当前域名下不可用。可以返回对话首页继续。 +

+
+ +
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index bd19c400..491151d0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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(null); const [draftEntrypoint, setDraftEntrypoint] = useState(null); - const [, setComposerNotice] = useState(""); const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null); const [cancellationPending, setCancellationPending] = useState(false); const [pendingSessionId, setPendingSessionId] = useState(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 &&

{activeError}

} )} + {jumpToLatestVisible && ( +
+ +
+ )} )} diff --git a/frontend/src/components/admin/admin-antd-registry.tsx b/frontend/src/components/admin/admin-antd-registry.tsx new file mode 100644 index 00000000..0dfd5201 --- /dev/null +++ b/frontend/src/components/admin/admin-antd-registry.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { StyleProvider, createCache, extractStyle } from "@ant-design/cssinjs"; +import { unstableSetRender } from "antd"; +import { useServerInsertedHTML } from "next/navigation"; +import { useState, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +type ReactRootContainer = (Element | DocumentFragment) & { _reactRoot?: Root }; + +unstableSetRender((node, container) => { + const target = container as ReactRootContainer; + target._reactRoot ??= createRoot(target); + const root = target._reactRoot; + root.render(node); + return async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + root.unmount(); + }; +}); + +export function AdminAntdRegistry({ children }: { children: ReactNode }) { + const [cache] = useState(() => createCache()); + useServerInsertedHTML(() => { + const styleText = extractStyle(cache, { plain: true, once: true }); + if (styleText.includes('.data-ant-cssinjs-cache-path{content:"";}')) return null; + return ( +