diff --git a/CHANGELOG.md b/CHANGELOG.md index 1303d922..285a4f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-06 — 设置弹窗固定尺寸,账户与点数进弹窗 + +设置弹窗四个分区共用一个外框,切换时不再跳变。星盘资料先出列表,点进去才编辑或添加。账户与点数成为第四个分区,套餐、点数包、兑换码和订单都在里面完成;原来的 `/membership` 页面删除,旧链接会回到首页并打开这一格。Skill 版本未变。 + ## 2026-09-06 — 普通咨询记住结论,超长会缩窗,后台能看见缓存命中 多轮咨询不再只留每条开头几千字。服务端会维护一份会话摘要,并把后半段原文按模型窗口留给下一轮;报告被截断时会写明省略了多少字。上下文真的超了,会在同一次等待里缩到摘要加最后一对再试一次。后台用量能看到各模型的缓存命中率和缓存读 tokens。Skill 版本未变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 10ba0e86..8b2b39c5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8565,6 +8565,22 @@ - 复发自:无 - 修复版本:`a1956deb` +## BUG-554 | 设置弹窗随分区跳变,星盘资料铺开整张表单,账户与点数离开首页 + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:设置弹窗、`chart-library-panel`、`billing-panel`、首页 `page.tsx`、`/membership` 路由 +- 用户现象:三个设置分区弹窗尺寸不同;星盘资料列表下面无条件铺开添加表单;点「账户与点数」会离开首页打开整页会员。 +- 触发条件:登录后打开设置弹窗,切换个人资料 / 星盘资料 / 通用设置,或从余额、头像菜单、余额不足进入充值。 +- 根因:`accountDialogClasses` 给每个分区不同宽度,高度随内容撑开。星盘资料没有列表/详情视图,添加表单始终渲染。第四个导航项直接 `router.push` 到 `/membership`。 +- 修复:四个分区共用 `.settings-modal` 固定尺寸。星盘资料改为列表→详情。账户与点数成为弹窗第四分区;删除 `/membership` 与 `/membership/orders`,旧链接重定向到 `/?settings=billing`。七处入口改为 `openAccountDialog("billing")`。 +- 验证:`frontend/tests/account-dialog-overlay.test.ts`、`billing-panel.test.ts`、`membership-page.test.ts`、`membership-navigation-contract.test.ts`、`chart-library-other-profile.test.ts`、`settings-url.test.ts`、`beam-avatar.test.ts`、`class-name-definition-contract.test.ts`、`tests/test_api_server_security.py -k capability_audit`。`next build --webpack` 中 `/` 为 Static,无 `/membership` 页面。 +- 防复发:设置弹窗必须同时声明 width 与 height;星盘资料默认视图不得无条件渲染 ``,头像 48px | 5.2 净减行;去掉重复入口 | +| `chart-library-panel` | 无条件 `` | 弹窗标题「账户与点数」+ `billing-summary` | 不再是整页 | +| `.membership-page` / `.membership-shell` / `.membership-hero` | 整页壳 CSS | 删除;卡片/页签 CSS 保留 | 只删整页 chrome | +| `membershipHref` | `/membership?source=` | `/?settings=billing&source=` | 深链接 | +| `chat-navigation` 4 条软跳 | `router.push(membershipHref(...))` | `openAccountDialog("billing", { source })` | 决策 7 | +| 校正 402 | `window.location.assign(membershipHref("rectification"))` | `onOpenBilling({ source: "rectification" })` | 硬跳转清单缩小 1 | +| 会员 401 | membership 页 3 次 + 订单页 1 次 | billing hook 4 次 `assign("/login")` | 认证仍硬跳 | +| `test_api_server_security` `app_routes` | 含 `membership`、`membership/orders` | 去掉这两条 | BUG-143 路由集合 | +| 付费墙 | `` | `onOpenBilling` 按钮 | 关掉墙后打开 billing | +| `beam-avatar` | 头像编辑 JSX 在 `page.tsx` | 读 `profile-panel.tsx`;`page.tsx` 只挂 `ProfilePanel` | 5.2 搬出 | +| `knownUnstyled` | 允许 `membership-orders` / `membership-segment` | 从白名单删除 | 整页删除后不再渲染 | + +`git grep -n "/membership" frontend/src` 还会命中 `@/lib/membership` 模块 import,不是路由。路由字符串只出现在 `next.config.ts` 重定向。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| 任务书收尾 tsx 合同(overlay / chart-library / membership / billing / settings-url / profile-panel / chat-navigation / epay / redeem-orders / starter / settings-mvp / consultation / rectification-surface) | **168 pass / 0 fail** | +| `beam-avatar` + `class-name-definition-contract` + `profile-panel` | **10 pass / 0 fail** | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | +| 改动文件 `eslint --quiet`;`npm run lint` | **0 error**(全仓 79 条既有 warning) | +| `page.tsx` | 1976 行(开工 2035,净减 59) | +| `/opt/anaconda3/bin/python3.12 -m pytest tests/test_api_server_security.py -k capability_audit` | 2 passed | +| `npm run build -- --webpack` | `/` 为 `○` Static;产物无 `/membership` 页面;`routes-manifest` 有两条 307 重定向。首页 `page-*.js` gzip **181103** 字节;账单文案在按需 chunk `773.*.js`(gzip **4994**),未打进首屏合同路径。 | +| 默认 `npm run build`(Turbopack) | 本工作树 `frontend/node_modules` 是指向主仓的 symlink,Turbopack 报 `points out of the filesystem root`。验收用 webpack 构建。 | +| `npm test` 全量 | 产品合同已绿。其余失败均走 `startPostgresFixture` / `docker compose up postgres`,本机 compose 起不来,与本单无关。 | + +Next.js 16 typed routes 不允许 `route.ts` 额外 export。`dossierResponse` 去掉 `export`(仍只给同文件 GET 用),否则 `next build --webpack` 的生成类型检查失败。不是产品改动。 + +分支相对当前 `origin/staging` 落后 2 个提交(BUG-555/556)。未在脏树上合并,避免和本单的 `CHANGELOG` / `BUG_HISTORY` 撞车。 + +浏览器走查见 `docs/testing/settings-dialog-and-billing-pane-20260906.md`。本机未起登录会话,真实四分区切换留给 staging 验收。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index ba6c500b..3ad12aae 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -57,7 +57,7 @@ | `TASK-rectification-probe-answer-covers-domain-20260905.md` | `PROGRESS-rectification-probe-answer-covers-domain-20260905.md` | 区分卡答 A/B 后紧接着又问同领域"哪年…"采集题:点选答案只进推断层不写账本,计划层领域覆盖只看账本;改为 yes/weak_yes 视为该领域采集已覆盖(不写账本、不动门) | 待验收 | `988d97ae`(BUG-549) | | `TASK-composer-live-input-and-stop-20260906.md` | `PROGRESS-composer-live-input-and-stop-20260906.md` | 生成中输入框整个禁用致焦点丢失、回车丢消息(改为永不禁用 + 排队发送);生时校正停止呈现为红色告警且选择题等待中停止无效(改为中性停止态、所有 fetch 挂 abort) | 待执行 | `codex/composer-live-input-and-stop-20260906`(BUG-551、552) | | `TASK-session-list-title-and-order-20260906.md` | `PROGRESS-session-list-title-and-order-20260906.md` | 历史对话标题改为首轮模型总结(一次、不扣点数、校正/今日运势保持日期标题)、侧栏去资料前缀;排序只按置顶排且元数据 PATCH 推进 `updated_at`(改为置顶 + 活动时间,改名/收藏/换模型不动顺序);历史区加 今天/昨天/7天/30天/更早 分组;列表按游标分页(每页 40、置顶首页全量、滚到底静默续取) | 待执行(串行:在 composer 单之后) | `codex/session-list-title-and-order-20260906`(BUG-553) | -| `TASK-settings-dialog-and-billing-pane-20260906.md` | `PROGRESS-settings-dialog-and-billing-pane-20260906.md` | 设置弹窗尺寸随分区跳变、星盘资料格无条件铺开整张添加表单(改为固定尺寸四分区、列表→详情);「账户与点数」成为弹窗分区,删除 `/membership` 与 `/membership/orders` 页面并重定向,七处入口改回调 | 待执行(串行:在会话列表单之后) | `codex/settings-dialog-and-billing-pane-20260906`(BUG-554) | +| `TASK-settings-dialog-and-billing-pane-20260906.md` | `PROGRESS-settings-dialog-and-billing-pane-20260906.md` | 设置弹窗尺寸随分区跳变、星盘资料格无条件铺开整张添加表单(改为固定尺寸四分区、列表→详情);「账户与点数」成为弹窗分区,删除 `/membership` 与 `/membership/orders` 页面并重定向,七处入口改回调 | 待验收 | `codex/settings-dialog-and-billing-pane-20260906`(BUG-554) | | `TASK-consultation-context-and-cache-20260906.md` | `PROGRESS-consultation-context-and-cache-20260906.md` | 普通对话历史只取每条前 4,000 字(报告结论被砍、无标记)、历史窗口不看模型 `context_window`、无溢出识别;改为检查点式会话摘要 + append-only 尾巴 + 按模型预算 + 一次降级重试;Anthropic 历史断点;共享方法段进系统块;后台用量页显示缓存命中率 | 待执行(串行:在会话列表单之后;动表需迁移) | `codex/consultation-context-and-cache-20260906`(BUG-555/556) | | `TASK-rectification-collect-vs-offer-consistency-20260905.md` | `PROGRESS-rectification-collect-vs-offer-consistency-20260905.md` | 带年份采集没问完就出采用卡 + 报告,同一轮又被搬家采集题把卡挤掉:决策层判 `adopt_representative` 而计划层仍有 dated 采集(BUG-546 只修了一半);改为剩余采集未完保持 `collect_evidence`,出牌轮才出卡写报告 | 已验收通过 `ca4e2408`(2026-09-05;staging 部署仍停在 `afd14948`,`deploy-staging` 自 `c295b853` 起连续失败,先解决 `bab07187` 的待迁移) | `codex/rectification-collect-vs-offer-consistency-20260905`(BUG-550) | | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待验收 | `codex/api-not-configured-mislabel-20260904`(BUG-542) | diff --git a/docs/testing/settings-dialog-and-billing-pane-20260906.md b/docs/testing/settings-dialog-and-billing-pane-20260906.md new file mode 100644 index 00000000..9e6bc8d8 --- /dev/null +++ b/docs/testing/settings-dialog-and-billing-pane-20260906.md @@ -0,0 +1,68 @@ +# Staging 人肉复核 · 设置弹窗与账户与点数(2026-09-06) + +给产品负责人。不要把真实出生资料、真实用户问题或对话正文写进任何记录。试用问题时用虚构句,例如「半年内换工作时机」。 + +对应 BUG-554。测之前先做第 0 条。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 四个分区外框不动(P0) + +1. 登录后点头像,打开「个人资料」。 +2. 依次点左侧 星盘资料、账户与点数、通用设置,再点回个人资料。 + +- ✅ 预期:弹窗外框宽高不变;只有右侧内容换。退出登录仍是小确认窗。 +- ❌ 失败:换分区时窗口变大变小,或主题格缩成小盒子。 + +## 2. 星盘资料默认只有列表(P0) + +1. 打开「星盘资料」。 + +- ✅ 预期:先看到「我的星盘」一条和「其他人」列表(可空),底部「添加其他人」。看不到关系下拉、出生表单铺在列表下面。 +- ❌ 失败:一打开就出现整张「添加其他人的星盘」表单。 + +## 3. 添加 → 保存 → 回列表(P0) + +1. 点「添加其他人」,用虚构资料保存。 +2. 看是否回到列表,新的一行出现。 +3. 点那一行进入详情:有 设为默认 / 删除 / 用于合盘;点「← 星盘资料」返回。 +4. 点本人那一行:有编辑表单,没有删除。 + +- ✅ 预期:表单只在详情/添加视图;保存后回列表。 +- ❌ 失败:保存后仍停在铺开的表单,或本人详情能删除。 + +## 4. 余额 / 头像菜单 / 402 / 付费墙都在当前页打开账单(P0) + +1. 点顶栏余额。 +2. 点头像菜单「账户与点数」。 +3. 用一个余额不足的账户发一条虚构问题(或看 402)。 +4. 若出现入门付费墙,点「查看套餐与会员」。 + +- ✅ 预期:都打开同一设置弹窗的「账户与点数」,对话还在,不跳到 `/membership`。 +- ❌ 失败:整页刷新或地址变成 `/membership`。 + +## 5. 下单打开新标签,回来余额刷新(P0) + +1. 在会员套餐或点数包点购买。 +2. 看是否新标签打开收银台,按钮短暂显示「跳转收银台…」,区内显示「等待支付结果」。 +3. 支付完成后回到原标签(可用测试单)。 + +- ✅ 预期:当前页还在;余额更新。不要出现 spinner 或「正在加载」。 +- ❌ 失败:当前页被收银台顶掉,或一直转圈。 + +## 6. 旧链接落到首页并打开分区(P1) + +1. 浏览器打开 `https://staging.jyotisha.chat/membership`。 +2. 再打开 `https://staging.jyotisha.chat/membership/orders`。 + +- ✅ 预期:落在首页,设置弹窗打开账户与点数;订单链接打开订单记录页签。地址栏随后不再带 `settings=`。 +- ❌ 失败:404,或停在旧会员整页。 + +## 7. 手机端全屏四页签可滚(P1) + +1. 把窗口缩到手机宽度,打开设置。 + +- ✅ 预期:全屏;顶部四格页签;内容区自己滚,外框不动。 +- ❌ 失败:页签把内容顶没,或整页跟着内容一起撑高。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index bfd920fd..2abb2560 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -169,7 +169,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and - Admin content width: 1200px, centered. - Desktop shell: 288px sidebar plus flexible reading panel. - Layout breakpoints: mobile below 768px, tablet 768–1023px, desktop 1024px and above. These three decide the shell — sidebar mode, grid columns, drawer versus rail. -- Component breakpoints are separate and narrower, each owned by one surface rather than the shell: 900px and 480px (redemption and profile forms), 760px and 720px (personal report and report centre), 640px (rectification candidates), 639px (membership header), 620px (onboarding cards), 430px (toasts and the conversational rectification panel). Do not repurpose one for another surface, and do not assume testing the three layout tiers covers them. +- Component breakpoints are separate and narrower, each owned by one surface rather than the shell: 900px and 480px (redemption and profile forms), 760px and 720px (personal report and report centre), 640px (rectification candidates), 767px (settings dialog mobile full-screen), 620px (onboarding cards), 430px (toasts and the conversational rectification panel). Do not repurpose one for another surface, and do not assume testing the three layout tiers covers them. - All full-height surfaces use `100dvh`. Touch targets are at least 44px. Where adjacent controls sit closer than 44px, the hit expands to the largest size that does not overlap; those exceptions are locked by `frontend/tests/touch-target-contract.test.ts`. ## 5. Components @@ -183,7 +183,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and ### Button -- **Implementation:** report surfaces and membership use `@/components/ui/button`. Login, onboarding, and birth-time dialogs still use `.button-primary` / `.button-secondary`, which share the same 44px height, 8px radius, and action tokens. Those CSS classes stay until those surfaces can move without growing `page.tsx`. +- **Implementation:** report surfaces and the billing pane use `@/components/ui/button`. Login, onboarding, and birth-time dialogs still use `.button-primary` / `.button-secondary`, which share the same 44px height, 8px radius, and action tokens. Those CSS classes stay until those surfaces can move without growing `page.tsx`. - **Variants:** ink primary, cream secondary, text, circular icon, deep-brown emphasis. - **Spacing:** 44px minimum height; radii 8px for standard and full radius for icon-only. - **States:** default, hover, active, focus-visible, disabled, loading. @@ -379,27 +379,26 @@ Text release is paced, not animated: the frame buffer commits at most once per a ### Account popover -- **Structure:** identity header, profile action, redeem action with balance, administrator-only code-management link, divider, and logout action. +- **Structure:** identity header, then 个人资料 / 星盘资料 / 通用设置 / 账户与点数 (with the current balance), theme preference, divider, and logout. - **Surface:** 280px elevated canvas popover anchored above the sidebar account trigger; warm hairline, existing elevated shadow, no nested cards. -- **States:** closed, open, hover, focus-visible, administrator, and logout routing. +- **States:** closed, open, hover, focus-visible, and logout confirmation. - **Accessibility:** `aria-expanded`, `aria-controls`, menu semantics, 44px rows, outside-click and Escape dismissal, and focus return. +- **Billing:** the 账户与点数 row opens the settings dialog on the billing pane. It does not leave the homepage. -### Profile dialog +### Settings dialog -- **Structure:** profile title, existing profile fields, inline result, and save action. -- **Width:** 560px desktop maximum with viewport-safe spacing and internal scrolling. -- **States:** open, invalid, saving, success, and error. +- **Structure:** one fixed chrome for four panes — 个人资料, 星盘资料, 账户与点数, 通用设置. Left nav is 176px and does not scroll; the title bar stays put; only the right-hand content pane scrolls. Logout stays a separate 400px confirmation. +- **Width / height:** desktop `width: min(100vw - 32px, 880px); height: min(84dvh, 640px)`. Switching panes does not change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top. +- **Personal profile:** one row of avatar editing (48px preview, eight palettes, 换一个形象) plus nickname and login email. No duplicate 管理星盘资料 button. +- **Chart library:** list first (self row, other rows, 添加其他人). A row opens a detail with ← 星盘资料. Other details own 设为默认 / 删除 / 用于合盘 and that person's synastry history. The add form is a view, not an always-on stack. +- **States:** open, pane switch, list / self / other / add, saving, success, and error. -### Redeem dialog +### Billing pane -- **Structure:** current balance, redemption-code form, and inline result. -- **Width:** 420px desktop maximum. -- **States:** open, submitting, success, and error. - -### Membership - -- **Structure:** full-page archive on the page floor. Header chrome is back, orders, and redeem; the hero names the current plan; tabs switch membership vs credits; each plan/credit card carries one purchase action. -- **Actions:** header chrome stays hairline `--color-ink-secondary`. The one filled terracotta control is the recommended-plan purchase (`Button` default). Other plan and credit buys use `Button` outline. Redeem opens a dialog; it is not a second filled button on the same screen. +- **Structure:** summary line (balance · membership / expiry), then tabs 会员套餐 / 点数包 / 兑换码 / 订单记录. +- **Actions:** recommended-plan purchase is `Button` default. Other plan and credit buys use `Button` outline. Cashier still opens with `window.open` in a new tab. Paying copy is 跳转收银台…; pending copy is 等待支付结果. Redeem is an inline form on its tab, not a nested dialog. +- **Deep links:** `/?settings=billing&source=…[&plan=…][&tab=…]` open this pane on `/` and are then stripped. `/membership` and `/membership/orders` redirect here. +- **Waiting:** the summary renders from the in-memory account. Package cards stay empty until `/api/payment/packages` arrives. No spinner, skeleton, or 「正在加载」. ### Logout dialog diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index f5a07dff..2529c151 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -58,6 +58,16 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 会话标题是主题总结,不是资料名,也不是第一句话的截断。起名提示词只输出标题本身:6 到 12 个字,不要引号、书名号、句号或解释;不要复述出生日期、时间或地点,也不要写资料姓名。 +## 设置弹窗 + +分区名只用:个人资料 / 星盘资料 / 账户与点数 / 通用设置。不要写成「会员页」「充值页」。 + +账户与点数的页签只用:会员套餐 / 点数包 / 兑换码 / 订单记录。购买中按钮写「跳转收银台…」,轮询中写「等待支付结果」,不要写「正在加载」。 + +星盘资料列表条只写人名、关系、出生摘要。详情返回用「← 星盘资料」。添加入口写「添加其他人」。 + +余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。 + - 不得虚构星盘事实或唯一出生分钟。 - 问哪道题、年份、选项语义仍由服务端 focus 唯一所有;Agent 用 `spokenPrompt` 写题干,不得改年份、不得改选项含义。正文不得出题、复述或改写题干——题干作为同一条消息里正文之后的独立段落出现。 - 正文不得断言界面当前状态(「界面上有/出现了下一问」);过渡用「接下来我们继续」这类中性句。 diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 869f9f57..7b9fc756 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -40,6 +40,12 @@ const nextConfig: NextConfig = { "../assets/**/*", ], }, + async redirects() { + return [ + { source: "/membership", destination: "/?settings=billing&source=legacy", permanent: false }, + { source: "/membership/orders", destination: "/?settings=billing&tab=orders", permanent: false }, + ]; + }, }; export default nextConfig; diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index a553e6fc..18e04322 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -91,7 +91,7 @@ export async function GET(request: Request, context: RouteContext) { } } -export function dossierResponse( +function dossierResponse( dossier: V9CaseDossier, receipts: Array>>, skillIdentity: Awaited>, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3f3557dd..70865e22 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1645,22 +1645,18 @@ button:disabled { cursor: default; opacity: .45; } .account-modal-overlay { position: fixed; z-index: 40; inset: 0; display: grid; place-items: center; padding: var(--space-4); background: var(--color-scrim); animation: account-overlay-enter 180ms ease-out both; } .account-modal { width: min(100%, 560px); max-height: min(84dvh, 760px); overflow-y: auto; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); animation: account-dialog-enter 180ms var(--ease-out) both; } -.profile-modal { width: min(100%, 680px); scroll-padding-block-start: calc(var(--space-8) + 72px); } -.chart-library-modal { width: min(100%, 760px); scroll-padding-block-start: calc(var(--space-8) + 72px); } -.account-settings-shell { display: grid; grid-template-columns: 176px minmax(0, 1fr); gap: var(--space-8); align-items: start; } -.settings-dialog-nav { display: grid; gap: var(--space-1); position: sticky; top: 0; } +.settings-modal { width: min(100vw - 32px, 880px); height: min(84dvh, 640px); max-height: min(84dvh, 640px); overflow: hidden; display: grid; grid-template-rows: auto 1fr; } +.account-settings-shell { display: grid; grid-template-columns: 176px minmax(0, 1fr); min-height: 0; overflow: hidden; align-items: stretch; } +.settings-dialog-nav { display: grid; align-content: start; gap: var(--space-1); overflow: hidden; } .settings-dialog-nav-item { min-height: 44px; display: grid; grid-template-columns: 18px minmax(0, 1fr) 16px; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; } .settings-dialog-nav-item:hover, .settings-dialog-nav-item[aria-current="page"] { background: var(--color-canvas-muted); color: var(--color-ink); } .settings-dialog-nav-item[aria-current="page"] { box-shadow: inset 2px 0 0 var(--color-action); } .settings-dialog-nav-item > svg { width: 18px; height: 18px; color: var(--color-ink-tertiary); } .settings-dialog-nav-item > svg:last-child { width: 15px; height: 15px; margin-left: auto; } -.settings-dialog-content { min-width: 0; } -.chart-library-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-3); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); } +.settings-dialog-content { min-width: 0; min-height: 0; overflow-y: auto; } .settings-dialog-copy { margin: 0 0 var(--space-5); color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; } -.profile-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-5); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); } -.profile-modal .birth-section { padding-top: var(--space-5); } .avatar-section { padding-top: 0; } -.avatar-editor { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: var(--space-5); margin-top: var(--space-5); } +.avatar-editor { display: grid; grid-template-columns: 48px minmax(0, 1fr); align-items: center; gap: var(--space-5); margin-top: var(--space-5); } .avatar-editor-controls { min-width: 0; display: grid; gap: var(--space-4); } .avatar-palette-list { display: grid; grid-template-columns: repeat(4, minmax(48px, 1fr)); gap: var(--space-2); } .avatar-palette { min-width: 0; height: 34px; overflow: hidden; display: flex; padding: 3px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, box-shadow 120ms ease-out, transform 120ms ease-out; } @@ -1707,7 +1703,11 @@ button:disabled { cursor: default; opacity: .45; } .chart-library-group-heading b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; } .chart-library-group-heading small { margin-top: 3px; color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 400; } .chart-library-count { display: grid; min-width: 24px; height: 24px; place-items: center; border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink-secondary); font-size: var(--type-overline); } -.chart-library-item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } +.chart-library-item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; min-height: 44px; padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } +button.chart-library-item { cursor: pointer; text-align: left; } +.chart-library-item-chevron { width: 16px; height: 16px; flex: 0 0 auto; color: var(--color-ink-tertiary); } +.chart-library-back { min-height: 44px; display: inline-flex; align-items: center; gap: var(--space-2); margin: 0 0 var(--space-4); padding: 0; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; font-size: var(--type-body-sm); } +.chart-library-add { margin-top: var(--space-4); } .chart-library-item-main { min-width: 0; } .chart-library-item strong, .chart-library-item small { display: block; } .chart-library-item strong { color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; } @@ -1836,15 +1836,15 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .select-list { max-height: min(288px, 48dvh) !important; } .account-modal-overlay { padding: 0; } .account-modal { width: 100%; max-height: 100dvh; min-height: 100dvh; border-radius: 0; padding: var(--space-6); } - .account-settings-shell { grid-template-columns: 1fr; gap: var(--space-4); } + .settings-modal { width: 100%; height: 100dvh; max-height: 100dvh; } + .account-settings-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr; gap: var(--space-4); } .settings-dialog-nav { position: static; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-1); padding-bottom: var(--space-3); border-bottom: 1px solid var(--color-border); } .settings-dialog-nav-item { min-height: 40px; grid-template-columns: 1fr; justify-items: center; gap: 2px; padding: var(--space-2); text-align: center; font-size: var(--type-caption); } .settings-dialog-nav-item > svg:last-child { display: none; } .settings-dialog-nav-item[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-action); } - .profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); } - .chart-library-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); } - .avatar-editor { grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-4); } - .avatar-editor > .user-avatar { width: 72px !important; height: 72px !important; } + .settings-dialog-content { min-height: 0; overflow-y: auto; } + .avatar-editor { grid-template-columns: 48px minmax(0, 1fr); gap: var(--space-4); } + .avatar-editor > .user-avatar { width: 48px !important; height: 48px !important; } .avatar-palette-list { grid-template-columns: repeat(2, minmax(64px, 1fr)); } .auth-page { height: 100dvh; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0; } .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; } @@ -4011,27 +4011,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class /* ---- Membership page ---- */ -.membership-page { height: 100dvh; min-height: 100dvh; overflow-y: auto; background: var(--color-canvas); } -.membership-shell { width: min(1160px, 100%); display: grid; gap: var(--space-6); margin: 0 auto; padding: var(--space-6) var(--space-5) var(--space-16); } -.membership-loading { padding: var(--space-16) var(--space-5); color: var(--color-ink-secondary); text-align: center; } -.membership-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); } -.membership-header-left, .membership-header-actions { min-width: 0; display: flex; align-items: center; gap: var(--space-3); } -.membership-header-actions { gap: var(--space-2); } -.membership-back, .membership-orders-entry, .membership-redeem-entry { min-height: 44px; display: inline-flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-canvas); color: var(--color-ink-secondary); cursor: pointer; font-size: var(--type-body-sm); text-decoration: none; } -.membership-back:hover, .membership-orders-entry:hover, .membership-redeem-entry:hover { border-color: var(--color-border-strong); color: var(--color-ink); } -.membership-back svg, .membership-redeem-entry svg { width: 16px; height: 16px; } -.membership-title { margin: 0; color: var(--color-ink); font-family: var(--font-body); font-size: var(--type-title-lg); font-weight: 600; letter-spacing: -.02em; } - -.membership-hero { min-height: 0; display: block; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); } -.membership-hero-copy { max-width: 760px; } -.membership-greeting { margin: 0 0 var(--space-3); color: var(--color-ink-secondary); font-size: var(--type-body-sm); } -.membership-hero h2 { max-width: 680px; margin: 0; color: var(--color-ink); font-family: var(--font-body); font-size: clamp(28px, 4vw, 40px); font-weight: 600; letter-spacing: -.03em; line-height: 1.12; text-wrap: balance; } -.membership-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin-top: var(--space-6); border-top: 1px solid var(--color-border); } -.membership-summary-card { min-width: 0; display: grid; gap: var(--space-1); padding: var(--space-4) 0 0; border: 0; border-radius: 0; background: transparent; } -.membership-summary-card + .membership-summary-card { padding-left: var(--space-5); border-left: 1px solid var(--color-border); } -.membership-summary-card span { color: var(--color-ink-secondary); font-size: var(--type-caption); } -.membership-summary-card strong { color: var(--color-ink); font-family: var(--font-body); font-size: var(--type-title-md); font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.membership-summary-card small { color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; } +.billing-panel { display: grid; gap: var(--space-5); } +.billing-summary { margin: 0; color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; font-variant-numeric: tabular-nums; } .membership-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-4); } .membership-section-heading > div { display: grid; gap: var(--space-1); } @@ -4091,23 +4072,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .membership-payment-error p { margin: 0; color: var(--color-danger); font-size: var(--type-body-sm); line-height: 1.55; } @media (max-width: 767px) { - .membership-shell { padding-inline: var(--space-4); } - .membership-header { align-items: flex-start; } - .membership-header-left { align-items: flex-start; } - .membership-title { padding-top: 9px; font-size: var(--type-title-md); } - .membership-header-actions { flex-wrap: wrap; justify-content: flex-end; } - .membership-hero { padding: var(--space-6); } - .membership-summary { grid-template-columns: 1fr; } - .membership-summary-card + .membership-summary-card { margin-top: var(--space-4); padding: var(--space-4) 0 0; border-top: 1px solid var(--color-border); border-left: 0; } - .membership-order-row { grid-template-columns: minmax(0, 1fr) auto; } .membership-order-row b { display: none; } .membership-payment-panel { grid-template-columns: auto minmax(0, 1fr); } .membership-payment-panel .dialog-actions { grid-column: 1 / -1; } } @media (max-width: 639px) { - .membership-header { display: grid; } - .membership-header-actions { justify-content: flex-start; } .membership-plan-card--recommended { order: -1; } .membership-payment-error { align-items: flex-start; flex-direction: column; } } diff --git a/frontend/src/app/membership/orders/page.tsx b/frontend/src/app/membership/orders/page.tsx deleted file mode 100644 index 8488d12a..00000000 --- a/frontend/src/app/membership/orders/page.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import "@/app/site-styles"; -import { useCallback, useEffect, useState } from "react"; -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; -import { formatPrice, orderStatusLabel } from "@/lib/membership"; - -type PaymentOrder = { - orderNo: string; - status: string; - credits: number; - moneyCents: number; - currency: string; - productName: string; - paidAt: string | null; - createdAt: string; -}; - -function normalizeOrders(list: readonly unknown[]): PaymentOrder[] { - return list.map((raw) => { - const item = (raw ?? {}) as Record; - const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record; - const price = - typeof item.price === "number" - ? item.price - : typeof item.money_cents === "number" - ? item.money_cents - : typeof item.moneyCents === "number" - ? item.moneyCents - : 0; - return { - orderNo: String(item.order_no ?? item.orderNo ?? ""), - status: String(item.status ?? "unknown"), - credits: typeof item.credits === "number" ? item.credits : 0, - moneyCents: price, - currency: String(item.currency ?? "CNY"), - productName: - typeof snapshot.name === "string" - ? snapshot.name - : typeof item.name === "string" - ? item.name - : String(item.product_code ?? item.productCode ?? ""), - paidAt: item.paid_at ? String(item.paid_at) : item.paidAt ? String(item.paidAt) : null, - createdAt: String(item.created_at ?? item.createdAt ?? ""), - }; - }); -} - -export default function MembershipOrdersPage() { - const [orders, setOrders] = useState([]); - const [ordersState, setOrdersState] = useState<"loading" | "ready" | "unavailable">("loading"); - - const fetchOrders = useCallback(async () => { - setOrdersState("loading"); - try { - const response = await fetch("/api/payment/orders", { cache: "no-store" }); - if (response.status === 401) { - window.location.assign("/login"); - return; - } - if (!response.ok) throw new Error("订单记录暂时不可用"); - const payload = await response.json().catch(() => null); - const list = Array.isArray(payload) - ? payload - : payload && typeof payload === "object" && Array.isArray(payload.orders) - ? payload.orders - : []; - setOrders(normalizeOrders(list)); - setOrdersState("ready"); - } catch { - setOrders([]); - setOrdersState("unavailable"); - } - }, []); - - useEffect(() => { - void (async () => { - await fetchOrders(); - })(); - }, [fetchOrders]); - - return ( -
-
-
-
- -
-
- -
-
-
-

全部订单

-

查看套餐与点数充值的支付和到账状态。

-
- -
- {ordersState === "loading" &&

正在加载订单…

} - {ordersState === "unavailable" &&

订单记录暂时不可用,请稍后刷新。

} - {ordersState === "ready" && orders.length === 0 &&

暂无订单。

} - {orders.length > 0 && ( -
    - {orders.map((order) => ( -
  • -
    - {order.productName || "订单"} - {order.orderNo} -
    - - {orderStatusLabel(order.status)} - - {formatPrice(order.moneyCents, order.currency)} -
  • - ))} -
- )} -
-
-
- ); -} diff --git a/frontend/src/app/membership/page.tsx b/frontend/src/app/membership/page.tsx deleted file mode 100644 index cbda3608..00000000 --- a/frontend/src/app/membership/page.tsx +++ /dev/null @@ -1,583 +0,0 @@ -"use client"; - -import "@/app/site-styles"; -import { Suspense, useCallback, useEffect, useRef, useState } from "react"; -import type { FormEvent } from "react"; -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { ArrowLeft, CheckCircle2, Gift, Sparkles, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { keepFocusWithin } from "@/lib/focus-trap"; -import { - entitlementLabel, - formatMembershipDate, - formatPrice, - formatProductDuration, - notifyBalanceChanged, - planAlias, - planDisplayLabel, - productAudience, - redeemErrorMessage, - selectMembershipPlans, - type MembershipPlanAlias, - type MembershipProduct, -} from "@/lib/membership"; - -type MembershipAccount = { - user: { id: string; email: string | null }; - credits: number; - activeSubscription: { - id: string; - status: string; - startsAt: string; - endsAt: string; - productCode: string; - productVersion: number; - product: { name?: string; productType?: string } | null; - entitlements: unknown; - } | null; -}; - -type PaymentOrderState = { - orderNo: string; - payUrl: string | null; - qrCode: string | null; - status: string; - productId: string; -}; - -function goBack() { - if (window.history.length > 1) window.history.back(); - else window.location.assign("/"); -} - -function MembershipContent() { - const searchParams = useSearchParams(); - const planHint = searchParams.get("plan"); - const highlightedPlan: MembershipPlanAlias | null = - planHint === "trial" || planHint === "monthly" || planHint === "yearly" ? planHint : null; - - const [account, setAccount] = useState(null); - const [accountError, setAccountError] = useState(""); - const [paymentPackages, setPaymentPackages] = useState([]); - const [paymentEnabled, setPaymentEnabled] = useState(false); - const [packagesError, setPackagesError] = useState(""); - const [redeemOpen, setRedeemOpen] = useState(false); - const [redeemCode, setRedeemCode] = useState(""); - const [redeemError, setRedeemError] = useState(""); - const [redeemMessage, setRedeemMessage] = useState(""); - const [redeeming, setRedeeming] = useState(false); - const [paymentOrder, setPaymentOrder] = useState(null); - const [paymentError, setPaymentError] = useState(""); - const [payingProductId, setPayingProductId] = useState(null); - const [selectedProductId, setSelectedProductId] = useState(null); - const [redeemDone, setRedeemDone] = useState(false); - const redeemDialogRef = useRef(null); - const redeemInputRef = useRef(null); - const highlightedCardRef = useRef(null); - const planHighlightScrolled = useRef(false); - - const activeSubscription = account?.activeSubscription ?? null; - const currentProductCode = activeSubscription?.status === "active" - ? activeSubscription.productCode - : null; - - const fetchAccountData = useCallback(async (): Promise => { - try { - const response = await fetch("/api/account", { cache: "no-store" }); - if (response.status === 401) { - window.location.assign("/login"); - return null; - } - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payload?.error || "暂时无法读取账户信息"); - setAccount(payload); - setAccountError(""); - return payload as MembershipAccount; - } catch (caught) { - setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息"); - return null; - } - }, []); - - const resetPaymentState = useCallback(() => { - setPaymentEnabled(false); - setPaymentPackages([]); - setPaymentOrder(null); - setPaymentError(""); - setPayingProductId(null); - setSelectedProductId(null); - }, []); - - const fetchPackages = useCallback(async () => { - try { - const response = await fetch("/api/payment/packages", { cache: "no-store" }); - const payload = await response.json().catch(() => null); - if (response.ok && payload?.enabled === true) { - setPaymentEnabled(true); - setPaymentPackages(payload.packages || []); - setPackagesError(""); - return; - } - if (!response.ok) { - setPackagesError("套餐支付暂时不可用,请稍后重试"); - resetPaymentState(); - } - } catch { - setPackagesError("套餐支付暂时不可用,请稍后重试"); - resetPaymentState(); - } - }, [resetPaymentState]); - - useEffect(() => { - void (async () => { - await Promise.allSettled([fetchAccountData(), fetchPackages()]); - })(); - }, [fetchAccountData, fetchPackages]); - - useEffect(() => { - if (!redeemOpen) return; - window.requestAnimationFrame(() => redeemInputRef.current?.focus()); - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") closeRedeem(); - else if (redeemDialogRef.current) keepFocusWithin(event, redeemDialogRef.current); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [redeemOpen]); - - useEffect(() => { - if (!account) return; - const onBalanceStorage = (event: StorageEvent) => { - if (event.key === "jyotisha:balance:updated") void fetchAccountData(); - }; - const onBalanceChanged = () => void fetchAccountData(); - window.addEventListener("storage", onBalanceStorage); - window.addEventListener("jyotisha:balance:changed", onBalanceChanged); - return () => { - window.removeEventListener("storage", onBalanceStorage); - window.removeEventListener("jyotisha:balance:changed", onBalanceChanged); - }; - }, [account, fetchAccountData]); - - useEffect(() => { - if (!highlightedPlan || planHighlightScrolled.current || paymentPackages.length === 0) return; - planHighlightScrolled.current = true; - const card = highlightedCardRef.current; - if (!card) return; - window.requestAnimationFrame(() => { - card.scrollIntoView({ behavior: "smooth", block: "center" }); - card.focus({ preventScroll: true }); - }); - }, [highlightedPlan, paymentPackages.length]); - - function closeRedeem() { - setRedeemOpen(false); - setRedeemError(""); - } - - async function redeem(event: FormEvent) { - event.preventDefault(); - const code = redeemCode.trim(); - if (!code || redeeming) return; - setRedeeming(true); - setRedeemError(""); - setRedeemMessage(""); - try { - const response = await fetch("/api/redeem", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ code }), - }); - const payload = await response.json().catch(() => null); - if (response.status === 401) { - window.location.assign("/login"); - return; - } - if (!response.ok) throw new Error(redeemErrorMessage(response.status, payload)); - const result = payload as { awardedCredits?: number; credits: number; message?: string }; - const awarded = typeof result.awardedCredits === "number" ? result.awardedCredits : result.credits; - setRedeemCode(""); - setRedeemDone(true); - setRedeemMessage(result.message || `本次到账 ${awarded} 点,最新余额 ${result.credits} 点。`); - const refreshed = await fetchAccountData(); - notifyBalanceChanged(refreshed?.credits ?? result.credits); - } catch (caught) { - setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试"); - } finally { - setRedeeming(false); - } - } - - async function createPayment(productId: string) { - if (payingProductId) return; - setSelectedProductId(productId); - setPayingProductId(productId); - setPaymentError(""); - try { - const response = await fetch("/api/payment/epay/create", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ productId }), - }); - const payload = await response.json().catch(() => null); - if (response.status === 401) { - window.location.assign("/login"); - return; - } - if (!response.ok) throw new Error(payload?.error || "创建支付失败"); - if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") { - throw new Error("创建支付失败"); - } - setPaymentOrder({ - orderNo: payload.orderNo, - payUrl: payload.payUrl, - qrCode: payload.qrCode ?? null, - status: "pending", - productId, - }); - window.open(payload.payUrl, "_blank", "noopener,noreferrer"); - } catch (caught) { - setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); - } finally { - setPayingProductId(null); - } - } - - useEffect(() => { - if (!paymentOrder || paymentOrder.status !== "pending") return; - 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); - const creditPacks = paymentPackages.filter((product) => product.productType === "credit_pack"); - - return ( -
-
-
-
- -

套餐与会员

-
-
- 订单记录 - -
-
- -
-
-

{account?.user.email ? `账户:${account.user.email}` : "欢迎回来"}

-

- {activeSubscription ? "查看当前会员权益" : "选择会员套餐"} -

-
-
- 会员状态 - {activeSubscription ? activeSubscription.product?.name || activeSubscription.productCode : "未开通会员"} - {activeSubscription && ( - {formatMembershipDate(activeSubscription.startsAt)} 至 {formatMembershipDate(activeSubscription.endsAt)} - )} -
-
- 当前余额 - {account ? `${account.credits} 点` : accountError || "正在读取…"} -
-
-
-
- -
- - - - 会员套餐 - - - 点数充值 - - - - {paymentError && ( -
-

{paymentError}

- {selectedProductId && ( - - )} -
- )} - {packagesError &&

{packagesError}

} - - - {!packagesError && !paymentEnabled &&

在线支付暂未开放。

} - {plans.length > 0 && ( -
- {plans.map((product) => { - const alias = planAlias(product); - const recommended = alias === "monthly"; - const isCurrent = currentProductCode === product.code; - const highlighted = alias === highlightedPlan; - return ( -
-
-
-

{product.name}

-

{planDisplayLabel(alias ?? "monthly")}

-
- {(isCurrent || recommended) && {isCurrent ? "当前套餐" : "推荐"}} -
-

- {formatPrice(product.priceCents, product.currency)} - /{formatProductDuration(product)} -

-

{productAudience(product)}

-
    - {product.entitlements.map((entitlement, index) => ( -
  • {entitlementLabel(entitlement)}
  • - ))} - {product.entitlements.length === 0 &&
  • 购买后按页面说明到账
  • } -
-
- 详细规则 -
    -
  • {isCurrent - ? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。` - : "支付成功后权益立即生效。"}
  • -
  • 订单与到账状态可在「订单记录」页面查看。
  • -
  • 支付由合作渠道处理;如长时间未到账,请联系支持并出示订单号。
  • -
-
- -
- ); - })} -
- )} - {!packagesError && paymentEnabled && plans.length === 0 &&

会员套餐正在上架中。

} -
- - - {creditPacks.length > 0 && ( -
- {creditPacks.map((product) => ( -
-
-

{product.name}

-

{product.credits > 0 ? `${product.credits} 点` : "点数包"}

-

{product.description || "按需补充咨询点数"}

-
-

{formatPrice(product.priceCents, product.currency)}

-
    - {product.entitlements.map((entitlement, index) => ( -
  • {entitlementLabel(entitlement)}
  • - ))} -
-
- 详细规则 -
    -
  • 点数充值支付成功后立即到账。
  • -
-
- -
- ))} -
- )} - {!packagesError && paymentEnabled && creditPacks.length === 0 &&

点数包正在上架中。

} -
-
-
- - {paymentOrder && ( -
- -
- {paymentOrder.status === "paid" && ( - <> -

支付成功,权益已到账

-

订单号:{paymentOrder.orderNo}

-

余额与会员状态已刷新,可以返回继续使用。

- - )} - {paymentOrder.status === "failed" && ( - <> -

支付失败

-

订单号:{paymentOrder.orderNo}

-

订单未完成支付,可重新支付或稍后再试。

- - )} - {paymentOrder.status === "pending" && ( - <> -

等待完成支付

-

订单号:{paymentOrder.orderNo}

-

请在已打开的收银台页面完成支付;本页会自动检查支付状态并刷新余额。

- - )} -
-
- {paymentOrder.status === "failed" && ( - - )} - -
-
- )} -
- - {redeemOpen && ( -
-
event.stopPropagation()} - > -
-

兑换点数

- -
-
- {activeSubscription ? "当前会员" : "当前余额"} - {activeSubscription - ? `${activeSubscription.product?.name || activeSubscription.productCode} · ${formatMembershipDate(activeSubscription.endsAt)} 到期` - : `${account?.credits ?? 0} 点`} -
-
- -
- { - setRedeemCode(event.target.value); - setRedeemError(""); - setRedeemMessage(""); - setRedeemDone(false); - }} - placeholder="输入完整兑换码" - /> - -
- {redeemError &&

{redeemError}

} - {redeemMessage &&

{redeemMessage}

} - {redeemDone && ( -
- -
- )} -
-
- 兑换规则 -
    -
  • 请完整输入兑换码,系统会按 JYOTISH-XXXX-XXXX 格式校验。
  • -
  • 兑换成功后点数立即到账,余额实时更新。
  • -
  • 每个兑换码仅可使用一次,无法转让或退换。
  • -
-
-
-
- )} -
- ); -} - -export default function MembershipPage() { - return ( -
正在加载会员信息…
}> - -
- ); -} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f64c39ed..491119e9 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -8,9 +8,9 @@ import { Sparkles } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; +import { ProfilePanel } from "@/components/profile-panel"; import { ThemePreferencePanel } from "@/components/theme-preference-menu"; import { AppSidebar } from "@/components/app-sidebar"; -import { UserAvatar } from "@/components/user-avatar"; import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase, @@ -46,9 +46,6 @@ import { BirthLocationFields } from "@/components/birth-location-fields"; import { OnboardingChatMessage } from "@/components/onboarding-chat-message"; import { ChartLibraryPanel } from "@/components/chart-library-panel"; import { StarterHome } from "@/components/starter-home"; -import { - beamAvatarPalettes, -} from "@/lib/beam-avatar"; import { GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION, isGeneralDailyFortuneQuestion, @@ -93,8 +90,8 @@ import { keepFocusWithin } from "@/lib/focus-trap"; import { BALANCE_SYNC_KEY, BALANCE_CHANGED_EVENT, - membershipHref, } from "@/lib/membership"; +import { parseSettingsQuery, stripSettingsQuery } from "@/lib/settings-url"; import { nextActivityView, activityCompletedTrail, type AgentActivityView } from "@/lib/chat-message-view"; import { chartLibraryFromCloudOthers, @@ -169,12 +166,12 @@ import { type Account, type AccountDialog, type ChartLibraryRecord, - type ChartRelationship, type ChatSession, type ConsultationStatus, type DailyStarlanguageState, type Message, type OnboardingStep, + type OpenAccountDialogOptions, type PendingConsultation, type Profile, type ReplyOutcome, @@ -245,6 +242,10 @@ const BirthTimeRectification = dynamic( loading: () =>

正在加载出生时间评估...

, }, ); +const BillingPanel = dynamic( + () => import("@/components/billing-panel").then((module) => module.BillingPanel), + { ssr: false }, +); export default function Home() { const router = useRouter(); @@ -256,12 +257,9 @@ export default function Home() { const [activeChartId, setActiveChartId] = useState("self"); const [synastryRelationshipType, setSynastryRelationshipType] = useState("romance"); const [synastryPendingId, setSynastryPendingId] = useState(null); - const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); - const [otherChartRelationship, setOtherChartRelationship] = useState>("other"); - const [editingChartId, setEditingChartId] = useState(null); - const [editingSelfChart, setEditingSelfChart] = useState(false); const [synastryReportCard, setSynastryReportCard] = useState(null); const [synastryHistory, setSynastryHistory] = useState([]); + const [billingPane, setBillingPane] = useState({}); const [dailyStarlanguage, setDailyStarlanguage] = useState({ kind: "pending" }); const [profileNotice, setProfileNotice] = useState(""); const [avatarNotice, setAvatarNotice] = useState(""); @@ -469,8 +467,8 @@ export default function Home() { birthTimeRevisionPending, chartLibrary, dialogReturnTarget, profile, profileDraft, profileSaving, setAccount, setAccountError, setAccountMenuOpen, setActiveAccountDialog, setActiveChartId, setAvatarNotice, setAvatarSaving, setBirthTimeAssessmentPhase, - setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setDraft, - setEditingSelfChart, setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength, + setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setBillingPane, setDraft, + setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength, setProfile, setProfileDraft, setProfileNotice, setProfileSaving, setRectificationError, setSigningOut, setStartGreeting, signingOut, uiPreview, guidedBirthTimeReadyRef, editDeclaredBirthTimeDetailsRef, @@ -506,7 +504,18 @@ export default function Home() { useEffect(() => { if (!hydrated || uiPreview.current) return; - const onPopState = () => applySessionPopStateRef.current(window.location.search); + const applySettingsFromSearch = (search: string) => { + const query = parseSettingsQuery(search); + if (!query) return; + openAccountDialog("billing", { source: query.source ?? undefined, plan: query.plan ?? undefined, tab: query.tab ?? undefined }); + const next = stripSettingsQuery(search, window.location.pathname); + if (`${window.location.pathname}${window.location.search}` !== next) window.history.replaceState(null, "", next); + }; + applySettingsFromSearch(window.location.search); + const onPopState = () => { + applySettingsFromSearch(window.location.search); + applySessionPopStateRef.current(window.location.search); + }; window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); }, [hydrated]); @@ -1448,6 +1457,7 @@ export default function Home() { const reportCard: SynastryReportCard = { id: `${record.id}-${timestamp()}`, partnerName: record.profile.name || "对方", + partnerChartId: record.id, score, maxScore: max, assessment, @@ -1611,104 +1621,30 @@ export default function Home() { signingOut, close: closeAccountDialog, navigate: (dialog) => openAccountDialog(dialog, dialogReturnTarget.current), - openRedeem: () => router.push(membershipHref("account-menu")), overlayRef: accountDialog, closeButtonRef: closeButton, - renderProfile() { - return ( - <> - {accountError &&

{accountError}

} - {account.avatar && ( -
-
- 头像 - Beam 形象由随机种子生成,刷新和换设备后保持一致 -
-
- -
-
- {beamAvatarPalettes.map((palette, index) => ( - - ))} -
- -
-
- {avatarNotice &&

{avatarNotice}

} -
- )} -
-
账户信息登录与账户识别信息
-
-
昵称
{profile.name.trim() || "尚未设置"}与“我的星盘”名称同步
-
登录邮箱
{account.user.email || "尚未读取邮箱"}
-
-
-
- -
- - ); - }, + renderProfile: () => ( + + ), renderChartLibrary() { return ( <>

管理本人和其他人的出生资料。本人资料用于默认解盘,其他人资料可用于合盘或单独查看。

); }, + renderBilling: () => ( + + ), renderGeneral() { return ; @@ -1772,7 +1708,7 @@ export default function Home() { onOpenProfile={() => openAccountDialog("profile")} onOpenChartLibrary={() => openAccountDialog("chart-library")} onOpenGeneral={() => openAccountDialog("general")} - onOpenRedeem={() => router.push(membershipHref("account-menu"))} + onOpenBilling={() => openAccountDialog("billing", { source: "account-menu" })} onOpenLogout={() => openAccountDialog("logout")} /> {pendingSessionDeletion ? ( @@ -1800,7 +1736,7 @@ export default function Home() {
- @@ -1964,6 +1900,7 @@ export default function Home() { }} onPendingChange={setRectificationMutationPending} onProfileIncomplete={handleRectificationProfileIncomplete} + onOpenBilling={(options) => openAccountDialog("billing", { source: options?.source ?? "rectification" })} onSaved={() => void refreshAccount()} pendingConsultationQuestion={rectificationPendingQuestion} onRestart={() => void startNewRectification()} @@ -1985,7 +1922,7 @@ export default function Home() { ? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…" : "请先完成上方资料" : account.credits === 0 && !account.activeSubscription - ? "余额不足,可前往会员页充值点数或开通会员" + ? "余额不足,可在账户与点数中补充" : "例如:未来半年是否适合换工作?"} maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500} inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} @@ -2027,6 +1964,10 @@ export default function Home() { credits={account.credits} onClose={() => setOnboardingPaywallOpen(false)} onCreditsChanged={(credits) => setAccount((current) => current ? { ...current, credits } : current)} + onOpenBilling={() => { + setOnboardingPaywallOpen(false); + openAccountDialog("billing", { source: "onboarding-paywall" }); + }} /> )} diff --git a/frontend/src/components/account-dialog-overlay.tsx b/frontend/src/components/account-dialog-overlay.tsx index a291d72f..4ab1a878 100644 --- a/frontend/src/components/account-dialog-overlay.tsx +++ b/frontend/src/components/account-dialog-overlay.tsx @@ -3,7 +3,7 @@ import { ChevronRight, Settings, UserRound, Users, WalletCards, X } from "lucide-react"; import { memo, type MutableRefObject, type ReactNode } from "react"; -export type AccountSettingsDialog = "profile" | "chart-library" | "general"; +export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general"; export type AccountOverlayModel = Readonly<{ title: string; @@ -11,11 +11,11 @@ export type AccountOverlayModel = Readonly<{ signingOut: boolean; close: () => void; navigate: (dialog: AccountSettingsDialog) => void; - openRedeem: () => void; overlayRef: MutableRefObject; closeButtonRef: MutableRefObject; renderProfile: () => ReactNode; renderChartLibrary: () => ReactNode; + renderBilling: () => ReactNode; renderGeneral: () => ReactNode; renderLogout: () => ReactNode; }>; @@ -27,6 +27,7 @@ const settingsItems: ReadonlyArray<{ }> = [ { dialog: "profile", label: "个人资料", icon: UserRound }, { dialog: "chart-library", label: "星盘资料", icon: Users }, + { dialog: "billing", label: "账户与点数", icon: WalletCards }, { dialog: "general", label: "通用设置", icon: Settings }, ]; @@ -36,7 +37,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({ model, }: Readonly<{ open: boolean; - dialog: "profile" | "chart-library" | "general" | "logout" | null; + dialog: "profile" | "chart-library" | "billing" | "general" | "logout" | null; model: AccountOverlayModel | null; }>) { if (!open || dialog === null || model === null) return null; @@ -45,6 +46,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({ const renderSettingsContent = () => { if (dialog === "profile") return model.renderProfile(); if (dialog === "chart-library") return model.renderChartLibrary(); + if (dialog === "billing") return model.renderBilling(); return model.renderGeneral(); }; @@ -87,11 +89,6 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 0fc7598e..a622ab8e 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -77,7 +77,7 @@ export type AppSidebarProps = { onOpenProfile: () => void; onOpenChartLibrary: () => void; onOpenGeneral: () => void; - onOpenRedeem: () => void; + onOpenBilling: () => void; onOpenLogout: () => void; }; @@ -101,7 +101,7 @@ export function AppSidebar({ onOpenProfile, onOpenChartLibrary, onOpenGeneral, - onOpenRedeem, + onOpenBilling, onOpenLogout, }: AppSidebarProps) { const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar(); @@ -341,7 +341,7 @@ export function AppSidebar({ - + diff --git a/frontend/src/components/billing-panel.tsx b/frontend/src/components/billing-panel.tsx new file mode 100644 index 00000000..a0d2ede0 --- /dev/null +++ b/frontend/src/components/billing-panel.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useEffect } from "react"; +import { CheckCircle2, Sparkles, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useBillingPanel } from "@/hooks/use-billing-panel"; +import type { Account } from "@/lib/home-types"; +import { + entitlementLabel, + formatMembershipDate, + formatPrice, + formatProductDuration, + orderStatusLabel, + planAlias, + planDisplayLabel, + productAudience, + selectMembershipPlans, + type BillingPaneTab, + type MembershipPlanAlias, +} from "@/lib/membership"; + +export type BillingPanelProps = { + readonly account: Account; + readonly highlightedPlan: MembershipPlanAlias | null; + readonly initialTab: BillingPaneTab; +}; + +function payingLabel(paying: boolean, idle: string) { + return paying ? "跳转收银台…" : idle; +} + +export function BillingPanel({ account: seedAccount, highlightedPlan, initialTab }: BillingPanelProps) { + const billing = useBillingPanel({ account: seedAccount, highlightedPlan, initialTab }); + const { + account, + activeSubscription, + closeRedeem, + createPayment, + currentProductCode, + fetchOrders, + highlightedCardRef, + orders, + ordersState, + packagesError, + packagesSettled, + payingProductId, + paymentEnabled, + paymentError, + paymentOrder, + paymentPackages, + redeem, + redeemCode, + redeemDone, + redeemError, + redeeming, + redeemMessage, + selectedProductId, + setRedeemCode, + setRedeemDone, + setRedeemError, + setRedeemMessage, + } = billing; + const plans = selectMembershipPlans(paymentPackages); + const creditPacks = paymentPackages.filter((product) => product.productType === "credit_pack"); + const credits = account?.credits ?? seedAccount.credits; + + useEffect(() => { + if (billing.initialTab === "orders") void fetchOrders(); + }, [billing.initialTab, fetchOrders]); + + return ( +
+

+ 余额 {credits} 点 + {" · "} + {activeSubscription + ? `${activeSubscription.product?.name || activeSubscription.productCode},${formatMembershipDate(activeSubscription.endsAt)} 到期` + : "未开通会员"} +

+ + { + if (value === "orders") void fetchOrders(); + }} + > + + + 会员套餐 + + + 点数包 + + + 兑换码 + + + 订单记录 + + + + {paymentError && ( +
+

{paymentError}

+ {selectedProductId && ( + + )} +
+ )} + {packagesError &&

{packagesError}

} + + + {packagesSettled && !packagesError && !paymentEnabled &&

在线支付暂未开放。

} + {plans.length > 0 && ( +
+ {plans.map((product) => { + const alias = planAlias(product); + const recommended = alias === "monthly"; + const isCurrent = currentProductCode === product.code; + const highlighted = alias === highlightedPlan; + return ( +
+
+
+

{product.name}

+

{planDisplayLabel(alias ?? "monthly")}

+
+ {(isCurrent || recommended) && {isCurrent ? "当前套餐" : "推荐"}} +
+

+ {formatPrice(product.priceCents, product.currency)} + /{formatProductDuration(product)} +

+

{productAudience(product)}

+
    + {product.entitlements.map((entitlement, index) => ( +
  • {entitlementLabel(entitlement)}
  • + ))} + {product.entitlements.length === 0 &&
  • 购买后按页面说明到账
  • } +
+
+ 详细规则 +
    +
  • {isCurrent + ? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。` + : "支付成功后权益立即生效。"}
  • +
  • 订单与到账状态可在「订单记录」页签查看。
  • +
  • 支付由合作渠道处理;如长时间未到账,请联系支持并出示订单号。
  • +
+
+ +
+ ); + })} +
+ )} + {packagesSettled && !packagesError && paymentEnabled && plans.length === 0 &&

会员套餐正在上架中。

} +
+ + + {creditPacks.length > 0 && ( +
+ {creditPacks.map((product) => ( +
+
+

{product.name}

+

{product.credits > 0 ? `${product.credits} 点` : "点数包"}

+

{product.description || "按需补充咨询点数"}

+
+

{formatPrice(product.priceCents, product.currency)}

+
    + {product.entitlements.map((entitlement, index) => ( +
  • {entitlementLabel(entitlement)}
  • + ))} +
+
+ 详细规则 +
    +
  • 点数充值支付成功后立即到账。
  • +
+
+ +
+ ))} +
+ )} + {packagesSettled && !packagesError && paymentEnabled && creditPacks.length === 0 &&

点数包正在上架中。

} +
+ + +

兑换点数

+
+ {activeSubscription ? "当前会员" : "当前余额"} + {activeSubscription + ? `${activeSubscription.product?.name || activeSubscription.productCode} · ${formatMembershipDate(activeSubscription.endsAt)} 到期` + : `${credits} 点`} +
+
+ +
+ { + setRedeemCode(event.target.value); + setRedeemError(""); + setRedeemMessage(""); + setRedeemDone(false); + }} + placeholder="输入完整兑换码" + /> + +
+ {redeemError &&

{redeemError}

} + {redeemMessage &&

{redeemMessage}

} + {redeemDone && ( +
+ +
+ )} +
+
+ 兑换规则 +
    +
  • 请完整输入兑换码,系统会按 JYOTISH-XXXX-XXXX 格式校验。
  • +
  • 兑换成功后点数立即到账,余额实时更新。
  • +
  • 每个兑换码仅可使用一次,无法转让或退换。
  • +
+
+
+ + +
+
+

全部订单

+

查看套餐与点数包的支付和到账状态。

+
+ +
+ {ordersState === "unavailable" &&

订单记录暂时不可用,请稍后刷新。

} + {ordersState === "ready" && orders.length === 0 &&

暂无订单。

} + {orders.length > 0 && ( +
    + {orders.map((order) => ( +
  • +
    + {order.productName || "订单"} + {order.orderNo} +
    + + {orderStatusLabel(order.status)} + + {formatPrice(order.moneyCents, order.currency)} +
  • + ))} +
+ )} +
+
+ + {paymentOrder && ( +
+ +
+ {paymentOrder.status === "paid" && ( + <> +

支付成功,权益已到账

+

订单号:{paymentOrder.orderNo}

+

余额与会员状态已刷新。

+ + )} + {paymentOrder.status === "failed" && ( + <> +

支付失败

+

订单号:{paymentOrder.orderNo}

+

订单未完成支付,可重新支付或稍后再试。

+ + )} + {paymentOrder.status === "pending" && ( + <> +

等待支付结果

+

订单号:{paymentOrder.orderNo}

+

请在已打开的收银台页面完成支付;打开后会自动检查支付状态并刷新余额。

+ + )} +
+
+ {paymentOrder.status === "failed" && ( + + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/chart-library-panel.tsx b/frontend/src/components/chart-library-panel.tsx index c1ca5d74..33ffb299 100644 --- a/frontend/src/components/chart-library-panel.tsx +++ b/frontend/src/components/chart-library-panel.tsx @@ -1,7 +1,8 @@ "use client"; -import type { Dispatch, FormEvent, SetStateAction } from "react"; -import { ProfileFields } from "@/components/profile-fields"; +import { ChevronRight } from "lucide-react"; +import { useState, type Dispatch, FormEvent, SetStateAction } from "react"; +import { ChartProfileForm } from "@/components/chart-profile-form"; import { activeChartStorageKey, deleteCloudChartProfile, saveCloudChartProfile, updateCloudChartProfile } from "@/lib/home-cloud-sync"; import { chartRelationshipLabel, @@ -23,8 +24,11 @@ import { type SynastryRelationshipType, type SynastryReportCard, } from "@/lib/home-types"; -import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field"; -import { resolveAyanamsa } from "@/lib/ayanamsa"; +import { + CHART_LIBRARY_LIST_VIEW, + synastryHistoryForPartner, + type ChartLibraryView, +} from "@/lib/chart-library-view"; export type ChartLibraryPanelProps = { readonly account: Account | null; @@ -39,14 +43,6 @@ export type ChartLibraryPanelProps = { readonly profileNotice: string; readonly setProfileNotice: Dispatch>; readonly setAccountError: Dispatch>; - readonly editingSelfChart: boolean; - readonly setEditingSelfChart: Dispatch>; - readonly otherProfileDraft: Profile; - readonly setOtherProfileDraft: Dispatch>; - readonly otherChartRelationship: Exclude; - readonly setOtherChartRelationship: Dispatch>>; - readonly editingChartId: string | null; - readonly setEditingChartId: Dispatch>; readonly synastryRelationshipType: SynastryRelationshipType; readonly setSynastryRelationshipType: Dispatch>; readonly synastryPendingId: string | null; @@ -72,14 +68,6 @@ export function ChartLibraryPanel({ profileNotice, setProfileNotice, setAccountError, - editingSelfChart, - setEditingSelfChart, - otherProfileDraft, - setOtherProfileDraft, - otherChartRelationship, - setOtherChartRelationship, - editingChartId, - setEditingChartId, synastryRelationshipType, setSynastryRelationshipType, synastryPendingId, @@ -91,6 +79,31 @@ export function ChartLibraryPanel({ saveProfile, draftSynastryQuestionFromChart, }: ChartLibraryPanelProps) { + const [view, setView] = useState(CHART_LIBRARY_LIST_VIEW); + const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); + const [otherChartRelationship, setOtherChartRelationship] = useState>("other"); + const [editingChartId, setEditingChartId] = useState(null); + + const selfCharts = chartLibrary.filter((record) => record.role === "self"); + const otherCharts = chartLibrary.filter((record) => record.role === "other"); + const selectedOther = view.kind === "other" + ? otherCharts.find((record) => record.id === view.id) ?? null + : null; + const partnerHistory = selectedOther + ? synastryHistoryForPartner(synastryHistory, { + id: selectedOther.id, + name: selectedOther.profile.name || "对方", + }) + : []; + + function goList() { + setView(CHART_LIBRARY_LIST_VIEW); + setEditingChartId(null); + setOtherProfileDraft(emptyProfile); + setOtherChartRelationship("other"); + setProfileDraft(profile); + } + async function saveOtherChart(event: FormEvent) { event.preventDefault(); const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim(), chartRelationship: otherChartRelationship }; @@ -123,6 +136,7 @@ export function ChartLibraryPanel({ setEditingChartId(null); setAccountError(""); setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。"); + setView(CHART_LIBRARY_LIST_VIEW); } catch { setProfileNotice("保存失败,请重试"); setAccountError(""); @@ -136,6 +150,7 @@ export function ChartLibraryPanel({ setEditingChartId(record.id); setAccountError(""); setProfileNotice(""); + setView({ kind: "other", id: record.id }); } async function deleteOtherChart(recordId: string) { @@ -157,6 +172,7 @@ export function ChartLibraryPanel({ }); setAccountError(""); setProfileNotice("已从云端星盘库删除。"); + goList(); } function makeDefaultChart(record: ChartLibraryRecord) { @@ -167,118 +183,165 @@ export function ChartLibraryPanel({ setProfileNotice("已设为当前使用资料,账户本人的出生资料未被覆盖。"); } - return ( -
-
-
-
我的星盘当前账号的默认资料
- {chartLibrary.filter((record) => record.role === "self").length} -
- {chartLibrary.filter((record) => record.role === "self").map((record) => ( -
-
-
{record.profile.name || "未命名"}本人当前默认
- {record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)} - {profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)} -
-
- -
-
- ))} - {editingSelfChart && ( -
-
编辑本人星盘这份资料会用于默认解盘与新对话。
- - setProfileDraft({ ...profileDraft, ayanamsa })} - /> - {profileNotice &&

{profileNotice}

} -
- - -
- - )} - {chartLibrary.filter((record) => record.role === "self").length === 0 &&

请先在个人资料中补全你的出生资料。

} -
-
-
-
其他人的星盘亲友、伴侣或客户资料
- {chartLibrary.filter((record) => record.role === "other").length} -
- {chartLibrary.filter((record) => record.role === "other").length === 0 &&

还没有其他星盘,先添加一份资料。

} - {chartLibrary.filter((record) => record.role === "other").map((record) => ( -
-
-
{record.profile.name || "未命名"}{chartRelationshipLabel(record.relationship)}
- {record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)} - {profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)} -
-
- - - - - -
-
- ))} -
- {synastryReportCard && ( -
-
- 合盘结果摘要 - {synastryReportCard.partnerName} - Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"} -
- {synastryReportCard.headline &&

{synastryReportCard.headline}

} -
- 查看证据 -
    - {(synastryReportCard.strengths || []).map((item) =>
  • {item}
  • )} - {(synastryReportCard.risks || []).map((item) =>
  • {item}
  • )} -
- 下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"} -
-
- )} - {synastryHistory.length > 0 && ( -
- 合盘历史 - {synastryHistory.slice(0, 5).map((item) => ( - - ))} -
- )} -
-
{editingChartId ? "编辑其他人的星盘" : "添加其他人的星盘"}用于合盘、亲友盘或客户盘。
- - -
- {editingChartId && } - -
- -
+ function listRow(record: ChartLibraryRecord) { + const isSelf = record.role === "self"; + return ( + + ); + } + if (view.kind === "self") { + return ( +
+ + +
+ ); + } + + if (view.kind === "add" || (view.kind === "other" && selectedOther)) { + const isAdd = view.kind === "add"; + return ( +
+ + + {!isAdd && selectedOther ? ( + <> +
+ + +
+
+
+
合盘把这份资料用于合盘分析
+
+
+ + +
+ {synastryReportCard && synastryHistoryForPartner([synastryReportCard], { id: selectedOther.id, name: selectedOther.profile.name || "对方" }).length > 0 && ( +
+
+ 合盘结果摘要 + {synastryReportCard.partnerName} + Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"} +
+ {synastryReportCard.headline &&

{synastryReportCard.headline}

} +
+ 查看证据 +
    + {(synastryReportCard.strengths || []).map((item) =>
  • {item}
  • )} + {(synastryReportCard.risks || []).map((item) =>
  • {item}
  • )} +
+ 下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"} +
+
+ )} + {partnerHistory.length > 0 && ( +
+ 合盘历史 + {partnerHistory.slice(0, 5).map((item) => ( + + ))} +
+ )} +
+ + ) : null} +
+ ); + } + + return ( +
+
+
+
我的星盘当前账号的默认资料
+ {selfCharts.length} +
+ {selfCharts.map(listRow)} + {selfCharts.length === 0 &&

请先在个人资料中补全你的出生资料。

} +
+
+
+
其他人亲友、伴侣或客户资料
+ {otherCharts.length} +
+ {otherCharts.length === 0 &&

还没有其他星盘,先添加一份资料。

} + {otherCharts.map(listRow)} + +
+
); } diff --git a/frontend/src/components/chart-profile-form.tsx b/frontend/src/components/chart-profile-form.tsx new file mode 100644 index 00000000..b1ee6267 --- /dev/null +++ b/frontend/src/components/chart-profile-form.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { Dispatch, FormEvent, SetStateAction } from "react"; +import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field"; +import { ProfileFields } from "@/components/profile-fields"; +import { resolveAyanamsa } from "@/lib/ayanamsa"; +import type { ChartRelationship, Profile } from "@/lib/home-types"; + +export type ChartProfileFormProps = { + readonly title: string; + readonly description: string; + readonly value: Profile; + readonly onChange: Dispatch>; + readonly nameInputId: string; + readonly showAyanamsa?: boolean; + readonly relationship?: Exclude; + readonly onRelationshipChange?: (value: Exclude) => void; + readonly onSubmit: (event: FormEvent) => void; + readonly onCancel?: () => void; + readonly cancelLabel?: string; + readonly submitLabel: string; + readonly submitDisabled: boolean; + readonly notice?: string; +}; + +export function ChartProfileForm({ + title, + description, + value, + onChange, + nameInputId, + showAyanamsa = false, + relationship, + onRelationshipChange, + onSubmit, + onCancel, + cancelLabel = "取消", + submitLabel, + submitDisabled, + notice, +}: ChartProfileFormProps) { + return ( +
+
{title}{description}
+ {relationship !== undefined && onRelationshipChange ? ( + + ) : null} + + {showAyanamsa ? ( + onChange((current) => ({ ...current, ayanamsa }))} + /> + ) : null} + {notice ?

{notice}

: null} +
+ {onCancel ? : null} + +
+ + ); +} diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 26dbffb3..5178bf01 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -47,6 +47,7 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{ onCompleted?: () => void; onPendingChange?: (pending: boolean) => void; onProfileIncomplete?: () => void; + onOpenBilling?: (options?: { source?: string }) => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; onOpeningConsumed?: () => void; pendingConsultationQuestion?: string | null; diff --git a/frontend/src/components/onboarding-redeem-paywall.tsx b/frontend/src/components/onboarding-redeem-paywall.tsx index 2bf52634..f0a64a4a 100644 --- a/frontend/src/components/onboarding-redeem-paywall.tsx +++ b/frontend/src/components/onboarding-redeem-paywall.tsx @@ -1,22 +1,23 @@ "use client"; -import Link from "next/link"; import { Gift, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import { keepFocusWithin } from "@/lib/focus-trap"; -import { membershipHref, notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership"; +import { notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership"; type OnboardingRedeemPaywallProps = { credits: number; onClose: () => void; onCreditsChanged: (credits: number) => void; + onOpenBilling: () => void; }; export function OnboardingRedeemPaywall({ credits, onClose, onCreditsChanged, + onOpenBilling, }: OnboardingRedeemPaywallProps) { const [code, setCode] = useState(""); const [error, setError] = useState(""); @@ -126,7 +127,7 @@ export function OnboardingRedeemPaywall({ {unlocked ? ( ) : ( - 查看套餐与会员 + )}
diff --git a/frontend/src/components/profile-panel.tsx b/frontend/src/components/profile-panel.tsx new file mode 100644 index 00000000..4f52d4a2 --- /dev/null +++ b/frontend/src/components/profile-panel.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { UserAvatar } from "@/components/user-avatar"; +import { beamAvatarPalettes, type BeamAvatarPatch } from "@/lib/beam-avatar"; +import type { Account, Profile } from "@/lib/home-types"; + +export type ProfilePanelProps = { + readonly account: Account; + readonly profile: Profile; + readonly avatarSaving: boolean; + readonly avatarNotice: string; + readonly accountError: string; + readonly persistAvatar: (patch: BeamAvatarPatch) => void; +}; + +export function ProfilePanel({ + account, + profile, + avatarSaving, + avatarNotice, + accountError, + persistAvatar, +}: ProfilePanelProps) { + return ( + <> + {accountError &&

{accountError}

} + {account.avatar && ( +
+
+ 头像 + Beam 形象由随机种子生成,刷新和换设备后保持一致 +
+
+ +
+
+ {beamAvatarPalettes.map((palette, index) => ( + + ))} +
+ +
+
+ {avatarNotice &&

{avatarNotice}

} +
+ )} +
+
账户信息登录与账户识别信息
+
+
昵称
{profile.name.trim() || "尚未设置"}与“我的星盘”名称同步
+
登录邮箱
{account.user.email || "尚未读取邮箱"}
+
+
+ + ); +} diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 3e12b347..8d987a31 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -44,14 +44,12 @@ import { diffRectificationBoard, RECTIFICATION_BOARD_SPLIT_MIN_PX, } from "@/lib/rectification-board-model"; -import { membershipHref } from "@/lib/membership"; import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter"; import { rectificationAdoptingLabel, RECTIFICATION_EMPTY_ACTION_LABEL, RECTIFICATION_EMPTY_COPY, RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE, - RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS, RECTIFICATION_QUESTION_PREPARING_LABEL, RECTIFICATION_QUESTION_RELOAD_LABEL, RECTIFICATION_QUESTION_RETRY_INTERVAL_MS, @@ -240,6 +238,7 @@ type RectificationAgenticChatProps = Readonly<{ onCompleted?: () => void; onPendingChange?: (pending: boolean) => void; onProfileIncomplete?: () => void; + onOpenBilling?: (options?: { source?: string }) => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; onOpeningConsumed?: () => void; pendingConsultationQuestion?: string | null; @@ -476,6 +475,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onCompleted, onPendingChange, onProfileIncomplete, + onOpenBilling, onSaved, onOpeningConsumed, pendingConsultationQuestion, @@ -824,11 +824,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { return; } if (response.status === 402) { - // Say why before the page leaves, instead of vanishing mid-chat. + // Keep the conversation on this page; the settings pane covers billing. setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE); - window.setTimeout(() => { - window.location.assign(membershipHref("rectification")); - }, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS); + onOpenBilling?.({ source: "rectification" }); return; } if (response.status === 401) setError("请先登录。"); @@ -1053,7 +1051,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { lastRunOutcome.current = readonly ? "readonly" : runOutcome; setPending(false); } - }, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]); + }, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]); useEffect(() => { if (busy) return; diff --git a/frontend/src/hooks/use-billing-panel.ts b/frontend/src/hooks/use-billing-panel.ts new file mode 100644 index 00000000..58268d19 --- /dev/null +++ b/frontend/src/hooks/use-billing-panel.ts @@ -0,0 +1,353 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { FormEvent } from "react"; +import type { Account } from "@/lib/home-types"; +import { + notifyBalanceChanged, + redeemErrorMessage, + type BillingPaneTab, + type MembershipPlanAlias, + type MembershipProduct, +} from "@/lib/membership"; + +export type BillingAccount = { + user: { id: string; email: string | null }; + credits: number; + activeSubscription: Account["activeSubscription"]; +}; + +export type PaymentOrderState = { + orderNo: string; + payUrl: string | null; + qrCode: string | null; + status: string; + productId: string; +}; + +export type PaymentOrder = { + orderNo: string; + status: string; + credits: number; + moneyCents: number; + currency: string; + productName: string; + paidAt: string | null; + createdAt: string; +}; + +export type OrdersState = "idle" | "ready" | "unavailable"; + +function toBillingAccount(account: Account | BillingAccount | null): BillingAccount | null { + if (!account) return null; + return { + user: account.user, + credits: account.credits, + activeSubscription: account.activeSubscription, + }; +} + +export function normalizeOrders(list: readonly unknown[]): PaymentOrder[] { + return list.map((raw) => { + const item = (raw ?? {}) as Record; + const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record; + const price = + typeof item.price === "number" + ? item.price + : typeof item.money_cents === "number" + ? item.money_cents + : typeof item.moneyCents === "number" + ? item.moneyCents + : 0; + return { + orderNo: String(item.order_no ?? item.orderNo ?? ""), + status: String(item.status ?? "unknown"), + credits: typeof item.credits === "number" ? item.credits : 0, + moneyCents: price, + currency: String(item.currency ?? "CNY"), + productName: + typeof snapshot.name === "string" + ? snapshot.name + : typeof item.name === "string" + ? item.name + : String(item.product_code ?? item.productCode ?? ""), + paidAt: item.paid_at ? String(item.paid_at) : item.paidAt ? String(item.paidAt) : null, + createdAt: String(item.created_at ?? item.createdAt ?? ""), + }; + }); +} + +export function useBillingPanel(input: { + readonly account: Account; + readonly highlightedPlan: MembershipPlanAlias | null; + readonly initialTab: BillingPaneTab; +}) { + const seedAccount = input.account; + const [accountError, setAccountError] = useState(""); + const [paymentPackages, setPaymentPackages] = useState([]); + const [paymentEnabled, setPaymentEnabled] = useState(false); + const [packagesError, setPackagesError] = useState(""); + const [redeemCode, setRedeemCode] = useState(""); + const [redeemError, setRedeemError] = useState(""); + const [redeemMessage, setRedeemMessage] = useState(""); + const [redeeming, setRedeeming] = useState(false); + const [paymentOrder, setPaymentOrder] = useState(null); + const [paymentError, setPaymentError] = useState(""); + const [payingProductId, setPayingProductId] = useState(null); + const [selectedProductId, setSelectedProductId] = useState(null); + const [redeemDone, setRedeemDone] = useState(false); + const [orders, setOrders] = useState([]); + const [ordersState, setOrdersState] = useState("idle"); + const [packagesSettled, setPackagesSettled] = useState(false); + const highlightedCardRef = useRef(null); + const planHighlightScrolled = useRef(false); + const ordersRequested = useRef(input.initialTab === "orders"); + + const activeSubscription = seedAccount.activeSubscription ?? null; + const currentProductCode = activeSubscription?.status === "active" + ? activeSubscription.productCode + : null; + + const fetchAccountData = useCallback(async (): Promise => { + try { + const response = await fetch("/api/account", { cache: "no-store" }); + if (response.status === 401) { + window.location.assign("/login"); + return null; + } + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error || "暂时无法读取账户信息"); + const next = toBillingAccount(payload as Account); + setAccountError(""); + return next; + } catch (caught) { + setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息"); + return null; + } + }, []); + + const resetPaymentState = useCallback(() => { + setPaymentEnabled(false); + setPaymentPackages([]); + setPaymentOrder(null); + setPaymentError(""); + setPayingProductId(null); + setSelectedProductId(null); + }, []); + + const fetchPackages = useCallback(async () => { + try { + const response = await fetch("/api/payment/packages", { cache: "no-store" }); + const payload = await response.json().catch(() => null); + if (response.ok && payload?.enabled === true) { + setPaymentEnabled(true); + setPaymentPackages(payload.packages || []); + setPackagesError(""); + return; + } + if (!response.ok) { + setPackagesError("套餐支付暂时不可用,请稍后重试"); + resetPaymentState(); + } + } catch { + setPackagesError("套餐支付暂时不可用,请稍后重试"); + resetPaymentState(); + } finally { + setPackagesSettled(true); + } + }, [resetPaymentState]); + + const fetchOrders = useCallback(async () => { + ordersRequested.current = true; + try { + const response = await fetch("/api/payment/orders", { cache: "no-store" }); + if (response.status === 401) { + window.location.assign("/login"); + return; + } + if (!response.ok) throw new Error("订单记录暂时不可用"); + const payload = await response.json().catch(() => null); + const list = Array.isArray(payload) + ? payload + : payload && typeof payload === "object" && Array.isArray(payload.orders) + ? payload.orders + : []; + setOrders(normalizeOrders(list)); + setOrdersState("ready"); + } catch { + setOrders([]); + setOrdersState("unavailable"); + } + }, []); + + useEffect(() => { + void (async () => { + await Promise.allSettled([fetchAccountData(), fetchPackages()]); + })(); + }, [fetchAccountData, fetchPackages]); + + useEffect(() => { + if (!input.highlightedPlan || planHighlightScrolled.current || paymentPackages.length === 0) return; + planHighlightScrolled.current = true; + const card = highlightedCardRef.current; + if (!card) return; + window.requestAnimationFrame(() => { + card.scrollIntoView({ behavior: "smooth", block: "center" }); + card.focus({ preventScroll: true }); + }); + }, [input.highlightedPlan, paymentPackages.length]); + + async function redeem(event: FormEvent) { + event.preventDefault(); + const code = redeemCode.trim(); + if (!code || redeeming) return; + setRedeeming(true); + setRedeemError(""); + setRedeemMessage(""); + try { + const response = await fetch("/api/redeem", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code }), + }); + const payload = await response.json().catch(() => null); + if (response.status === 401) { + window.location.assign("/login"); + return; + } + if (!response.ok) throw new Error(redeemErrorMessage(response.status, payload)); + const result = payload as { awardedCredits?: number; credits: number; message?: string }; + const awarded = typeof result.awardedCredits === "number" ? result.awardedCredits : result.credits; + setRedeemCode(""); + setRedeemDone(true); + setRedeemMessage(result.message || `本次到账 ${awarded} 点,最新余额 ${result.credits} 点。`); + const refreshed = await fetchAccountData(); + notifyBalanceChanged(refreshed?.credits ?? result.credits); + } catch (caught) { + setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试"); + } finally { + setRedeeming(false); + } + } + + function closeRedeem() { + setRedeemDone(false); + setRedeemMessage(""); + setRedeemError(""); + } + + async function createPayment(productId: string) { + if (payingProductId) return; + setSelectedProductId(productId); + setPayingProductId(productId); + setPaymentError(""); + try { + const response = await fetch("/api/payment/epay/create", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ productId }), + }); + const payload = await response.json().catch(() => null); + if (response.status === 401) { + window.location.assign("/login"); + return; + } + if (!response.ok) throw new Error(payload?.error || "创建支付失败"); + if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") { + throw new Error("创建支付失败"); + } + setPaymentOrder({ + orderNo: payload.orderNo, + payUrl: payload.payUrl, + qrCode: payload.qrCode ?? null, + status: "pending", + productId, + }); + window.open(payload.payUrl, "_blank", "noopener,noreferrer"); + } catch (caught) { + setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); + } finally { + setPayingProductId(null); + } + } + + useEffect(() => { + if (!paymentOrder || paymentOrder.status !== "pending") return; + 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]); + + return { + account: toBillingAccount(seedAccount), + accountError, + activeSubscription, + createPayment, + currentProductCode, + fetchOrders, + closeRedeem, + highlightedCardRef, + highlightedPlan: input.highlightedPlan, + initialTab: input.highlightedPlan ? "membership" : input.initialTab, + orders, + ordersState, + packagesError, + packagesSettled, + payingProductId, + paymentEnabled, + paymentError, + paymentOrder, + paymentPackages, + redeem, + redeemCode, + redeemDone, + redeemError, + redeeming, + redeemMessage, + requestOrders: () => { + if (!ordersRequested.current) void fetchOrders(); + }, + selectedProductId, + setRedeemCode, + setRedeemDone, + setRedeemError, + setRedeemMessage, + }; +} diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index f28fa26b..39970123 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -45,7 +45,6 @@ import { import { BALANCE_CHANGED_EVENT, BALANCE_SYNC_KEY, - membershipHref, } from "@/lib/membership"; import { CancellationResponseError, @@ -69,6 +68,7 @@ import type { ChatSession, ConsultationStatus, Message, + OpenAccountDialogOptions, PendingConsultation, Profile, ReplyOutcome, @@ -138,7 +138,7 @@ export type ConsultationRunParams = { startNewChat: () => Promise; continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise; refreshAccount: () => Promise; - openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void; + openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void; openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise; }; @@ -166,7 +166,7 @@ export function useConsultationRun(params: ConsultationRunParams) { pendingConsultation, pendingSessionId, profile, - router, + router: _router, sessions, setAccount, setActiveSessionId, @@ -555,7 +555,7 @@ export function useConsultationRun(params: ConsultationRunParams) { : initialConsultationRoute; if (account.credits <= 0 && !account.activeSubscription) { - router.push(membershipHref("insufficient-credits")); + openAccountDialog("billing", { source: "insufficient-credits" }); return false; } @@ -750,7 +750,7 @@ export function useConsultationRun(params: ConsultationRunParams) { const contentType = response.headers.get("content-type") ?? ""; const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() }; if (response.status === 401) window.location.assign("/login"); - if (response.status === 402) router.push(membershipHref("insufficient-credits")); + if (response.status === 402) openAccountDialog("billing", { source: "insufficient-credits" }); throw new ConsultationResponseError( response.status, payloadMessage(errorPayload, "服务暂时不可用"), diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts index d27750e6..1cb4d806 100644 --- a/frontend/src/hooks/use-profile-onboarding.ts +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -1,6 +1,6 @@ "use client"; -import type { Dispatch, FormEvent, MutableRefObject, SetStateAction } from "react"; +import { type Dispatch, FormEvent, MutableRefObject, SetStateAction } from "react"; import { beamAvatarSchema, @@ -40,7 +40,9 @@ import { type AccountDialog, type ChartLibraryRecord, type OnboardingStep, + type OpenAccountDialogOptions, type Profile, + accountDialogOptions, } from "@/lib/home-types"; import { preserveShallowEqual } from "@/lib/preserve-shallow-equal"; import { @@ -74,8 +76,8 @@ export type ProfileOnboardingParams = { setBirthTimeConsultationConsent: Dispatch>; setBirthTimeError: Dispatch>; setBirthTimeJourney: Dispatch>; + setBillingPane: Dispatch>; setDraft: (value: string) => void; - setEditingSelfChart: Dispatch>; setOnboardingJustCompleted: Dispatch>; setOnboardingStep: Dispatch>; setPresetMessageLength: Dispatch>; @@ -116,8 +118,8 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, + setBillingPane, setDraft, - setEditingSelfChart, setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength, @@ -155,8 +157,12 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { } - function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) { - dialogReturnTarget.current = returnTarget ?? accountTrigger.current; + function openAccountDialog( + dialog: AccountDialog, + returnTargetOrOptions?: HTMLButtonElement | null | OpenAccountDialogOptions, + ) { + const options = accountDialogOptions(returnTargetOrOptions); + dialogReturnTarget.current = options.returnTarget ?? accountTrigger.current; setAccountMenuOpen(false); setAccountError(""); if (dialog === "profile") { @@ -166,7 +172,13 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { if (dialog === "chart-library") { setProfileDraft(profile); setProfileNotice(""); - setEditingSelfChart(false); + } + if (dialog === "billing") { + setBillingPane({ + source: options.source, + plan: options.plan, + tab: options.tab, + }); } setActiveAccountDialog(dialog); } diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index 3ea02015..7343795e 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -15,6 +15,7 @@ import { type ChatSession, type Message, type OnboardingStep, + type OpenAccountDialogOptions, type Profile, type Theme, } from "@/lib/home-types"; @@ -72,7 +73,7 @@ export type RectificationSurfaceParams = { setSessions: Dispatch>; uiPreview: MutableRefObject; updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void; - openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void; + openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void; refreshAccount: () => Promise; rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise>; }; diff --git a/frontend/src/lib/chart-library-view.ts b/frontend/src/lib/chart-library-view.ts new file mode 100644 index 00000000..6b1eba19 --- /dev/null +++ b/frontend/src/lib/chart-library-view.ts @@ -0,0 +1,18 @@ +export type ChartLibraryView = + | { readonly kind: "list" } + | { readonly kind: "self" } + | { readonly kind: "other"; readonly id: string } + | { readonly kind: "add" }; + +export const CHART_LIBRARY_LIST_VIEW: ChartLibraryView = { kind: "list" }; + +export function synastryHistoryForPartner(history: readonly T[], partner: { readonly id: string; readonly name: string }) { + return history.filter((item) => ( + item.partnerChartId + ? item.partnerChartId === partner.id + : item.partnerName === partner.name + )); +} diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index f3e6736e..60f46b37 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -50,6 +50,7 @@ export type ChartLibraryApiRecord = { export type SynastryReportCard = { id: string; partnerName: string; + partnerChartId?: string; score?: number; maxScore?: number; assessment?: string; @@ -132,7 +133,24 @@ export type Account = { profile: unknown; }; export type OnboardingStep = "name" | "birth" | "place" | "rectification"; -export type AccountDialog = "profile" | "chart-library" | "general" | "logout"; +export type AccountDialog = "profile" | "chart-library" | "billing" | "general" | "logout"; +export type BillingPaneTab = "membership" | "credits" | "redeem" | "orders"; +export type OpenAccountDialogOptions = { + returnTarget?: HTMLButtonElement | null; + source?: string; + plan?: "trial" | "monthly" | "yearly"; + tab?: BillingPaneTab; +}; + +export function accountDialogOptions( + value?: HTMLButtonElement | null | OpenAccountDialogOptions, +): OpenAccountDialogOptions { + if (value == null) return {}; + if (typeof value === "object" && "nodeType" in value) { + return { returnTarget: value }; + } + return value; +} export type DailyStarlanguageCard = { trend: string; action: string; caution: string }; export type DailyStarlanguageApiResponse = { status?: "ok" | "unavailable" | "unauthenticated"; @@ -185,14 +203,16 @@ export const themes = defaultGuidedJyotishTopics; export const accountDialogTitles = { profile: "个人资料", "chart-library": "星盘资料", + billing: "账户与点数", general: "通用设置", logout: "退出登录?", } as const satisfies Record; export const accountDialogClasses = { - profile: "profile-modal", - "chart-library": "chart-library-modal", - general: "general-modal", + profile: "settings-modal", + "chart-library": "settings-modal", + billing: "settings-modal", + general: "settings-modal", logout: "logout-modal", } as const satisfies Record; diff --git a/frontend/src/lib/membership.ts b/frontend/src/lib/membership.ts index 7351e94f..7a763412 100644 --- a/frontend/src/lib/membership.ts +++ b/frontend/src/lib/membership.ts @@ -27,11 +27,16 @@ export type MembershipProduct = { }; export type MembershipPlanAlias = "trial" | "monthly" | "yearly"; +export type BillingPaneTab = "membership" | "credits" | "redeem" | "orders"; -export function membershipHref(source: string, options: { plan?: MembershipPlanAlias } = {}) { - const params = new URLSearchParams({ source }); +export function membershipHref( + source: string, + options: { plan?: MembershipPlanAlias; tab?: BillingPaneTab } = {}, +) { + const params = new URLSearchParams({ settings: "billing", source }); if (options.plan) params.set("plan", options.plan); - return `/membership?${params.toString()}`; + if (options.tab) params.set("tab", options.tab); + return `/?${params.toString()}`; } export function planDisplayLabel(alias: MembershipPlanAlias) { diff --git a/frontend/src/lib/settings-url.ts b/frontend/src/lib/settings-url.ts new file mode 100644 index 00000000..bf9f57b5 --- /dev/null +++ b/frontend/src/lib/settings-url.ts @@ -0,0 +1,40 @@ +import type { BillingPaneTab, MembershipPlanAlias } from "@/lib/membership"; + +export const SETTINGS_QUERY_KEY = "settings"; + +export type SettingsQuery = { + readonly pane: "billing"; + readonly source: string | null; + readonly plan: MembershipPlanAlias | null; + readonly tab: BillingPaneTab | null; +}; + +const PLAN_ALIASES: ReadonlySet = new Set(["trial", "monthly", "yearly"]); +const BILLING_TABS: ReadonlySet = new Set(["membership", "credits", "redeem", "orders"]); + +function searchParams(search: string) { + return new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); +} + +export function parseSettingsQuery(search: string): SettingsQuery | null { + const params = searchParams(search); + if (params.get(SETTINGS_QUERY_KEY) !== "billing") return null; + const planRaw = params.get("plan"); + const tabRaw = params.get("tab"); + return { + pane: "billing", + source: params.get("source"), + plan: planRaw && PLAN_ALIASES.has(planRaw) ? planRaw as MembershipPlanAlias : null, + tab: tabRaw && BILLING_TABS.has(tabRaw) ? tabRaw as BillingPaneTab : null, + }; +} + +export function stripSettingsQuery(search: string, pathname = "/") { + const params = searchParams(search); + params.delete(SETTINGS_QUERY_KEY); + params.delete("source"); + params.delete("plan"); + params.delete("tab"); + const query = params.toString(); + return query ? `${pathname}?${query}` : pathname || "/"; +} diff --git a/frontend/tests/account-dialog-overlay.test.ts b/frontend/tests/account-dialog-overlay.test.ts index 27919f71..7327e56a 100644 --- a/frontend/tests/account-dialog-overlay.test.ts +++ b/frontend/tests/account-dialog-overlay.test.ts @@ -9,35 +9,39 @@ import { type AccountOverlayModel, } from "../src/components/account-dialog-overlay.tsx"; -test("a closed account overlay does not render profile or logout content", () => { +function overlayModel(overrides: Partial = {}): AccountOverlayModel { const overlayRef = createRef() as AccountOverlayModel["overlayRef"]; const closeButtonRef = createRef() as AccountOverlayModel["closeButtonRef"]; - let profileRenders = 0; - let logoutRenders = 0; - const model: AccountOverlayModel = { + return { title: "个人资料", - dialogClass: "profile-modal", + dialogClass: "settings-modal", signingOut: false, close() {}, navigate() {}, - openRedeem() {}, overlayRef, closeButtonRef, + renderProfile() { return null; }, + renderChartLibrary() { return null; }, + renderBilling() { return null; }, + renderGeneral() { return null; }, + renderLogout() { return null; }, + ...overrides, + }; +} + +test("a closed account overlay does not render profile or logout content", () => { + let profileRenders = 0; + let logoutRenders = 0; + const model = overlayModel({ renderProfile() { profileRenders += 1; return null; }, - renderChartLibrary() { - return null; - }, - renderGeneral() { - return null; - }, renderLogout() { logoutRenders += 1; return null; }, - }; + }); const html = renderToString(createElement(AccountDialogOverlay, { open: false, @@ -61,3 +65,26 @@ test("home does not write overlay epoch or chat actions during render", () => { assert.match(page, /queueMicrotask\(\(\) => setActiveChartId\(storedChartId\)\)/); assert.match(page, /useEffect\(\(\) => \{\s*chatActionsRef\.current = \{/); }); + +test("settings navigation lists four panes and billing can be current", () => { + const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8"); + const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + const html = renderToString(createElement(AccountDialogOverlay, { + open: true, + dialog: "billing", + model: overlayModel({ title: "账户与点数" }), + })); + + assert.match(overlay, /dialog: "profile"/); + assert.match(overlay, /dialog: "chart-library"/); + assert.match(overlay, /dialog: "billing"/); + assert.match(overlay, /dialog: "general"/); + assert.doesNotMatch(overlay, /openRedeem/); + assert.match(html, /个人资料/); + assert.match(html, /星盘资料/); + assert.match(html, /账户与点数/); + assert.match(html, /通用设置/); + assert.match(html, /aria-current="page"/); + assert.match(styles, /\.settings-modal \{[^}]*width:[^}]*height:/); + assert.doesNotMatch(styles, /chart-library-modal|profile-modal/); +}); diff --git a/frontend/tests/beam-avatar.test.ts b/frontend/tests/beam-avatar.test.ts index fdab40f8..3e7c4f83 100644 --- a/frontend/tests/beam-avatar.test.ts +++ b/frontend/tests/beam-avatar.test.ts @@ -18,6 +18,7 @@ const avatarRoute = readProjectFile("src/app/api/account/avatar/route.ts"); const avatarComponent = readProjectFile("src/components/user-avatar.tsx"); const sidebar = readProjectFile("src/components/app-sidebar.tsx"); const page = readProjectFile("src/app/page.tsx"); +const profilePanel = readProjectFile("src/components/profile-panel.tsx"); const migration = readProjectFile("db/migrations/20260807010000_profile_beam_avatars.sql"); const seed = "11111111-1111-4111-8111-111111111111"; @@ -92,9 +93,10 @@ test("renders the persisted Beam avatar in the sidebar, account menu, and profil assert.match(avatarComponent, /name=\{avatar\.seed\}/); assert.match(sidebar, / new URL(`../${path}`, import.meta.url); +const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8"); +const hookSource = readProjectFile("src/hooks/use-billing-panel.ts"); +const panelSource = readProjectFile("src/components/billing-panel.tsx"); +const billingSource = `${hookSource}\n${panelSource}`; +const membershipLib = readProjectFile("src/lib/membership.ts"); +const nextConfig = readProjectFile("next.config.ts"); +const pageSource = readProjectFile("src/app/page.tsx"); +const tabsSource = readProjectFile("src/components/ui/tabs.tsx"); + +function countMatches(source: string, pattern: RegExp) { + return source.match(pattern)?.length ?? 0; +} + +test("membership routes are gone and billing loads without a spinner", () => { + // 原值: src/app/membership/page.tsx 存在且含 Suspense / membership-loading + // 新值: 页面删除;billing-panel 是 client 组件,无 spinner / 骨架 / 「正在加载」 + // 原因: 账户与点数进入设置弹窗,等待态遵守 AGENTS §6 + assert.equal(existsSync(projectFile("src/app/membership/page.tsx")), false); + assert.equal(existsSync(projectFile("src/app/membership/orders/page.tsx")), false); + assert.match(panelSource, /"use client"/); + assert.doesNotMatch(billingSource, /membership-loading|正在加载|Suspense/); + assert.doesNotMatch(panelSource, /正在加载订单/); + assert.match(nextConfig, /source: "\/membership"/); + assert.match(nextConfig, /destination: "\/\?settings=billing&source=legacy"/); + assert.match(nextConfig, /source: "\/membership\/orders"/); + assert.match(nextConfig, /destination: "\/\?settings=billing&tab=orders"/); +}); + +test("membership page loads account, packages and redeem endpoints without fetching orders", () => { + // 原值: 会员页不请求 /api/payment/orders + // 新值: 挂载只并发账户与套餐;订单仅在 orders 页签拉取 + // 原因: 订单记录改成分区内页签,不能一打开就打列表接口 + assert.match(hookSource, /fetch\("\/api\/account"/); + assert.match(hookSource, /fetch\("\/api\/payment\/packages"/); + assert.match(hookSource, /fetch\("\/api\/payment\/epay\/create"/); + assert.match(hookSource, /fetch\(`\/api\/payment\/epay\/status\?orderNo=/); + assert.match(hookSource, /fetch\("\/api\/redeem"/); + assert.match(hookSource, /await Promise\.allSettled\(\[fetchAccountData\(\), fetchPackages\(\)\]\)/); + const mountStart = hookSource.indexOf("await Promise.allSettled"); + const mountEnd = hookSource.indexOf("}, [fetchAccountData, fetchPackages]);"); + const mountEffect = hookSource.slice(mountStart, mountEnd); + assert.doesNotMatch(mountEffect, /fetchOrders|\/api\/payment\/orders/); + assert.match(panelSource, /if \(value === "orders"\) void fetchOrders\(\)/); +}); + +test("creates orders with productId and opens the signed cashier URL safely", () => { + assert.match(hookSource, /JSON\.stringify\(\{ productId \}\)/); + assert.match(hookSource, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/); + assert.doesNotMatch(billingSource, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/); + assert.match(hookSource, /if \(!paymentOrder \|\| paymentOrder\.status !== "pending"\) return;/); +}); + +test("terminal payment failures stop polling and offer re-pay with the same product", () => { + assert.match(hookSource, /\["failed", "closed", "cancelled"\]\.includes\(payload\.status\)/); + assert.match(hookSource, /payload\.grantStatus === "failed"/); + assert.match(hookSource, /status !== "pending"\) return;/); + assert.match(hookSource, /status: paid \? "paid" : failed \? "failed" : "pending"/); + assert.match(panelSource, /paymentOrder\.status === "failed"/); + assert.match(panelSource, /支付失败/); + assert.match(panelSource, /createPayment\(paymentOrder\.productId\)/); + assert.match(panelSource, /重新支付/); +}); + +test("failed status beats paid and non-terminal statuses keep polling", () => { + assert.match(hookSource, /const paid = payload\.status === "paid" && !failed;/); + assert.match(hookSource, /status: paid \? "paid" : failed \? "failed" : "pending"/); + assert.match(hookSource, /if \(paid\) \{/); + assert.doesNotMatch(hookSource, /status: paid \? "paid" : failed \? "failed" : payload\.status/); + assert.doesNotMatch(hookSource, /payload\.status === "paid" \? "paid"/); +}); + +test("polls order status and refreshes only the balance on paid", () => { + const poll = hookSource.slice( + hookSource.indexOf("const checkPaymentStatus"), + hookSource.indexOf("let timer = 0"), + ); + assert.match(poll, /const paid = payload\.status === "paid" && !failed;/); + assert.match(poll, /if \(paid\) \{/); + assert.match(poll, /status: paid \? "paid" : failed \? "failed" : "pending"/); + assert.match(poll, /fetchAccountData\(\)/); + assert.match(poll, /notifyBalanceChanged\(refreshed\?\.credits/); + assert.doesNotMatch(poll, /fetchOrders/); +}); + +test("keeps the selected product and allows retry on payment failure", () => { + assert.match(hookSource, /setSelectedProductId\(productId\)/); + assert.match(hookSource, /setPaymentError\(caught instanceof Error \? caught\.message : "创建支付失败"\)/); + assert.match(hookSource, /finally \{\s*setPayingProductId\(null\);/); + assert.match(hookSource, /if \(payingProductId\) return;/); + assert.match(panelSource, /createPayment\(selectedProductId\)/); + assert.match(panelSource, /membership-payment-error/); + assert.doesNotMatch(hookSource, /setPayingProductId\(null\)[\s\S]{0,40}setPaymentOrder\(null\)/); +}); + +test("membership never opens the redeem dialog from the URL", () => { + // 原值: redeemOpen 状态,URL 不得带 redeem= + // 新值: 兑换码是页签;settings-url 的 tab=redeem 打开页签,不再套子弹窗 + // 原因: 决策 5,兑换码不再是子弹窗 + assert.doesNotMatch(billingSource, /redeemOpen|setRedeemOpen/); + assert.doesNotMatch(billingSource, /searchParams\.get\("redeem"\)|searchParams\.delete\("redeem"\)|redeem:\s*true/); + assert.match(panelSource, /value="redeem"/); + assert.doesNotMatch(panelSource, /className="account-modal membership-redeem"/); +}); + +test("returning prefers history.back and keeps browser back semantics", () => { + // 原值: goBack → history.back 或 assign("/") + // 新值: 账单在首页弹窗内,无 goBack;深链接用 stripSettingsQuery 抹参数 + // 原因: 删除整页后返回就是关掉弹窗,浏览器后退仍是原生行为 + assert.doesNotMatch(billingSource, /goBack|history\.back\(/); + assert.doesNotMatch(panelSource, /window\.location\.assign\("\/"\)/); + assert.match(pageSource, /stripSettingsQuery\(/); +}); + +test("trims the redeem code without changing its case", () => { + assert.match(hookSource, /const code = redeemCode\.trim\(\);/); + assert.doesNotMatch(hookSource, /redeemCode\.(?:toUpperCase|toLowerCase)\(\)/); + assert.doesNotMatch(hookSource, /normalizeRedeemCode/); + assert.match(hookSource, /JSON\.stringify\(\{ code \}\)/); +}); + +test("redeem success reports awarded credits, latest balance and completes", () => { + assert.match(hookSource, /awardedCredits/); + assert.match(hookSource, /本次到账/); + assert.match(hookSource, /最新余额/); + assert.match(hookSource, /setRedeemDone\(true\)/); + assert.match(hookSource, /setRedeemCode\(""\)/); + assert.match(hookSource, /const refreshed = await fetchAccountData\(\);/); + assert.match(hookSource, /notifyBalanceChanged\(refreshed\?\.credits \?\? result\.credits\)/); + assert.match(panelSource, /完成/); + assert.match(panelSource, /onClick=\{closeRedeem\}/); +}); + +test("supports plan hints without rendering source notices", () => { + assert.match(panelSource, /const highlighted = alias === highlightedPlan;/); + assert.doesNotMatch(billingSource, /searchParams\.get\("source"\)|membershipSourceNotice|sourceNotice/); + assert.doesNotMatch(billingSource, /membership-notice|已为你定位到/); + assert.doesNotMatch(billingSource, /体验 \/ 月卡 \/ 年卡见下方套餐|用于咨询与生时校正等服务/); +}); + +test("membership section shows fixed trial/monthly/yearly plans with monthly recommended", () => { + assert.match(panelSource, /selectMembershipPlans\(paymentPackages\)/); + assert.match(panelSource, /planAlias\(product\)/); + assert.match(panelSource, /alias === "monthly"/); + assert.match(panelSource, /membership-plan-card--recommended/); + assert.match(membershipLib, /trial: "体验"/); + assert.match(membershipLib, /monthly: "月卡"/); + assert.match(membershipLib, /yearly: "年卡"/); + assert.match(panelSource, /会员套餐/); + // 原值: 点数充值 + // 新值: 点数包 + // 原因: VOICE 页签名「会员套餐 / 点数包 / 兑换码 / 订单记录」 + assert.match(panelSource, /点数包/); + assert.match(panelSource, /id="membership-plans-tab"/); + assert.match(panelSource, /id="membership-credits-tab"/); + assert.match(panelSource, /]*value="membership"/); + assert.match(panelSource, /]*value="credits"/); + assert.match(panelSource, /]*value="membership"/); + assert.match(panelSource, /]*value="credits"/); +}); + +test("plan cards carry price, duration, audience, entitlements, purchase and rules", () => { + assert.match(panelSource, /formatPrice\(product\.priceCents, product\.currency\)/); + assert.match(panelSource, /formatProductDuration\(product\)/); + assert.match(panelSource, /productAudience\(product\)/); + assert.match(panelSource, /entitlementLabel\(entitlement\)/); + assert.match(panelSource, /
/); + assert.match(panelSource, /详细规则<\/summary>/); + assert.match(panelSource, /import \{ Button \} from "@\/components\/ui\/button"/); + assert.match(panelSource, /className="membership-buy"/); + assert.match(panelSource, /variant=\{recommended \? "default" : "outline"\}/); +}); + +test("renewal semantics map the active subscription to the matching plan", () => { + assert.match(hookSource, /const currentProductCode = activeSubscription\?\.status === "active"/); + assert.match(panelSource, /currentProductCode === product\.code/); + assert.match(panelSource, /isCurrent \? "续费" : product\.productType === "trial" \? "立即开通" : "立即购买"/); + assert.match(panelSource, /当前套餐/); +}); + +test("membership and credits use the local Base UI tabs wrapper without manual segment state", () => { + assert.match(panelSource, /import \{ Tabs, TabsContent, TabsList, TabsTrigger \} from "@\/components\/ui\/tabs"/); + assert.doesNotMatch(billingSource, /activeSegment|setActiveSegment/); + assert.match(tabsSource, /import \{ Tabs as TabsPrimitive \} from "@base-ui\/react\/tabs"/); + assert.match(tabsSource, /TabsPrimitive\.Root/); + assert.match(tabsSource, /TabsPrimitive\.List/); + assert.match(tabsSource, /TabsPrimitive\.Tab/); + assert.match(tabsSource, /TabsPrimitive\.Panel/); + assert.match(panelSource, /const plans = selectMembershipPlans\(paymentPackages\);/); + assert.match(panelSource, /const creditPacks = paymentPackages\.filter\(\(product\) => product\.productType === "credit_pack"\);/); + assert.match(panelSource, /className="membership-plan-grid"/); + assert.match(panelSource, /className="membership-credit-grid"/); + assert.doesNotMatch(panelSource, /creditPacks\.map[\.\s\S]{0,120}membership-plan-card/); +}); + +test("plan param forces the membership segment and highlights the matching card", () => { + assert.match(panelSource, /const highlighted = alias === highlightedPlan;/); + assert.match(panelSource, /membership-plan-card--highlighted/); + assert.match(panelSource, /aria-current=\{highlighted \? "true" : undefined\}/); + assert.match(panelSource, /highlightedCardRef/); + assert.match(hookSource, /scrollIntoView\(\{ behavior: "smooth", block: "center" \}\)/); + assert.match(hookSource, /card\.focus\(\{ preventScroll: true \}\)/); + assert.match(hookSource, /initialTab: input\.highlightedPlan \? "membership" : input\.initialTab/); + assert.doesNotMatch(billingSource, /setActiveSegment\("credits"\)[\s\S]{0,80}highlightedPlan/); +}); + +test("page main title is 套餐与会员", () => { + // 原值:

套餐与会员

+ // 新值: 分区标题由弹窗壳提供「账户与点数」,摘要行写余额与会员状态 + // 原因: 不再是独立页面 + assert.doesNotMatch(panelSource, /

/); + assert.match(panelSource, /className="billing-summary"/); + assert.match(panelSource, /aria-label="账户与点数"/); +}); + +test("payment errors sit in a common position above the visible segment", () => { + const errorAt = panelSource.indexOf("membership-payment-error"); + const plansPanelAt = panelSource.indexOf(''); + assert.notEqual(errorAt, -1, "missing common payment error surface"); + assert.notEqual(plansPanelAt, -1, "missing plans panel"); + assert.ok(errorAt < plansPanelAt, "payment error must render before the segment panels"); + assert.doesNotMatch(panelSource, /

\{paymentError\}<\/p>/); +}); + +test("summary stays on the membership page and links to the independent orders page", () => { + // 原值: membership-summary-card + Link 到 /membership/orders + // 新值: billing-summary + 订单记录页签,行内列出订单 + // 原因: 删除独立订单页 + assert.match(panelSource, /className="billing-summary"/); + assert.match(panelSource, /value="orders"/); + assert.match(panelSource, /membership-order-row/); + assert.match(panelSource, /orderStatusLabel/); + assert.doesNotMatch(panelSource, /href="\/membership\/orders"/); +}); + +test("independent orders page preserves loading, empty, error, refresh and list states", () => { + // 原值: 独立订单页含「正在加载订单…」 + // 新值: 页签内 idle/ready/unavailable;空与失败有文案,没有加载句 + // 原因: AGENTS §6 禁止「正在加载」 + assert.match(hookSource, /fetch\("\/api\/payment\/orders"/); + assert.match(hookSource, /response\.status === 401[\s\S]*window\.location\.assign\("\/login"\)/); + assert.doesNotMatch(billingSource, /正在加载订单/); + assert.match(panelSource, /订单记录暂时不可用,请稍后刷新。/); + assert.match(panelSource, /暂无订单。/); + assert.match(panelSource, /onClick=\{\(\) => void fetchOrders\(\)\}>刷新/); + assert.match(panelSource, /membership-order-row/); + assert.match(panelSource, /orderStatusLabel\(order\.status\)/); + assert.match(panelSource, /formatPrice\(order\.moneyCents, order\.currency\)/); + assert.match(membershipLib, /paid: "已支付"/); +}); + +test("orders return replaces its history entry so membership back cannot reopen orders", () => { + // 原值: Link replace 回 /membership + // 新值: 页签切换不写历史;无 membership-back + // 原因: 不再有两页互相推历史 + assert.doesNotMatch(billingSource, /membership-back|href="\/membership"/); +}); + +test("payment and plan copy points users to the separate orders page", () => { + // 原值: 本页会自动检查支付状态并刷新余额 / 「订单记录」页面 + // 新值: 收银台打开后自动检查;订单在页签查看 + // 原因: 订单不再是另一页 + assert.match(panelSource, /请在已打开的收银台页面完成支付;打开后会自动检查支付状态并刷新余额。/); + assert.match(panelSource, /「订单记录」页签/); + assert.doesNotMatch(panelSource, /本页会自动刷新余额与订单记录|本页「订单记录」|用于咨询、报告与生时校正等服务/); +}); + +test("redeem modal surface contains title, balance, input, redeem, rules and close", () => { + // 原值: 兑换子弹窗含 aria-label=关闭 + // 新值: 兑换码页签含标题、余额、输入、兑换、规则;关闭属于设置弹窗外框 + // 原因: 决策 5 + assert.match(panelSource, /id="membership-redeem-title"/); + assert.match(panelSource, /

兑换点数<\/h2>/); + assert.match(panelSource, /className="redeem-balance"/); + assert.match(panelSource, /id="membership-redeem-code"/); + assert.match(panelSource, /立即兑换/); + assert.match(panelSource, /兑换规则<\/summary>/); + assert.match(panelSource, /className="redeem-form"/); + assert.match(panelSource, /