后台管理端呈现会员快照、用户会员状态与三池命名
- 新增 config/membershipDisplay.ts 收敛档位与账期展示并供三处复用 - 用户详情对话框新增会员区块,钱包月度池改名「月度泥点」 - 充值订单列表与退款弹窗渲染会员变更快照 - 通用表浏览器补 profile_membership_plan 标签 - 补齐管理端类型、用例与本地假数据 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user