Files
Genarrative/apps/admin-web/src/pages/AdminInviteCodePage.tsx
T
kdletters 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 全部通过
2026-09-29 22:39:41 +08:00

452 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}