From ce46aae5c85385803d9f8d04081e832c2dcc946b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 11 Aug 2026 11:59:03 +0800 Subject: [PATCH] fix: polish storefront account and membership state --- docs/BUG_HISTORY.md | 16 ++++++++ frontend/src/app/membership/page.tsx | 5 +-- frontend/src/app/page.tsx | 37 ++++++++++--------- .../components/admin/product-management.tsx | 37 ++++++++++++++++--- frontend/src/lib/membership.ts | 3 +- .../admin-ui-permission-contract.test.ts | 24 ++++++++++++ frontend/tests/membership-page.test.ts | 31 +++++++++++++--- frontend/tests/starter-questions.test.ts | 6 +-- 8 files changed, 122 insertions(+), 37 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7f6ea8b8..d529b2cc 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2712,3 +2712,19 @@ - 相关记录:ERR-020、ERR-021、ERR-022、ERR-024、ERR-025、ERR-026、ERR-104 - 复发自:无 - 修复版本:待提交 + +## BUG-160 | 管理端商品权益枚举未本地化且登录、套餐与充值返回状态出现前端闪烁或陈旧状态 + +- 状态:resolved(local candidate,待真实登录态浏览器验收) +- 首次发现:2026-08-11 +- 最近更新:2026-08-11 +- 影响面:管理端商品与权益列表/编辑表单、主站登录跳转、首页 composer、套餐页兑换码弹窗、支付后首页点数余额 +- 用户现象:商品类型、计费周期、权益类型和重置周期直接显示英文枚举;未登录进入 `jyotisha.chat` 时登录页接管前短暂出现“暂时无法进入 Jyotisha”;首页 composer 展示过多状态提示;套餐页默认打开兑换码弹窗;充值成功返回首页后仍显示旧点数。 +- 触发条件:管理端读取数据库枚举;首页并行账户/会话请求返回 401;composer 业务流程写入 notice;账户菜单携带 `redeem=1` 进入套餐页;支付页返回已存在的首页历史记录且该记录未按普通 `pageshow` 刷新账户。 +- 根因:商品与权益 UI 直接渲染数据库英文值;401 跳转后继续抛出普通错误并被首页 bootstrap 写入 `accountError`;composer footer 集中渲染所有 notice;套餐页用 URL 参数初始化 `redeemOpen`,首页账户菜单默认写入该参数;余额同步事件只在套餐页当前窗口发出,而首页已卸载,首页又只在 BFCache `pageshow.persisted` 时刷新。 +- 修复:在管理端为商品类型、计费周期、权益类型和重置周期增加中文显示映射,API 原始值保持不变;401 使用 `location.replace` 和专用跳转错误,bootstrap/账户刷新忽略该跳转并保持 loading;删除 composer notice 可见渲染;移除 `membershipHref` 的 redeem 参数和套餐页 URL 自动开窗逻辑,保留用户手动兑换按钮;首页每次 `pageshow` 都重新读取账户与点数。 +- 验证:商品/会员/首页入口 66 条聚焦测试全部通过;本次 7 个目标文件 ESLint 通过;`git diff --check` 通过。完整 `tsc --noEmit` 仍被本任务外既有问题阻塞,包括缺少本地 `boring-avatars` 安装及既有报告、生产迁移和 staging workflow 测试类型错误,未把这些错误计入本修复通过条件。 +- 防复发:数据库枚举必须在管理 UI 显示边界映射,不得修改 API 真值;认证跳转不得进入普通错误页状态;对离开页面期间可能变化的账户余额,页面恢复必须主动重新读取服务端;兑换弹窗只能由明确用户操作开启。 +- 相关记录:BUG-010、BUG-090、BUG-123 +- 复发自:无 +- 修复版本:本地 staging 候选(未 push / deploy) diff --git a/frontend/src/app/membership/page.tsx b/frontend/src/app/membership/page.tsx index ad0b0d9a..3fc79b24 100644 --- a/frontend/src/app/membership/page.tsx +++ b/frontend/src/app/membership/page.tsx @@ -61,7 +61,7 @@ function MembershipContent() { const [paymentPackages, setPaymentPackages] = useState([]); const [paymentEnabled, setPaymentEnabled] = useState(false); const [packagesError, setPackagesError] = useState(""); - const [redeemOpen, setRedeemOpen] = useState(() => searchParams.get("redeem") === "1"); + const [redeemOpen, setRedeemOpen] = useState(false); const [redeemCode, setRedeemCode] = useState(""); const [redeemError, setRedeemError] = useState(""); const [redeemMessage, setRedeemMessage] = useState(""); @@ -174,9 +174,6 @@ function MembershipContent() { function closeRedeem() { setRedeemOpen(false); setRedeemError(""); - const url = new URL(window.location.href); - url.searchParams.delete("redeem"); - window.history.replaceState(null, "", `${url.pathname}${url.search}`); } async function redeem(event: FormEvent) { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index aa4df977..e9269100 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -882,6 +882,18 @@ class ConsultationStatusError extends Error { } } +class LoginRedirectError extends Error { + constructor() { + super("Redirecting to login"); + this.name = "LoginRedirectError"; + } +} + +function redirectToLogin(): never { + window.location.replace("/login"); + throw new LoginRedirectError(); +} + function waitForUndoWindow(signal: AbortSignal) { return new Promise((resolve) => { const finish = () => { @@ -896,10 +908,7 @@ function waitForUndoWindow(signal: AbortSignal) { async function fetchAccount(signal?: AbortSignal): Promise { const response = await fetch("/api/account", { signal, cache: "no-store" }); - if (response.status === 401) { - window.location.assign("/login"); - throw new Error("请先登录"); - } + if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息")); return payload as Account; @@ -914,10 +923,7 @@ async function fetchModelCatalog(signal?: AbortSignal) { async function fetchSessions(signal?: AbortSignal): Promise { const response = await fetch("/api/sessions", { signal, cache: "no-store" }); - if (response.status === 401) { - window.location.assign("/login"); - throw new Error("请先登录"); - } + if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null; @@ -1008,7 +1014,7 @@ export default function Home() { const [draft, setDraft] = useState(""); const [draftTheme, setDraftTheme] = useState(null); const [draftEntrypoint, setDraftEntrypoint] = useState(null); - const [composerNotice, setComposerNotice] = useState(""); + const [, setComposerNotice] = useState(""); const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null); const [cancellationPending, setCancellationPending] = useState(false); const [pendingSessionId, setPendingSessionId] = useState(null); @@ -1485,6 +1491,7 @@ export default function Home() { } } } catch (caught) { + if (caught instanceof LoginRedirectError) return; if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据")); } @@ -1734,8 +1741,8 @@ export default function Home() { if (event.key === BALANCE_SYNC_KEY) void refreshAccount(); }; const onBalanceChanged = () => void refreshAccount(); - const onPageShow = (event: PageTransitionEvent) => { - if (event.persisted) void refreshAccount(); + const onPageShow = () => { + void refreshAccount(); consultationRecoveryCheck.current(); }; const onOnline = () => consultationRecoveryCheck.current(); @@ -1768,6 +1775,7 @@ export default function Home() { setAccount(latest); setAccountError(""); } catch (caught) { + if (caught instanceof LoginRedirectError) return; if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息"); } @@ -3081,7 +3089,7 @@ export default function Home() { onOpenReports={() => window.location.assign("/reports")} onSelectSession={selectSession} onOpenProfile={() => openAccountDialog("profile")} - onOpenRedeem={() => window.location.assign(membershipHref("account-menu", { redeem: true }))} + onOpenRedeem={() => window.location.assign(membershipHref("account-menu"))} onOpenLogout={() => openAccountDialog("logout")} /> {pendingSessionDeletion ? ( @@ -3374,11 +3382,6 @@ export default function Home() { disabled={!activeSession || isLoading || cancellationPending || creatingSession} onSelect={(modelId) => void selectSessionModel(modelId)} /> - {(composerNotice || consultationPhase === "undo" || (!profileComplete && onboardingStep !== "name")) && ( -

{composerNotice || (consultationPhase === "undo" - ? "已加入发送队列,2.5 秒内可免费撤回。" - : onboardingStep === "rectification" ? "生时校正为可选增强" : "请先完成上方资料")}

- )} } diff --git a/frontend/src/components/admin/product-management.tsx b/frontend/src/components/admin/product-management.tsx index 7b2a3ce4..0de6b955 100644 --- a/frontend/src/components/admin/product-management.tsx +++ b/frontend/src/components/admin/product-management.tsx @@ -59,6 +59,33 @@ type Product = { entitlements: Entitlement[]; }; +const productTypeLabels: Record = { + credit_pack: "点数包", + trial: "体验套餐", + subscription: "订阅套餐", +}; + +const billingPeriodLabels: Record = { + none: "一次性", + day: "按天", + month: "按月", + year: "按年", +}; + +const allowanceTypeLabels: Record = { + access: "使用权限", + unlimited: "不限量", + quota: "限额", + credits: "点数", +}; + +const resetPeriodLabels: Record = { + none: "不重置", + day: "每日", + month: "每月", + billing_period: "每个计费周期", +}; + type ProductForm = Omit & { id?: string; priceYuan: number; @@ -198,10 +225,10 @@ export default function ProductManagement() { dataIndex: "name", render: (_, item) => {item.name}{item.code} · v{item.version}, }, - { title: "类型", dataIndex: "productType", render: (value) => {value} }, - { title: "周期", render: (_, item) => item.billingPeriod === "none" ? "—" : `${item.intervalCount} ${item.billingPeriod}` }, + { title: "类型", dataIndex: "productType", render: (value: Product["productType"]) => {productTypeLabels[value]} }, + { title: "周期", render: (_, item) => item.billingPeriod === "none" ? billingPeriodLabels.none : `${item.intervalCount} 个周期(${billingPeriodLabels[item.billingPeriod]})` }, { title: "价格", render: (_, item) => `¥${(item.priceCents / 100).toFixed(2)}` }, - { title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => {items.map((item) => {item.featureKey}: {item.allowanceType})} }, + { title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => {items.map((item) => {item.featureKey}:{allowanceTypeLabels[item.allowanceType] ?? item.allowanceType}{item.allowanceCount === null ? "" : ` ${item.allowanceCount}`} · {resetPeriodLabels[item.resetPeriod] ?? item.resetPeriod})} }, { title: "状态", render: (_, item) => {item.status}{item.enabled ? 可售 : 停用} }, { title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate }, { @@ -237,8 +264,8 @@ export default function ProductManagement() { - ({ value, label: value }))} /> + ({ value, label }))} /> diff --git a/frontend/src/lib/membership.ts b/frontend/src/lib/membership.ts index ca730d20..7351e94f 100644 --- a/frontend/src/lib/membership.ts +++ b/frontend/src/lib/membership.ts @@ -28,9 +28,8 @@ export type MembershipProduct = { export type MembershipPlanAlias = "trial" | "monthly" | "yearly"; -export function membershipHref(source: string, options: { redeem?: boolean; plan?: MembershipPlanAlias } = {}) { +export function membershipHref(source: string, options: { plan?: MembershipPlanAlias } = {}) { const params = new URLSearchParams({ source }); - if (options.redeem) params.set("redeem", "1"); if (options.plan) params.set("plan", options.plan); return `/membership?${params.toString()}`; } diff --git a/frontend/tests/admin-ui-permission-contract.test.ts b/frontend/tests/admin-ui-permission-contract.test.ts index 634a4e27..62e9eae9 100644 --- a/frontend/tests/admin-ui-permission-contract.test.ts +++ b/frontend/tests/admin-ui-permission-contract.test.ts @@ -74,6 +74,30 @@ test("high-risk UI reauth permissions match their API guards", async (t) => { } }); +test("product management localizes product, billing and entitlement enums", () => { + const ui = source("src/components/admin/product-management.tsx"); + + for (const label of [ + "点数包", + "体验套餐", + "订阅套餐", + "一次性", + "按天", + "按月", + "按年", + "使用权限", + "不限量", + "限额", + "点数", + "不重置", + "每个计费周期", + ]) { + assert.match(ui, new RegExp(label)); + } + assert.doesNotMatch(ui, /\{value\}<\/Tag>/); + assert.doesNotMatch(ui, /\$\{item\.intervalCount\} \$\{item\.billingPeriod\}/); +}); + test("model management uses ordinary admin mutation guards without reauth", () => { const ui = source("src/components/admin/model-management.tsx"); const route = source("src/app/api/admin/models/route.ts"); diff --git a/frontend/tests/membership-page.test.ts b/frontend/tests/membership-page.test.ts index 6b7bfe47..dac2fb81 100644 --- a/frontend/tests/membership-page.test.ts +++ b/frontend/tests/membership-page.test.ts @@ -81,11 +81,10 @@ test("keeps the selected product and allows retry on payment failure", () => { assert.doesNotMatch(pageSource, /setPayingProductId\(null\)[\s\S]{0,40}setPaymentOrder\(null\)/); }); -test("redeem opens from redeem=1 and removes the param without new history", () => { - assert.match(pageSource, /searchParams\.get\("redeem"\) === "1"/); - assert.match(pageSource, /window\.history\.replaceState/); - assert.match(pageSource, /url\.searchParams\.delete\("redeem"\)/); - assert.doesNotMatch(pageSource, /pushState/); +test("membership never opens the redeem dialog from the URL", () => { + assert.match(pageSource, /const \[redeemOpen, setRedeemOpen\] = useState\(false\)/); + assert.doesNotMatch(pageSource, /searchParams\.get\("redeem"\)|searchParams\.delete\("redeem"\)|redeem:\s*true/); + assert.match(pageSource, /onClick=\{\(\) => setRedeemOpen\(true\)\}/); }); test("returning prefers history.back and keeps browser back semantics", () => { @@ -94,6 +93,25 @@ test("returning prefers history.back and keeps browser back semantics", () => { assert.match(pageSource, /onClick=\{goBack\}/); }); +test("home refreshes the account whenever it is shown again", () => { + const pageShow = homePageSource.slice( + homePageSource.indexOf("const onPageShow"), + homePageSource.indexOf("const onOnline"), + ); + assert.match(pageShow, /void refreshAccount\(\)/); + assert.doesNotMatch(pageShow, /event\.persisted/); +}); + +test("login redirects keep the loading screen instead of flashing the account error page", () => { + assert.match(homePageSource, /class LoginRedirectError extends Error/); + assert.match(homePageSource, /window\.location\.replace\("\/login"\)/); + assert.match(homePageSource, /if \(caught instanceof LoginRedirectError\) return;/); +}); + +test("homepage composer does not render notice copy", () => { + assert.doesNotMatch(homePageSource, /composer-notice/); +}); + test("trims the redeem code without changing its case", () => { assert.match(pageSource, /const code = redeemCode\.trim\(\);/); assert.doesNotMatch(pageSource, /redeemCode\.(?:toUpperCase|toLowerCase)\(\)/); @@ -319,5 +337,6 @@ test("membership lib keeps helpers and the balance sync contract", () => { assert.match(membershipLib, /export function formatPrice\(priceCents: number, currency = "CNY"\)/); assert.match(membershipLib, /export function orderStatusLabel/); assert.match(membershipLib, /export function planDisplayLabel/); - assert.match(membershipLib, /export function membershipHref\(source: string, options: \{ redeem\?: boolean; plan\?: MembershipPlanAlias \} = \{\}\)/); + assert.match(membershipLib, /export function membershipHref\(source: string, options: \{ plan\?: MembershipPlanAlias \} = \{\}\)/); + assert.doesNotMatch(membershipLib, /params\.set\("redeem"/); }); diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index 25d705ca..c0a1bf33 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -129,10 +129,10 @@ test("routes account actions through a menu and focused dialogs", () => { assert.match(appSidebarSource, //); }); -test("redeem and balance entries navigate to the membership page with sources", () => { +test("membership entries navigate without auto-opening redeem", () => { // Given: the account menu, credit control and insufficient-balance paths. - // Then: every redeem/purchase path lands on /membership with a source hint. - assert.match(pageSource, /membershipHref\("account-menu", \{ redeem: true \}\)/); + // Then: every redeem/purchase path lands on /membership with a source hint, without opening a dialog. + assert.match(pageSource, /membershipHref\("account-menu"\)/); assert.match(pageSource, /membershipHref\("credits"\)/); assert.match(pageSource, /membershipHref\("insufficient-credits"\)/); assert.doesNotMatch(pageSource, /activeAccountDialog === "redeem"/);