diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 598d3e571..f79e3dec4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -930,7 +930,8 @@ export interface AdminProfileWalletPayload { totalBalance: number; spendableBalance: number; dailyFreePoints: number; - membershipLimitedPoints: number; + /** 月度泥点:当前会员账期内发放、随账期重置而清零的泥点;无会员时为 0。 */ + monthlyPoints: number; permanentPoints: number; heldPoints: number; refundDebtPoints: number; @@ -974,6 +975,25 @@ export interface AdminRechargeRefundHoldPayload { updatedAtMicros: number; } +export interface AdminMembershipOrderChangePayload { + /** `purchase`(新购)或 `upgrade`(补差升级)。 */ + changeKind: 'purchase' | 'upgrade' | string; + planBefore?: ProfileMembershipPlan | null; + planAfter?: ProfileMembershipPlan | null; + cycleKindBefore?: ProfileMembershipCycleKind | null; + cycleKindAfter?: ProfileMembershipCycleKind | null; + periodPointsBefore?: number | null; + periodPointsAfter?: number | null; + grantedPointsDelta: number; + cycleIndex?: number | null; + expiresAtBeforeMicros?: number | null; + expiresAtAfterMicros?: number | null; + cycleResetsAtBeforeMicros?: number | null; + cycleResetsAtAfterMicros?: number | null; + /** 订单价格拆分 JSON(目标档位价、当前档位价、实付金额);人工复核时原样展示。 */ + priceBreakdownJson?: string | null; +} + export interface AdminRechargeOrderEntryPayload { orderId: string; userId: string; @@ -1001,6 +1021,8 @@ export interface AdminRechargeOrderEntryPayload { remainingRefundableCents: number; refundEligible: boolean; refundBlockReasonCode?: string | null; + /** 会员购买 / 升级快照;非会员订单为 null。 */ + membershipChange?: AdminMembershipOrderChangePayload | null; } export interface AdminRechargeOrderListResponse { @@ -1027,9 +1049,25 @@ export interface AdminUserDetailResponse { cumulativeRechargedCents?: number | null; canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; + membership: AdminProfileMembershipPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; } +/** 当前会员状态与账期;无会员时为 `normal` 档位、无到期时间。 */ +export interface AdminProfileMembershipPayload { + plan: ProfileMembershipPlan; + status: string; + cycleKind: ProfileMembershipCycleKind; + cycleIndex: number; + cycleCount: number; + cycleGrantedPoints: number; + cycleRemainingPoints: number; + startedAtMicros?: number | null; + expiresAtMicros?: number | null; + cycleResetsAtMicros?: number | null; + updatedAtMicros?: number | null; +} + export interface AdminUserConsumptionReconcileRequest { userId: string; } diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx index 0c14b58be..cd82e6929 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -30,7 +30,7 @@ const wallet: AdminProfileWalletPayload = { totalBalance: 96, spendableBalance: 40, dailyFreePoints: 6, - membershipLimitedPoints: 20, + monthlyPoints: 20, permanentPoints: 70, heldPoints: 5, refundDebtPoints: 25, @@ -54,6 +54,19 @@ const detail: AdminUserDetailResponse = { cumulativeRechargedCents: 128800, canReconcileConsumption: true, wallet, + membership: { + plan: 'plus', + status: 'active', + cycleKind: 'monthly', + cycleIndex: 2, + cycleCount: 1, + cycleGrantedPoints: 500, + cycleRemainingPoints: 320, + startedAtMicros: 1_720_000_000_000_000, + expiresAtMicros: 1_722_592_000_000_000, + cycleResetsAtMicros: 1_722_592_000_000_000, + updatedAtMicros: 1_720_000_000_000_000, + }, rechargeOrders: [ { orderId: 'order-1', @@ -125,6 +138,8 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 expect(screen.getByText('退款欠账限制')).toBeTruthy(); expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy(); expect(screen.getByText('历史花费')).toBeTruthy(); + expect(screen.getByText('月度泥点')).toBeTruthy(); + expect(screen.getByText('Plus(plus)')).toBeTruthy(); expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy(); expect(screen.getByText('累计充值')).toBeTruthy(); expect(screen.getByText('¥1288.00')).toBeTruthy(); diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index 3fadef888..2da40b420 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -22,9 +22,14 @@ import { updateAdminWalletRestriction, } from '../api/adminApiClient'; import type { + AdminProfileMembershipPayload, AdminProfileWalletPayload, AdminUserDetailResponse, } from '../api/adminApiTypes'; +import { + formatMembershipCycleKind, + formatMembershipPlan, +} from '../config/membershipDisplay'; import { useAdminWriteConfirm } from './useAdminWriteConfirm'; interface AdminUserDetailDialogProps { @@ -265,6 +270,8 @@ export function AdminUserDetailDialog({ onReconcile={() => void handleConsumptionReconcile()} /> + +
= [ + ['档位', `${formatMembershipPlan(membership.plan)}(${membership.plan})`], + ['状态', membership.status || '-'], + [ + '账期', + `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期`, + ], + ['本账期发放', `${membership.cycleGrantedPoints} 泥点`], + ['本账期剩余', `${membership.cycleRemainingPoints} 泥点`], + [ + '到期', + membership.expiresAtMicros + ? formatMicros(membership.expiresAtMicros) + : '-', + ], + [ + '账期重置', + membership.cycleResetsAtMicros + ? formatMicros(membership.cycleResetsAtMicros) + : '-', + ], + ]; + return ( +
+ +
+ {metrics.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ ); +} + function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } diff --git a/apps/admin-web/src/config/membershipDisplay.ts b/apps/admin-web/src/config/membershipDisplay.ts new file mode 100644 index 000000000..7bf7b09e6 --- /dev/null +++ b/apps/admin-web/src/config/membershipDisplay.ts @@ -0,0 +1,32 @@ +import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, +} from '../api/adminApiTypes'; + +/** 会员档位展示名;`normal` 即非会员。 */ +export const membershipPlanLabels: Record = { + normal: '非会员', + starter: 'Starter', + plus: 'Plus', + pro: 'Pro', + max: 'Max', +}; + +/** 账期展示名;有效期内不支持月年互换。 */ +export const membershipCycleKindLabels: Record< + ProfileMembershipCycleKind, + string +> = { + monthly: '月付', + yearly: '年付', +}; + +/** 把后端的档位 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipPlan(plan: string): string { + return membershipPlanLabels[plan as ProfileMembershipPlan] ?? plan; +} + +/** 把后端的账期 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipCycleKind(kind: string): string { + return membershipCycleKindLabels[kind as ProfileMembershipCycleKind] ?? kind; +} diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index b8d05c742..ef87c4058 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -2537,6 +2537,7 @@ const databaseTableLabelMap: Record = { public_work_play_daily_stat: '公开作品日游玩统计', public_work_like: '公开作品点赞', profile_membership: '会员状态', + profile_membership_plan: '会员档位目录', profile_recharge_order: '充值订单', profile_feedback_submission: '反馈提交', profile_save_archive: '存档记录', @@ -2578,6 +2579,7 @@ const databaseTableDescriptionMap: Record = { public_work_play_daily_stat: '公开作品日游玩统计表', public_work_like: '公开作品点赞表', profile_membership: '会员状态表', + profile_membership_plan: '会员档位价格与权益目录表', profile_recharge_order: '充值订单表', profile_feedback_submission: '反馈提交记录表', profile_save_archive: '用户存档记录表', diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index f5df76933..1f05bda26 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -22,6 +22,7 @@ import type { ProfileMembershipPlanAdminResponse, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { membershipPlanLabels } from '../config/membershipDisplay'; import { handlePageError } from './pageUtils'; interface AdminMembershipPlanPageProps { @@ -42,14 +43,6 @@ const modelAccessOptions: Array<{ { value: 'full', label: '基础及高性能模型' }, ]; -const planLabels: Record = { - normal: '非会员', - starter: 'Starter', - plus: 'Plus', - pro: 'Pro', - max: 'Max', -}; - /** * 会员档位目录。 * @@ -133,7 +126,7 @@ export function AdminMembershipPlanPage({ const confirmed = await confirmWrite({ action: '保存会员档位', - target: `${planLabels[plan]}(${plan})`, + target: `${membershipPlanLabels[plan]}(${plan})`, }); if (!confirmed) { return; @@ -234,7 +227,7 @@ export function AdminMembershipPlanPage({ } }} > - {Object.entries(planLabels).map(([value, label]) => ( + {Object.entries(membershipPlanLabels).map(([value, label]) => ( @@ -393,7 +386,7 @@ export function AdminMembershipPlanPage({ type="button" onClick={() => fillForm(entry)} > - {entry.title || planLabels[entry.plan]} + {entry.title || membershipPlanLabels[entry.plan]} {entry.plan} · rank {entry.rank} @@ -419,7 +412,7 @@ export function AdminMembershipPlanPage({ label: <>档位, value: ( <> - {planLabels[result.plan]}({result.plan}) + {membershipPlanLabels[result.plan]}({result.plan}) ), }, diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 79f150653..8e7bffb85 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -81,7 +81,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = { totalBalance: 60, spendableBalance: 60, dailyFreePoints: 0, - membershipLimitedPoints: 0, + monthlyPoints: 0, permanentPoints: 60, heldPoints: 0, refundDebtPoints: 0, @@ -729,6 +729,43 @@ test('疑似微信 refund_id 的编号交由服务端按真实查单结果判断 }); }); +test('会员订单在发放泥点列展示会员变更快照', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-membership', + productId: 'membership-pro-monthly', + productTitle: 'Pro 月付', + productKind: 'membership', + pointsDelta: 320, + membershipChange: { + changeKind: 'upgrade', + planBefore: 'plus', + planAfter: 'pro', + cycleKindBefore: 'monthly', + cycleKindAfter: 'monthly', + periodPointsBefore: 200, + periodPointsAfter: 520, + grantedPointsDelta: 320, + cycleIndex: 2, + expiresAtBeforeMicros: null, + expiresAtAfterMicros: 1_723_000_000_000_000, + cycleResetsAtBeforeMicros: null, + cycleResetsAtAfterMicros: 1_723_000_000_000_000, + priceBreakdownJson: '{"targetPlanPriceCents":3900}', + }, + }, + ], + }); + renderPage(); + + const row = (await screen.findByText('order-membership')).closest( + 'tr', + ) as HTMLElement; + expect(within(row).getByText('升级 Plus→Pro / 月付')).toBeTruthy(); +}); + function renderPage(token = 'admin-token') { return render( , diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 753311e58..5fe0acbdf 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -35,6 +35,7 @@ import { resolveAdminRechargeRefundManualReview, } from '../api/adminApiClient'; import type { + AdminMembershipOrderChangePayload, AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeRefundActionResponse, @@ -43,6 +44,10 @@ import type { } from '../api/adminApiTypes'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { + formatMembershipCycleKind, + formatMembershipPlan, +} from '../config/membershipDisplay'; import { handlePageError } from './pageUtils'; interface AdminRechargeOrderPageProps { @@ -740,6 +745,31 @@ export function AdminRechargeOrderPage({ /> + {refundOrder.membershipChange ? ( +
+ + + +
+ ) : null} +
{order.pointsDelta} 泥点 + {order.membershipChange ? ( + {formatMembershipChangeSummary(order.membershipChange)} + ) : null} 累计 {formatMoney(order.cumulativeSuccessRefundCents)} @@ -1115,7 +1148,8 @@ function RechargeOrderRow({ 可消费 {order.wallet.spendableBalance} - 永久 {order.wallet.permanentPoints} / 占用 {order.wallet.heldPoints} + 永久 {order.wallet.permanentPoints} / 月度{' '} + {order.wallet.monthlyPoints} / 占用 {order.wallet.heldPoints} {order.wallet.manualFrozen ? 人工冻结 : null} @@ -1260,6 +1294,26 @@ function parseYuanToCents(value: string) { return Number.isSafeInteger(cents) && cents > 0 ? cents : null; } +function formatMembershipChangeSummary( + change: AdminMembershipOrderChangePayload, +): string { + const action = change.changeKind === 'upgrade' ? '升级' : '新购'; + const before = change.planBefore + ? formatMembershipPlan(change.planBefore) + : null; + const after = change.planAfter + ? formatMembershipPlan(change.planAfter) + : null; + const planPath = + before && after && before !== after + ? `${before}→${after}` + : (after ?? before ?? '-'); + const cycle = formatMembershipCycleKind( + change.cycleKindAfter ?? change.cycleKindBefore ?? '', + ); + return `${action} ${planPath} / ${cycle}`; +} + function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index 171af14d1..102703ee4 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -455,6 +455,19 @@ const userDetail = { manualFrozen: false, refundDebtFrozen: false, }, + membership: { + plan: 'plus', + status: 'active', + cycleKind: 'monthly', + cycleIndex: 2, + cycleCount: 1, + cycleGrantedPoints: 600, + cycleRemainingPoints: 320, + startedAtMicros: 1_790_640_000_000_000, + expiresAtMicros: 1_793_232_000_000_000, + cycleResetsAtMicros: 1_793_232_000_000_000, + updatedAtMicros: 1_790_640_000_000_000, + }, rechargeOrders: [], restrictions: {}, tags: ['vip', '内测'], @@ -617,7 +630,7 @@ function route(pathname, method, url) { totalBalance: 1_860, spendableBalance: 1_660, dailyFreePoints: 0, - membershipLimitedPoints: 0, + monthlyPoints: 0, permanentPoints: 1_200, heldPoints: 200, refundDebtPoints: 0,