feat(后台): 新增会员档位页并收敛充值商品页

- 充值商品页改为纯泥点商品:去掉会员档位与 membership_* 字段,固定 kind=points,首充赠送默认 0
- 新增会员档位页:按档位枚举改月价 / 年价 / 每期泥点 / 模型权限 / 并发上限 / 可购买 / 排序,rank 只读
- adminApiTypes 用档位枚举替换旧 tier 类型并补会员档位请求响应类型,客户端补 listProfileMembershipPlans / upsertProfileMembershipPlan
- 注册 #membership-plans 路由与侧边栏图标

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 19:03:40 +08:00
parent 144068187c
commit de082ad81a
7 changed files with 579 additions and 252 deletions
+24
View File
@@ -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<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 = {},
+40 -16
View File
@@ -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;
+12
View File
@@ -9,6 +9,7 @@ import {
} from '../api/adminApiClient';
import type {
AdminSessionPayload,
ProfileMembershipPlanAdminResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileTaskConfigAdminResponse,
ProfileWalletConfigAdminResponse,
@@ -35,6 +36,7 @@ import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { AdminMembershipPlanPage } from '../pages/AdminMembershipPlanPage';
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
@@ -68,6 +70,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],
@@ -291,6 +295,14 @@ export function AdminApp() {
onResultChange={setRechargeProductResult}
/>
) : null}
{activeRouteId === 'membership-plans' ? (
<AdminMembershipPlanPage
result={membershipPlanResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setMembershipPlanResult}
/>
) : null}
{activeRouteId === 'recharge-orders' ? (
<AdminRechargeOrderPage
token={token}
+1
View File
@@ -50,6 +50,7 @@ const routeIcons = {
'profile-wallet': WalletCards,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'membership-plans': ShieldCheck,
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
+2
View File
@@ -13,6 +13,7 @@ export type AdminRouteId =
| 'profile-wallet'
| 'tasks'
| 'recharge-products'
| 'membership-plans'
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
@@ -52,6 +53,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: 'editor-generation-pricing',
@@ -0,0 +1,461 @@
import {
AdminAlert,
AdminButton,
AdminField,
AdminFormRow,
AdminInfoList,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
} 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 { handlePageError } from './pageUtils';
interface AdminMembershipPlanPageProps {
token: string;
result: ProfileMembershipPlanAdminResponse | null;
onUnauthorized: (message?: string) => 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<ProfileMembershipPlan, string> = {
normal: '非会员',
starter: 'Starter',
plus: 'Plus',
pro: 'Pro',
max: 'Max',
};
/**
* 会员档位目录。
*
* 一行一档,档位即主键(也是身份)。价格与权益以本表为唯一真相源:充值中心展示、
* 下单金额重算、补差升级报价都读它。`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 [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<HTMLFormElement>) {
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 (
<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 {
setPlan(nextPlan);
}
}}
>
{Object.entries(planLabels).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={1}
step={1}
type="number"
value={monthPriceCents}
onChange={(event) => setMonthPriceCents(event.target.value)}
/>
</AdminField>
<AdminField label="年价(分,独立可配置)">
<input
min={1}
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="并发上限(128 = 不设上限)">
<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>
<AdminField label="排序">
<input
inputMode="numeric"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</AdminField>
</AdminFormRow>
{!isPurchasable ? (
<AdminAlert>
非会员档位不可购买,价格与额度会被后端按 0 落库。
</AdminAlert>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
<AdminButton variant="primary" disabled={isSaving} 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,
sortValue: (entry) => entry.sortOrder,
},
{
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 || planLabels[entry.plan]}
</AdminButton>
<small>
{entry.plan} · rank {entry.rank}
</small>
</td>
<td>
{formatPrice(entry.monthPriceCents)} /{' '}
{formatPrice(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: (
<>
{planLabels[result.plan]}({result.plan})
</>
),
},
{
label: <>可购买</>,
value: <>{result.enabled ? '是' : '否'}</>,
},
]}
/>
</AdminPanel>
) : null}
</div>
</div>
{confirmDialog}
</AdminPage>
);
}
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;
}
@@ -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<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 [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({
<AdminPage wide>
<AdminPageHeading
title="充值商品"
description="泥点与会员档位"
description="泥点充值档位(¥1 = 10 泥点)"
actions={
<AdminButton
variant="secondary"
@@ -231,39 +181,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 +199,30 @@ 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>
)}
<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>
<AdminFormRow>
<AdminField label="角标">
<input
disabled={kind !== 'points'}
value={badgeLabel}
onChange={(event) => 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({
</AdminButton>
<small>{entry.productId}</small>
</td>
<td>{formatProductKind(entry.kind)}</td>
<td>{formatPrice(entry.priceCents)}</td>
<td>{formatProductContent(entry)}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
@@ -489,6 +320,10 @@ export function AdminRechargeProductPage({
label: <>状态</>,
value: <>{result.enabled ? '启用' : '停用'}</>,
},
{
label: <>时间</>,
value: <>{formatAdminDateTime(result.updatedAt)}</>,
},
]}
/>
</AdminPanel>
@@ -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) {