feat: 支持充值商品配置和档位首充

This commit is contained in:
2026-05-15 06:11:57 +08:00
parent 9c33cc565c
commit c7fe793a9e
36 changed files with 2096 additions and 72 deletions

View File

@@ -14,6 +14,7 @@ import type {
AdminTrackingEventListQuery,
AdminTrackingEventListResponse,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
ApiErrorEnvelope,
@@ -21,6 +22,8 @@ import type {
ApiSuccessEnvelope,
ProfileInviteCodeAdminListResponse,
ProfileInviteCodeAdminResponse,
ProfileRechargeProductConfigAdminListResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminListResponse,
@@ -279,6 +282,27 @@ export function disableProfileTaskConfig(
);
}
export function listProfileRechargeProducts(token: string) {
return request<ProfileRechargeProductConfigAdminListResponse>(
'/admin/api/profile/recharge-products',
{token},
);
}
export function upsertProfileRechargeProduct(
token: string,
payload: AdminUpsertProfileRechargeProductRequest,
) {
return request<ProfileRechargeProductConfigAdminResponse>(
'/admin/api/profile/recharge-products',
{
method: 'POST',
token,
body: payload,
},
);
}
function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}

View File

@@ -132,6 +132,8 @@ export interface AdminDebugHttpResponse {
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';
export interface AdminTrackingEventListQuery {
eventKey?: string;
@@ -207,6 +209,21 @@ export interface AdminDisableProfileTaskConfigRequest {
taskId: string;
}
export interface AdminUpsertProfileRechargeProductRequest {
productId: string;
title: string;
priceCents: number;
kind: ProfileRechargeProductKind;
pointsAmount: number;
bonusPoints: number;
durationDays: number;
badgeLabel?: string | null;
description?: string | null;
tier: ProfileMembershipTier;
enabled: boolean;
sortOrder: number;
}
export interface ProfileRedeemCodeAdminResponse {
code: string;
mode: ProfileRedeemCodeMode;
@@ -260,6 +277,29 @@ export interface ProfileTaskConfigAdminListResponse {
entries: ProfileTaskConfigAdminResponse[];
}
export interface ProfileRechargeProductConfigAdminResponse {
productId: string;
title: string;
priceCents: number;
kind: ProfileRechargeProductKind;
pointsAmount: number;
bonusPoints: number;
durationDays: number;
badgeLabel: string;
description: string;
tier: ProfileMembershipTier;
enabled: boolean;
sortOrder: number;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface ProfileRechargeProductConfigAdminListResponse {
entries: ProfileRechargeProductConfigAdminResponse[];
}
export interface AdminTrackingEventEntryPayload {
eventId: string;
eventKey: string;

View File

@@ -9,6 +9,7 @@ import {
import type {
AdminSessionPayload,
ProfileInviteCodeAdminResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminResponse,
} from '../api/adminApiTypes';
@@ -23,6 +24,7 @@ import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
@@ -47,6 +49,8 @@ export function AdminApp() {
useState<ProfileInviteCodeAdminResponse | null>(null);
const [taskConfigResult, setTaskConfigResult] =
useState<ProfileTaskConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(null);
const clearSession = useCallback((message = '') => {
clearStoredAdminToken();
@@ -55,6 +59,7 @@ export function AdminApp() {
setRedeemResult(null);
setInviteResult(null);
setTaskConfigResult(null);
setRechargeProductResult(null);
setStatus('guest');
setLoginNotice(message);
}, []);
@@ -124,6 +129,7 @@ export function AdminApp() {
setRedeemResult(null);
setInviteResult(null);
setTaskConfigResult(null);
setRechargeProductResult(null);
setLoginNotice('');
setStatus('authenticated');
}, []);
@@ -207,6 +213,14 @@ export function AdminApp() {
onResultChange={setTaskConfigResult}
/>
) : null}
{routeId === 'recharge-products' ? (
<AdminRechargeProductPage
result={rechargeProductResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setRechargeProductResult}
/>
) : null}
</AdminShell>
);
}

View File

@@ -1,5 +1,6 @@
import {
Bug,
BadgeDollarSign,
LayoutDashboard,
LogOut,
ShieldCheck,
@@ -32,6 +33,7 @@ const routeIcons = {
redeem: TicketPercent,
invite: TicketCheck,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'creation-entry': SlidersHorizontal,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;

View File

@@ -6,6 +6,7 @@ export type AdminRouteId =
| 'redeem'
| 'invite'
| 'tasks'
| 'recharge-products'
| 'creation-entry';
export interface AdminRouteDefinition {
@@ -22,6 +23,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
];

View File

@@ -0,0 +1,451 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import {
listProfileRechargeProducts,
upsertProfileRechargeProduct,
} from '../api/adminApiClient';
import type {
ProfileMembershipTier,
ProfileRechargeProductConfigAdminResponse,
ProfileRechargeProductKind,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminRechargeProductPageProps {
token: string;
result: ProfileRechargeProductConfigAdminResponse | null;
onUnauthorized: (message?: string) => void;
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: 'month', label: '月卡'},
{value: 'season', label: '季卡'},
{value: 'year', label: '年卡'},
];
export function AdminRechargeProductPage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminRechargeProductPageProps) {
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('60');
const [durationDays, setDurationDays] = useState('0');
const [badgeLabel, setBadgeLabel] = useState('首充双倍');
const [description, setDescription] = useState('首充送60泥点');
const [tier, setTier] = useState<ProfileMembershipTier>('normal');
const [enabled, setEnabled] = useState(true);
const [sortOrder, setSortOrder] = 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 refreshProducts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshProducts() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileRechargeProducts(token);
const sortedEntries = sortProducts(response.entries);
setEntries(sortedEntries);
const firstEntry = 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 confirmed = await confirmWrite({
action: enabled ? '保存充值商品' : '停用充值商品',
target: productId.trim(),
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
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() : '',
description: description.trim(),
tier: kind === 'membership' ? tier : 'normal',
enabled,
sortOrder: parseInteger(sortOrder),
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
function upsertEntry(next: ProfileRechargeProductConfigAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.productId !== next.productId);
return sortProducts([...rest, next]);
});
}
function fillForm(entry: ProfileRechargeProductConfigAdminResponse) {
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);
setEnabled(entry.enabled);
setSortOrder(String(entry.sortOrder));
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshProducts}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span>Product ID</span>
<input
value={productId}
onChange={(event) => setProductId(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<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');
} else {
setBonusPoints('0');
setPointsAmount('0');
setTier(tier === 'normal' ? 'month' : tier);
}
}}
>
{item.label}
</button>
))}
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={priceCents}
onChange={(event) => setPriceCents(event.target.value)}
/>
</label>
</div>
{kind === 'points' ? (
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={pointsAmount}
onChange={(event) => setPointsAmount(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={0}
step={1}
type="number"
value={bonusPoints}
onChange={(event) => setBonusPoints(event.target.value)}
/>
</label>
</div>
) : (
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<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>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={durationDays}
onChange={(event) => setDurationDays(event.target.value)}
/>
</label>
</div>
)}
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
disabled={kind !== 'points'}
value={badgeLabel}
onChange={(event) => setBadgeLabel(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
inputMode="numeric"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span></span>
<input
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !productId.trim() || !title.trim()}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.productId}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || entry.productId}
</button>
<small>{entry.productId}</small>
</td>
<td>{formatProductKind(entry.kind)}</td>
<td>{formatPrice(entry.priceCents)}</td>
<td>{formatProductContent(entry)}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{result ? (
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result.updatedAt}</span>
</div>
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{result.productId}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
</dl>
</section>
) : null}
</div>
</div>
{confirmDialog}
</section>
);
}
function sortProducts(entries: ProfileRechargeProductConfigAdminResponse[]) {
return [...entries].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.productId.localeCompare(right.productId);
});
}
function formatProductKind(kind: ProfileRechargeProductKind) {
return kind === 'points' ? '泥点' : '会员';
}
function formatTier(tier: ProfileMembershipTier) {
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}`;
}
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;
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return 0;
}
return parsed;
}