Fix admin SQL count parsing for local SpacetimeDB
This commit is contained in:
275
apps/admin-web/src/pages/AdminRedeemCodePage.tsx
Normal file
275
apps/admin-web/src/pages/AdminRedeemCodePage.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import {PowerOff, Save} from 'lucide-react';
|
||||
import {FormEvent, useState} from 'react';
|
||||
|
||||
import {
|
||||
disableProfileRedeemCode,
|
||||
upsertProfileRedeemCode,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
ProfileRedeemCodeAdminResponse,
|
||||
ProfileRedeemCodeMode,
|
||||
} from '../api/adminApiTypes';
|
||||
import {handlePageError, splitLines} from './pageUtils';
|
||||
|
||||
interface AdminRedeemCodePageProps {
|
||||
token: string;
|
||||
result: ProfileRedeemCodeAdminResponse | null;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
onResultChange: (result: ProfileRedeemCodeAdminResponse) => void;
|
||||
}
|
||||
|
||||
const redeemModes: Array<{value: ProfileRedeemCodeMode; label: string}> = [
|
||||
{value: 'public', label: '公共码'},
|
||||
{value: 'unique', label: '唯一码'},
|
||||
{value: 'private', label: '私有码'},
|
||||
];
|
||||
|
||||
export function AdminRedeemCodePage({
|
||||
token,
|
||||
result,
|
||||
onUnauthorized,
|
||||
onResultChange,
|
||||
}: AdminRedeemCodePageProps) {
|
||||
const [code, setCode] = useState('');
|
||||
const [mode, setMode] = useState<ProfileRedeemCodeMode>('public');
|
||||
const [rewardPoints, setRewardPoints] = useState('100');
|
||||
const [maxUses, setMaxUses] = useState('1');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [allowedUserIds, setAllowedUserIds] = useState('');
|
||||
const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [disableErrorMessage, setDisableErrorMessage] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
|
||||
async function handleSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await upsertProfileRedeemCode(token, {
|
||||
code: code.trim(),
|
||||
mode,
|
||||
rewardPoints: parsePositiveInteger(rewardPoints),
|
||||
maxUses: parsePositiveInteger(maxUses),
|
||||
enabled,
|
||||
allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [],
|
||||
allowedPublicUserCodes:
|
||||
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
|
||||
});
|
||||
onResultChange(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDisableErrorMessage('');
|
||||
setIsDisabling(true);
|
||||
try {
|
||||
const response = await disableProfileRedeemCode(token, {
|
||||
code: disableCode.trim(),
|
||||
});
|
||||
onResultChange(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setDisableErrorMessage);
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>兑换码</h2>
|
||||
<p>创建、更新与停用</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>Code</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(event) => setCode(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">
|
||||
{redeemModes.map((item) => (
|
||||
<button
|
||||
data-active={mode === item.value}
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setMode(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>奖励点数</span>
|
||||
<input
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={rewardPoints}
|
||||
onChange={(event) => setRewardPoints(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>最大次数</span>
|
||||
<input
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={maxUses}
|
||||
onChange={(event) => setMaxUses(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === 'private' ? (
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>内部 userId</span>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={allowedUserIds}
|
||||
onChange={(event) => setAllowedUserIds(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>公开陶泥号</span>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={allowedPublicUserCodes}
|
||||
onChange={(event) =>
|
||||
setAllowedPublicUserCodes(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={
|
||||
isSaving ||
|
||||
!code.trim() ||
|
||||
!parsePositiveInteger(rewardPoints) ||
|
||||
!parsePositiveInteger(maxUses)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
<span>{isSaving ? '保存中' : '保存'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="admin-stack">
|
||||
<form className="admin-panel admin-form" onSubmit={handleDisable}>
|
||||
<label className="admin-field">
|
||||
<span>停用 Code</span>
|
||||
<input
|
||||
value={disableCode}
|
||||
onChange={(event) => setDisableCode(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{disableErrorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{disableErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="admin-danger-button"
|
||||
disabled={isDisabling || !disableCode.trim()}
|
||||
type="submit"
|
||||
>
|
||||
<PowerOff size={17} aria-hidden="true" />
|
||||
<span>{isDisabling ? '停用中' : '停用'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section className="admin-panel admin-result-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>记录</h3>
|
||||
<span>{result?.mode ?? '-'}</span>
|
||||
</div>
|
||||
{result ? (
|
||||
<dl className="admin-info-list">
|
||||
<div>
|
||||
<dt>Code</dt>
|
||||
<dd>{result.code}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>奖励</dt>
|
||||
<dd>{result.rewardPoints}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最大次数</dt>
|
||||
<dd>{result.maxUses}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>全局已用</dt>
|
||||
<dd>{result.globalUsedCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>{result.enabled ? '启用' : '停用'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>创建人</dt>
|
||||
<dd>{result.createdBy}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新</dt>
|
||||
<dd>{result.updatedAt}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<div className="admin-empty-state">暂无记录</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user