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

@@ -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;
}