481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
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);
|
||
}
|