合并最新主分支并保留美术包合同修复
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m6s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m10s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Backend tests (pull_request) Successful in 7m33s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m8s
Project CI / Repository checks (pull_request) Successful in 5m55s
Project CI / Native shell tests (pull_request) Successful in 8m10s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m6s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m10s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Backend tests (pull_request) Successful in 7m33s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m8s
Project CI / Repository checks (pull_request) Successful in 5m55s
Project CI / Native shell tests (pull_request) Successful in 8m10s
合并 origin/master 的 9f4c7d763 到当前修复分支
合并双方技能包内容,将版本递增至 2026-08-26.44
保留双方共享记忆条目以及本分支的 API 说明、提示词和实际切片处理修复
验证图集回归 26 项、Rust 技能包 7 项、提示词 24 项和技能包脚本 3 项通过
验证三个变更技能的通用校验、技能包指纹、文档索引、编码与差异检查通过
保留提交钩子对 Pingora 路由校验脚本的纯格式调整
This commit is contained in:
@@ -218,6 +218,8 @@ module.exports = {
|
||||
'!src/services/frontendRuntimeConfigService.ts',
|
||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||
'!src/services/gameDistributionClient.ts',
|
||||
'!src/services/creatorProfileClient.ts',
|
||||
'!src/services/creatorProfileClient.test.ts',
|
||||
'!src/services/gameDistributionClient.test.ts',
|
||||
'!src/services/gamePlayClientId.ts',
|
||||
'!src/services/sseStream.ts',
|
||||
|
||||
+5
-2
@@ -2,13 +2,16 @@ dist
|
||||
node_modules
|
||||
.git
|
||||
.codex-logs
|
||||
public/Icons
|
||||
# ts-rs 生成绑定:一律以原始输出提交,禁止 prettier 二次改写
|
||||
# ts-rs 生成产物:必须与 Rust 声明逐字节一致,`check:generated-bindings` 会重新生成并比对。
|
||||
# 一律以原始输出提交,禁止 prettier 二次改写。
|
||||
packages/shared/src/contracts/generated/
|
||||
public/Icons
|
||||
apps/ai-game-creator-shell/src/features/ui-editor/types/
|
||||
apps/ai-game-creator-shell/src/view/project-development/chat/generated/
|
||||
apps/ai-game-creator-shell/src/services/generated/
|
||||
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
|
||||
# 预览桥脚本:随包注入浏览器的资源,按字节搬运自 Rust 内联字符串,禁止 prettier 二次改写
|
||||
apps/ai-game-creator-shell/src-tauri/resources/preview/
|
||||
media
|
||||
*.log
|
||||
.preview.*
|
||||
|
||||
+50
@@ -287,6 +287,56 @@ _Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另
|
||||
聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。
|
||||
_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件
|
||||
|
||||
### 泥点与会员计费
|
||||
|
||||
**每日免费泥点**:
|
||||
账号每天按固定额度自动获得的泥点,只在北京时间当天有效,当日 24 点清零后重新发放;不受会员状态影响。
|
||||
_Avoid_: 永久赠送、会员额度、可累计的每日额度
|
||||
|
||||
**月度泥点**:
|
||||
会员在当前会员账期内可用的泥点,只在本期有效;换期时本期余额清零并由新一期额度覆盖,会员到期时一并清零。
|
||||
_Avoid_: 会员点数、周期点数、限时点数、永久可用的会员赠送
|
||||
|
||||
**永久泥点**:
|
||||
不过期、不受会员状态影响的泥点,来源是充值与非会员奖励,创作扣点与月度泥点完全一致。
|
||||
_Avoid_: 不限时泥点、充值余额、无限泥点、免费泥点
|
||||
|
||||
**永久余数**:
|
||||
当前实现中「钱包总额扣除每日免费泥点与月度泥点之后剩下的部分」,即永久泥点在存储层的临时形态,不表示一种来源。
|
||||
_Avoid_: 独立余额、第四个池、充值本金
|
||||
|
||||
**会员账期**:
|
||||
会员权益的一期有效期,从开通日时刻起按自然月推进到同一天的下一月;年付包含 12 期。
|
||||
_Avoid_: 自然月、固定 30 天、账单周期
|
||||
|
||||
**补差升级**:
|
||||
在会员有效期内升到更高档位,只补付本期剩余时间段与新档位后续各期的差价,并补发当前期额度差额;已用泥点不返还。
|
||||
_Avoid_: 套餐切换、退订重买、即时月转年
|
||||
|
||||
**可用总额**:
|
||||
界面上的「现在可用」口径,等于月度泥点加永久泥点,不含每日免费泥点。
|
||||
_Avoid_: 钱包总额、总泥点、含每日免费的总和
|
||||
|
||||
**会员档位**:
|
||||
会员等级身份,取值为「非会员」与 `Starter` / `Plus` / `Pro` / `Max`,决定每期泥点额度、可用的模型范围、同时运行的独立任务数与升级比较次序;每个档位分月付与年付两种周期。
|
||||
_Avoid_: 旧的月卡 / 季卡 / 年卡命名、把档位写成自由字符串、用 `tier` 当档位的规范词
|
||||
|
||||
**会员计划目录**:
|
||||
会员档位可配置的价格与权益集合:月价、年价、每期泥点、模型权限、并发上限、排序与上下架。
|
||||
_Avoid_: 把价格写死在代码、按档位名字硬编码权益
|
||||
|
||||
**模型权限**:
|
||||
会员档位允许用户选择的 Agent 模型范围,分「基础」与「高性能」两档;每个模型属于哪一档由模型目录的权限标注决定,`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。
|
||||
_Avoid_: 计价分辨率档、模型定价档位、把档位写成自由字符串
|
||||
|
||||
**并发上限**:
|
||||
同一账号可同时处于执行中的服务端生成任务数上限;排队等待中的任务不计入,`Max` 不设上限。
|
||||
_Avoid_: 旧的排队展示字段、把「不设上限」写成空值、把排队中的任务计入
|
||||
|
||||
**独立生成任务**:
|
||||
用户提交、由服务端排队并执行的单条生成工作单元(图片 / 视频 / 音频等),带独立生命周期与计点;并发上限只对处于执行中的该任务计数,与用户本地的 Agent 回合无关。
|
||||
_Avoid_: 本地 Agent 回合、前端提交节流、直连同步生成请求(当前不计入并发)
|
||||
|
||||
## Relationships
|
||||
|
||||
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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 {
|
||||
@@ -1138,6 +1183,8 @@ export interface AdminGameDistributionReviewEntry {
|
||||
packageBytes: number;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
/** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */
|
||||
priceMudPoints?: number;
|
||||
reviewReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -1166,6 +1213,8 @@ export interface AdminGameDistributionGameDetail {
|
||||
orientation: string;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
/** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */
|
||||
priceMudPoints?: number;
|
||||
playCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '用户存档记录表',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
reviewAdminGameDistributionVersion,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameDistributionGameDetail,
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionReviewEntry,
|
||||
AdminGameDistributionVersionDetailResponse,
|
||||
@@ -48,6 +51,7 @@ const entry: AdminGameDistributionReviewEntry = {
|
||||
packageBytes: 2048,
|
||||
status: 'pending_review',
|
||||
publicationRevision: 4,
|
||||
priceMudPoints: 30,
|
||||
reviewReason: null,
|
||||
// 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。
|
||||
createdAt: '1791008678.817257Z',
|
||||
@@ -92,6 +96,7 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = {
|
||||
orientation: 'landscape',
|
||||
status: 'unpublished',
|
||||
publicationRevision: 7,
|
||||
priceMudPoints: 30,
|
||||
playCount: 0,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
},
|
||||
@@ -228,7 +233,8 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间',
|
||||
|
||||
const row = screen.getByText(gameTitle).closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3];
|
||||
// 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。
|
||||
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[4];
|
||||
// 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。
|
||||
expect(submittedCell?.textContent).not.toBe('-');
|
||||
expect(submittedCell?.textContent).toMatch(
|
||||
@@ -236,6 +242,56 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间',
|
||||
);
|
||||
});
|
||||
|
||||
test('价格列按买断制口径展示,免费与缺失价格都不显示 NaN', async () => {
|
||||
const freeEntry: AdminGameDistributionReviewEntry = {
|
||||
...entry,
|
||||
versionId: 'version-2',
|
||||
gameId: 'game_2',
|
||||
versionNumber: 1,
|
||||
priceMudPoints: 0,
|
||||
};
|
||||
// 旧响应可能完全没有该字段;列表必须回落到“免费”而不是 NaN。
|
||||
const legacyEntry = {
|
||||
...entry,
|
||||
versionId: 'version-3',
|
||||
gameId: 'game_3',
|
||||
versionNumber: 3,
|
||||
priceMudPoints: undefined,
|
||||
} as unknown as AdminGameDistributionReviewEntry;
|
||||
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
|
||||
entries: [entry, freeEntry, legacyEntry],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
const table = screen.getByText(gameTitle).closest('table');
|
||||
expect(table).not.toBeNull();
|
||||
// 按列名定位:绑「第 4 个格子」的话,任何插列或换序都会读到别的字段,断言随之失效。
|
||||
const priceColumnIndex = within(table as HTMLElement)
|
||||
.getAllByRole('columnheader')
|
||||
.findIndex((header) => header.textContent?.trim() === '价格');
|
||||
expect(priceColumnIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const cellAt = (text: string) => {
|
||||
const row = screen.getByText(text).closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
return within(row as HTMLElement).getAllByRole('cell')[priceColumnIndex]
|
||||
?.textContent;
|
||||
};
|
||||
|
||||
// 三行三态都按精确文案钉死:付费、免费与旧响应缺字段都不能渲染成 NaN。
|
||||
expect(cellAt(gameTitle)).toBe('30 泥点');
|
||||
expect(cellAt('game_2')).toBe('免费');
|
||||
expect(cellAt('game_3')).toBe('免费');
|
||||
});
|
||||
|
||||
test('取消理由输入时不做审核操作', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
@@ -298,6 +354,9 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('测试详细介绍')).toBeTruthy();
|
||||
// 详情资料区展示买断制价格(0 或缺字段按“免费”)。
|
||||
expect(screen.getByText('价格')).toBeTruthy();
|
||||
expect(screen.getByText('30 泥点')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
|
||||
expect(frame.getAttribute('src')).toBe(
|
||||
@@ -317,3 +376,155 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/** 打开详情并点开试玩,返回试玩 iframe。 */
|
||||
async function openPendingVersionPreview() {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
await screen.findAllByText(/测试作者/u);
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
return screen.findByTitle('测试游戏 待审版本试玩');
|
||||
}
|
||||
|
||||
test('试玩期间先给加载面,待审版本真正载入后让位给画面', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
expiresAt: '2026-10-02T12:10:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
const frame = await openPendingVersionPreview();
|
||||
|
||||
// 加载期不能只留一片空白:审核员要看到正在加载哪个游戏。
|
||||
const loadingSurface = screen.getByRole('status');
|
||||
expect(loadingSurface.textContent).toContain('测试游戏');
|
||||
expect(
|
||||
loadingSurface.classList.contains('platform-game-loading--panel'),
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.load(frame);
|
||||
await waitFor(() => expect(screen.queryByRole('status')).toBeNull());
|
||||
});
|
||||
|
||||
test('试玩加载超过上限后给出超时说明与重建会话入口', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
|
||||
vi.mocked(createAdminGameDistributionPreviewSession)
|
||||
.mockResolvedValueOnce({
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
expiresAt: '2026-10-02T12:10:00Z',
|
||||
versionId: 'version-1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/',
|
||||
expiresAt: '2026-10-02T12:20:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
await screen.findAllByText(/测试作者/u);
|
||||
|
||||
// 试玩会话与超时定时器都必须落在假定时器窗口内,才能按上限推进等待。
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTitle('测试游戏 待审版本试玩')).toBeTruthy();
|
||||
expect(screen.queryByText('试玩版本加载超时')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
|
||||
});
|
||||
expect(screen.getByRole('status').textContent).toContain(
|
||||
'试玩版本加载超时',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新创建试玩会话' }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(createAdminGameDistributionPreviewSession).toHaveBeenCalledTimes(2);
|
||||
// 新会话必须回到加载态,不能沿用上一个会话的「已就绪」或超时结论。
|
||||
const rebuilt = screen.getByTitle('测试游戏 待审版本试玩');
|
||||
expect(rebuilt.getAttribute('src')).toBe(
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/',
|
||||
);
|
||||
expect(screen.queryByText('试玩版本加载超时')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('详情价格只渲染服务端下发的 priceMudPoints,不按冻结资料二次取价', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
|
||||
// 服务端 `version_detail_payload` 已把「待审版本冻结价优先、缺字段按 0」归一写进
|
||||
// `game.priceMudPoints`,审核页只渲染这个字段。
|
||||
game: { ...detailResponse.game, priceMudPoints: 30 },
|
||||
version: {
|
||||
...detailResponse.version,
|
||||
// 对抗用例:冻结资料里的价格与服务端归一结果故意不同(999 ≠ 30)。客户端若自行
|
||||
// 复刻「冻结价优先」,就会显示 999 —— 这里必须仍然是服务端字段的 30。
|
||||
frozenMetadata: { title: '测试游戏', priceMudPoints: 999 },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
|
||||
|
||||
expect(screen.getByText('价格')).toBeTruthy();
|
||||
expect(screen.getByText('30 泥点')).toBeTruthy();
|
||||
expect(screen.queryByText('999 泥点')).toBeNull();
|
||||
expect(screen.queryByText('免费')).toBeNull();
|
||||
});
|
||||
|
||||
test('旧版本冻结资料没有价格时按免费回落', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
|
||||
game: {
|
||||
...detailResponse.game,
|
||||
priceMudPoints: undefined,
|
||||
} as unknown as AdminGameDistributionGameDetail,
|
||||
version: {
|
||||
...detailResponse.version,
|
||||
frozenMetadata: { title: '测试游戏' },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
|
||||
|
||||
expect(screen.getByText('免费')).toBeTruthy();
|
||||
expect(screen.queryByText(/NaN/u)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
formatAdminDateTime,
|
||||
GameDetailDisplay,
|
||||
Modal,
|
||||
PLATFORM_GAME_LOADING_TIMEOUT_MS,
|
||||
PlatformGameLoadingSurface,
|
||||
TextField,
|
||||
} from '@genarrative/shared/components';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
@@ -98,6 +100,8 @@ function applyFrozenGameMetadata(
|
||||
typeof metadata.orientation === 'string'
|
||||
? metadata.orientation
|
||||
: game.orientation,
|
||||
// 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、
|
||||
// 缺字段按 0」把结果归一写进 `game.priceMudPoints`,客户端直接沿用该字段。
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,6 +115,11 @@ function formatBytes(value: number) {
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
/** 买断制价格口径:0 或缺失(旧响应 / undefined)都按“免费”展示,避免出现 NaN。 */
|
||||
function formatPriceMudPoints(value: number | null | undefined) {
|
||||
return typeof value === 'number' && value > 0 ? `${value} 泥点` : '免费';
|
||||
}
|
||||
|
||||
function createReviewIdempotencyKey(versionId: string) {
|
||||
const random =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
@@ -437,6 +446,7 @@ export function AdminGameDistributionReviewPage({
|
||||
{ key: 'gameId', label: '游戏' },
|
||||
{ key: 'versionNumber', label: '版本' },
|
||||
{ key: 'packageBytes', label: '发行包' },
|
||||
{ key: 'priceMudPoints', label: '价格' },
|
||||
{ key: 'createdAt', label: '提交时间' },
|
||||
{ key: 'actions', label: '审核' },
|
||||
]}
|
||||
@@ -482,6 +492,7 @@ export function AdminGameDistributionReviewPage({
|
||||
<code>{entry.packageSha256.slice(0, 12)}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatPriceMudPoints(entry.priceMudPoints)}</td>
|
||||
<td>{formatAdminDateTime(entry.createdAt)}</td>
|
||||
<td className="admin-game-review-cell--actions">
|
||||
<AdminActionRow className="admin-review-actions">
|
||||
@@ -591,6 +602,20 @@ function AdminGameDistributionReviewDetailView({
|
||||
onReject: () => void;
|
||||
onApprove: () => void;
|
||||
}) {
|
||||
// 试玩会话是按 version 现建的:新会话必须回到加载态,不能沿用上一个会话的 ready。
|
||||
const [isPreviewFrameReady, setIsPreviewFrameReady] = useState(false);
|
||||
const [isPreviewTimedOut, setIsPreviewTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsPreviewFrameReady(false);
|
||||
setIsPreviewTimedOut(false);
|
||||
if (!previewUrl) return undefined;
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsPreviewTimedOut(true);
|
||||
}, PLATFORM_GAME_LOADING_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [previewUrl]);
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<AdminPage wide>
|
||||
@@ -606,6 +631,10 @@ function AdminGameDistributionReviewDetailView({
|
||||
);
|
||||
}
|
||||
|
||||
const coverUrl = detail.game.coverObjectKey
|
||||
? assetUrls[detail.game.coverObjectKey]
|
||||
: '';
|
||||
|
||||
return (
|
||||
<AdminPage wide>
|
||||
<AdminPageHeading
|
||||
@@ -629,6 +658,10 @@ function AdminGameDistributionReviewDetailView({
|
||||
versionLabel={`v${detail.version.versionNumber}`}
|
||||
infoCards={[
|
||||
{ label: '状态', value: detail.version.status },
|
||||
{
|
||||
label: '价格',
|
||||
value: formatPriceMudPoints(detail.game.priceMudPoints),
|
||||
},
|
||||
{
|
||||
label: '提交时间',
|
||||
value: formatAdminDateTime(detail.version.createdAt),
|
||||
@@ -682,13 +715,46 @@ function AdminGameDistributionReviewDetailView({
|
||||
<AdminAlert tone="warning">{previewError}</AdminAlert>
|
||||
) : null}
|
||||
{previewUrl ? (
|
||||
<iframe
|
||||
title={`${detail.game.title} 待审版本试玩`}
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
className="admin-game-review-preview"
|
||||
/>
|
||||
<div className="admin-game-review-preview-shell">
|
||||
{isPreviewFrameReady ? null : (
|
||||
<PlatformGameLoadingSurface
|
||||
tone="panel"
|
||||
title={detail.game.title}
|
||||
cover={
|
||||
coverUrl ? (
|
||||
<img alt={`${detail.game.title} 封面`} src={coverUrl} />
|
||||
) : null
|
||||
}
|
||||
message={isPreviewTimedOut ? '试玩版本加载超时' : undefined}
|
||||
hint={
|
||||
isPreviewTimedOut
|
||||
? '可以重新创建试玩会话,或稍后再试。'
|
||||
: undefined
|
||||
}
|
||||
stalled={isPreviewTimedOut}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
title={`${detail.game.title} 待审版本试玩`}
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
className="admin-game-review-preview"
|
||||
onLoad={() => setIsPreviewFrameReady(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{previewUrl && isPreviewTimedOut && !isPreviewFrameReady ? (
|
||||
<AdminActionRow className="admin-review-actions">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={isPreviewLoading}
|
||||
onClick={onPreview}
|
||||
>
|
||||
{isPreviewLoading ? '准备试玩…' : '重新创建试玩会话'}
|
||||
</AdminButton>
|
||||
</AdminActionRow>
|
||||
) : null}
|
||||
</section>
|
||||
</GameDetailDisplay>
|
||||
|
||||
@@ -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,14 +27,22 @@ table.admin-game-review-table td:nth-child(3) {
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
/* 价格列:只展示「免费」或「N 泥点」,上限 1000000 泥点最长;给足宽度并允许换行,
|
||||
固定列宽 + overflow:hidden 下保持 nowrap 会把最大价硬裁掉。 */
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4) {
|
||||
width: 172px;
|
||||
white-space: nowrap;
|
||||
width: 120px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
width: 172px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(6),
|
||||
table.admin-game-review-table td:nth-child(6) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
@@ -100,7 +108,9 @@ table.admin-game-review-table td .admin-muted-text {
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4),
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
table.admin-game-review-table td:nth-child(5),
|
||||
table.admin-game-review-table th:nth-child(6),
|
||||
table.admin-game-review-table td:nth-child(6) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3590,16 +3590,28 @@ button:disabled {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.admin-game-review-preview-shell {
|
||||
/* 加载面(PlatformGameLoadingSurface)按绝对定位铺在预览区上,外壳必须是定位祖先。 */
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
min-height: 42rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.admin-game-review-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 42rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.admin-game-review-preview-shell .platform-game-loading {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* 游戏管理列表(AdminGameManagementPage)专属排布,选择器全部框在本页表格内,
|
||||
* 不影响其它后台页面:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user