feat: 支持充值商品配置和档位首充
This commit is contained in:
@@ -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(/\/+$/, '');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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'},
|
||||
];
|
||||
|
||||
|
||||
451
apps/admin-web/src/pages/AdminRechargeProductPage.tsx
Normal file
451
apps/admin-web/src/pages/AdminRechargeProductPage.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user