后台管理端呈现会员快照、用户会员状态与三池命名

- 新增 config/membershipDisplay.ts 收敛档位与账期展示并供三处复用
- 用户详情对话框新增会员区块,钱包月度池改名「月度泥点」
- 充值订单列表与退款弹窗渲染会员变更快照
- 通用表浏览器补 profile_membership_plan 标签
- 补齐管理端类型、用例与本地假数据

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-03 11:48:36 +08:00
parent 7acae0d308
commit 76fd96fa06
9 changed files with 251 additions and 18 deletions
+39 -1
View File
@@ -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;
}
@@ -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();
@@ -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()}
/>
<MembershipSection membership={detail.membership} />
<section className="admin-user-restriction-section">
<AdminPanelHeading
title="人工冻结"
@@ -478,7 +485,7 @@ function WalletSection({
['可消费', wallet.spendableBalance],
['永久泥点', wallet.permanentPoints],
['每日免费', wallet.dailyFreePoints],
['会员限时', wallet.membershipLimitedPoints],
['月度泥点', wallet.monthlyPoints],
['退款占用', wallet.heldPoints],
['退款欠账', wallet.refundDebtPoints],
['历史花费', historicalConsumedPoints],
@@ -527,6 +534,48 @@ function WalletSection({
);
}
function MembershipSection({
membership,
}: {
membership: AdminProfileMembershipPayload;
}) {
const metrics: Array<[string, string]> = [
['档位', `${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 (
<section className="admin-user-wallet-section">
<AdminPanelHeading title="会员" />
<div className="admin-user-wallet-grid">
{metrics.map(([label, value]) => (
<div className="admin-recharge-metric" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</section>
);
}
function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
@@ -0,0 +1,32 @@
import type {
ProfileMembershipCycleKind,
ProfileMembershipPlan,
} from '../api/adminApiTypes';
/** 会员档位展示名;`normal` 即非会员。 */
export const membershipPlanLabels: Record<ProfileMembershipPlan, string> = {
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;
}
@@ -2537,6 +2537,7 @@ const databaseTableLabelMap: Record<string, string> = {
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<string, string> = {
public_work_play_daily_stat: '公开作品日游玩统计表',
public_work_like: '公开作品点赞表',
profile_membership: '会员状态表',
profile_membership_plan: '会员档位价格与权益目录表',
profile_recharge_order: '充值订单表',
profile_feedback_submission: '反馈提交记录表',
profile_save_archive: '用户存档记录表',
@@ -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<ProfileMembershipPlan, string> = {
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]) => (
<option key={value} value={value}>
{label}({value})
</option>
@@ -393,7 +386,7 @@ export function AdminMembershipPlanPage({
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || planLabels[entry.plan]}
{entry.title || membershipPlanLabels[entry.plan]}
</AdminButton>
<small>
{entry.plan} · rank {entry.rank}
@@ -419,7 +412,7 @@ export function AdminMembershipPlanPage({
label: <>档位</>,
value: (
<>
{planLabels[result.plan]}({result.plan})
{membershipPlanLabels[result.plan]}({result.plan})
</>
),
},
@@ -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(
<AdminRechargeOrderPage token={token} onUnauthorized={vi.fn()} />,
@@ -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({
/>
</div>
{refundOrder.membershipChange ? (
<div className="admin-refund-summary-grid">
<Metric
label="会员变更"
value={formatMembershipChangeSummary(
refundOrder.membershipChange,
)}
/>
<Metric
label="补点"
value={`${refundOrder.membershipChange.grantedPointsDelta} 泥点`}
/>
<Metric
label="变更后到期"
value={
refundOrder.membershipChange.expiresAtAfterMicros
? formatMicros(
refundOrder.membershipChange.expiresAtAfterMicros,
)
: '-'
}
/>
</div>
) : null}
<div
className="admin-segmented-control admin-refund-mode"
role="tablist"
@@ -1082,6 +1112,9 @@ function RechargeOrderRow({
</td>
<td>
<span>{order.pointsDelta} 泥点</span>
{order.membershipChange ? (
<small>{formatMembershipChangeSummary(order.membershipChange)}</small>
) : null}
</td>
<td>
<span>累计 {formatMoney(order.cumulativeSuccessRefundCents)}</span>
@@ -1115,7 +1148,8 @@ function RechargeOrderRow({
<td>
<span>可消费 {order.wallet.spendableBalance}</span>
<small>
永久 {order.wallet.permanentPoints} / 占用 {order.wallet.heldPoints}
永久 {order.wallet.permanentPoints} / 月度{' '}
{order.wallet.monthlyPoints} / 占用 {order.wallet.heldPoints}
</small>
<AdminTagList as="div" className="admin-recharge-tags">
{order.wallet.manualFrozen ? <AdminTag>人工冻结</AdminTag> : 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)}`;
}
+14 -1
View File
@@ -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,