452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
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;
|
|
}
|