From 3e0c615fd8e3a186d98d4c22f3fbf125a7073f74 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 30 Sep 2026 13:44:45 +0800 Subject: [PATCH] feat(ui): action results and errors are top-centre toasts, never inline bars; login card centred (BUG-1121, BUG-1122) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- CHANGELOG.md | 9 ++ docs/BUG_HISTORY.md | 28 ++++ frontend/DESIGN.md | 15 ++- frontend/src/app/(app)/layout.tsx | 2 - frontend/src/app/(app)/page.tsx | 10 +- frontend/src/app/globals.css | 72 +++++------ .../src/components/account-deletion-gate.tsx | 8 +- .../components/account-deletion-section.tsx | 11 +- frontend/src/components/app-sidebar.tsx | 5 - frontend/src/components/billing-panel.tsx | 8 -- .../birth-sky/birth-sky-converge.css | 7 - .../components/birth-sky/birth-sky-replay.tsx | 8 +- .../birth-time-candidate-result.tsx | 7 +- .../components/birth-time-choice-question.tsx | 3 +- .../birth-time-legacy-rectification.tsx | 3 +- .../src/components/birth-time-life-events.tsx | 3 +- .../components/birth-time-rectification.tsx | 3 +- .../src/components/chart-profile-form.tsx | 3 - frontend/src/components/email-otp-login.tsx | 121 +++++++++--------- frontend/src/components/error-toast.tsx | 17 +++ frontend/src/components/feedback-dialog.tsx | 10 +- .../src/components/home-onboarding-shell.tsx | 5 - .../components/legal/legal-consent-gate.tsx | 6 +- .../src/components/legal/login-consent.tsx | 10 +- .../components/onboarding-redeem-paywall.tsx | 13 +- .../src/components/people/people-page.tsx | 37 ++---- .../generate-personal-report-button.tsx | 8 +- .../personal-report/raw-appendix-download.tsx | 10 +- .../personal-report/report-export-drawer.tsx | 11 +- .../personal-report/report-row-delete.tsx | 10 +- frontend/src/components/profile-panel.tsx | 6 - frontend/src/components/reply-down-reason.tsx | 19 +-- .../src/components/sidebar-session-row.tsx | 8 +- frontend/src/components/starter-home.tsx | 11 -- frontend/src/components/ui/sonner.tsx | 13 +- frontend/src/hooks/use-billing-panel.ts | 15 +-- .../src/hooks/use-home-shell-registration.ts | 10 +- frontend/src/hooks/use-profile-onboarding.ts | 22 +--- .../src/hooks/use-rectification-surface.ts | 5 + frontend/src/lib/chat-notice.ts | 3 + frontend/src/lib/notify.ts | 69 ++++++++++ frontend/src/lib/session-list-context.tsx | 2 - .../birth-time-guided-review-fixes.test.ts | 6 +- .../feedback-complaints-20260930.test.tsx | 20 ++- .../tests/home-bootstrap-screens.test.tsx | 4 - .../tests/home-warm-return-lifecycle.test.tsx | 2 - .../tests/identity-login-provider.test.ts | 16 ++- .../tests/legal-consent-20260930.test.tsx | 18 ++- .../new-chat-from-people-lifecycle.test.tsx | 2 - frontend/tests/notify-toasts-20260930.test.ts | 48 +++++++ frontend/tests/people-archive-view.test.tsx | 19 ++- frontend/tests/personal-report-entry.test.ts | 13 +- .../tests/profile-gender-ui-20260927.test.tsx | 6 +- .../tests/rectification-agentic-entry.test.ts | 13 +- ...ectification-history-open-20260909.test.ts | 33 ++--- .../tests/reply-quality-20260930.test.tsx | 14 +- frontend/tests/report-export-drawer.test.tsx | 11 +- frontend/tests/report-row-delete.test.tsx | 13 +- .../tests/self-edit-people-20260928.test.tsx | 7 +- .../tests/session-list-lifecycle.test.tsx | 1 - .../starter-home-polish-20260926.test.tsx | 3 - 61 files changed, 523 insertions(+), 372 deletions(-) create mode 100644 frontend/src/components/error-toast.tsx create mode 100644 frontend/src/lib/notify.ts create mode 100644 frontend/tests/notify-toasts-20260930.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77bb1e82..a58b3a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ - 星盘档案读人物列表时显示与星历、报告相同的轨道动画,不再只写「人物列表还在读」;顶部人物切换弹层同样。 - 首页问候下面那句「今天月亮在……」:本人资料不再等人物目录,揭幕时就在;之后出现或更换时淡入(240ms),不再突然把问候顶上去。Skill 版本不 bump。 +## 2026-09-30 — 报错和操作结果改成顶部浮动提示;登录卡片回到正中 + +- 「验证码已发送」「已保存」「保存失败」「删除失败」「兑换成功」这类提示不再插在页面里把内容往下推,统一改成页面顶部正中的浮动小提示:成功 3 秒消失,报错停 6 秒、可以手动关掉。弹窗(反馈、导出、协议更新)里出的提示也显示在弹窗上面。 +- 测试环境的固定验证码不再占登录页一块,点「发送验证码」后在顶部提示里给出(10 秒)。 +- 没勾选同意就点登录:勾选框标红 + 顶部提示,不再在框下插一行字。 +- 登录页底部的「用户协议 · 隐私政策」不再把登录卡片往上顶,卡片回到正中(BUG-1122)。 +- 星盘档案保存失败以前显示成绿色的「成功」样式,现在是红色报错提示(BUG-1121);档案存满 5 人时点「添加一个人」会提示原因。 +- 仍留在页面里的只有页面本身的状态:读不出来并带「重试」的页面、空列表、对话里某条回答失败、校正进度说明。Skill 版本不 bump。 + ## 2026-09-30 — 星盘符号气泡不再闪;星盘表格恢复圆角 - 鼠标停在星盘里的行星、上升或星座编号上,说明气泡会稳定显示;之前鼠标每动一下气泡就消失再出现。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 308203d9..eb543c55 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -15004,3 +15004,31 @@ - 相关记录:BUG-1040(暖快照)、BUG-1104 / 1109 / 1110(今日句库与预取) - 修复版本:分支 `codex/loading-motion-20260930` +## BUG-1121 | 星盘档案保存失败以绿色「成功」样式显示 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-09-30 / 2026-09-30 +- 影响面:`frontend/src/components/chart-profile-form.tsx`(`notice` 一律渲染成 `.form-success`)、`frontend/src/components/people/people-page.tsx`(保存本人 / 他人失败把错误文字写进 `notice` 传给表单)。 +- 现象:星盘档案「编辑」里保存失败(「保存失败,请重试」或接口返回的错误)显示在绿色成功条里,用户会以为已保存。 +- 触发条件:档案编辑表单保存时网络失败或接口返回非 2xx。 +- 根因:表单的 `notice` 只有一种样式(成功),调用方却用同一个通道传失败文案;删除相关的两句另有分支挑出来画红色,保存失败没有。 +- 修复:随全站提示改 toast(2026-09-30):人物页不再有 `notice` 状态,保存 / 删除 / 查询失败一律 `notifyError`(红色 error toast,6 秒,可关);`ChartProfileForm` 删除 `notice` 属性。 +- 验证:`self-edit-people-20260928.test.tsx` 断言本人保存失败走 `notifyError`;`people-archive-view.test.tsx` D3 断言视图不再渲染 `.form-error` / `.form-success`、删除失败走 `notifyError`(三栏注释);前端全量 4,475 条,24 条失败与基线逐条相同。 +- 防复发:结果提示只经 `src/lib/notify.ts`,成功 / 失败由调用函数决定类型,不再有「一个文本槽、一种样式」的通道。 +- 相关记录:BUG-1081(本人档案可编辑) +- 修复版本:分支 `codex/toast-notices-20260930` + +## BUG-1122 | 登录页底部协议链接把登录卡片顶到偏上 + +- 状态:resolved(代码 + CSS 合同测试;待部署,真机欠) +- 首次发现 / 最近更新:2026-09-30 / 2026-09-30 +- 影响面:`frontend/src/app/globals.css` 的 `.auth-page` / `.auth-legal-footer`;`frontend/src/components/legal/login-consent.tsx::LoginLegalFooter`。 +- 现象:产品截图——合规轮加上底部「用户协议 · 隐私政策」后,整个登录卡片明显偏上。 +- 触发条件:用户登录页(`successPath === "/"`)渲染底部法律链接时。 +- 根因:`.auth-page` 是 `display: grid; place-items: center` 的单列网格,页脚作为第二个网格项进入文档流,卡片与页脚被当成一组居中,卡片因此上移页脚高度的一半多。 +- 修复:`.auth-page` 改为三行 `minmax(0,1fr) auto minmax(0,1fr)`,卡片固定第 2 行、页脚第 3 行贴底;767px 以下为 `1fr auto`,页脚在全高卡片之下,不与卡片重叠。 +- 验证:`legal-consent-20260930.test.tsx` 新增 CSS 合同(三行网格、卡片第 2 行、页脚第 3 行贴底、手机两行、不再 `place-items: center`);前端全量无新增失败。真机(iPhone / 桌面)走查欠,见 `docs/testing/compliance-launch-20260930.md`。 +- 防复发:往居中网格里加新元素时给它指定行,不依赖自动排布。 +- 相关记录:合规轮 A(用户协议 / 隐私政策 / 登录同意) +- 修复版本:分支 `codex/toast-notices-20260930` + diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index e00b6983..2398b203 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -567,9 +567,20 @@ one skeleton, none of them carrying the sidebar. The ephemeris, the report list and the people archive (2026-09-30) wait with one motion: the home loading screen's orbit ring at 40px (`OrbitWaiting`), centred in the page. No sentence is shown; the old 「……还没拿到。」 / 「人物列表还在读」 copy is kept only as the screen-reader status. The person picker popover shows the same orbit while its list loads. The chart page is the exception (BUG-1016): since 2026-09-30 the empty chart itself is the wait — faint still lines while the houses light up one after another in house order — with no orbit on top. +### 提示与报错:toast(2026-09-30) + +Action results and errors never take page layout. They are toasts from `src/lib/notify.ts` (`notifySuccess` / `notifyInfo` / `notifyError`; chat notices keep `showChatNotice`, same toaster and durations). + +- **Where:** top centre on every screen (`components/ui/sonner.tsx`, `position="top-center"`), 16px under the safe area; phones get 16px side gutters and full width. The composer lives at the bottom on phones, so the top is the only place that never covers it. +- **Look:** a compact `--color-canvas` card, 1px `--color-border`, `--radius-lg`, `--shadow-elevated`, body-sm text, a coloured icon (success `--color-success`, error `--color-danger`, info `--color-ink-secondary`). No left bar, no tinted background. +- **How long:** success and info 3s; errors 6s with a close button on the right; the staging fixed login code 10s. Messages from one surface share an id, so a new one replaces the last instead of stacking (login uses `auth-notice` / `auth-error`; typing dismisses the stale error). +- **Above modal dialogs:** a toast raised while a modal `` is open (反馈与投诉, 导出, 协议更新, 那一刻的天空) moves the toaster into the top layer as a manual popover, re-shown on every toast, so it is never under the dialog's backdrop. +- **Errors held in flow state** (the birth-time guided flow) are said through ``: once per new message, id = message. +- **What stays in the page:** states that are the page's content, not a report about an action — a pane that failed to load with its own 重试 (chart, ephemeris, report list, report detail, the home bootstrap error, billing packages / payment status); empty states; the failed reply inside a conversation (`.error-message`) and the stopped-reply line; rectification progress and gap notes; the feedback dialog's done panel; a field's own validation inside the evidence draft card. What remains of `.form-error` is plain `--color-danger` caption text, no bar and no background; `.form-success` is gone. + ### Login consent and the legal pages (2026-09-30) -- **Login:** under the form, an unticked checkbox 「我已阅读并同意《用户协议》和《隐私政策》」 (`.auth-consent`, caption size, links underlined, open in a new tab). Sending a code or a password sign-in with the box unticked does nothing but show one red caption line under it; ticking clears it. The operators' admin login (password-only, `/admin`) has no box. A centred caption footer under the login card links 用户协议 · 隐私政策 and, once configured, the ICP number to beian.miit.gov.cn. +- **Login:** under the form, an unticked checkbox 「我已阅读并同意《用户协议》和《隐私政策》」 (`.auth-consent`, caption size, links underlined, open in a new tab). Sending a code or a password sign-in with the box unticked does nothing but mark the box (a `--color-danger` outline, `.auth-consent.is-attention`) and raise the error toast 「请先阅读并勾选同意,再继续。」; ticking clears the mark. The operators' admin login (password-only, `/admin`) has no box. The login page is a three-row grid (`1fr auto 1fr`): the card sits in the middle row, exactly centred, and the caption footer 用户协议 · 隐私政策 (plus the ICP number to beian.miit.gov.cn once configured) takes the bottom row, so it never pushes the card up (BUG-1122). Below 768px the rows are `1fr auto` and the footer sits under the full-height card. - **Account menu:** a caption row at the very bottom, 「用户协议 · 隐私政策」, below 退出登录. - **/terms and /privacy:** public, static, one 720px column on `--color-canvas-soft`; a top row with 「返回 Jyotisha」 and a link to the other document; while the text is a draft, a warning-tinted note 「草稿 · 版本 …,待律师审定」 above the title; h1 in the display face, h2 per section, bullet lists for enumerations. - **Re-consent:** when a final version replaces the one a signed-in user accepted, one modal 「协议已更新」 with the same checkbox and 「同意并继续」; it cannot be dismissed without agreeing. Off while the documents are a draft. @@ -593,7 +604,7 @@ The ephemeris, the report list and the people archive (2026-09-30) wait with one - **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right `⋯` menu containing only 「删除」. It opens the row-local 「确定删除这份报告?」 with 删除 / 取消, never `window.confirm`. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row. - **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut. - **Actions:** “生成报告” in the generate card is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the card above already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time. -- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export is local to the ready reader's drawer and never starts a second writing flow or fetch. Download failures remain in the open drawer; only success closes it and emits a toast. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate. +- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export is local to the ready reader's drawer and never starts a second writing flow or fetch. Download failures raise an error toast and leave the drawer open; only success closes it and emits the 「已导出」 toast. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate. - **Accessibility:** generate, refresh, and row actions are 44px. The generating row's chip is a live region; a failed export is an alert. ### Personal report reader diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 288ba033..047fa425 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -21,8 +21,6 @@ function AppShell({ children }: { children: ReactNode }) { sessions={sessions} activeSessionId={registration?.activeSessionId ?? null} openingSessionId={registration?.openingSessionId ?? null} - openErrorSessionId={registration?.openErrorSessionId ?? null} - openErrorMessage={registration?.openErrorMessage ?? ""} account={account} settled={list.settled} signedOut={list.signedOut} diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 796cae27..02719a09 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -310,7 +310,6 @@ export default function Home() { setProfileDraft, profileNotice, setProfileNotice, - avatarNotice, avatarSaving, profileSaving, refreshAccount, @@ -1074,8 +1073,6 @@ export default function Home() { openAccountDialog, pendingSessionId, profile, - rectificationErrorMessage, - rectificationErrorSessionId, rectificationOpeningSessionId, rectificationSurfaceOpen, registerShellControls, @@ -1128,7 +1125,7 @@ export default function Home() { overlayRef: accountDialog, closeButtonRef: closeButton, renderProfile: () => ( - + ), renderChartLibrary() { return null; @@ -1144,7 +1141,6 @@ export default function Home() { return ( <>

退出后,需要重新登录才能继续查看对话。

- {accountError &&

{accountError}

}
@@ -1229,7 +1225,6 @@ export default function Home() { birthTimeAssessmentPhase={birthTimeAssessmentPhase} birthTimeError={birthTimeError} birthTimeGuided={birthTimeGuided} - accountError={accountError} profileSaving={profileSaving} saveOnboardingBirth={saveOnboardingBirth} saveOnboardingPlace={saveOnboardingPlace} @@ -1330,9 +1325,6 @@ export default function Home() { rectificationMutationPending={rectificationMutationPending} openRectificationFromHomepage={openRectificationFromHomepage} rectificationEntrySummary={rectificationEntrySummary} - rectificationError={rectificationErrorSessionId ? "" : rectificationError} - rectificationSurfaceOpen={rectificationSurfaceOpen} - rectificationErrorMessage={rectificationErrorSessionId ? "" : rectificationErrorMessage} /> )}
} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 864adb0e..34568ffb 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -461,15 +461,27 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 .onboarding-card-actions { display: flex; justify-content: flex-end; padding-top: 2px; } .onboarding-card-actions .button-primary { min-width: 84px; } .birth-time-transition-card { position: relative; overflow: visible; } -[data-sonner-toaster] [data-sonner-toast] { border: 1px solid var(--color-border) !important; border-radius: var(--radius-lg) !important; background: var(--color-canvas) !important; color: var(--color-ink) !important; box-shadow: var(--shadow-elevated) !important; } -[data-sonner-toast] [data-title] { font-size: var(--type-body-sm); font-weight: 600; } +/* Toasts (2026-09-30): the only place action results and errors appear. A + compact card at the top centre with a coloured icon; no left bar, no page + layout taken. Errors carry a close button on the right. */ +[data-sonner-toaster] [data-sonner-toast] { gap: var(--space-2) !important; padding: 10px 14px !important; border: 1px solid var(--color-border) !important; border-radius: var(--radius-lg) !important; background: var(--color-canvas) !important; color: var(--color-ink) !important; box-shadow: var(--shadow-elevated) !important; font-size: var(--type-body-sm) !important; line-height: 1.5; } +[data-sonner-toast] [data-title] { font-size: var(--type-body-sm); font-weight: 500; } [data-sonner-toast] [data-description] { color: var(--color-ink-secondary) !important; font-size: var(--type-caption); line-height: 1.5; } +[data-sonner-toast] [data-icon] { color: var(--color-ink-secondary); } +[data-sonner-toast][data-type="success"] [data-icon] { color: var(--color-success); } +[data-sonner-toast][data-type="error"] [data-icon] { color: var(--color-danger); } +[data-sonner-toast][data-type="error"] { padding-right: 36px !important; } +[data-sonner-toast] [data-close-button] { left: auto !important; right: 8px !important; top: 50% !important; transform: translateY(-50%) !important; border: 0 !important; background: transparent !important; color: var(--color-ink-tertiary) !important; } +[data-sonner-toast] [data-close-button]:hover { color: var(--color-ink) !important; } +/* The toaster host once it has joined the top layer above a modal dialog + (liftToastsAboveModal): a zero-size, transparent anchor; the list inside + keeps its own fixed position. */ +section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页面提示"] { position: fixed; inset: 0 auto auto 0; width: 0; height: 0; margin: 0; padding: 0; overflow: visible; border: 0; background: transparent; } .birth-time-transition-fields { min-width: 0; display: grid; gap: var(--space-4); margin: 0; padding: 0; border: 0; } .birth-time-assessment-overlay { position: absolute; z-index: 2; inset: 0; display: grid; place-items: center; padding: var(--space-6); border-radius: inherit; background: var(--color-canvas-soft); } .birth-time-assessment-progress { width: min(360px, 100%); display: grid; justify-items: center; gap: var(--space-2); color: var(--color-ink-secondary); text-align: center; } .birth-time-assessment-progress strong { margin-top: var(--space-2); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 500; } .birth-time-assessment-progress > span { font-size: var(--type-body-sm); line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } -.onboarding-inline-error { margin-left: 0; } .message-assistant { align-items: flex-start; justify-content: flex-start; gap: var(--space-3); } .message-user { justify-content: flex-end; } @@ -659,8 +671,7 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 .birth-place-retry { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; border: 0; background: transparent; color: var(--color-action); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } .birth-place-retry svg { width: 12px; height: 12px; } .save-profile { justify-self: end; } -.form-error { background: var(--color-danger-muted); color: var(--color-danger); } -.form-success { background: var(--color-success-muted); color: var(--color-success); } +.form-error { color: var(--color-danger); } .standalone-page { width: 100%; height: 100vh; overflow: auto; } .auth-brand strong { font-weight: 600; } @@ -901,18 +912,6 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 font-weight: 500; letter-spacing: .02em; } -.session-open-error { - grid-column: 1 / -1; - margin: 0 var(--space-2) var(--space-2); - padding: 0; - border: 0; - background: transparent; - color: var(--color-danger); - font-size: var(--type-overline); - font-weight: 500; - line-height: 1.35; - letter-spacing: .02em; -} .session-main small, .session-subtitle { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); } .sidebar-group-label { display: flex; align-items: center; gap: 5px; margin: var(--space-2) 0 var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 500; letter-spacing: .02em; } .sidebar-group-label > svg { flex: 0 0 auto; color: var(--color-action); } @@ -1242,7 +1241,6 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 .phrase-nowrap { white-space: nowrap; } -.rectification-entry-error { grid-column: 1 / -1; margin: 0; } .message-list { /* Follow-up chips and the run timeline line up with the assistant's text, which starts past the avatar and its gap. */ @@ -1474,7 +1472,6 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 color: var(--color-ink-secondary); font-size: var(--type-caption); } -.reply-down-reason.is-sent { display: block; border: 0; padding: 0; background: transparent; } .reply-down-reason-head, .reply-down-reason-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); } .reply-down-reason-close { display: grid; place-items: center; min-width: 32px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; } .reply-down-reason-close svg { width: 16px; height: 16px; } @@ -2005,7 +2002,6 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 .avatar-palette:disabled { cursor: wait; opacity: .65; } .avatar-palette:not(:disabled):active { transform: scale(.96); } .avatar-randomize { justify-self: start; } -.avatar-notice { margin-top: var(--space-4); } .redeem-modal { width: min(100%, 420px); } .paywall-modal { width: min(100%, 480px); } .logout-modal { width: min(100%, 400px); } @@ -2078,11 +2074,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .button-primary { border-color: var(--color-action-fill); background: var(--color-action-fill); color: var(--color-on-action-fill); } .button-primary:disabled { opacity: 1; border-color: var(--color-action-fill-disabled); background: var(--color-action-fill-disabled); color: var(--color-on-action-fill-disabled); } .button-secondary { border-color: var(--color-border-strong); background: var(--color-canvas); color: var(--color-ink); } -.form-error, .form-success { margin: var(--space-3) 0 0; padding: var(--space-3); border-left: 3px solid currentColor; line-height: 1.5; border-radius: 0 var(--radius-md) var(--radius-md) 0; font-size: var(--type-caption); } +/* Only what stays in the page uses this (2026-09-30): a field's own validation + and a pane that cannot load. Plain caption text, no bar; action results and + errors are toasts (src/lib/notify.ts). */ +.form-error { margin: var(--space-2) 0 0; line-height: 1.5; font-size: var(--type-caption); } /* Keeps its own label colour: 7.13:1 light, 6.04:1 dark (BUG-1079 D5). */ .danger-primary { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-on-dark); } -.auth-page { display: grid; place-items: center; padding: var(--space-8); background: var(--color-canvas-soft); } +/* Three rows so the legal footer sits at the bottom without pushing the card up + (BUG-1122): the card is centred in the viewport, the footer takes the last row. */ +.auth-page { display: grid; grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr); justify-items: center; padding: var(--space-8) var(--space-8) 0; background: var(--color-canvas-soft); } +.auth-page > .auth-shell { grid-row: 2; } +.auth-page > .auth-legal-footer { grid-row: 3; align-self: end; } .auth-shell { width: min(1040px, 100%); min-height: min(680px, calc(100dvh - 64px)); display: grid; grid-template-columns: 1.08fr .92fr; overflow: hidden; border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); } .auth-story { display: flex; flex-direction: column; justify-content: space-between; padding: var(--space-12); border-right: 1px solid var(--color-border); background: var(--color-canvas-muted); color: var(--color-ink); } .auth-story-brand, .auth-brand { display: flex; align-items: center; gap: var(--space-3); font-family: var(--font-display); font-size: var(--type-title-lg); } @@ -2096,7 +2099,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .auth-consent label, label.auth-consent { display: flex; align-items: flex-start; gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.6; cursor: pointer; } .auth-consent input[type="checkbox"] { flex-shrink: 0; width: 16px; height: 16px; margin-top: 3px; accent-color: var(--color-action); } .auth-consent a { color: var(--color-ink); text-decoration: underline; text-underline-offset: 2px; } -.auth-consent-hint { margin: 0; color: var(--color-danger); font-size: var(--type-caption); } +/* A try without ticking marks the box; the sentence itself is a toast (2026-09-30). */ +.auth-consent.is-attention input[type="checkbox"] { outline: 2px solid var(--color-danger); outline-offset: 2px; } +.auth-consent.is-attention label { color: var(--color-ink); } .auth-legal-footer { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2); padding: var(--space-4); color: var(--color-ink-tertiary); font-size: var(--type-caption); } .auth-legal-footer a { color: inherit; text-decoration: none; } .auth-legal-footer a:hover { color: var(--color-ink); text-decoration: underline; } @@ -2127,8 +2132,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .auth-brand { align-items: center; gap: var(--space-3); font-size: var(--type-title-lg); display: none; margin-bottom: var(--space-10); } .auth-panel h1 { font-size: var(--type-display-md); } .page-intro { line-height: 1.6; margin: var(--space-3) 0 var(--space-8); color: var(--color-ink-secondary); font-size: var(--type-body-md); } -.auth-test-channel { margin: 0 0 var(--space-6); padding: var(--space-3); border: 1px dashed color-mix(in srgb, var(--color-action) 40%, var(--color-border)); border-radius: var(--radius-md); background: var(--color-action-soft); color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); } -.auth-test-channel strong { color: var(--color-action); font-family: var(--font-mono); font-size: 15px; font-variant-numeric: tabular-nums; letter-spacing: .12em; } .stack-form { display: grid; gap: var(--space-3); } .stack-form label { font-size: 13px; } .stack-form .button-primary { margin-top: var(--space-2); } @@ -2225,7 +2228,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .chart-library-group-heading { align-items: flex-start; } .chart-library-group-heading-actions { align-items: flex-end; flex-direction: column; gap: var(--space-1); } .chart-library-add--heading { min-height: 40px; padding-inline: var(--space-3); } - .auth-page { height: 100vh; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0; } + .auth-page { height: 100vh; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0; grid-template-rows: minmax(0, 1fr) auto; justify-items: stretch; } + .auth-page > .auth-shell { grid-row: 1; } + .auth-page > .auth-legal-footer { grid-row: 2; background: var(--color-canvas); } .auth-shell { min-height: 100%; overflow: visible; align-content: start; grid-template-columns: 1fr; grid-template-rows: auto auto; border-radius: 0; box-shadow: none; } .auth-story { min-height: 0; justify-content: flex-start; gap: var(--space-3); padding: var(--space-6) var(--space-6) var(--space-5); } .auth-story h2 { max-width: 560px; margin: var(--space-3) 0 0; font-size: var(--type-display-sm); } @@ -2265,7 +2270,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } @media (max-width: 480px) { - [data-sonner-toaster] { right: var(--space-4) !important; left: var(--space-4) !important; width: auto !important; } .conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; } .conversational-narrative-body, .conversational-composer, .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-empty-state { padding: var(--space-3); } .conversational-candidate dl { grid-template-columns: minmax(0, 1fr); } @@ -3974,12 +3978,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class justify-content: flex-end; gap: var(--space-2); } -.report-center-delete-error { - flex-basis: 100%; - color: var(--color-danger); - font-size: var(--type-body-sm); - text-align: right; -} /* The page's generate entry: one centred card above the archive (2026-09-29, product sketch). Same sheet as an empty state used to be, so a first visit still reads calm; the button inside is the page's one filled action. */ @@ -4051,8 +4049,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-center-row { grid-template-columns: 1fr; gap: var(--space-3); } .report-center-row-actions { justify-content: flex-start; justify-self: start; } .report-center-failure { justify-self: start; } - .report-center-delete-error { text-align: left; } - .report-center-section-heading { align-items: center; } + .report-center-section-heading { align-items: center; } } .report-center-delete { grid-column: 1 / -1; } @@ -4171,7 +4168,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .feedback-dialog-field input { width: 100%; padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font: inherit; font-size: var(--type-body-md); } .feedback-dialog-field textarea { resize: vertical; min-height: 120px; } .feedback-dialog-check { display: flex; align-items: center; gap: var(--space-2); min-height: 44px; font-size: var(--type-body-sm); color: var(--color-ink-secondary); } -.feedback-dialog-error { margin: 0; color: var(--color-danger); font-size: var(--type-body-sm); } .feedback-dialog-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); } .feedback-dialog-done p { margin: 0; } .feedback-dialog button:focus-visible, @@ -6272,8 +6268,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-weight: 500; cursor: pointer; } -.people-archive-add:hover:not(:disabled) { background: var(--color-action-soft); } -.people-archive-add:disabled { color: var(--color-ink-tertiary); cursor: not-allowed; } +.people-archive-add:hover:not([aria-disabled="true"]) { background: var(--color-action-soft); } +.people-archive-add[aria-disabled="true"] { color: var(--color-ink-tertiary); cursor: not-allowed; } .people-archive-hint { margin: 0; color: var(--color-ink-tertiary); diff --git a/frontend/src/components/account-deletion-gate.tsx b/frontend/src/components/account-deletion-gate.tsx index e57154ba..18d845f9 100644 --- a/frontend/src/components/account-deletion-gate.tsx +++ b/frontend/src/components/account-deletion-gate.tsx @@ -9,6 +9,7 @@ import { } from "@/lib/account-deletion"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { selfHostedOtpActions } from "@/modules/identity/client"; +import { notifyError } from "@/lib/notify"; /** * 「账号注销中」 (2026-09-30). An account with a pending deletion that signs in @@ -19,7 +20,6 @@ import { selfHostedOtpActions } from "@/modules/identity/client"; export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean }>) { const [status, setStatus] = useState({ status: "none" }); const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); useEffect(() => { if (!signedIn) return; @@ -36,19 +36,18 @@ export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean } async function restore() { if (busy) return; setBusy(true); - setError(""); try { const response = await fetch("/api/account/deletion", { method: "DELETE", credentials: "same-origin", headers: { Accept: "application/json" } }); if (!response.ok) { const json = await response.json().catch(() => null) as { error?: unknown } | null; - setError(typeof json?.error === "string" ? json.error : "撤销暂时没能完成,请稍后再试。"); + notifyError(typeof json?.error === "string" ? json.error : "撤销暂时没能完成,请稍后再试。"); setBusy(false); return; } clearHomeWarmSnapshot(); window.location.reload(); } catch { - setError("网络异常,请检查连接后重试。"); + notifyError("网络异常,请检查连接后重试。"); setBusy(false); } } @@ -71,7 +70,6 @@ export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean }

这个账号将在 {formatAccountDeletionDate(status.scheduledFor)} 永久删除。在那之前,你可以撤销注销,恢复全部内容。

- {error ?

{error}

: null}
diff --git a/frontend/src/components/account-deletion-section.tsx b/frontend/src/components/account-deletion-section.tsx index 3bd39a92..d48ee47d 100644 --- a/frontend/src/components/account-deletion-section.tsx +++ b/frontend/src/components/account-deletion-section.tsx @@ -10,6 +10,7 @@ import { } from "@/lib/account-deletion"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { LEGAL_ENTITY } from "@/lib/legal-entity"; +import { notifyError } from "@/lib/notify"; /** * 「注销账号」 at the bottom of 通用设置 (2026-09-30). The confirmation opens in @@ -22,12 +23,10 @@ export function AccountDeletionSection() { const [open, setOpen] = useState(false); const [confirm, setConfirm] = useState(""); const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(""); async function submit() { if (submitting || !isAccountDeletionConfirmation(confirm)) return; setSubmitting(true); - setError(""); try { const response = await fetch("/api/account/deletion", { method: "POST", @@ -37,14 +36,14 @@ export function AccountDeletionSection() { }); const json = await response.json().catch(() => null) as { error?: unknown } | null; if (!response.ok) { - setError(typeof json?.error === "string" ? json.error : "注销申请暂时没能提交,请稍后再试。"); + notifyError(typeof json?.error === "string" ? json.error : "注销申请暂时没能提交,请稍后再试。"); setSubmitting(false); return; } clearHomeWarmSnapshot(); window.location.assign("/login"); } catch { - setError("网络异常,请检查连接后重试。"); + notifyError("网络异常,请检查连接后重试。"); setSubmitting(false); } } @@ -73,12 +72,10 @@ export function AccountDeletionSection() { value={confirm} onChange={(event) => setConfirm(event.target.value)} autoComplete="off" - aria-describedby={error ? "account-deletion-error" : undefined} /> - {error ? : null}
- +
- {redeemError &&

{redeemError}

} - {redeemMessage &&

{redeemMessage}

} {redeemDone && (
diff --git a/frontend/src/components/birth-sky/birth-sky-converge.css b/frontend/src/components/birth-sky/birth-sky-converge.css index d3ebc721..b5bbda58 100644 --- a/frontend/src/components/birth-sky/birth-sky-converge.css +++ b/frontend/src/components/birth-sky/birth-sky-converge.css @@ -60,10 +60,3 @@ .birth-sky-control:focus-visible { outline: 2px solid #f2f0ea; outline-offset: 2px; } .birth-sky-control svg { width: 20px; height: 20px; } .birth-sky-share { border: 1px solid color-mix(in srgb, #f2f0ea 42%, transparent); } -.birth-sky-control-error { - flex-basis: 100%; - margin: var(--space-2) 0 0; - text-align: right; - color: #b3afa4; - font-size: var(--type-body-sm); -} diff --git a/frontend/src/components/birth-sky/birth-sky-replay.tsx b/frontend/src/components/birth-sky/birth-sky-replay.tsx index ea2258e9..c1517241 100644 --- a/frontend/src/components/birth-sky/birth-sky-replay.tsx +++ b/frontend/src/components/birth-sky/birth-sky-replay.tsx @@ -1,12 +1,13 @@ "use client"; import { Share, X } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef } from "react"; import { BirthSkyConverge } from "./birth-sky-converge"; import type { BirthSkyCover } from "@/lib/birth-sky/cover"; import { saveBirthSkyImage } from "@/lib/birth-sky/export"; +import { notifyError } from "@/lib/notify"; /** VOICE.md: the one control that hands the poster out; share sheet on a phone, download elsewhere. */ export const BIRTH_SKY_SHARE_LABEL = "分享"; @@ -23,16 +24,14 @@ export const BIRTH_SKY_SHARE_FAILED = "没能分享,再试一次。"; export function BirthSkyReplay({ cover, onClose }: { cover: BirthSkyCover; onClose: () => void }) { const reveal = useMemo(() => ({ sky: cover.sky, serif: cover.serif }), [cover.sky, cover.serif]); const sharing = useRef(false); - const [error, setError] = useState(null); async function share() { if (sharing.current) return; sharing.current = true; - setError(null); try { await saveBirthSkyImage(cover.blob); } catch { - setError(BIRTH_SKY_SHARE_FAILED); + notifyError(BIRTH_SKY_SHARE_FAILED); } finally { sharing.current = false; } @@ -52,7 +51,6 @@ export function BirthSkyReplay({ cover, onClose }: { cover: BirthSkyCover; onClo
)} /> diff --git a/frontend/src/components/birth-time-candidate-result.tsx b/frontend/src/components/birth-time-candidate-result.tsx index d1509616..369728fb 100644 --- a/frontend/src/components/birth-time-candidate-result.tsx +++ b/frontend/src/components/birth-time-candidate-result.tsx @@ -3,6 +3,7 @@ import type { JourneyClientResponse } from "@/lib/birth-time-journey-client"; import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey"; import { guidedTerminalPath } from "@/lib/birth-time-guided-terminal"; +import { ErrorToast } from "@/components/error-toast"; type CandidateResultProps = { readonly journey: JourneyClientResponse; @@ -52,7 +53,7 @@ export function BirthTimeCandidateResult({ journey, controller, error }: Candida

系统不会选择或应用未经证据支持的具体分钟,当前排盘使用时间保持不变。

{terminalPath && } - {!terminalPath && error ?

{error}

: null} + {!terminalPath ? : null}
); } @@ -135,7 +136,7 @@ export function BirthTimeCandidateResult({ journey, controller, error }: Candida
)} - {!terminalPath && error ?

{error}

: null} + {!terminalPath ? : null} ); } @@ -150,7 +151,7 @@ function TerminalAction({ controller, error, path }: { 尚未达到采用条件

候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。

- {error ?

{error}

: null} + 会建立新的记录,当前结果仍会保留。 ); diff --git a/frontend/src/components/birth-time-choice-question.tsx b/frontend/src/components/birth-time-choice-question.tsx index c1e7aa7b..f2a3b624 100644 --- a/frontend/src/components/birth-time-choice-question.tsx +++ b/frontend/src/components/birth-time-choice-question.tsx @@ -13,6 +13,7 @@ import type { DynamicJourneyProgress, PublicDynamicChoiceQuestion, } from "@/lib/birth-time-journey-turn-protocol"; +import { ErrorToast } from "@/components/error-toast"; type SharedQuestionProps = { readonly progress: DynamicJourneyProgress; @@ -145,7 +146,7 @@ function JourneyControls(props: SharedQuestionProps) { - {props.error &&

{props.error}

} + ); } diff --git a/frontend/src/components/birth-time-legacy-rectification.tsx b/frontend/src/components/birth-time-legacy-rectification.tsx index 5e3dc65b..7309b902 100644 --- a/frontend/src/components/birth-time-legacy-rectification.tsx +++ b/frontend/src/components/birth-time-legacy-rectification.tsx @@ -8,6 +8,7 @@ import { assistantIntentCopy } from "@/lib/birth-time-intake-model"; import type { LegacyJourneyClientResponse } from "@/lib/birth-time-journey-response-schema"; import { guidedTurnIdentity } from "@/lib/birth-time-guided-turn-identity"; import type { NextAction } from "@/lib/birth-time-journey-turn"; +import { ErrorToast } from "@/components/error-toast"; type LegacyRectificationProps = { readonly journey: LegacyJourneyClientResponse; @@ -83,7 +84,7 @@ export function BirthTimeLegacyRectification(props: LegacyRectificationProps) { journey={props.journey} /> ) : null} - {error && !showsCandidate ?

{error}

: null} + {error && !showsCandidate ? : null} ); } diff --git a/frontend/src/components/birth-time-life-events.tsx b/frontend/src/components/birth-time-life-events.tsx index f9ea0dfb..5c58d49a 100644 --- a/frontend/src/components/birth-time-life-events.tsx +++ b/frontend/src/components/birth-time-life-events.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from "react"; import { lifeEventSchema } from "@/lib/birth-time-journey"; import type { LifeEvent } from "@/lib/birth-time-evidence"; +import { ErrorToast } from "@/components/error-toast"; type EventDraft = { readonly rowId: string; @@ -191,7 +192,7 @@ export function BirthTimeLifeEvents({ ))} - {error &&

{error}

} +
: null} diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index 1720dc49..874929f9 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -3,8 +3,9 @@ import Image from "next/image"; import { FormEvent, useState } from "react"; -import { LoginConsentField, LoginLegalFooter } from "@/components/legal/login-consent"; +import { LOGIN_CONSENT_HINT, LoginConsentField, LoginLegalFooter } from "@/components/legal/login-consent"; import { recordLegalConsent } from "@/lib/legal-consent-client"; +import { dismissNotice, notifyError, notifyInfo, notifySuccess, NOTICE_DURATION_MS } from "@/lib/notify"; import { createBrowserSupabaseClient } from "@/lib/supabase/client"; import { selfHostedAuthActions } from "@/modules/identity/client"; @@ -13,6 +14,17 @@ type AuthMode = "otp" | "password" | "register" | "forgot"; type AuthStep = "email" | "otp" | "two-factor" | "set-password" | "existing"; type MfaMethod = "totp" | "backup-code"; +/** Results and errors are toasts (2026-09-30); one id each, so a new one replaces the last. */ +const AUTH_NOTICE = "auth-notice"; +const AUTH_ERROR = "auth-error"; +/** The staging fixed code stays up long enough to type it. */ +const TEST_OTP_NOTICE_MS = 10_000; + +function clearAuthNotices() { + dismissNotice(AUTH_NOTICE); + dismissNotice(AUTH_ERROR); +} + function authMessage(caught: unknown) { const message = caught instanceof Error ? caught.message : "暂时无法登录"; const lower = message.toLowerCase(); @@ -57,8 +69,6 @@ export function EmailOtpLogin({ const [confirmation, setConfirmation] = useState(""); const [mfaMethod, setMfaMethod] = useState("totp"); const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [notice, setNotice] = useState(""); const [consentAccepted, setConsentAccepted] = useState(false); const [consentHint, setConsentHint] = useState(false); @@ -73,6 +83,7 @@ export function EmailOtpLogin({ function consentGiven(): boolean { if (!showConsent || consentAccepted) return true; setConsentHint(true); + notifyError(LOGIN_CONSENT_HINT, { id: AUTH_ERROR }); return false; } @@ -100,8 +111,8 @@ export function EmailOtpLogin({ setPassword(""); setConfirmation(""); setMfaMethod("totp"); - setError(""); - setNotice(nextNotice); + clearAuthNotices(); + if (nextNotice) notifySuccess(nextNotice, { id: AUTH_NOTICE }); } async function sendOtp(event?: FormEvent) { @@ -110,8 +121,7 @@ export function EmailOtpLogin({ if (!normalizedEmail || busy) return; if (mode !== "forgot" && !consentGiven()) return; setBusy(true); - setError(""); - setNotice(""); + clearAuthNotices(); try { if (provider === "self-hosted") { if (mode === "forgot") { @@ -128,14 +138,20 @@ export function EmailOtpLogin({ if (otpError) throw otpError; } setStep("otp"); - setNotice( - mode === "forgot" - ? "如果该邮箱可用,验证码已发送,请检查收件箱" - : `验证码已发送至 ${normalizedEmail}`, - ); + if (testMode) { + // Staging only: the fixed code rides on this toast instead of a box on the page. + notifyInfo(`测试环境:验证码固定为 ${testOtp},不会发送真实邮件。`, { id: AUTH_NOTICE, duration: TEST_OTP_NOTICE_MS }); + } else { + notifySuccess( + mode === "forgot" + ? "如果该邮箱可用,验证码已发送,请检查收件箱" + : `验证码已发送至 ${normalizedEmail}`, + { id: AUTH_NOTICE }, + ); + } } catch (caught) { if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); + notifyError(authMessage(caught), { id: AUTH_ERROR }); } finally { setBusy(false); } @@ -145,25 +161,25 @@ export function EmailOtpLogin({ event.preventDefault(); if (!token || busy) return; setBusy(true); - setError(""); + dismissNotice(AUTH_ERROR); try { if (provider === "self-hosted") { const result = await selfHostedAuthActions.verify(email, token); if (result.twoFactorRequired) { setStep("two-factor"); setToken(""); - setNotice("请输入验证器动态码,或使用一枚未使用的恢复码"); + notifyInfo("请输入验证器动态码,或使用一枚未使用的恢复码", { id: AUTH_NOTICE, duration: NOTICE_DURATION_MS.error }); return; } const hasPassword = await selfHostedAuthActions.hasPassword(); if (!hasPassword) { setStep("set-password"); - setNotice("邮箱验证成功,请设置登录密码"); + notifySuccess("邮箱验证成功,请设置登录密码", { id: AUTH_NOTICE }); return; } if (mode === "register") { setStep("existing"); - setNotice("此邮箱已有账户,您已登录;原密码未被更改"); + notifyInfo("此邮箱已有账户,您已登录;原密码未被更改", { id: AUTH_NOTICE, duration: NOTICE_DURATION_MS.error }); return; } } else { @@ -178,7 +194,7 @@ export function EmailOtpLogin({ await enterApp(); } catch (caught) { if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); + notifyError(authMessage(caught), { id: AUTH_ERROR }); } finally { setBusy(false); } @@ -189,21 +205,20 @@ export function EmailOtpLogin({ if (!email.trim() || !password || busy) return; if (!consentGiven()) return; setBusy(true); - setError(""); - setNotice(""); + clearAuthNotices(); try { const result = await selfHostedAuthActions.signInWithPassword(email, password); if (result.twoFactorRequired) { setStep("two-factor"); setToken(""); setPassword(""); - setNotice("请输入验证器动态码,或使用一枚未使用的恢复码"); + notifyInfo("请输入验证器动态码,或使用一枚未使用的恢复码", { id: AUTH_NOTICE, duration: NOTICE_DURATION_MS.error }); return; } await enterApp(); } catch (caught) { if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); + notifyError(authMessage(caught), { id: AUTH_ERROR }); setBusy(false); } } @@ -212,13 +227,13 @@ export function EmailOtpLogin({ event.preventDefault(); if (!token || busy) return; setBusy(true); - setError(""); + dismissNotice(AUTH_ERROR); try { await selfHostedAuthActions.verifyTwoFactor(token, mfaMethod); await enterApp(); } catch (caught) { if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); + notifyError(authMessage(caught), { id: AUTH_ERROR }); setBusy(false); } } @@ -227,12 +242,12 @@ export function EmailOtpLogin({ event.preventDefault(); const validationError = passwordError(password, confirmation); if (validationError) { - setError(validationError); + notifyError(validationError, { id: AUTH_ERROR }); return; } if (busy) return; setBusy(true); - setError(""); + dismissNotice(AUTH_ERROR); try { await selfHostedAuthActions.setPassword(password); await enterApp(); @@ -241,9 +256,9 @@ export function EmailOtpLogin({ const message = authMessage(caught); if (message.includes("原密码未被更改")) { setStep("existing"); - setNotice(message); + notifyInfo(message, { id: AUTH_NOTICE, duration: NOTICE_DURATION_MS.error }); } else { - setError(message); + notifyError(message, { id: AUTH_ERROR }); } setBusy(false); } @@ -253,18 +268,18 @@ export function EmailOtpLogin({ event.preventDefault(); const validationError = passwordError(password, confirmation); if (validationError) { - setError(validationError); + notifyError(validationError, { id: AUTH_ERROR }); return; } if (!token || busy) return; setBusy(true); - setError(""); + dismissNotice(AUTH_ERROR); try { await selfHostedAuthActions.resetPassword(email, token, password); chooseMode("password", "密码已重置,请使用新密码登录"); } catch (caught) { if (!(caught instanceof Error)) throw caught; - setError(authMessage(caught)); + notifyError(authMessage(caught), { id: AUTH_ERROR }); } finally { setBusy(false); } @@ -276,8 +291,7 @@ export function EmailOtpLogin({ setPassword(""); setConfirmation(""); setMfaMethod("totp"); - setError(""); - setNotice(""); + clearAuthNotices(); } const title = @@ -333,12 +347,6 @@ export function EmailOtpLogin({

{title}

{intro}

- {testMode && ( -

- 测试环境:验证码固定为 {testOtp},不会发送真实邮件。 -

- )} - {showLoginNavigation && (
setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} /> - {accountError &&

{accountError}

}
{birthTimeContinueHint ?

{birthTimeContinueHint}

: } @@ -113,7 +110,6 @@ export function HomeOnboardingShell({
出生地点搜索全球城市或区县,系统会匹配出生当日的时区。
- {accountError &&

{accountError}

}
@@ -142,7 +138,6 @@ export function HomeOnboardingShell({
)} - {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} ); } diff --git a/frontend/src/components/legal/legal-consent-gate.tsx b/frontend/src/components/legal/legal-consent-gate.tsx index 999939d8..7f814be0 100644 --- a/frontend/src/components/legal/legal-consent-gate.tsx +++ b/frontend/src/components/legal/legal-consent-gate.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { CURRENT_LEGAL_VERSION, LEGAL_RECONSENT_BLOCKING } from "@/lib/legal-consent"; import { recordLegalConsent } from "@/lib/legal-consent-client"; +import { notifyError } from "@/lib/notify"; /** One check per tab and version: the answer does not change while the tab is open. */ let checkedVersion: string | null = null; @@ -20,7 +21,6 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) { const [required, setRequired] = useState(false); const [accepted, setAccepted] = useState(false); const [busy, setBusy] = useState(false); - const [failed, setFailed] = useState(false); const dialog = useRef(null); useEffect(() => { @@ -43,14 +43,13 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) { async function agree() { if (!accepted || busy) return; setBusy(true); - setFailed(false); const ok = await recordLegalConsent(); setBusy(false); if (ok) { dialog.current?.close(); setRequired(false); } else { - setFailed(true); + notifyError("没能记录,请稍后再试。"); } } @@ -67,7 +66,6 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) { 《隐私政策》 - {failed &&

没能记录,请稍后再试。

}
); diff --git a/frontend/src/components/legal/login-consent.tsx b/frontend/src/components/legal/login-consent.tsx index 53b9b1ea..ef46f178 100644 --- a/frontend/src/components/legal/login-consent.tsx +++ b/frontend/src/components/legal/login-consent.tsx @@ -2,14 +2,19 @@ import Link from "next/link"; import { LEGAL_ENTITY } from "@/lib/legal-entity"; -/** The consent box under the login form (2026-09-30). Unticked until the user ticks it. */ +export const LOGIN_CONSENT_HINT = "请先阅读并勾选同意,再继续。"; + +/** + * The consent box under the login form (2026-09-30). Unticked until the user ticks it. + * `hint` marks the box after a try without ticking; the sentence itself is a toast. + */ export function LoginConsentField({ checked, hint, onChange }: { checked: boolean; hint: boolean; onChange: (checked: boolean) => void; }) { return ( -
+
- {hint &&

请先阅读并勾选同意,再继续。

}
); } diff --git a/frontend/src/components/onboarding-redeem-paywall.tsx b/frontend/src/components/onboarding-redeem-paywall.tsx index 679f19bb..f62bfcfc 100644 --- a/frontend/src/components/onboarding-redeem-paywall.tsx +++ b/frontend/src/components/onboarding-redeem-paywall.tsx @@ -6,6 +6,7 @@ import type { FormEvent } from "react"; import { createPortal } from "react-dom"; import { keepFocusWithin } from "@/lib/focus-trap"; import { notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership"; +import { notifyError, notifySuccess } from "@/lib/notify"; type OnboardingRedeemPaywallProps = { credits: number; @@ -21,8 +22,6 @@ export function OnboardingRedeemPaywall({ onOpenBilling, }: OnboardingRedeemPaywallProps) { const [code, setCode] = useState(""); - const [error, setError] = useState(""); - const [message, setMessage] = useState(""); const [pending, setPending] = useState(false); const [unlocked, setUnlocked] = useState(false); const inputRef = useRef(null); @@ -45,8 +44,6 @@ export function OnboardingRedeemPaywall({ const trimmedCode = code.trim(); if (!trimmedCode || pending || unlocked) return; setPending(true); - setError(""); - setMessage(""); try { const response = await fetch("/api/redeem", { method: "POST", @@ -64,10 +61,10 @@ export function OnboardingRedeemPaywall({ onCreditsChanged(result.credits); setCode(""); setUnlocked(true); - setMessage(result.message || `已到账 ${awarded} 点,现在可以开始完整咨询。`); + notifySuccess(result.message || `已到账 ${awarded} 点,现在可以开始完整咨询。`); notifyBalanceChanged(result.credits); } catch (caught) { - setError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试"); + notifyError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试"); } finally { setPending(false); } @@ -110,8 +107,6 @@ export function OnboardingRedeemPaywall({ value={code} onChange={(event) => { setCode(event.target.value); - setError(""); - setMessage(""); setUnlocked(false); }} placeholder="输入完整兑换码" @@ -120,8 +115,6 @@ export function OnboardingRedeemPaywall({ {pending ? "兑换中" : unlocked ? "已解锁" : "兑换并继续"}
- {error &&

{error}

} - {message &&

{message}

}
{unlocked ? "点数已到账,回到输入框点发送就行。" : "还没有兑换码?"} diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index b39784d5..4b7a5a3e 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -12,6 +12,7 @@ import { fetchAccount } from "@/lib/home-cloud-sync"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { invalidateEphemerisPage, pinChartSnapshotIdentity } from "@/lib/secondary-page-data"; import { saveSelfGender, saveSelfProfile } from "@/lib/self-profile-save"; +import { notifyError, notifyInfo } from "@/lib/notify"; import { useSessionList } from "@/hooks/use-session-list"; import { missingOtherProfileStep, @@ -40,7 +41,6 @@ export type PersonRow = { export type Usage = { id: string; sessions: number; reports: number }; const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试"; -const DELETE_FAILED = "删除失败,请重试"; /** `?person=new` marks the add form; a reload or Forward onto it shows the list. */ const NEW_PERSON_PARAM = "new"; @@ -80,7 +80,6 @@ export function PeoplePage() { const [view, setView] = useState<"list" | "detail">("list"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(emptyProfile); - const [notice, setNotice] = useState(""); const [pendingDelete, setPendingDelete] = useState(null); const [saving, setSaving] = useState(false); const pushedDetail = useRef(false); @@ -173,14 +172,15 @@ export function PeoplePage() { function selectPerson(id: string) { setEditing(false); setPendingDelete(null); - setNotice(""); showDetail(id); } function startAdd() { - if (atLimit) return; + if (atLimit) { + notifyInfo(LIMIT_COPY); + return; + } setDraft(emptyProfile); - setNotice(""); setEditing(true); setPendingDelete(null); showDetail(""); @@ -188,7 +188,6 @@ export function PeoplePage() { function startEdit(profile: Profile) { setDraft(profile); - setNotice(""); setPendingDelete(null); setEditing(true); } @@ -197,7 +196,6 @@ export function PeoplePage() { event.preventDefault(); if (missingOtherProfileStep(draft) || saving) return; setSaving(true); - setNotice(""); try { const editingOther = selectedId && selected?.role === "other" ? selected.id : ""; const response = await fetch(editingOther ? `/api/chart-profiles/${editingOther}` : "/api/chart-profiles", { @@ -207,11 +205,10 @@ export function PeoplePage() { }); const payload = await response.json().catch(() => null) as { error?: string; profile?: { id?: string } } | null; if (!response.ok) { - setNotice(payload?.error || "保存失败,请重试"); + notifyError(payload?.error || "保存失败,请重试"); return; } setEditing(false); - setNotice(""); invalidateSubjectCatalog(); await load(); if (payload?.profile?.id) { @@ -219,7 +216,7 @@ export function PeoplePage() { replaceDetailParam(payload.profile.id); } } catch { - setNotice("保存失败,请重试"); + notifyError("保存失败,请重试"); } finally { setSaving(false); } @@ -237,7 +234,6 @@ export function PeoplePage() { const current = people.find((person) => person.role === "self"); if (!current || missingOtherProfileStep(draft) || saving) return; setSaving(true); - setNotice(""); try { const saved = await saveSelfProfile(draft); const gender = draft.gender ?? null; @@ -255,7 +251,7 @@ export function PeoplePage() { invalidateSubjectCatalog(); await load(); } catch (caught) { - setNotice(caught instanceof Error && caught.message ? caught.message : "保存失败,请重试"); + notifyError(caught instanceof Error && caught.message ? caught.message : "保存失败,请重试"); } finally { setSaving(false); } @@ -264,7 +260,6 @@ export function PeoplePage() { async function askDelete() { if (!selected || selected.role !== "other") return; setPendingDelete(null); - setNotice(""); try { const response = await fetch(`/api/chart-profiles/${selected.id}`, { cache: "no-store" }); const payload = await response.json().catch(() => null) as { usage?: Omit } | null; @@ -273,7 +268,7 @@ export function PeoplePage() { || !Number.isInteger(usage.reports) || usage.reports < 0) throw new Error("usage_unavailable"); setPendingDelete({ id: selected.id, ...usage }); } catch { - setNotice(DELETE_USAGE_UNAVAILABLE); + notifyError(DELETE_USAGE_UNAVAILABLE); } } @@ -281,7 +276,7 @@ export function PeoplePage() { if (!selected || selected.role !== "other" || pendingDelete?.id !== selected.id) return; const response = await fetch(`/api/chart-profiles/${selected.id}`, { method: "DELETE" }); if (!response.ok) { - setNotice("删除失败,请重试"); + notifyError("删除失败,请重试"); return; } if (subjectId === selected.id) setCurrentSubject("self"); @@ -315,7 +310,6 @@ export function PeoplePage() { view={view} editing={editing} draft={draft} - notice={notice} pendingDelete={pendingDelete} saving={saving} atLimit={atLimit} @@ -354,7 +348,6 @@ export type PeopleArchiveViewProps = { readonly view: "list" | "detail"; readonly editing: boolean; readonly draft: Profile; - readonly notice: string; readonly pendingDelete: Usage | null; readonly saving: boolean; readonly atLimit: boolean; @@ -384,7 +377,6 @@ export function PeopleArchiveView({ view, editing, draft, - notice, pendingDelete, saving, atLimit, @@ -402,7 +394,6 @@ export function PeopleArchiveView({ openPerson, }: PeopleArchiveViewProps) { const remaining = MAX_SAVED_SUBJECTS - people.length; - const deleteNotice = notice === DELETE_USAGE_UNAVAILABLE || notice === DELETE_FAILED; const editingSelf = editing && selectedId !== "" && selected?.role === "self"; return (
@@ -433,10 +424,11 @@ export function PeopleArchiveView({ ); })} - - {atLimit ?

{LIMIT_COPY}

:

还可以保存 {remaining} 个人

} +

{atLimit ? LIMIT_COPY : `还可以保存 ${remaining} 个人`}

- {notice ?

{notice}

: null}
) : null} {/* D3: deleting someone lives at the bottom of their edit view; 本人 never gets it. */} @@ -520,7 +510,6 @@ export function PeopleArchiveView({ ) : null} - {deleteNotice ?

{notice}

: null} ) : null} diff --git a/frontend/src/components/personal-report/generate-personal-report-button.tsx b/frontend/src/components/personal-report/generate-personal-report-button.tsx index f835eccf..7cdd5157 100644 --- a/frontend/src/components/personal-report/generate-personal-report-button.tsx +++ b/frontend/src/components/personal-report/generate-personal-report-button.tsx @@ -20,7 +20,7 @@ import { readCurrentSubjectId } from "@/lib/current-subject"; import { navigateAppPath } from "@/lib/app-navigation"; import { FileText } from "lucide-react"; -import { toast } from "sonner"; +import { notifyError } from "@/lib/notify"; import { Button } from "@/components/ui/button"; @@ -162,7 +162,7 @@ export function GeneratePersonalReportButton({ try { const requestId = createReportRequestId(); if (requestId === null) { - toast.error("请使用支持安全请求标识的现代浏览器。"); + notifyError("请使用支持安全请求标识的现代浏览器。"); return; } const body = buildPersonalReportCreateRequest(requestId); @@ -190,9 +190,9 @@ export function GeneratePersonalReportButton({ } return; } - toast.error(outcome.message); + notifyError(outcome.message); } catch { - toast.error("网络异常,请检查连接后重试。"); + notifyError("网络异常,请检查连接后重试。"); } finally { inFlight.current = false; setSubmitting(false); diff --git a/frontend/src/components/personal-report/raw-appendix-download.tsx b/frontend/src/components/personal-report/raw-appendix-download.tsx index 2db75298..c69e4614 100644 --- a/frontend/src/components/personal-report/raw-appendix-download.tsx +++ b/frontend/src/components/personal-report/raw-appendix-download.tsx @@ -4,14 +4,13 @@ import { useState } from "react"; import { FileText } from "lucide-react"; import { Button } from "@/components/ui/button"; import { downloadMarkdownReport } from "@/lib/consultation-report-export"; +import { notifyError } from "@/lib/notify"; export function RawAppendixDownload({ reportId }: { reportId: string }) { const [busy, setBusy] = useState(false); - const [notice, setNotice] = useState(null); async function download() { if (busy) return; setBusy(true); - setNotice(null); try { const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/raw-appendix`, { method: "POST", credentials: "same-origin", headers: { Accept: "application/json" }, @@ -21,15 +20,14 @@ export function RawAppendixDownload({ reportId }: { reportId: string }) { || typeof result.markdown !== "string" || !result.markdown.trim()) throw new Error("unavailable"); downloadMarkdownReport("个人报告-原始附录", result.markdown); } catch { - setNotice("原始附录暂时无法下载,请稍后重试。"); + notifyError("原始附录暂时无法下载,请稍后重试。"); } finally { setBusy(false); } } - return <> + return ( - {notice && {notice}} - ; + ); } diff --git a/frontend/src/components/personal-report/report-export-drawer.tsx b/frontend/src/components/personal-report/report-export-drawer.tsx index 3a5bbc34..3b90612e 100644 --- a/frontend/src/components/personal-report/report-export-drawer.tsx +++ b/frontend/src/components/personal-report/report-export-drawer.tsx @@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronRight, Minus, X } from "lucide-react"; -import { toast } from "sonner"; import { detectPrintRestriction } from "@/lib/client-report-export"; import { downloadMarkdownReport } from "@/lib/consultation-report-export"; +import { notifyError, notifySuccess } from "@/lib/notify"; import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, type ReportExportBlock } from "@/lib/report-export-blocks"; import { Button } from "@/components/ui/button"; import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language"; @@ -39,7 +39,6 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd const [mode, setMode] = useState<"whole" | "pick">("whole"); const [selection, setSelection] = useState>(() => bodyIds(blocks)); const [open, setOpen] = useState>(() => new Set(["body"])); - const [error, setError] = useState(null); const exporting = useRef(false); const selected = useMemo( @@ -81,21 +80,20 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd if (!selected.length || exporting.current) return; const restriction = detectPrintRestriction(); if (restriction.restricted) { - setError(restriction.message ?? "请在系统浏览器中打开本页。"); + notifyError(restriction.message ?? "请在系统浏览器中打开本页。"); return; } exporting.current = true; - setError(null); try { const body = blocks.filter(block => block.group === "body"); const full = mode === "whole" || (body.length > 0 && body.every(block => selection.has(block.id))); downloadMarkdownReport(exportFilename(reportDate, full, language), joinExportBlocks(selected)); dialog.current?.close(); onClose(); - toast.success("已导出"); + notifySuccess("已导出"); } catch (cause) { exporting.current = false; - setError(cause instanceof Error ? cause.message : "报告暂不可导出"); + notifyError(cause instanceof Error ? cause.message : "报告暂不可导出"); } } @@ -170,7 +168,6 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd ) : null}