合并 origin/master(52c83cc2a,248 个提交)到 feat/game-purchase
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m56s
Project CI / Backend tests (pull_request) Successful in 9m4s
Project CI / Repository checks (pull_request) Successful in 7m13s
Project CI / Native shell tests (pull_request) Successful in 10m16s
Project CI / Frontend tests (pull_request) Successful in 2m26s

- 同步 master 侧 248 个提交:会员与泥点计费(PR #588 / #603)、创作者主页与关注粉丝(PR #638)、运行页刷新真重载(PR #640)、AGC 模板 templateVersion 递增(PR #643)等;确认 master 未触碰发行网关与 `api-server/src/modules/game_distribution.rs`。
- 冲突 1(server-rs/crates/module-runtime/src/domain.rs:1157-1162)`RuntimeProfileWalletLedgerSourceType` 两侧各自在末尾追加变体:保留 master 的 `MembershipUpgradeGrant` 为索引 16(线上已落库口径),本分支 `GamePurchase` 顺延为索引 17,并保留其「每账号每游戏一次扣费」注释。
- 冲突 2(server-rs/crates/module-runtime/src/domain.rs:1184-1188)`as_str()` 同时保留 `membership_upgrade_grant` 与 `game_purchase` 两个分支。
- 冲突 3(server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs:95-101)bindings→领域枚举映射同时保留 `MembershipUpgradeGrant` 与 `GamePurchase` 两支,按新索引顺序排列。
- 冲突 4(server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs:43-47)生成枚举按模块源码顺序合并为 …`MembershipUpgradeGrant`、`GamePurchase`;随后 `npm run spacetime:generate` 复核该文件与生成结果逐字节一致。
- 冲突 5(server-rs/crates/api-server/src/admin.rs:4849-4853)sats 索引映射改为 `16 => membership_upgrade_grant` 与 `17 => game_purchase` 两条并存。
- 冲突 6(server-rs/crates/api-server/src/admin.rs:6368-6461)索引断言并入 master 的 `[16] => membership_upgrade_grant`,并把 master 的「17 尚未映射」占位断言替换为 `[17] => game_purchase`;本分支的后台消耗统计两个用例原样保留。
- 冲突 7(server-rs/crates/api-server/src/runtime_profile.rs:219-226)来源文案格式化同时保留 `MembershipUpgradeGrant` 与 `GamePurchase` 两支,注释随 `GamePurchase` 保留。
- 冲突 8(docs/project-memory/shared-memory/decision-log.md:3-24)两条决策记录并列保留:本分支 2026-10-05「播放会话前缀在三处入口清空 Cookie」在前,master 2026-10-03「每日免费发放额为 0 时前端隐藏该池」在后。
- 保住 master 侧:发行响应 ETag/304/gzip(`release_asset_response_with_cache` 五参形态与 `release_asset_etag` / `if_none_match_matches` / `accepts_gzip_encoding` / `gzip_release_asset` / `release_asset_not_modified_response`)、共享加载面 `PlatformGameLoadingSurface` 及其使用方、三池钱包与会员档位目录改造。
- 保住本分支侧:付费作品 404 守卫(`price_mud_points > 0` 且位于 `load_package` 之前)、购买与播放会话网关路由、nginx×3 / Pingora / Vite 的播放会话 Cookie 清空与 `nginx-route-parity.matrix.json` 登记、viewer 感知公开详情、后台审核价格、`PlatformGamePricingField` 共享定价组件与两端接入、AGC 项目版本只读 + 平台派发下一版本号、schema 与迁移白名单、钱包来源 `game_purchase`。
- 验证:`git merge-base --is-ancestor origin/master HEAD` 返回 0;`check:encoding`、`check:doc-index`、`check:spacetime-schema`、`check:server-rs-ddd`、`check:rustfmt`、`check:npm-workspaces`、`check:game-distribution-dto-parity`、`check:game-distribution-price-limit-parity`、`check:pingora-route-parity`、`check:nginx-spa-routes`、根/admin-web/AGC typecheck、`git diff --check` 全绿。
- 测试:`cargo test -p module-game-distribution` 30 passed,`cargo test -p api-server game_distribution` 61 passed,`cargo test -p spacetime-module game_distribution` 7 passed;定向 vitest 28 个文件 252 个用例全通过。
- `npm run spacetime:generate` 除 11 个 procedure 文件的纯空白格式差异外无任何语义改动(`git diff -w` 为空),已保留 master 的生成产物形态,提交内无 schema/绑定语义变化。
This commit is contained in:
2026-10-06 13:12:32 +08:00
201 changed files with 15890 additions and 13199 deletions
+24
View File
@@ -74,6 +74,7 @@ import type {
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileMembershipPlanRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
@@ -90,6 +91,8 @@ import type {
EditorGenerationPricingConfigPayload,
ProfileInviteCodeAdminListResponse,
ProfileInviteCodeAdminResponse,
ProfileMembershipPlanAdminListResponse,
ProfileMembershipPlanAdminResponse,
ProfileRechargeProductConfigAdminListResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileRedeemCodeAdminListResponse,
@@ -795,6 +798,27 @@ export function upsertProfileRechargeProduct(
);
}
export function listProfileMembershipPlans(token: string) {
return request<ProfileMembershipPlanAdminListResponse>(
'/admin/api/profile/membership-plans',
{ token },
);
}
export function upsertProfileMembershipPlan(
token: string,
payload: AdminUpsertProfileMembershipPlanRequest,
) {
return request<ProfileMembershipPlanAdminResponse>(
'/admin/api/profile/membership-plans',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listAdminRechargeOrders(
token: string,
query: AdminRechargeOrderListQuery = {},
+68 -23
View File
@@ -1,3 +1,12 @@
import type {
ProfileMembershipChangeKindToken,
ProfileMembershipCycleKind,
ProfileMembershipModelAccess,
ProfileMembershipPlan,
ProfileMembershipPlanResponse,
ProfileMembershipStatus,
} from '@genarrative/shared';
export interface ApiMeta {
apiVersion?: string;
requestId?: string;
@@ -381,16 +390,13 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipTier =
| 'normal'
| 'month'
| 'season'
| 'year'
| 'starter'
| 'basic'
| 'pro'
| 'ultimate';
/** 会员档位 / 周期 / 权限:统一来自 `@genarrative/shared` 的生成类型,禁止手写字面量。 */
export type {
ProfileMembershipCycleKind,
ProfileMembershipModelAccess,
ProfileMembershipPlan,
ProfileMembershipStatus,
};
export interface AdminTrackingEventListQuery {
eventKey?: string;
userId?: string;
@@ -678,15 +684,8 @@ export interface AdminUpsertProfileRechargeProductRequest {
priceCents: number;
kind: ProfileRechargeProductKind;
pointsAmount: number;
bonusPoints: number;
durationDays: number;
badgeLabel?: string | null;
description?: string | null;
tier: ProfileMembershipTier;
membershipPeriodPoints: number;
membershipPeriodDays: number;
membershipQueueLimit: number;
membershipDiscountBps: number;
enabled: boolean;
sortOrder: number;
}
@@ -775,11 +774,6 @@ export interface ProfileRechargeProductConfigAdminResponse {
durationDays: number;
badgeLabel: string;
description: string;
tier: ProfileMembershipTier;
membershipPeriodPoints: number;
membershipPeriodDays: number;
membershipQueueLimit: number;
membershipDiscountBps: number;
enabled: boolean;
sortOrder: number;
createdBy: string;
@@ -792,6 +786,14 @@ export interface ProfileRechargeProductConfigAdminListResponse {
entries: ProfileRechargeProductConfigAdminResponse[];
}
/** 会员档位目录行;直接复用 `@genarrative/shared` 的后端生成类型,避免手工漂移。 */
export type ProfileMembershipPlanAdminResponse = ProfileMembershipPlanResponse;
export type {
AdminUpsertProfileMembershipPlanRequest,
ProfileMembershipPlanAdminListResponse,
} from '@genarrative/shared';
export interface ProfileWalletConfigAdminResponse {
configId: string;
initialMudPoints: number;
@@ -906,7 +908,8 @@ export interface AdminProfileWalletPayload {
totalBalance: number;
spendableBalance: number;
dailyFreePoints: number;
membershipLimitedPoints: number;
/** 月度泥点:当前会员账期内发放、随账期重置而清零的泥点;无会员时为 0。 */
monthlyPoints: number;
permanentPoints: number;
heldPoints: number;
refundDebtPoints: number;
@@ -950,6 +953,25 @@ export interface AdminRechargeRefundHoldPayload {
updatedAtMicros: number;
}
export interface AdminMembershipOrderChangePayload {
/** `purchase`(新购)或 `upgrade`(补差升级);复用后端生成 token。 */
changeKind: ProfileMembershipChangeKindToken;
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;
@@ -977,6 +999,8 @@ export interface AdminRechargeOrderEntryPayload {
remainingRefundableCents: number;
refundEligible: boolean;
refundBlockReasonCode?: string | null;
/** 会员购买 / 升级快照;非会员订单为 null。 */
membershipChange?: AdminMembershipOrderChangePayload | null;
}
export interface AdminRechargeOrderListResponse {
@@ -1003,9 +1027,26 @@ export interface AdminUserDetailResponse {
cumulativeRechargedCents?: number | null;
canReconcileConsumption: boolean;
wallet: AdminProfileWalletPayload;
membership: AdminProfileMembershipPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
}
/** 当前会员状态与账期;无会员时为 `normal` 档位、无到期时间。 */
export interface AdminProfileMembershipPayload {
plan: ProfileMembershipPlan;
/** 与后端 `RuntimeProfileMembershipStatus` 对齐:`normal` / `active`。 */
status: ProfileMembershipStatus;
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;
}
@@ -1109,6 +1150,8 @@ export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
export type AdminAgcAgentMode = 'codex' | 'cc';
/** 复用 `@genarrative/shared` 的生成类型,与后端 `ProfileMembershipModelAccessToken` 同源。 */
export type AdminAgcModelAccess = ProfileMembershipModelAccess;
export type AdminAgcModelProtocol =
| 'openai_responses'
| 'openai_chat'
@@ -1122,6 +1165,8 @@ export interface AdminAgcModel {
agentMode: AdminAgcAgentMode;
/** 上游线协议,与执行器正交。 */
protocol?: AdminAgcModelProtocol;
/** 该模型要求的会员档位,缺省 `basic`(失败开放)。 */
access?: AdminAgcModelAccess;
}
export interface AdminAgcModelCatalog {
+14
View File
@@ -9,6 +9,7 @@ import {
} from '../api/adminApiClient';
import type {
AdminSessionPayload,
ProfileMembershipPlanAdminResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileTaskConfigAdminResponse,
ProfileWalletConfigAdminResponse,
@@ -35,6 +36,7 @@ import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { AdminMembershipPlanPage } from '../pages/AdminMembershipPlanPage';
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage';
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
@@ -69,6 +71,8 @@ export function AdminApp() {
useState<ProfileWalletConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(null);
const [membershipPlanResult, setMembershipPlanResult] =
useState<ProfileMembershipPlanAdminResponse | null>(null);
const accessibleRoutes = useMemo(
() => (admin ? getAccessibleAdminRoutes(admin) : []),
[admin],
@@ -84,6 +88,7 @@ export function AdminApp() {
setTaskConfigResult(null);
setProfileWalletConfigResult(null);
setRechargeProductResult(null);
setMembershipPlanResult(null);
setStatus('guest');
setLoginNotice(message);
}, []);
@@ -174,6 +179,7 @@ export function AdminApp() {
setTaskConfigResult(null);
setProfileWalletConfigResult(null);
setRechargeProductResult(null);
setMembershipPlanResult(null);
setLoginNotice('');
setStatus('authenticated');
},
@@ -292,6 +298,14 @@ export function AdminApp() {
onResultChange={setRechargeProductResult}
/>
) : null}
{activeRouteId === 'membership-plans' ? (
<AdminMembershipPlanPage
result={membershipPlanResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setMembershipPlanResult}
/>
) : null}
{activeRouteId === 'recharge-orders' ? (
<AdminRechargeOrderPage
token={token}
+1
View File
@@ -56,6 +56,7 @@ const routeIcons = {
'profile-wallet': WalletCards,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'membership-plans': ShieldCheck,
'recharge-orders': ReceiptText,
'payment-orders': ReceiptText,
'editor-generation-pricing': Coins,
+8 -1
View File
@@ -13,6 +13,7 @@ export type AdminRouteId =
| 'profile-wallet'
| 'tasks'
| 'recharge-products'
| 'membership-plans'
| 'recharge-orders'
| 'payment-orders'
| 'editor-generation-pricing'
@@ -53,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet' },
{ id: 'tasks', label: '任务配置', hash: '#tasks' },
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
{ id: 'membership-plans', label: '会员档位', hash: '#membership-plans' },
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
{ id: 'payment-orders', label: '支付订单', hash: '#payment-orders' },
{
@@ -109,7 +111,12 @@ export const adminNavGroups: AdminNavGroupDefinition[] = [
{
id: 'billing',
label: '充值与支付',
routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'],
routeIds: [
'recharge-products',
'membership-plans',
'recharge-orders',
'payment-orders',
],
},
{
id: 'agc',
@@ -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();
@@ -9,6 +9,7 @@ import {
AdminStatusPill,
AdminTag,
AdminTagList,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import { RefreshCcw, ShieldAlert, UserRound, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
@@ -22,9 +23,15 @@ import {
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
AdminProfileMembershipPayload,
AdminProfileWalletPayload,
AdminUserDetailResponse,
} from '../api/adminApiTypes';
import {
formatMembershipCycleKind,
formatMembershipPlan,
formatMembershipStatus,
} from '../config/membershipDisplay';
import { useAdminWriteConfirm } from './useAdminWriteConfirm';
interface AdminUserDetailDialogProps {
@@ -265,6 +272,8 @@ export function AdminUserDetailDialog({
onReconcile={() => void handleConsumptionReconcile()}
/>
<MembershipSection membership={detail.membership} />
<section className="admin-user-restriction-section">
<AdminPanelHeading
title="人工冻结"
@@ -374,12 +383,14 @@ export function AdminUserDetailDialog({
<td>{order.productTitle || order.productId}</td>
<td>
{order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
? formatAdminCurrencyCents(order.paidAmountCents)
: '未支付'}
</td>
<td>{order.pointsDelta} 泥点</td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
{formatAdminCurrencyCents(
order.cumulativeSuccessRefundCents,
)}
<small>欠账 {order.unrecoveredPoints} 泥点</small>
</td>
<td>{formatOrderStatus(order.status)}</td>
@@ -434,7 +445,7 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
value: (
<>
{typeof detail.cumulativeRechargedCents === 'number'
? formatMoney(detail.cumulativeRechargedCents)
? formatAdminCurrencyCents(detail.cumulativeRechargedCents)
: '未知'}
</>
),
@@ -478,7 +489,7 @@ function WalletSection({
['可消费', wallet.spendableBalance],
['永久泥点', wallet.permanentPoints],
['每日免费', wallet.dailyFreePoints],
['会员限时', wallet.membershipLimitedPoints],
['月度泥点', wallet.monthlyPoints],
['退款占用', wallet.heldPoints],
['退款欠账', wallet.refundDebtPoints],
['历史花费', historicalConsumedPoints],
@@ -527,8 +538,49 @@ function WalletSection({
);
}
function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
function MembershipSection({
membership,
}: {
membership: AdminProfileMembershipPayload;
}) {
const metrics: Array<[string, string]> = [
['档位', `${formatMembershipPlan(membership.plan)}(${membership.plan})`],
['状态', formatMembershipStatus(membership.status) || '-'],
[
'账期',
// 非会员时后端投影返回占位账期(monthly / 1/1),不展示成“正在计费”。
membership.status === 'active'
? `${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 formatMicros(value: number) {
@@ -0,0 +1,44 @@
import type {
ProfileMembershipCycleKind,
ProfileMembershipPlan,
ProfileMembershipStatus,
} 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: '年付',
};
/** 会员状态展示名;与后端 `RuntimeProfileMembershipStatus` 的 `normal` / `active` 对齐。 */
export const membershipStatusLabels: Record<ProfileMembershipStatus, string> = {
normal: '非会员',
active: '生效中',
};
/** 把后端的档位 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;
}
/** 把后端的会员状态 code 映射成展示名,未知值原样返回。 */
export function formatMembershipStatus(status: string): string {
return membershipStatusLabels[status as ProfileMembershipStatus] ?? status;
}
@@ -101,10 +101,46 @@ test('new models default to Codex and save the selected Agent mode', async () =>
modelId: '',
enabled: true,
agentMode: 'cc',
access: 'basic',
});
});
});
test('权限档可按模型单独选择并随目录保存', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{
id: 'quality',
alias: '高质量',
modelId: 'gpt-6-astra',
enabled: true,
agentMode: 'codex' as const,
access: 'basic' as const,
},
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
...input,
revision: 4,
}));
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
await screen.findByDisplayValue('gpt-6-astra');
const access = screen.getByLabelText('模型 1 权限档') as HTMLSelectElement;
expect(access.value).toBe('basic');
fireEvent.change(access, { target: { value: 'full' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
const payload = calls[calls.length - 1]?.[1];
expect(payload?.models[0]).toMatchObject({ access: 'full' });
});
});
test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
const catalog = {
revision: 3,
@@ -118,6 +118,7 @@ export function AdminAgcModelsPage({
{ key: 'modelId', label: '实际模型名' },
{ key: 'agentMode', label: 'Agent 模式' },
{ key: 'protocol', label: '协议' },
{ key: 'access', label: '权限档' },
{ key: 'enabled', label: '启用' },
{ key: 'default', label: '默认' },
{ key: 'actions', label: '操作' },
@@ -159,6 +160,7 @@ export function AdminAgcModelsPage({
enabled: true,
agentMode: 'codex',
protocol: 'openai_responses',
access: 'basic',
},
],
},
@@ -246,6 +248,21 @@ export function AdminAgcModelsPage({
<option value="anthropic">Anthropic Messages</option>
</select>
</td>
<td>
<select
aria-label={`模型 ${index + 1} 权限档`}
value={model.access ?? 'basic'}
disabled={busy}
onChange={(e) =>
update(model.id, {
access: e.target.value as AdminAgcModel['access'],
})
}
>
<option value="basic">基础模型</option>
<option value="full">高性能模型</option>
</select>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
@@ -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: '用户存档记录表',
@@ -0,0 +1,463 @@
import {
AdminAlert,
AdminButton,
AdminField,
AdminFormRow,
AdminInfoList,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
listProfileMembershipPlans,
upsertProfileMembershipPlan,
} from '../api/adminApiClient';
import type {
ProfileMembershipModelAccess,
ProfileMembershipPlan,
ProfileMembershipPlanAdminResponse,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import {
formatMembershipPlan,
membershipPlanLabels,
} from '../config/membershipDisplay';
import { handlePageError, parsePositiveIntegerOrZero } from './pageUtils';
interface AdminMembershipPlanPageProps {
token: string;
result: ProfileMembershipPlanAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileMembershipPlanAdminResponse) => void;
}
/** 模型权限的唯一文案表:表单选项与列表摘要共用,避免两处手工映射漂移。 */
const modelAccessLabels: Record<ProfileMembershipModelAccess, string> = {
basic: '仅基础模型',
full: '基础及高性能模型',
};
const modelAccessOptions: Array<{
value: ProfileMembershipModelAccess;
label: string;
}> = (Object.keys(modelAccessLabels) as ProfileMembershipModelAccess[]).map(
(value) => ({ value, label: modelAccessLabels[value] }),
);
/**
* 会员档位目录。
*
* 一行一档,档位即主键(也是身份)。价格与权益以本表为唯一真相源:充值中心展示、
* 下单金额重算、补差升级报价都读它。`rank` 由代码内的档位目录决定,后台不能改,
* 因此这里只读展示。模型权限与并发上限本期只落字段、只用于展示,服务端不做拦截。
*/
export function AdminMembershipPlanPage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminMembershipPlanPageProps) {
const [entries, setEntries] = useState<ProfileMembershipPlanAdminResponse[]>(
[],
);
const [plan, setPlan] = useState<ProfileMembershipPlan>('starter');
const [title, setTitle] = useState('');
const [monthPriceCents, setMonthPriceCents] = useState('0');
const [yearPriceCents, setYearPriceCents] = useState('0');
const [periodPoints, setPeriodPoints] = useState('0');
const [modelAccess, setModelAccess] =
useState<ProfileMembershipModelAccess>('basic');
const [concurrentJobLimit, setConcurrentJobLimit] = useState('1');
const [unlimitedConcurrencyThreshold, setUnlimitedConcurrencyThreshold] =
useState(0);
const [enabled, setEnabled] = useState(false);
const [rankLabel, setRankLabel] = useState('0');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshPlans();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshPlans() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileMembershipPlans(token);
// 与保存后的排序保持一致:列表按升级基准 `rank` 展示。
const sortedEntries = sortPlanEntries(response.entries);
setEntries(sortedEntries);
setUnlimitedConcurrencyThreshold(response.unlimitedConcurrencyThreshold);
const firstEntry =
sortedEntries.find((entry) => entry.plan === plan) ?? sortedEntries[0];
if (firstEntry) {
fillForm(firstEntry);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
const normalizedMonthPriceCents =
parsePositiveIntegerOrZero(monthPriceCents);
const normalizedYearPriceCents = parsePositiveIntegerOrZero(yearPriceCents);
const normalizedConcurrentJobLimit =
parsePositiveIntegerOrZero(concurrentJobLimit);
const normalizedPeriodPoints = parsePositiveIntegerOrZero(periodPoints);
if (plan !== 'normal') {
if (!normalizedMonthPriceCents || !normalizedYearPriceCents) {
setErrorMessage('月价与年价必须是大于 0 的整数分');
return;
}
if (!normalizedPeriodPoints) {
setErrorMessage('每期泥点必须是大于 0 的整数');
return;
}
if (!normalizedConcurrentJobLimit) {
setErrorMessage(
unlimitedConcurrencyThreshold > 0
? `并发上限必须是大于 0 的整数;${unlimitedConcurrencyThreshold} 表示不设上限`
: '并发上限必须是大于 0 的整数',
);
return;
}
}
const confirmed = await confirmWrite({
action: '保存会员档位',
target: `${membershipPlanLabels[plan]}(${plan})`,
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertProfileMembershipPlan(token, {
plan,
title: title.trim(),
monthPriceCents: normalizedMonthPriceCents,
yearPriceCents: normalizedYearPriceCents,
periodPoints: normalizedPeriodPoints,
modelAccess,
concurrentJobLimit: normalizedConcurrentJobLimit,
enabled,
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
function upsertEntry(next: ProfileMembershipPlanAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.plan !== next.plan);
return sortPlanEntries([...rest, next]);
});
}
function fillForm(entry: ProfileMembershipPlanAdminResponse) {
setPlan(entry.plan);
setTitle(entry.title);
setMonthPriceCents(String(entry.monthPriceCents));
setYearPriceCents(String(entry.yearPriceCents));
setPeriodPoints(String(entry.periodPoints));
setModelAccess(entry.modelAccess);
setConcurrentJobLimit(String(entry.concurrentJobLimit));
setEnabled(entry.enabled);
setRankLabel(String(entry.rank));
}
// 目录里还没有这一档时清空表单,避免把上一档的价格 / 权益误当成它的值提交。
function clearForm(nextPlan: ProfileMembershipPlan) {
setPlan(nextPlan);
setTitle('');
setMonthPriceCents('0');
setYearPriceCents('0');
setPeriodPoints('0');
setModelAccess('basic');
setConcurrentJobLimit('1');
setEnabled(false);
setRankLabel('');
}
const isPurchasable = plan !== 'normal';
return (
<AdminPage wide>
<AdminPageHeading
title="会员档位"
description="月价 / 年价 / 每期额度 / 模型权限 / 并发上限,充值中心与下单都读这张表"
actions={
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={refreshPlans}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
}
/>
{listErrorMessage ? <AdminAlert>{listErrorMessage}</AdminAlert> : null}
<div className="admin-two-column admin-two-column-wide">
<AdminPanel as="form" onSubmit={handleSave}>
<AdminFormRow>
<AdminField label="档位" fill>
<select
value={plan}
onChange={(event) => {
const nextPlan = event.target.value as ProfileMembershipPlan;
const entry = entries.find((item) => item.plan === nextPlan);
if (entry) {
fillForm(entry);
} else {
clearForm(nextPlan);
}
}}
>
{Object.entries(membershipPlanLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}({value})
</option>
))}
</select>
</AdminField>
<AdminField label="升级基准 rank(只读)">
<input readOnly value={rankLabel} />
</AdminField>
</AdminFormRow>
<AdminFormRow>
<AdminField label="标题">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</AdminField>
<label className="admin-switch-field">
<input
checked={enabled}
disabled={!isPurchasable}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>可购买</span>
</label>
</AdminFormRow>
<AdminFormRow>
<AdminField label="月价(分)">
<input
min={isPurchasable ? 1 : 0}
step={1}
type="number"
value={monthPriceCents}
onChange={(event) => setMonthPriceCents(event.target.value)}
/>
</AdminField>
<AdminField label="年价(分,独立可配置)">
<input
min={isPurchasable ? 1 : 0}
step={1}
type="number"
value={yearPriceCents}
onChange={(event) => setYearPriceCents(event.target.value)}
/>
</AdminField>
</AdminFormRow>
<AdminFormRow>
<AdminField label="每期泥点(一个自然月)">
<input
min={0}
step={1}
type="number"
value={periodPoints}
onChange={(event) => setPeriodPoints(event.target.value)}
/>
</AdminField>
<AdminField
label={
unlimitedConcurrencyThreshold > 0
? `并发上限(${unlimitedConcurrencyThreshold} = 不设上限)`
: '并发上限'
}
>
<input
min={1}
step={1}
type="number"
value={concurrentJobLimit}
onChange={(event) => setConcurrentJobLimit(event.target.value)}
/>
</AdminField>
</AdminFormRow>
<AdminFormRow>
<AdminField label="模型权限">
<select
value={modelAccess}
onChange={(event) =>
setModelAccess(
event.target.value as ProfileMembershipModelAccess,
)
}
>
{modelAccessOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</AdminField>
</AdminFormRow>
{!isPurchasable ? (
<AdminAlert>
非会员档位不可购买,价格与额度会被后端按 0 落库。
</AdminAlert>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
<AdminButton
variant="primary"
disabled={isSaving || !title.trim()}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</AdminButton>
</AdminPanel>
<div className="admin-stack">
<AdminListPanel
count={entries.length}
rows={entries}
showEmpty={false}
title="档位目录"
columns={[
{
key: 'plan',
label: '档位',
sortable: true,
sortDescription: '按升级基准 rank',
sortValue: (entry) => entry.rank,
},
{
key: 'price',
label: '月价 / 年价',
sortable: true,
sortValue: (entry) => entry.monthPriceCents,
},
{
key: 'points',
label: '每期泥点',
sortable: true,
sortValue: (entry) => entry.periodPoints,
},
{
key: 'limits',
label: '权限',
sortable: true,
sortValue: (entry) => entry.concurrentJobLimit,
},
{
key: 'enabled',
label: '状态',
sortable: true,
sortDescription: '可购买状态',
sortValue: (entry) => (entry.enabled ? 1 : 0),
},
]}
renderRow={(entry) => (
<tr key={entry.plan}>
<td>
<AdminButton
variant="text"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || formatMembershipPlan(entry.plan)}
</AdminButton>
<small>
{entry.plan} · rank {entry.rank}
</small>
</td>
<td>
{formatAdminCurrencyCents(entry.monthPriceCents)} /{' '}
{formatAdminCurrencyCents(entry.yearPriceCents)}
</td>
<td>{entry.periodPoints}</td>
<td>{formatPlanLimits(entry)}</td>
<td>{entry.enabled ? '可购买' : '不可购买'}</td>
</tr>
)}
size="compact"
/>
{result ? (
<AdminPanel title="最近保存" count={result.plan}>
<AdminInfoList
items={[
{
label: <>档位</>,
value: (
<>
{formatMembershipPlan(result.plan)}({result.plan})
</>
),
},
{
label: <>可购买</>,
value: <>{result.enabled ? '是' : '否'}</>,
},
]}
/>
</AdminPanel>
) : null}
</div>
</div>
{confirmDialog}
</AdminPage>
);
}
function sortPlanEntries(entries: ProfileMembershipPlanAdminResponse[]) {
return [...entries].sort((left, right) => left.rank - right.rank);
}
function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) {
const modelLabel = modelAccessLabels[entry.modelAccess];
const concurrency = entry.unlimitedConcurrency
? '不设上限'
: `${entry.concurrentJobLimit} 个`;
return `${modelLabel} · 并发 ${concurrency}`;
}
@@ -94,7 +94,7 @@ test('账号配置页一次保存初始和每日免费泥点', async () => {
);
});
test('账号配置页拒绝非正整数每日免费额度', async () => {
test('账号配置页拒绝负数或非整数每日免费额度', async () => {
render(
<AdminProfileWalletConfigPage
token="admin-token"
@@ -109,5 +109,38 @@ test('账号配置页拒绝非正整数每日免费额度', async () => {
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
fireEvent.change(input, { target: { value: '-1' } });
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(upsertProfileWalletConfig).not.toHaveBeenCalled();
});
test('账号配置页允许把每日免费泥点配置为 0', async () => {
const user = userEvent.setup();
vi.mocked(upsertProfileWalletConfig).mockResolvedValue({
...configResponse,
dailyFreePointsPerDay: 0,
});
render(
<AdminProfileWalletConfigPage
token="admin-token"
result={configResponse}
onUnauthorized={vi.fn()}
onResultChange={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('每日免费泥点数'), {
target: { value: '0' },
});
await user.click(screen.getByRole('button', { name: '保存' }));
expect(screen.getByText('初始 100 泥点,每日免费 0 泥点')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {
initialMudPoints: 100,
dailyFreePointsPerDay: 0,
}),
);
});
@@ -18,7 +18,11 @@ import {
} from '../api/adminApiClient';
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
import {
handlePageError,
parseNonNegativeInteger,
parsePositiveInteger,
} from './pageUtils';
interface AdminProfileWalletConfigPageProps {
token: string;
@@ -66,11 +70,11 @@ export function AdminProfileWalletConfigPage({
if (isSaving) {
return;
}
const normalizedDailyFreePointsPerDay = parsePositiveInteger(
const normalizedDailyFreePointsPerDay = parseNonNegativeInteger(
dailyFreePointsPerDay,
);
if (!normalizedDailyFreePointsPerDay) {
setErrorMessage('每日免费泥点数必须是大于 0 的整数');
if (normalizedDailyFreePointsPerDay === null) {
setErrorMessage('每日免费泥点数必须是不小于 0 的整数');
return;
}
@@ -139,7 +143,7 @@ export function AdminProfileWalletConfigPage({
<AdminField label="每日免费泥点数">
<input
min={1}
min={0}
step={1}
type="number"
value={dailyFreePointsPerDay}
@@ -154,7 +158,7 @@ export function AdminProfileWalletConfigPage({
disabled={
isSaving ||
!parsePositiveInteger(initialMudPoints) ||
!parsePositiveInteger(dailyFreePointsPerDay)
parseNonNegativeInteger(dailyFreePointsPerDay) === null
}
type="submit"
>
@@ -200,8 +204,3 @@ export function AdminProfileWalletConfigPage({
</AdminPage>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}
@@ -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()} />,
@@ -13,6 +13,7 @@ import {
AdminPanelHeading,
AdminTag,
AdminTagList,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import {
AlertTriangle,
@@ -35,6 +36,7 @@ import {
resolveAdminRechargeRefundManualReview,
} from '../api/adminApiClient';
import type {
AdminMembershipOrderChangePayload,
AdminRechargeOrderEntryPayload,
AdminRechargeOrderListQuery,
AdminRechargeRefundActionResponse,
@@ -43,6 +45,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 {
@@ -307,7 +313,7 @@ export function AdminRechargeOrderPage({
setRefundRequestId(requestId);
const confirmed = await confirmWrite({
action: '发起微信退款并追回泥点',
target: `${refundOrder.orderId} / ${formatMoney(amountCents)}`,
target: `${refundOrder.orderId} / ${formatAdminCurrencyCents(amountCents)}`,
});
if (!confirmed || submitLockRef.current) {
return;
@@ -716,15 +722,19 @@ export function AdminRechargeOrderPage({
<div className="admin-refund-summary-grid">
<Metric
label="订单实付"
value={formatMoney(refundOrder.paidAmountCents)}
value={formatAdminCurrencyCents(refundOrder.paidAmountCents)}
/>
<Metric
label="累计已退"
value={formatMoney(refundOrder.cumulativeSuccessRefundCents)}
value={formatAdminCurrencyCents(
refundOrder.cumulativeSuccessRefundCents,
)}
/>
<Metric
label="剩余可退"
value={formatMoney(refundOrder.remainingRefundableCents)}
value={formatAdminCurrencyCents(
refundOrder.remainingRefundableCents,
)}
/>
<Metric
label="永久泥点"
@@ -740,6 +750,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"
@@ -829,7 +864,13 @@ export function AdminRechargeOrderPage({
},
{
label: <>预检后剩余可退</>,
value: <>{formatMoney(preview.remainingRefundableCents)}</>,
value: (
<>
{formatAdminCurrencyCents(
preview.remainingRefundableCents,
)}
</>
),
},
]}
/>
@@ -954,16 +995,32 @@ export function AdminRechargeOrderPage({
},
{
label: <>本地订单总额</>,
value: <>{formatMoney(manualReviewTarget.order.amountCents)}</>,
value: (
<>
{formatAdminCurrencyCents(
manualReviewTarget.order.amountCents,
)}
</>
),
},
{
label: <>退款订单总额</>,
value: <>{formatMoney(manualReviewTarget.refund.totalCents)}</>,
value: (
<>
{formatAdminCurrencyCents(
manualReviewTarget.refund.totalCents,
)}
</>
),
},
{
label: <>本次退款金额</>,
value: (
<>{formatMoney(manualReviewTarget.refund.refundCents)}</>
<>
{formatAdminCurrencyCents(
manualReviewTarget.refund.refundCents,
)}
</>
),
},
]}
@@ -1077,14 +1134,19 @@ function RechargeOrderRow({
<td>
<strong>{formatOrderPaidAmount(order)}</strong>
{order.paidAmountCents > 0 ? null : (
<small>订单 {formatMoney(order.amountCents)}</small>
<small>订单 {formatAdminCurrencyCents(order.amountCents)}</small>
)}
</td>
<td>
<span>{order.pointsDelta} 泥点</span>
{order.membershipChange ? (
<small>{formatMembershipChangeSummary(order.membershipChange)}</small>
) : null}
</td>
<td>
<span>累计 {formatMoney(order.cumulativeSuccessRefundCents)}</span>
<span>
累计 {formatAdminCurrencyCents(order.cumulativeSuccessRefundCents)}
</span>
<small>
应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints}
</small>
@@ -1115,7 +1177,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,14 +1323,29 @@ function parseYuanToCents(value: string) {
return Number.isSafeInteger(cents) && cents > 0 ? cents : null;
}
function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
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 cycleKind = change.cycleKindAfter ?? change.cycleKindBefore;
const cycle = cycleKind ? ` / ${formatMembershipCycleKind(cycleKind)}` : '';
return `${action} ${planPath}${cycle}`;
}
/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */
function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) {
return order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
? formatAdminCurrencyCents(order.paidAmountCents)
: '未支付';
}
@@ -8,6 +8,8 @@ import {
AdminPage,
AdminPageHeading,
AdminPanel,
formatAdminCurrencyCents,
formatAdminDateTime,
} from '@genarrative/shared/components';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
@@ -16,13 +18,9 @@ import {
listProfileRechargeProducts,
upsertProfileRechargeProduct,
} from '../api/adminApiClient';
import type {
ProfileMembershipTier,
ProfileRechargeProductConfigAdminResponse,
ProfileRechargeProductKind,
} from '../api/adminApiTypes';
import type { ProfileRechargeProductConfigAdminResponse } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
import { handlePageError, parsePositiveInteger } from './pageUtils';
interface AdminRechargeProductPageProps {
token: string;
@@ -31,25 +29,24 @@ interface AdminRechargeProductPageProps {
onResultChange: (result: ProfileRechargeProductConfigAdminResponse) => void;
}
const productKinds: Array<{
value: ProfileRechargeProductKind;
label: string;
}> = [
{ value: 'points', label: '泥点' },
{ value: 'membership', label: '会员' },
];
const membershipTiers: Array<{ value: ProfileMembershipTier; label: string }> =
[
{ value: 'starter', label: 'Starter' },
{ value: 'basic', label: 'Basic' },
{ value: 'pro', label: 'Pro' },
{ value: 'ultimate', label: 'Ultimate' },
{ value: 'month', label: '月卡' },
{ value: 'season', label: '季卡' },
{ value: 'year', label: '年卡' },
];
/** 新建泥点商品时的表单默认值;列表为空时回填它,避免残留上一次选中项。 */
const DEFAULT_RECHARGE_PRODUCT_FORM = {
productId: 'points_60',
title: '60泥点',
priceCents: '600',
pointsAmount: '60',
badgeLabel: '',
description: '60泥点',
enabled: true,
sortOrder: '0',
};
/**
* 泥点充值商品。
*
* 会员档位已迁到独立的「会员档位」页(`profile_membership_plan`),本页只维护充点商品。
* 表单不再提供首充赠送 / 天数等已退役字段。
*/
export function AdminRechargeProductPage({
token,
result,
@@ -59,22 +56,26 @@ export function AdminRechargeProductPage({
const [entries, setEntries] = useState<
ProfileRechargeProductConfigAdminResponse[]
>([]);
const [productId, setProductId] = useState('points_60');
const [title, setTitle] = useState('60泥点');
const [priceCents, setPriceCents] = useState('600');
const [kind, setKind] = useState<ProfileRechargeProductKind>('points');
const [pointsAmount, setPointsAmount] = useState('60');
const [bonusPoints, setBonusPoints] = useState('0');
const [durationDays, setDurationDays] = useState('0');
const [badgeLabel, setBadgeLabel] = useState('');
const [description, setDescription] = useState('60泥点');
const [tier, setTier] = useState<ProfileMembershipTier>('normal');
const [membershipPeriodPoints, setMembershipPeriodPoints] = useState('0');
const [membershipPeriodDays, setMembershipPeriodDays] = useState('0');
const [membershipQueueLimit, setMembershipQueueLimit] = useState('0');
const [membershipDiscountBps, setMembershipDiscountBps] = useState('0');
const [enabled, setEnabled] = useState(true);
const [sortOrder, setSortOrder] = useState('0');
const [productId, setProductId] = useState(
DEFAULT_RECHARGE_PRODUCT_FORM.productId,
);
const [title, setTitle] = useState(DEFAULT_RECHARGE_PRODUCT_FORM.title);
const [priceCents, setPriceCents] = useState(
DEFAULT_RECHARGE_PRODUCT_FORM.priceCents,
);
const [pointsAmount, setPointsAmount] = useState(
DEFAULT_RECHARGE_PRODUCT_FORM.pointsAmount,
);
const [badgeLabel, setBadgeLabel] = useState(
DEFAULT_RECHARGE_PRODUCT_FORM.badgeLabel,
);
const [description, setDescription] = useState(
DEFAULT_RECHARGE_PRODUCT_FORM.description,
);
const [enabled, setEnabled] = useState(DEFAULT_RECHARGE_PRODUCT_FORM.enabled);
const [sortOrder, setSortOrder] = useState(
DEFAULT_RECHARGE_PRODUCT_FORM.sortOrder,
);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState('');
@@ -91,11 +92,17 @@ export function AdminRechargeProductPage({
setListErrorMessage('');
try {
const response = await listProfileRechargeProducts(token);
const sortedEntries = sortProducts(response.entries);
// 中文注释:历史会员商品行已迁到「会员档位」页,本页只维护泥点商品,避免误展示为 0 + 0 泥点。
const sortedEntries = sortProducts(
response.entries.filter((entry) => entry.kind === 'points'),
);
setEntries(sortedEntries);
const firstEntry = sortedEntries[0];
if (firstEntry) {
fillForm(firstEntry);
} else {
// 中文注释:列表为空时回填新建默认值,避免表单残留已删除商品的旧值被误保存。
resetForm();
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
@@ -111,6 +118,13 @@ export function AdminRechargeProductPage({
}
setErrorMessage('');
// 中文注释:价格分与基础泥点必须是正整数;空串/非法输入不能静默落库成 0。
const normalizedPriceCents = parsePositiveInteger(priceCents);
const normalizedPointsAmount = parsePositiveInteger(pointsAmount);
if (normalizedPriceCents === null || normalizedPointsAmount === null) {
setErrorMessage('价格分与基础泥点必须是大于 0 的整数');
return;
}
const confirmed = await confirmWrite({
action: enabled ? '保存充值商品' : '停用充值商品',
target: productId.trim(),
@@ -124,33 +138,11 @@ export function AdminRechargeProductPage({
const response = await upsertProfileRechargeProduct(token, {
productId: productId.trim(),
title: title.trim(),
priceCents: parsePositiveInteger(priceCents),
kind,
pointsAmount:
kind === 'points' ? parsePositiveInteger(pointsAmount) : 0,
bonusPoints:
kind === 'points' ? parseNonNegativeInteger(bonusPoints) : 0,
durationDays:
kind === 'membership' ? parsePositiveInteger(durationDays) : 0,
badgeLabel: kind === 'points' ? badgeLabel.trim() : '',
priceCents: normalizedPriceCents,
kind: 'points',
pointsAmount: normalizedPointsAmount,
badgeLabel: badgeLabel.trim(),
description: description.trim(),
tier: kind === 'membership' ? tier : 'normal',
membershipPeriodPoints:
kind === 'membership'
? parsePositiveInteger(membershipPeriodPoints)
: 0,
membershipPeriodDays:
kind === 'membership'
? parsePositiveInteger(membershipPeriodDays)
: 0,
membershipQueueLimit:
kind === 'membership'
? parseNonNegativeInteger(membershipQueueLimit)
: 0,
membershipDiscountBps:
kind === 'membership'
? parseNonNegativeInteger(membershipDiscountBps)
: 0,
enabled,
sortOrder: parseInteger(sortOrder),
});
@@ -177,26 +169,29 @@ export function AdminRechargeProductPage({
setProductId(entry.productId);
setTitle(entry.title);
setPriceCents(String(entry.priceCents));
setKind(entry.kind);
setPointsAmount(String(entry.pointsAmount));
setBonusPoints(String(entry.bonusPoints));
setDurationDays(String(entry.durationDays));
setBadgeLabel(entry.badgeLabel);
setDescription(entry.description);
setTier(entry.tier);
setMembershipPeriodPoints(String(entry.membershipPeriodPoints));
setMembershipPeriodDays(String(entry.membershipPeriodDays));
setMembershipQueueLimit(String(entry.membershipQueueLimit));
setMembershipDiscountBps(String(entry.membershipDiscountBps));
setEnabled(entry.enabled);
setSortOrder(String(entry.sortOrder));
}
function resetForm() {
setProductId(DEFAULT_RECHARGE_PRODUCT_FORM.productId);
setTitle(DEFAULT_RECHARGE_PRODUCT_FORM.title);
setPriceCents(DEFAULT_RECHARGE_PRODUCT_FORM.priceCents);
setPointsAmount(DEFAULT_RECHARGE_PRODUCT_FORM.pointsAmount);
setBadgeLabel(DEFAULT_RECHARGE_PRODUCT_FORM.badgeLabel);
setDescription(DEFAULT_RECHARGE_PRODUCT_FORM.description);
setEnabled(DEFAULT_RECHARGE_PRODUCT_FORM.enabled);
setSortOrder(DEFAULT_RECHARGE_PRODUCT_FORM.sortOrder);
}
return (
<AdminPage wide>
<AdminPageHeading
title="充值商品"
description="泥点与会员档位"
description="泥点充值档位"
actions={
<AdminButton
variant="secondary"
@@ -231,39 +226,6 @@ export function AdminRechargeProductPage({
</label>
</AdminFormRow>
<div className="admin-segmented-control" role="tablist">
{productKinds.map((item) => (
<button
data-active={kind === item.value}
key={item.value}
type="button"
onClick={() => {
setKind(item.value);
if (item.value === 'points') {
setTier('normal');
setDurationDays('0');
setMembershipPeriodPoints('0');
setMembershipPeriodDays('0');
setMembershipQueueLimit('0');
setMembershipDiscountBps('0');
} else {
setBonusPoints('0');
setPointsAmount('0');
setTier(tier === 'normal' ? 'starter' : tier);
setDurationDays(durationDays === '0' ? '30' : durationDays);
setMembershipPeriodDays(
membershipPeriodDays === '0'
? '30'
: membershipPeriodDays,
);
}
}}
>
{item.label}
</button>
))}
</div>
<AdminFormRow>
<AdminField label="标题">
<input
@@ -282,109 +244,19 @@ export function AdminRechargeProductPage({
</AdminField>
</AdminFormRow>
{kind === 'points' ? (
<AdminFormRow>
<AdminField label="基础泥点">
<input
min={1}
step={1}
type="number"
value={pointsAmount}
onChange={(event) => setPointsAmount(event.target.value)}
/>
</AdminField>
<AdminField label="首充赠送">
<input
min={0}
step={1}
type="number"
value={bonusPoints}
onChange={(event) => setBonusPoints(event.target.value)}
/>
</AdminField>
</AdminFormRow>
) : (
<div className="admin-stack">
<AdminFormRow>
<AdminField label="会员档位">
<select
value={tier}
onChange={(event) =>
setTier(event.target.value as ProfileMembershipTier)
}
>
{membershipTiers.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</AdminField>
<AdminField label="有效天数">
<input
min={1}
step={1}
type="number"
value={durationDays}
onChange={(event) => setDurationDays(event.target.value)}
/>
</AdminField>
</AdminFormRow>
<AdminFormRow>
<AdminField label="每周期泥点">
<input
min={1}
step={1}
type="number"
value={membershipPeriodPoints}
onChange={(event) =>
setMembershipPeriodPoints(event.target.value)
}
/>
</AdminField>
<AdminField label="周期天数">
<input
min={1}
step={1}
type="number"
value={membershipPeriodDays}
onChange={(event) =>
setMembershipPeriodDays(event.target.value)
}
/>
</AdminField>
</AdminFormRow>
<AdminFormRow>
<AdminField label="队列上限">
<input
min={0}
step={1}
type="number"
value={membershipQueueLimit}
onChange={(event) =>
setMembershipQueueLimit(event.target.value)
}
/>
</AdminField>
<AdminField label="折扣 bps">
<input
min={0}
step={1}
type="number"
value={membershipDiscountBps}
onChange={(event) =>
setMembershipDiscountBps(event.target.value)
}
/>
</AdminField>
</AdminFormRow>
</div>
)}
<AdminField label="基础泥点">
<input
min={1}
step={1}
type="number"
value={pointsAmount}
onChange={(event) => setPointsAmount(event.target.value)}
/>
</AdminField>
<AdminFormRow>
<AdminField label="角标">
<input
disabled={kind !== 'points'}
value={badgeLabel}
onChange={(event) => setBadgeLabel(event.target.value)}
/>
@@ -430,12 +302,6 @@ export function AdminRechargeProductPage({
sortable: true,
sortValue: (entry) => entry.title || entry.productId,
},
{
key: 'kind',
label: '类型',
sortable: true,
sortValue: (entry) => formatProductKind(entry.kind),
},
{
key: 'price',
label: '价格',
@@ -468,8 +334,7 @@ export function AdminRechargeProductPage({
</AdminButton>
<small>{entry.productId}</small>
</td>
<td>{formatProductKind(entry.kind)}</td>
<td>{formatPrice(entry.priceCents)}</td>
<td>{formatAdminCurrencyCents(entry.priceCents)}</td>
<td>{formatProductContent(entry)}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
@@ -489,6 +354,10 @@ export function AdminRechargeProductPage({
label: <>状态</>,
value: <>{result.enabled ? '启用' : '停用'}</>,
},
{
label: <>时间</>,
value: <>{formatAdminDateTime(result.updatedAt)}</>,
},
]}
/>
</AdminPanel>
@@ -510,56 +379,10 @@ function sortProducts(entries: ProfileRechargeProductConfigAdminResponse[]) {
});
}
function formatProductKind(kind: ProfileRechargeProductKind) {
return kind === 'points' ? '泥点' : '会员';
}
function formatTier(tier: ProfileMembershipTier) {
if (tier === 'starter') {
return 'Starter';
}
if (tier === 'basic') {
return 'Basic';
}
if (tier === 'pro') {
return 'Pro';
}
if (tier === 'ultimate') {
return 'Ultimate';
}
if (tier === 'month') {
return '月卡';
}
if (tier === 'season') {
return '季卡';
}
if (tier === 'year') {
return '年卡';
}
return '普通';
}
function formatProductContent(
entry: ProfileRechargeProductConfigAdminResponse,
) {
if (entry.kind === 'points') {
return `${entry.pointsAmount}+${entry.bonusPoints}`;
}
return `${formatTier(entry.tier)} ${entry.durationDays}天 · 每${entry.membershipPeriodDays}天${entry.membershipPeriodPoints}泥点`;
}
function formatPrice(priceCents: number) {
return `¥${(priceCents / 100).toFixed(2)}`;
}
function parsePositiveInteger(value: string) {
const parsed = parseInteger(value);
return parsed > 0 ? parsed : 0;
}
function parseNonNegativeInteger(value: string) {
const parsed = parseInteger(value);
return parsed > 0 ? parsed : 0;
return `${entry.pointsAmount} 泥点`;
}
function parseInteger(value: string) {
+27
View File
@@ -31,3 +31,30 @@ export function formatUnknownJson(value: unknown) {
return String(value);
}
}
/** 内部共用:只接受纯十进制数字串(允许前后空白),拒绝小数、科学计数法与非法 token。 */
function parseSafeIntegerText(value: string): number | null {
const trimmed = value.trim();
// 中文注释:只接受纯十进制数字串,避免 `Number` 把 `1.0` / `1e2` / `0x10` / `+5` 当成整数。
if (!/^\d+$/.test(trimmed)) {
return null;
}
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) ? parsed : null;
}
/** 严格解析正安全整数:空串、小数、科学计数法、非十进制、非正数与非数字都返回 `null`。 */
export function parsePositiveInteger(value: string): number | null {
const parsed = parseSafeIntegerText(value);
return parsed !== null && parsed > 0 ? parsed : null;
}
/** 严格解析非负安全整数:空串、小数、科学计数法、非十进制、负数与非数字都返回 `null`。 */
export function parseNonNegativeInteger(value: string): number | null {
return parseSafeIntegerText(value);
}
/** 落库用宽松解析:非法输入按 `0` 处理,避免后台保存被输入抖动打断。 */
export function parsePositiveIntegerOrZero(value: string): number {
return parsePositiveInteger(value) ?? 0;
}