Files
Genarrative/apps/admin-web/src/pages/AdminRedeemCodePage.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

535 lines
16 KiB
TypeScript

import { PowerOff, RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
disableProfileRedeemCode,
listProfileRedeemCodes,
upsertProfileRedeemCode,
} from '../api/adminApiClient';
import type {
ProfileCodeOperationAdminResponse,
ProfileRedeemCodeAdminResponse,
ProfileRedeemCodeMode,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, splitLines } from './pageUtils';
interface AdminRedeemCodePageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const redeemModes: Array<{ value: ProfileRedeemCodeMode; label: string }> = [
{ value: 'public', label: '公共码' },
{ value: 'unique', label: '唯一码' },
{ value: 'private', label: '私有码' },
];
export function AdminRedeemCodePage({
token,
onUnauthorized,
}: 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 [startsAt, setStartsAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [allowedUserIds, setAllowedUserIds] = useState('');
const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState('');
const [disableCode, setDisableCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileRedeemCodeAdminResponse[]>([]);
const [operations, setOperations] = useState<
ProfileCodeOperationAdminResponse[]
>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshRedeemCodes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshRedeemCodes() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileRedeemCodes(token);
setEntries(response.entries);
setOperations(response.operations ?? []);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
const validityError = validateValidityWindow(startsAt, expiresAt);
if (validityError) {
setErrorMessage(validityError);
return;
}
const confirmed = await confirmWrite({
action: '保存兑换码',
target: code.trim(),
});
if (!confirmed) {
return;
}
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) : [],
startsAt: startsAt ? toIsoDateTime(startsAt) : null,
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
});
fillForm(response);
await refreshRedeemCodes();
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
async function handleDisable(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isDisabling) {
return;
}
setDisableErrorMessage('');
const confirmed = await confirmWrite({
action: '停用兑换码',
target: disableCode.trim(),
});
if (!confirmed) {
return;
}
setIsDisabling(true);
try {
const response = await disableProfileRedeemCode(token, {
code: disableCode.trim(),
});
fillForm(response);
await refreshRedeemCodes();
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
setIsDisabling(false);
}
}
function fillForm(entry: ProfileRedeemCodeAdminResponse) {
setCode(entry.code);
setMode(entry.mode);
setRewardPoints(String(entry.rewardPoints));
setMaxUses(String(entry.maxUses));
setEnabled(entry.enabled);
setStartsAt(toDateTimeLocalValue(entry.startsAt));
setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
setAllowedUserIds(entry.allowedUserIds.join('\n'));
setAllowedPublicUserCodes('');
setDisableCode(entry.code);
}
const validityError = validateValidityWindow(startsAt, expiresAt);
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2>兑换码</h2>
<p>创建、更新与停用</p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshRedeemCodes}
>
<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>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>
<div className="admin-form-row">
<label className="admin-field">
<span>开始时间</span>
<input
type="datetime-local"
value={startsAt}
onChange={(event) => setStartsAt(event.target.value)}
/>
</label>
<label className="admin-field">
<span>截止时间</span>
<input
type="datetime-local"
value={expiresAt}
onChange={(event) => setExpiresAt(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}
{validityError && validityError !== errorMessage ? (
<div className="admin-alert" role="status">
{validityError}
</div>
) : null}
<button
className="admin-primary-button"
disabled={
isSaving ||
!code.trim() ||
!parsePositiveInteger(rewardPoints) ||
!parsePositiveInteger(maxUses) ||
Boolean(validityError)
}
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>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>Code</th>
<th>奖励</th>
<th>状态</th>
<th>有效期</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.code}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.code}
</button>
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>
<span
className={`admin-status ${redeemValidityClass(entry)}`}
>
{redeemValidityLabel(entry)}
</span>
</td>
<td>
<small>{formatValidityWindow(entry)}</small>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无兑换码'}
</div>
)}
</section>
<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>{operations.length}</span>
</div>
{operations.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>操作</th>
<th>Code</th>
<th>操作人</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => (
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorDisplayName}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">暂无记录</div>
)}
</section>
</div>
</div>
{confirmDialog}
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function redeemModeLabel(value: ProfileRedeemCodeMode) {
return redeemModes.find((item) => item.value === value)?.label ?? value;
}
function operationActionLabel(action: string) {
if (action === 'create') {
return '新增';
}
if (action === 'update') {
return '更新';
}
if (action === 'disable') {
return '停用';
}
return action;
}
function formatDateTime(value: string) {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', { hour12: false });
}
function validateValidityWindow(startsAt: string, expiresAt: string) {
if (!startsAt || !expiresAt) {
return '';
}
const startsAtTime = Date.parse(toIsoDateTime(startsAt));
const expiresAtTime = Date.parse(toIsoDateTime(expiresAt));
if (!Number.isFinite(startsAtTime) || !Number.isFinite(expiresAtTime)) {
return '有效期时间无效';
}
return startsAtTime < expiresAtTime ? '' : '截止时间必须晚于开始时间';
}
function toIsoDateTime(value: string) {
const time = Date.parse(value);
if (!Number.isFinite(time)) {
throw new Error('有效期时间无效');
}
return new Date(time).toISOString();
}
function toDateTimeLocalValue(value?: string | null) {
if (!value) {
return '';
}
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return '';
}
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}
function redeemValidityLabel(entry: ProfileRedeemCodeAdminResponse) {
if (!entry.enabled) {
return '停用';
}
const now = Date.now();
const startsAtTime = entry.startsAt ? Date.parse(entry.startsAt) : null;
const expiresAtTime = entry.expiresAt ? Date.parse(entry.expiresAt) : null;
if (
startsAtTime !== null &&
Number.isFinite(startsAtTime) &&
now < startsAtTime
) {
return '未生效';
}
if (
expiresAtTime !== null &&
Number.isFinite(expiresAtTime) &&
now >= expiresAtTime
) {
return '已过期';
}
if (entry.startsAt || entry.expiresAt) {
return '有效';
}
return '长期有效';
}
function redeemValidityClass(entry: ProfileRedeemCodeAdminResponse) {
const label = redeemValidityLabel(entry);
if (label === '停用' || label === '已过期') {
return 'admin-status-error';
}
if (label === '未生效') {
return 'admin-status-pending';
}
return 'admin-status-ok';
}
function formatValidityWindow(entry: ProfileRedeemCodeAdminResponse) {
const startsAt = entry.startsAt ? formatDateTime(entry.startsAt) : '立即';
const expiresAt = entry.expiresAt ? formatDateTime(entry.expiresAt) : '长期';
return `${startsAt} / ${expiresAt}`;
}