feat(后台): 新增会员档位页并收敛充值商品页
- 充值商品页改为纯泥点商品:去掉会员档位与 membership_* 字段,固定 kind=points,首充赠送默认 0 - 新增会员档位页:按档位枚举改月价 / 年价 / 每期泥点 / 模型权限 / 并发上限 / 可购买 / 排序,rank 只读 - adminApiTypes 用档位枚举替换旧 tier 类型并补会员档位请求响应类型,客户端补 listProfileMembershipPlans / upsertProfileMembershipPlan - 注册 #membership-plans 路由与侧边栏图标 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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 = {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user