Files
Genarrative/apps/admin-web/src/pages/AdminInviteCodePage.tsx
T
kdletters bddc25b4e0 后台支持多账号与页面访问权限
保留环境变量管理员作为 owner,并新增可持久化管理的 member 账号
建立一级页面权限、实时会话失效和后端逐请求鉴权
统一操作记录与配置记录展示管理员显示名称,隐藏内部主体标识
补充账号管理页面、生成绑定、定向测试和技术文档
2026-07-14 19:21:33 +08:00

465 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,
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 (
<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>{operations.length}</span>
</div>
{operations.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>
{operations.map((operation) => (
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorDisplayName}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<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 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);
}