diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index ec574237d..71ea8d332 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -70,6 +70,7 @@ import type { AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileMembershipPlanRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, @@ -86,6 +87,8 @@ import type { EditorGenerationPricingConfigPayload, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, + ProfileMembershipPlanAdminListResponse, + ProfileMembershipPlanAdminResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRedeemCodeAdminListResponse, @@ -791,6 +794,27 @@ export function upsertProfileRechargeProduct( ); } +export function listProfileMembershipPlans(token: string) { + return request( + '/admin/api/profile/membership-plans', + { token }, + ); +} + +export function upsertProfileMembershipPlan( + token: string, + payload: AdminUpsertProfileMembershipPlanRequest, +) { + return request( + '/admin/api/profile/membership-plans', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listAdminRechargeOrders( token: string, query: AdminRechargeOrderListQuery = {}, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index c3a9e7256..598d3e571 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -381,15 +381,17 @@ 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` 是非会员占位档位,不可购买。 */ +export type ProfileMembershipPlan = | 'normal' - | 'month' - | 'season' - | 'year' | 'starter' - | 'basic' + | 'plus' | 'pro' - | 'ultimate'; + | 'max'; +/** 会员周期:有效期内不支持月年互换。 */ +export type ProfileMembershipCycleKind = 'monthly' | 'yearly'; +/** 模型权限:`basic` 仅基础模型,`full` 基础及高性能模型。 */ +export type ProfileMembershipModelAccess = 'basic' | 'full'; export interface AdminTrackingEventListQuery { eventKey?: string; @@ -682,11 +684,6 @@ export interface AdminUpsertProfileRechargeProductRequest { durationDays: number; badgeLabel?: string | null; description?: string | null; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; enabled: boolean; sortOrder: number; } @@ -775,11 +772,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 +784,38 @@ export interface ProfileRechargeProductConfigAdminListResponse { entries: ProfileRechargeProductConfigAdminResponse[]; } +/** 会员档位目录行;后端 `profile_membership_plan` 表是价格与权益的唯一真相源。 */ +export interface ProfileMembershipPlanAdminResponse { + plan: ProfileMembershipPlan; + title: string; + rank: number; + monthPriceCents: number; + yearPriceCents: number; + periodPoints: number; + modelAccess: ProfileMembershipModelAccess; + /** 并发上限;`128` 表示不设上限。 */ + concurrentJobLimit: number; + sortOrder: number; + enabled: boolean; +} + +export interface ProfileMembershipPlanAdminListResponse { + entries: ProfileMembershipPlanAdminResponse[]; +} + +/** 后台按档位改价 / 改权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 */ +export interface AdminUpsertProfileMembershipPlanRequest { + plan: ProfileMembershipPlan; + title: string; + monthPriceCents: number; + yearPriceCents: number; + periodPoints: number; + modelAccess: ProfileMembershipModelAccess; + concurrentJobLimit: number; + enabled: boolean; + sortOrder: number; +} + export interface ProfileWalletConfigAdminResponse { configId: string; initialMudPoints: number; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 374bc8e45..e89cc7b7c 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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 { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage'; import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage'; @@ -68,6 +70,8 @@ export function AdminApp() { useState(null); const [rechargeProductResult, setRechargeProductResult] = useState(null); + const [membershipPlanResult, setMembershipPlanResult] = + useState(null); const accessibleRoutes = useMemo( () => (admin ? getAccessibleAdminRoutes(admin) : []), [admin], @@ -291,6 +295,14 @@ export function AdminApp() { onResultChange={setRechargeProductResult} /> ) : null} + {activeRouteId === 'membership-plans' ? ( + + ) : null} {activeRouteId === 'recharge-orders' ? ( void; + onResultChange: (result: ProfileMembershipPlanAdminResponse) => void; +} + +/** 并发上限哨兵:与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 一致,128 表示不设上限。 */ +const UNLIMITED_CONCURRENT_JOB_LIMIT = 128; + +const modelAccessOptions: Array<{ + value: ProfileMembershipModelAccess; + label: string; +}> = [ + { value: 'basic', label: '仅基础模型' }, + { value: 'full', label: '基础及高性能模型' }, +]; + +const planLabels: Record = { + normal: '非会员', + starter: 'Starter', + plus: 'Plus', + pro: 'Pro', + max: 'Max', +}; + +/** + * 会员档位目录。 + * + * 一行一档,档位即主键(也是身份)。价格与权益以本表为唯一真相源:充值中心展示、 + * 下单金额重算、补差升级报价都读它。`rank` 由代码内的档位目录决定,后台不能改, + * 因此这里只读展示。模型权限与并发上限本期只落字段、只用于展示,服务端不做拦截。 + */ +export function AdminMembershipPlanPage({ + token, + result, + onUnauthorized, + onResultChange, +}: AdminMembershipPlanPageProps) { + const [entries, setEntries] = useState( + [], + ); + const [plan, setPlan] = useState('starter'); + const [title, setTitle] = useState(''); + const [monthPriceCents, setMonthPriceCents] = useState('0'); + const [yearPriceCents, setYearPriceCents] = useState('0'); + const [periodPoints, setPeriodPoints] = useState('0'); + const [modelAccess, setModelAccess] = + useState('basic'); + const [concurrentJobLimit, setConcurrentJobLimit] = useState('1'); + const [enabled, setEnabled] = useState(false); + const [sortOrder, setSortOrder] = useState('0'); + 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); + setEntries(response.entries); + const firstEntry = + response.entries.find((entry) => entry.plan === plan) ?? + response.entries[0]; + if (firstEntry) { + fillForm(firstEntry); + } + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + if (isSaving) { + return; + } + + setErrorMessage(''); + const normalizedMonthPriceCents = parsePositiveInteger(monthPriceCents); + const normalizedYearPriceCents = parsePositiveInteger(yearPriceCents); + const normalizedConcurrentJobLimit = + parsePositiveInteger(concurrentJobLimit); + if (plan !== 'normal') { + if (!normalizedMonthPriceCents || !normalizedYearPriceCents) { + setErrorMessage('月价与年价必须是大于 0 的整数分'); + return; + } + if (!normalizedConcurrentJobLimit) { + setErrorMessage( + `并发上限必须是大于 0 的整数;${UNLIMITED_CONCURRENT_JOB_LIMIT} 表示不设上限`, + ); + return; + } + } + + const confirmed = await confirmWrite({ + action: '保存会员档位', + target: `${planLabels[plan]}(${plan})`, + }); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + const response = await upsertProfileMembershipPlan(token, { + plan, + title: title.trim(), + monthPriceCents: normalizedMonthPriceCents, + yearPriceCents: normalizedYearPriceCents, + periodPoints: parsePositiveInteger(periodPoints), + modelAccess, + concurrentJobLimit: normalizedConcurrentJobLimit, + enabled, + sortOrder: parseInteger(sortOrder), + }); + 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); + setSortOrder(String(entry.sortOrder)); + setRankLabel(String(entry.rank)); + } + + const isPurchasable = plan !== 'normal'; + + return ( + + + + ); +} + +function sortPlanEntries(entries: ProfileMembershipPlanAdminResponse[]) { + return [...entries].sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.rank - right.rank; + }); +} + +function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) { + const modelLabel = entry.modelAccess === 'full' ? '基础+高性能' : '仅基础'; + const concurrency = + entry.concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT + ? '不设上限' + : `${entry.concurrentJobLimit} 个`; + return `${modelLabel} · 并发 ${concurrency}`; +} + +function formatPrice(priceCents: number) { + return `¥${(priceCents / 100).toFixed(2)}`; +} + +function parsePositiveInteger(value: string) { + const parsed = parseInteger(value); + return parsed > 0 ? parsed : 0; +} + +function parseInteger(value: string) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return 0; + } + return parsed; +} diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index 6fbaa879a..cf95a7030 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -8,6 +8,7 @@ import { AdminPage, AdminPageHeading, AdminPanel, + formatAdminDateTime, } from '@genarrative/shared/components'; import { RefreshCcw, Save } from 'lucide-react'; import { FormEvent, useEffect, useState } from 'react'; @@ -16,11 +17,7 @@ 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'; @@ -31,25 +28,12 @@ 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: '年卡' }, - ]; - +/** + * 泥点充值商品。 + * + * 会员档位已迁到独立的「会员档位」页(`profile_membership_plan`),本页只维护充点商品。 + * 新方案固定「¥1 = 10 泥点、无任何首充赠送」,首充赠送字段保留能力但默认 0。 + */ export function AdminRechargeProductPage({ token, result, @@ -62,17 +46,10 @@ export function AdminRechargeProductPage({ const [productId, setProductId] = useState('points_60'); const [title, setTitle] = useState('60泥点'); const [priceCents, setPriceCents] = useState('600'); - const [kind, setKind] = useState('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('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 [isLoading, setIsLoading] = useState(false); @@ -125,32 +102,12 @@ export function AdminRechargeProductPage({ 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() : '', + kind: 'points', + pointsAmount: parsePositiveInteger(pointsAmount), + bonusPoints: parseNonNegativeInteger(bonusPoints), + durationDays: 0, + 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,17 +134,10 @@ 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)); } @@ -196,7 +146,7 @@ export function AdminRechargeProductPage({ -
- {productKinds.map((item) => ( - - ))} -
- - {kind === 'points' ? ( - - - setPointsAmount(event.target.value)} - /> - - - setBonusPoints(event.target.value)} - /> - - - ) : ( -
- - - - - - setDurationDays(event.target.value)} - /> - - - - - - setMembershipPeriodPoints(event.target.value) - } - /> - - - - setMembershipPeriodDays(event.target.value) - } - /> - - - - - - setMembershipQueueLimit(event.target.value) - } - /> - - - - setMembershipDiscountBps(event.target.value) - } - /> - - -
- )} + + + setPointsAmount(event.target.value)} + /> + + + setBonusPoints(event.target.value)} + /> + + setBadgeLabel(event.target.value)} /> @@ -430,12 +268,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,7 +300,6 @@ export function AdminRechargeProductPage({ {entry.productId} - {formatProductKind(entry.kind)} {formatPrice(entry.priceCents)} {formatProductContent(entry)} {entry.enabled ? '启用' : '停用'} @@ -489,6 +320,10 @@ export function AdminRechargeProductPage({ label: <>状态, value: <>{result.enabled ? '启用' : '停用'}, }, + { + label: <>时间, + value: <>{formatAdminDateTime(result.updatedAt)}, + }, ]} /> @@ -510,42 +345,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}泥点`; + return `${entry.pointsAmount} + ${entry.bonusPoints} 泥点`; } function formatPrice(priceCents: number) {