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 全部通过
452 lines
13 KiB
TypeScript
452 lines
13 KiB
TypeScript
import type { AdminStatusTone } from '@genarrative/shared/components';
|
||
import {
|
||
AdminAlert,
|
||
AdminButton,
|
||
AdminField,
|
||
AdminFormRow,
|
||
AdminListPanel,
|
||
AdminPage,
|
||
AdminPageHeading,
|
||
AdminPanel,
|
||
AdminStatusPill,
|
||
AdminTagList,
|
||
formatAdminDateTime,
|
||
} from '@genarrative/shared/components';
|
||
import { RefreshCcw, Save } from 'lucide-react';
|
||
import { FormEvent, useEffect, useState } from 'react';
|
||
|
||
import {
|
||
listProfileInviteCodes,
|
||
upsertProfileInviteCode,
|
||
} from '../api/adminApiClient';
|
||
import type {
|
||
AdminUpsertProfileInviteCodeRequest,
|
||
ProfileCodeOperationAdminResponse,
|
||
ProfileInviteCodeAdminResponse,
|
||
} from '../api/adminApiTypes';
|
||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||
import { handlePageError } from './pageUtils';
|
||
|
||
interface AdminInviteCodePageProps {
|
||
token: string;
|
||
onUnauthorized: (message?: string) => void;
|
||
}
|
||
|
||
export function AdminInviteCodePage({
|
||
token,
|
||
onUnauthorized,
|
||
}: AdminInviteCodePageProps) {
|
||
const [inviteCode, setInviteCode] = useState('');
|
||
const [startsAt, setStartsAt] = useState('');
|
||
const [expiresAt, setExpiresAt] = useState('');
|
||
const [grantedTagsText, setGrantedTagsText] = useState('');
|
||
const [metadataText, setMetadataText] = useState('{}');
|
||
const [errorMessage, setErrorMessage] = useState('');
|
||
const [listErrorMessage, setListErrorMessage] = useState('');
|
||
const [entries, setEntries] = useState<ProfileInviteCodeAdminResponse[]>([]);
|
||
const [operations, setOperations] = useState<
|
||
ProfileCodeOperationAdminResponse[]
|
||
>([]);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
|
||
|
||
useEffect(() => {
|
||
void refreshInviteCodes();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [token]);
|
||
|
||
async function refreshInviteCodes() {
|
||
setIsLoading(true);
|
||
setListErrorMessage('');
|
||
try {
|
||
const response = await listProfileInviteCodes(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: inviteCode.trim(),
|
||
});
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
try {
|
||
const metadata = withMetadataUserTags(
|
||
parseMetadata(metadataText),
|
||
parseUserTags(grantedTagsText),
|
||
);
|
||
const payload: AdminUpsertProfileInviteCodeRequest = {
|
||
inviteCode: inviteCode.trim(),
|
||
metadata,
|
||
startsAt: startsAt ? toIsoDateTime(startsAt) : null,
|
||
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
|
||
};
|
||
const response = await upsertProfileInviteCode(token, payload);
|
||
fillForm(response);
|
||
await refreshInviteCodes();
|
||
} catch (error: unknown) {
|
||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
function fillForm(entry: ProfileInviteCodeAdminResponse) {
|
||
setInviteCode(entry.inviteCode);
|
||
setStartsAt(toDateTimeLocalValue(entry.startsAt));
|
||
setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
|
||
setGrantedTagsText(metadataUserTags(entry.metadata).join('、'));
|
||
setMetadataText(JSON.stringify(entry.metadata, null, 2));
|
||
}
|
||
|
||
const validityError = validateValidityWindow(startsAt, expiresAt);
|
||
|
||
return (
|
||
<AdminPage>
|
||
<AdminPageHeading
|
||
title="邀请码"
|
||
description="注册链路预置码"
|
||
actions={
|
||
<AdminButton
|
||
variant="secondary"
|
||
disabled={isLoading}
|
||
type="button"
|
||
onClick={refreshInviteCodes}
|
||
>
|
||
<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}>
|
||
<AdminField label="Invite Code">
|
||
<input
|
||
autoComplete="off"
|
||
value={inviteCode}
|
||
onChange={(event) => setInviteCode(event.target.value)}
|
||
/>
|
||
</AdminField>
|
||
|
||
<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>
|
||
|
||
<AdminField label="用户标签">
|
||
<input
|
||
autoComplete="off"
|
||
value={grantedTagsText}
|
||
onChange={(event) => setGrantedTagsText(event.target.value)}
|
||
/>
|
||
</AdminField>
|
||
|
||
<AdminField label="Metadata JSON">
|
||
<textarea
|
||
rows={10}
|
||
spellCheck={false}
|
||
value={metadataText}
|
||
onChange={(event) => setMetadataText(event.target.value)}
|
||
/>
|
||
</AdminField>
|
||
|
||
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
|
||
{validityError ? <AdminAlert>{validityError}</AdminAlert> : null}
|
||
|
||
<AdminButton
|
||
variant="primary"
|
||
disabled={
|
||
isSaving ||
|
||
!inviteCode.trim() ||
|
||
!isMetadataReady(metadataText) ||
|
||
Boolean(validityError)
|
||
}
|
||
type="submit"
|
||
>
|
||
<Save size={17} aria-hidden="true" />
|
||
<span>{isSaving ? '保存中' : '保存'}</span>
|
||
</AdminButton>
|
||
</AdminPanel>
|
||
|
||
<div className="admin-stack">
|
||
<AdminListPanel
|
||
busy={isLoading}
|
||
busyText="加载中"
|
||
columns={[
|
||
{ key: 'inviteCode', label: '邀请码', sortable: true },
|
||
{
|
||
key: 'tags',
|
||
label: '标签',
|
||
sortable: true,
|
||
sortValue: (entry) =>
|
||
metadataUserTags(entry.metadata).join(' '),
|
||
},
|
||
{
|
||
key: 'validity',
|
||
label: '有效期',
|
||
sortable: true,
|
||
sortDescription: '有效期(按截止时间)',
|
||
sortValue: (entry) =>
|
||
entry.expiresAt ?? entry.startsAt ?? '9999',
|
||
},
|
||
{
|
||
key: 'createdAt',
|
||
label: '创建',
|
||
sortable: true,
|
||
sortDescription: '创建时间',
|
||
},
|
||
]}
|
||
count={entries.length}
|
||
emptyText="暂无邀请码"
|
||
renderRow={(entry) => (
|
||
<tr key={entry.inviteCode}>
|
||
<td>
|
||
<AdminButton
|
||
variant="text"
|
||
type="button"
|
||
onClick={() => fillForm(entry)}
|
||
>
|
||
{entry.inviteCode}
|
||
</AdminButton>
|
||
</td>
|
||
<td>
|
||
<AdminTagList tags={metadataUserTags(entry.metadata)} />
|
||
</td>
|
||
<td>
|
||
<AdminStatusPill tone={inviteValidityTone(entry)}>
|
||
{inviteValidityLabel(entry)}
|
||
</AdminStatusPill>
|
||
<small>{formatValidityWindow(entry)}</small>
|
||
</td>
|
||
<td>{formatAdminDateTime(entry.createdAt)}</td>
|
||
</tr>
|
||
)}
|
||
rows={entries}
|
||
size="compact"
|
||
title="邀请码列表"
|
||
/>
|
||
|
||
<AdminListPanel
|
||
title="操作记录"
|
||
count={operations.length}
|
||
className="admin-result-panel"
|
||
columns={[
|
||
{ key: 'action', label: '操作', sortable: true },
|
||
{ key: 'code', label: '邀请码', sortable: true },
|
||
{
|
||
key: 'operator',
|
||
label: '操作人',
|
||
sortable: true,
|
||
sortValue: (operation) => operation.operatorDisplayName,
|
||
},
|
||
{ key: 'createdAt', label: '时间', sortable: true },
|
||
]}
|
||
emptyText="暂无记录"
|
||
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>
|
||
)}
|
||
rows={operations}
|
||
size="compact"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{confirmDialog}
|
||
</AdminPage>
|
||
);
|
||
}
|
||
|
||
function metadataUserTags(metadata: Record<string, unknown>) {
|
||
const raw = metadata.userTags ?? metadata.user_tags;
|
||
if (!Array.isArray(raw)) {
|
||
return [];
|
||
}
|
||
|
||
return parseUserTags(
|
||
raw
|
||
.filter((value): value is string => typeof value === 'string')
|
||
.join('、'),
|
||
);
|
||
}
|
||
|
||
function withMetadataUserTags(
|
||
metadata: Record<string, unknown>,
|
||
tags: string[],
|
||
): Record<string, unknown> {
|
||
const next = { ...metadata };
|
||
delete next.user_tags;
|
||
if (tags.length) {
|
||
next.userTags = tags;
|
||
} else {
|
||
delete next.userTags;
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function parseUserTags(value: string) {
|
||
const tags: string[] = [];
|
||
for (const raw of value.split(/[\n,,;;、]+/)) {
|
||
const tag = raw.trim();
|
||
if (tag && !tags.includes(tag)) {
|
||
tags.push(tag);
|
||
}
|
||
}
|
||
return tags;
|
||
}
|
||
|
||
function parseMetadata(value: string): Record<string, unknown> {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) {
|
||
return {};
|
||
}
|
||
|
||
const parsed = JSON.parse(trimmed) as unknown;
|
||
if (!isRecord(parsed)) {
|
||
throw new Error('Metadata 必须是 JSON 对象');
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function isMetadataReady(value: string) {
|
||
try {
|
||
parseMetadata(value);
|
||
return true;
|
||
} catch {
|
||
return 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 inviteValidityLabel(entry: ProfileInviteCodeAdminResponse) {
|
||
const now = Date.now();
|
||
const startsAtTime = entry.startsAt ? Date.parse(entry.startsAt) : null;
|
||
const expiresAtTime = entry.expiresAt ? Date.parse(entry.expiresAt) : null;
|
||
|
||
if (startsAtTime && Number.isFinite(startsAtTime) && now < startsAtTime) {
|
||
return '未生效';
|
||
}
|
||
if (expiresAtTime && Number.isFinite(expiresAtTime) && now >= expiresAtTime) {
|
||
return '已过期';
|
||
}
|
||
if (entry.startsAt || entry.expiresAt) {
|
||
return '有效';
|
||
}
|
||
return '长期有效';
|
||
}
|
||
|
||
function inviteValidityTone(
|
||
entry: ProfileInviteCodeAdminResponse,
|
||
): AdminStatusTone {
|
||
const label = inviteValidityLabel(entry);
|
||
if (label === '已过期') {
|
||
return 'error';
|
||
}
|
||
if (label === '未生效') {
|
||
return 'pending';
|
||
}
|
||
return 'ok';
|
||
}
|
||
|
||
function formatValidityWindow(entry: ProfileInviteCodeAdminResponse) {
|
||
const startsAt = entry.startsAt
|
||
? formatAdminDateTime(entry.startsAt)
|
||
: '立即';
|
||
const expiresAt = entry.expiresAt
|
||
? formatAdminDateTime(entry.expiresAt)
|
||
: '长期';
|
||
return `${startsAt} / ${expiresAt}`;
|
||
}
|
||
|
||
function operationActionLabel(action: string) {
|
||
if (action === 'create') {
|
||
return '新增';
|
||
}
|
||
if (action === 'update') {
|
||
return '更新';
|
||
}
|
||
if (action === 'disable') {
|
||
return '停用';
|
||
}
|
||
return action;
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|