8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出 - AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表) - AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序 - AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它 - AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间 - 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列 - 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable - 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心 - 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染 - docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑 - 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
530 lines
16 KiB
TypeScript
530 lines
16 KiB
TypeScript
import type { AdminStatusTone } from '@genarrative/shared/components';
|
|
import {
|
|
AdminAlert,
|
|
AdminButton,
|
|
AdminField,
|
|
AdminFormRow,
|
|
AdminListPanel,
|
|
AdminPage,
|
|
AdminPageHeading,
|
|
AdminPanel,
|
|
AdminStatusPill,
|
|
formatAdminDateTime,
|
|
} from '@genarrative/shared/components';
|
|
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 (
|
|
<AdminPage>
|
|
<AdminPageHeading
|
|
title="兑换码"
|
|
description="创建、更新与停用"
|
|
actions={
|
|
<AdminButton
|
|
variant="secondary"
|
|
disabled={isLoading}
|
|
type="button"
|
|
onClick={refreshRedeemCodes}
|
|
>
|
|
<RefreshCcw size={17} aria-hidden="true" />
|
|
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
|
</AdminButton>
|
|
}
|
|
/>
|
|
|
|
{listErrorMessage ? <AdminAlert>{listErrorMessage}</AdminAlert> : null}
|
|
|
|
<div className="admin-two-column admin-two-column-wide">
|
|
<AdminPanel as="form" onSubmit={handleSave}>
|
|
<AdminFormRow>
|
|
<AdminField label="Code" fill>
|
|
<input
|
|
value={code}
|
|
onChange={(event) => setCode(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<label className="admin-switch-field">
|
|
<input
|
|
checked={enabled}
|
|
type="checkbox"
|
|
onChange={(event) => setEnabled(event.target.checked)}
|
|
/>
|
|
<span>启用</span>
|
|
</label>
|
|
</AdminFormRow>
|
|
|
|
<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>
|
|
|
|
<AdminFormRow>
|
|
<AdminField label="奖励泥点">
|
|
<input
|
|
min={1}
|
|
step={1}
|
|
type="number"
|
|
value={rewardPoints}
|
|
onChange={(event) => setRewardPoints(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="最大次数">
|
|
<input
|
|
min={1}
|
|
step={1}
|
|
type="number"
|
|
value={maxUses}
|
|
onChange={(event) => setMaxUses(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
</AdminFormRow>
|
|
|
|
<AdminFormRow>
|
|
<AdminField label="开始时间">
|
|
<input
|
|
type="datetime-local"
|
|
value={startsAt}
|
|
onChange={(event) => setStartsAt(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="截止时间">
|
|
<input
|
|
type="datetime-local"
|
|
value={expiresAt}
|
|
onChange={(event) => setExpiresAt(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
</AdminFormRow>
|
|
|
|
{mode === 'private' ? (
|
|
<AdminFormRow>
|
|
<AdminField label="内部 userId">
|
|
<textarea
|
|
rows={6}
|
|
value={allowedUserIds}
|
|
onChange={(event) => setAllowedUserIds(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="公开陶泥号">
|
|
<textarea
|
|
rows={6}
|
|
value={allowedPublicUserCodes}
|
|
onChange={(event) =>
|
|
setAllowedPublicUserCodes(event.target.value)
|
|
}
|
|
/>
|
|
</AdminField>
|
|
</AdminFormRow>
|
|
) : null}
|
|
|
|
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
|
|
{validityError && validityError !== errorMessage ? (
|
|
<AdminAlert>{validityError}</AdminAlert>
|
|
) : null}
|
|
|
|
<AdminButton
|
|
variant="primary"
|
|
disabled={
|
|
isSaving ||
|
|
!code.trim() ||
|
|
!parsePositiveInteger(rewardPoints) ||
|
|
!parsePositiveInteger(maxUses) ||
|
|
Boolean(validityError)
|
|
}
|
|
type="submit"
|
|
>
|
|
<Save size={17} aria-hidden="true" />
|
|
<span>{isSaving ? '保存中' : '保存'}</span>
|
|
</AdminButton>
|
|
</AdminPanel>
|
|
|
|
<div className="admin-stack">
|
|
<AdminListPanel
|
|
columns={[
|
|
{
|
|
key: 'code',
|
|
label: 'Code',
|
|
sortable: true,
|
|
sortValue: (entry) => entry.code,
|
|
},
|
|
{
|
|
key: 'rewardPoints',
|
|
label: '奖励泥点',
|
|
sortable: true,
|
|
sortValue: (entry) => entry.rewardPoints,
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: '状态',
|
|
sortable: true,
|
|
sortValue: redeemValidityLabel,
|
|
},
|
|
{
|
|
key: 'validity',
|
|
label: '有效期',
|
|
sortable: true,
|
|
sortDescription: '有效期(按截止时间)',
|
|
sortValue: (entry) =>
|
|
entry.expiresAt ?? entry.startsAt ?? '9999',
|
|
},
|
|
]}
|
|
renderRow={(entry) => (
|
|
<tr key={entry.code}>
|
|
<td>
|
|
<AdminButton
|
|
variant="text"
|
|
type="button"
|
|
onClick={() => fillForm(entry)}
|
|
>
|
|
{entry.code}
|
|
</AdminButton>
|
|
<small>{redeemModeLabel(entry.mode)}</small>
|
|
</td>
|
|
<td>{entry.rewardPoints} 泥点</td>
|
|
<td>
|
|
<AdminStatusPill tone={redeemValidityTone(entry)}>
|
|
{redeemValidityLabel(entry)}
|
|
</AdminStatusPill>
|
|
</td>
|
|
<td>
|
|
<small>{formatValidityWindow(entry)}</small>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
title="兑换码列表"
|
|
count={entries.length}
|
|
rows={entries}
|
|
busy={isLoading}
|
|
busyText="加载中"
|
|
emptyText="暂无兑换码"
|
|
size="compact"
|
|
/>
|
|
|
|
<AdminPanel as="form" onSubmit={handleDisable}>
|
|
<AdminField label="停用 Code">
|
|
<input
|
|
value={disableCode}
|
|
onChange={(event) => setDisableCode(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
{disableErrorMessage ? (
|
|
<AdminAlert>{disableErrorMessage}</AdminAlert>
|
|
) : null}
|
|
<AdminButton
|
|
variant="danger"
|
|
disabled={isDisabling || !disableCode.trim()}
|
|
type="submit"
|
|
>
|
|
<PowerOff size={17} aria-hidden="true" />
|
|
<span>{isDisabling ? '停用中' : '停用'}</span>
|
|
</AdminButton>
|
|
</AdminPanel>
|
|
|
|
<AdminListPanel
|
|
columns={[
|
|
{ key: 'action', label: '操作', sortable: true },
|
|
{ key: 'code', label: 'Code', sortable: true },
|
|
{
|
|
key: 'operator',
|
|
label: '操作人',
|
|
sortable: true,
|
|
sortValue: (operation) => operation.operatorDisplayName,
|
|
},
|
|
{ key: 'createdAt', label: '时间', sortable: true },
|
|
]}
|
|
renderRow={(operation) => (
|
|
<tr key={operation.operationId}>
|
|
<td>{operationActionLabel(operation.action)}</td>
|
|
<td>{operation.code}</td>
|
|
<td>{operation.operatorDisplayName}</td>
|
|
<td>{formatAdminDateTime(operation.createdAt)}</td>
|
|
</tr>
|
|
)}
|
|
title="操作记录"
|
|
count={operations.length}
|
|
className="admin-result-panel"
|
|
rows={operations}
|
|
emptyText="暂无记录"
|
|
size="compact"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{confirmDialog}
|
|
</AdminPage>
|
|
);
|
|
}
|
|
|
|
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 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 redeemValidityTone(
|
|
entry: ProfileRedeemCodeAdminResponse,
|
|
): AdminStatusTone {
|
|
const label = redeemValidityLabel(entry);
|
|
if (label === '停用' || label === '已过期') {
|
|
return 'error';
|
|
}
|
|
if (label === '未生效') {
|
|
return 'pending';
|
|
}
|
|
return 'ok';
|
|
}
|
|
|
|
function formatValidityWindow(entry: ProfileRedeemCodeAdminResponse) {
|
|
const startsAt = entry.startsAt
|
|
? formatAdminDateTime(entry.startsAt)
|
|
: '立即';
|
|
const expiresAt = entry.expiresAt
|
|
? formatAdminDateTime(entry.expiresAt)
|
|
: '长期';
|
|
return `${startsAt} / ${expiresAt}`;
|
|
}
|