merge: storefront fixes into staging

This commit is contained in:
Jesse_Chen
2026-08-11 11:59:35 +08:00
8 changed files with 122 additions and 37 deletions
+16
View File
@@ -2712,3 +2712,19 @@
- 相关记录:ERR-020、ERR-021、ERR-022、ERR-024、ERR-025、ERR-026、ERR-104
- 复发自:无
- 修复版本:待提交
## BUG-160 | 管理端商品权益枚举未本地化且登录、套餐与充值返回状态出现前端闪烁或陈旧状态
- 状态:resolvedlocal 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
+1 -4
View File
@@ -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
View File
@@ -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>
+1 -2
View File
@@ -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");
+25 -6
View File
@@ -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"/);
});
+3 -3
View File
@@ -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"/);