Files
Genarrative/apps/admin-web/src/pages/AdminInviteCodePage.tsx
2026-05-11 16:15:48 +08:00

481 lines
14 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 {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import {
listProfileInviteCodes,
upsertProfileInviteCode,
} from '../api/adminApiClient';
import type {
AdminUpsertProfileInviteCodeRequest,
ProfileInviteCodeAdminResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminInviteCodePageProps {
token: string;
result: ProfileInviteCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileInviteCodeAdminResponse) => void;
}
export function AdminInviteCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: 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 [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);
} 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);
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
function upsertEntry(next: ProfileInviteCodeAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.inviteCode !== next.inviteCode);
return [...rest, next].sort((left, right) => {
const leftUpdatedAt = Date.parse(left.updatedAt);
const rightUpdatedAt = Date.parse(right.updatedAt);
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
if (updatedCompare !== 0) {
return updatedCompare;
}
}
return left.inviteCode.localeCompare(right.inviteCode);
});
});
}
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 (
<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={refreshInviteCodes}
>
<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}>
<label className="admin-field">
<span>Invite Code</span>
<input
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
/>
</label>
<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>
<label className="admin-field">
<span></span>
<input
autoComplete="off"
value={grantedTagsText}
onChange={(event) => setGrantedTagsText(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Metadata JSON</span>
<textarea
rows={10}
spellCheck={false}
value={metadataText}
onChange={(event) => setMetadataText(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{validityError ? (
<div className="admin-alert" role="status">
{validityError}
</div>
) : null}
<button
className="admin-primary-button"
disabled={
isSaving ||
!inviteCode.trim() ||
!isMetadataReady(metadataText) ||
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></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.inviteCode}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.inviteCode}
</button>
</td>
<td>
<TagList tags={metadataUserTags(entry.metadata)} />
</td>
<td>
<span className={`admin-status ${inviteValidityClass(entry)}`}>
{inviteValidityLabel(entry)}
</span>
<small>{formatValidityWindow(entry)}</small>
</td>
<td>{entry.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无邀请码'}
</div>
)}
</section>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.inviteCode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{result.inviteCode}</dd>
</div>
<div>
<dt></dt>
<dd>{formatValidityWindow(result)}</dd>
</div>
<div>
<dt></dt>
<dd>
<TagList tags={metadataUserTags(result.metadata)} />
</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
<pre className="admin-code-block">
{JSON.stringify(result.metadata, null, 2)}
</pre>
</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</div>
{confirmDialog}
</section>
);
}
function TagList({tags}: {tags: string[]}) {
if (!tags.length) {
return <span className="admin-muted-text">-</span>;
}
return (
<span className="admin-tag-list">
{tags.map((tag) => (
<span className="admin-tag" key={tag}>
{tag}
</span>
))}
</span>
);
}
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 inviteValidityClass(entry: ProfileInviteCodeAdminResponse) {
const label = inviteValidityLabel(entry);
if (label === '已过期') {
return 'admin-status-error';
}
if (label === '未生效') {
return 'admin-status-pending';
}
return 'admin-status-ok';
}
function formatValidityWindow(entry: ProfileInviteCodeAdminResponse) {
const startsAt = entry.startsAt ? formatDateTime(entry.startsAt) : '立即';
const expiresAt = entry.expiresAt ? formatDateTime(entry.expiresAt) : '长期';
return `${startsAt} / ${expiresAt}`;
}
function formatDateTime(value: string) {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {hour12: false});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}