fix: polish storefront account and membership state
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -61,7 +61,7 @@ function MembershipContent() {
|
||||
const [paymentPackages, setPaymentPackages] = useState<MembershipProduct[]>([]);
|
||||
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<HTMLFormElement>) {
|
||||
|
||||
+20
-17
@@ -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<void>((resolve) => {
|
||||
const finish = () => {
|
||||
@@ -896,10 +908,7 @@ function waitForUndoWindow(signal: AbortSignal) {
|
||||
|
||||
async function fetchAccount(signal?: AbortSignal): Promise<Account> {
|
||||
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<unknown> {
|
||||
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<Theme | null>(null);
|
||||
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(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<string | null>(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")) && (
|
||||
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo"
|
||||
? "已加入发送队列,2.5 秒内可免费撤回。"
|
||||
: onboardingStep === "rectification" ? "生时校正为可选增强" : "请先完成上方资料")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
</SidebarInset>
|
||||
|
||||
@@ -59,6 +59,33 @@ type Product = {
|
||||
entitlements: Entitlement[];
|
||||
};
|
||||
|
||||
const productTypeLabels: Record<Product["productType"], string> = {
|
||||
credit_pack: "点数包",
|
||||
trial: "体验套餐",
|
||||
subscription: "订阅套餐",
|
||||
};
|
||||
|
||||
const billingPeriodLabels: Record<Product["billingPeriod"], string> = {
|
||||
none: "一次性",
|
||||
day: "按天",
|
||||
month: "按月",
|
||||
year: "按年",
|
||||
};
|
||||
|
||||
const allowanceTypeLabels: Record<string, string> = {
|
||||
access: "使用权限",
|
||||
unlimited: "不限量",
|
||||
quota: "限额",
|
||||
credits: "点数",
|
||||
};
|
||||
|
||||
const resetPeriodLabels: Record<string, string> = {
|
||||
none: "不重置",
|
||||
day: "每日",
|
||||
month: "每月",
|
||||
billing_period: "每个计费周期",
|
||||
};
|
||||
|
||||
type ProductForm = Omit<Product, "id" | "version" | "priceCents" | "status" | "effectiveFrom" | "updatedAt" | "entitlements"> & {
|
||||
id?: string;
|
||||
priceYuan: number;
|
||||
@@ -198,10 +225,10 @@ export default function ProductManagement() {
|
||||
dataIndex: "name",
|
||||
render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.code} · v{item.version}</Text></Space>,
|
||||
},
|
||||
{ title: "类型", dataIndex: "productType", render: (value) => <Tag>{value}</Tag> },
|
||||
{ title: "周期", render: (_, item) => item.billingPeriod === "none" ? "—" : `${item.intervalCount} ${item.billingPeriod}` },
|
||||
{ title: "类型", dataIndex: "productType", render: (value: Product["productType"]) => <Tag>{productTypeLabels[value]}</Tag> },
|
||||
{ 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[]) => <Space wrap>{items.map((item) => <Tag key={`${item.featureKey}-${item.allowanceType}`}>{item.featureKey}: {item.allowanceType}</Tag>)}</Space> },
|
||||
{ title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => <Space wrap>{items.map((item) => <Tag key={`${item.featureKey}-${item.allowanceType}`}>{item.featureKey}:{allowanceTypeLabels[item.allowanceType] ?? item.allowanceType}{item.allowanceCount === null ? "" : ` ${item.allowanceCount}`} · {resetPeriodLabels[item.resetPeriod] ?? item.resetPeriod}</Tag>)}</Space> },
|
||||
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{item.status}</Tag>{item.enabled ? <Tag color="blue">可售</Tag> : <Tag>停用</Tag>}</Space> },
|
||||
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
|
||||
{
|
||||
@@ -237,8 +264,8 @@ export default function ProductManagement() {
|
||||
<Col xs={24} md={8}><Form.Item name="priceYuan" label="价格(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: "100%" }} /></Form.Item></Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12} lg={6}><Form.Item name="productType" label="类型" rules={[{ required: true }]}><Select options={["credit_pack", "trial", "subscription"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
|
||||
<Col xs={24} md={12} lg={6}><Form.Item name="billingPeriod" label="计费周期" rules={[{ required: true }]}><Select options={["none", "day", "month", "year"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
|
||||
<Col xs={24} md={12} lg={6}><Form.Item name="productType" label="类型" rules={[{ required: true }]}><Select options={Object.entries(productTypeLabels).map(([value, label]) => ({ value, label }))} /></Form.Item></Col>
|
||||
<Col xs={24} md={12} lg={6}><Form.Item name="billingPeriod" label="计费周期" rules={[{ required: true }]}><Select options={Object.entries(billingPeriodLabels).map(([value, label]) => ({ value, label }))} /></Form.Item></Col>
|
||||
<Col xs={24} md={12} lg={6}><Form.Item name="intervalCount" label="周期数量" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
|
||||
<Col xs={24} md={12} lg={6}><Form.Item name="sortOrder" label="排序" rules={[{ required: true }]}><InputNumber precision={0} style={{ width: "100%" }} /></Form.Item></Col>
|
||||
</Row>
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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, /<Tag>\{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");
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
@@ -129,10 +129,10 @@ test("routes account actions through a menu and focused dialogs", () => {
|
||||
assert.match(appSidebarSource, /<Menu\.Root open=\{accountMenuOpen\} onOpenChange=\{onAccountMenuOpenChange\} modal=\{false\}>/);
|
||||
});
|
||||
|
||||
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"/);
|
||||
|
||||
Reference in New Issue
Block a user