Merge branch 'codex/web-admin'

# Conflicts:
#	server-rs/crates/api-server/src/admin.rs
This commit is contained in:
2026-05-01 00:58:42 +08:00
30 changed files with 3326 additions and 632 deletions

View File

@@ -0,0 +1,214 @@
import {Plus, Send, Trash2} from 'lucide-react';
import {FormEvent, useMemo, useState} from 'react';
import {debugAdminHttp} from '../api/adminApiClient';
import type {
AdminDebugHeaderInput,
AdminDebugHttpMethod,
AdminDebugHttpResponse,
} from '../api/adminApiTypes';
import {formatUnknownJson, handlePageError} from './pageUtils';
interface AdminDebugHttpPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const httpMethods: AdminDebugHttpMethod[] = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
];
export function AdminDebugHttpPage({
token,
onUnauthorized,
}: AdminDebugHttpPageProps) {
const [method, setMethod] = useState<AdminDebugHttpMethod>('GET');
const [path, setPath] = useState('/healthz');
const [body, setBody] = useState('');
const [headers, setHeaders] = useState<AdminDebugHeaderInput[]>([]);
const [result, setResult] = useState<AdminDebugHttpResponse | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const jsonPreview = useMemo(
() => formatUnknownJson(result?.bodyJson),
[result?.bodyJson],
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSubmitting) {
return;
}
setErrorMessage('');
setIsSubmitting(true);
try {
const response = await debugAdminHttp(token, {
method,
path: path.trim(),
headers: headers.filter((header) => header.name.trim()),
body,
});
setResult(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSubmitting(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2>API </h2>
<p></p>
</div>
</div>
<div className="admin-two-column">
<form className="admin-panel admin-form" onSubmit={handleSubmit}>
<div className="admin-form-row">
<label className="admin-field admin-field-compact">
<span>Method</span>
<select
value={method}
onChange={(event) =>
setMethod(event.target.value as AdminDebugHttpMethod)
}
>
{httpMethods.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<label className="admin-field admin-field-fill">
<span>Path</span>
<input
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
</div>
<section className="admin-subsection">
<div className="admin-subsection-heading">
<span>Headers</span>
<button
className="admin-ghost-button"
type="button"
onClick={() =>
setHeaders((current) => [
...current,
{name: '', value: ''},
])
}
>
<Plus size={16} aria-hidden="true" />
</button>
</div>
<div className="admin-header-editor">
{headers.map((header, index) => (
<div className="admin-header-row" key={index}>
<input
value={header.name}
onChange={(event) =>
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? {...item, name: event.target.value}
: item,
),
)
}
/>
<input
value={header.value}
onChange={(event) =>
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? {...item, value: event.target.value}
: item,
),
)
}
/>
<button
className="admin-ghost-button"
title="移除"
type="button"
onClick={() =>
setHeaders((current) =>
current.filter((_, itemIndex) => itemIndex !== index),
)
}
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
))}
</div>
</section>
<label className="admin-field">
<span>Body</span>
<textarea
rows={9}
value={body}
onChange={(event) => setBody(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSubmitting || !path.trim().startsWith('/')}
type="submit"
>
<Send size={17} aria-hidden="true" />
<span>{isSubmitting ? '发送中' : '发送'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result ? `${result.status} ${result.statusText}` : '-'}</span>
</div>
{result ? (
<>
<dl className="admin-info-list">
<div>
<dt>Status</dt>
<dd>{result.status}</dd>
</div>
<div>
<dt>Headers</dt>
<dd>{result.headers.length}</dd>
</div>
</dl>
<pre className="admin-code-block">
{jsonPreview || result.bodyText || '(empty)'}
</pre>
</>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</section>
);
}

View File

@@ -0,0 +1,158 @@
import {Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {upsertProfileInviteCode} from '../api/adminApiClient';
import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes';
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 [metadataText, setMetadataText] = useState('{}');
const [errorMessage, setErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileInviteCode(token, {
inviteCode: inviteCode.trim(),
metadata: parseMetadata(metadataText),
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<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>
<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}
<button
className="admin-primary-button"
disabled={isSaving || !inviteCode.trim() || !isMetadataReady(metadataText)}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<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>User ID</dt>
<dd>{result.userId}</dd>
</div>
<div>
<dt></dt>
<dd>{result.inviteCode}</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>
</section>
);
}
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 isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,83 @@
import {LockKeyhole, ShieldCheck} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {formatAdminApiError} from '../api/adminApiClient';
interface AdminLoginPageProps {
notice: string;
onLogin: (username: string, password: string) => Promise<void>;
}
export function AdminLoginPage({notice, onLogin}: AdminLoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSubmitting) {
return;
}
setErrorMessage('');
setIsSubmitting(true);
try {
await onLogin(username.trim(), password);
} catch (error: unknown) {
setErrorMessage(formatAdminApiError(error));
} finally {
setIsSubmitting(false);
}
}
return (
<main className="admin-login-screen">
<form className="admin-login-panel" onSubmit={handleSubmit}>
<div className="admin-login-brand">
<div className="admin-brand-icon admin-brand-icon-large">
<ShieldCheck size={26} aria-hidden="true" />
</div>
<div>
<h1></h1>
<span>Admin Console</span>
</div>
</div>
<label className="admin-field">
<span></span>
<input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{notice || errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage || notice}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSubmitting || !username.trim() || !password}
type="submit"
>
<LockKeyhole size={18} aria-hidden="true" />
<span>{isSubmitting ? '登录中' : '登录'}</span>
</button>
</form>
</main>
);
}

View File

@@ -0,0 +1,171 @@
import {RefreshCw} from 'lucide-react';
import {useCallback, useEffect, useState} from 'react';
import {getAdminOverview} from '../api/adminApiClient';
import type {
AdminDatabaseTableStatPayload,
AdminOverviewResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminOverviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
export function AdminOverviewPage({
token,
onUnauthorized,
}: AdminOverviewPageProps) {
const [overview, setOverview] = useState<AdminOverviewResponse | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const loadOverview = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminOverview(token);
setOverview(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [onUnauthorized, token]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
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={() => void loadOverview()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-overview-grid">
<InfoPanel
title="服务"
rows={[
['监听', overview ? `${overview.service.bindHost}:${overview.service.bindPort}` : '-'],
['JWT issuer', overview?.service.jwtIssuer ?? '-'],
['后台', overview?.service.adminEnabled ? '已启用' : '未启用'],
]}
/>
<InfoPanel
title="SpacetimeDB"
rows={[
['Server', overview?.service.spacetimeServerUrl ?? '-'],
['Database', overview?.service.spacetimeDatabase ?? '-'],
['Identity', overview?.database.databaseIdentity ?? '-'],
['Owner', overview?.database.ownerIdentity ?? '-'],
['Host', overview?.database.hostType ?? '-'],
]}
/>
</div>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{overview?.database.schemaTableNames.length ?? 0} tables</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{(overview?.database.tableStats ?? []).map((stat) => (
<TableStatRow key={stat.tableName} stat={stat} />
))}
{overview && overview.database.tableStats.length === 0 ? (
<tr>
<td colSpan={3}></td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
{overview?.database.fetchErrors.length ? (
<section className="admin-panel admin-panel-warning">
<div className="admin-panel-heading">
<h3></h3>
<span>{overview.database.fetchErrors.length}</span>
</div>
<ul className="admin-error-list">
{overview.database.fetchErrors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</section>
) : null}
</section>
);
}
function InfoPanel({
title,
rows,
}: {
title: string;
rows: Array<[string, string]>;
}) {
return (
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>{title}</h3>
</div>
<dl className="admin-info-list">
{rows.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</section>
);
}
function TableStatRow({stat}: {stat: AdminDatabaseTableStatPayload}) {
return (
<tr>
<td>{stat.tableName}</td>
<td>{typeof stat.rowCount === 'number' ? stat.rowCount : '-'}</td>
<td>
{stat.errorMessage ? (
<span className="admin-status admin-status-error">
{stat.errorMessage}
</span>
) : (
<span className="admin-status admin-status-ok">OK</span>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,275 @@
import {PowerOff, Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {
disableProfileRedeemCode,
upsertProfileRedeemCode,
} from '../api/adminApiClient';
import type {
ProfileRedeemCodeAdminResponse,
ProfileRedeemCodeMode,
} from '../api/adminApiTypes';
import {handlePageError, splitLines} from './pageUtils';
interface AdminRedeemCodePageProps {
token: string;
result: ProfileRedeemCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileRedeemCodeAdminResponse) => void;
}
const redeemModes: Array<{value: ProfileRedeemCodeMode; label: string}> = [
{value: 'public', label: '公共码'},
{value: 'unique', label: '唯一码'},
{value: 'private', label: '私有码'},
];
export function AdminRedeemCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminRedeemCodePageProps) {
const [code, setCode] = useState('');
const [mode, setMode] = useState<ProfileRedeemCodeMode>('public');
const [rewardPoints, setRewardPoints] = useState('100');
const [maxUses, setMaxUses] = useState('1');
const [enabled, setEnabled] = useState(true);
const [allowedUserIds, setAllowedUserIds] = useState('');
const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState('');
const [disableCode, setDisableCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileRedeemCode(token, {
code: code.trim(),
mode,
rewardPoints: parsePositiveInteger(rewardPoints),
maxUses: parsePositiveInteger(maxUses),
enabled,
allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [],
allowedPublicUserCodes:
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
async function handleDisable(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isDisabling) {
return;
}
setDisableErrorMessage('');
setIsDisabling(true);
try {
const response = await disableProfileRedeemCode(token, {
code: disableCode.trim(),
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
setIsDisabling(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span>Code</span>
<input
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-segmented-control" role="tablist">
{redeemModes.map((item) => (
<button
data-active={mode === item.value}
key={item.value}
type="button"
onClick={() => setMode(item.value)}
>
{item.label}
</button>
))}
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={rewardPoints}
onChange={(event) => setRewardPoints(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={maxUses}
onChange={(event) => setMaxUses(event.target.value)}
/>
</label>
</div>
{mode === 'private' ? (
<div className="admin-form-row">
<label className="admin-field">
<span> userId</span>
<textarea
rows={6}
value={allowedUserIds}
onChange={(event) => setAllowedUserIds(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<textarea
rows={6}
value={allowedPublicUserCodes}
onChange={(event) =>
setAllowedPublicUserCodes(event.target.value)
}
/>
</label>
</div>
) : null}
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={
isSaving ||
!code.trim() ||
!parsePositiveInteger(rewardPoints) ||
!parsePositiveInteger(maxUses)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<div className="admin-stack">
<form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field">
<span> Code</span>
<input
value={disableCode}
onChange={(event) => setDisableCode(event.target.value)}
/>
</label>
{disableErrorMessage ? (
<div className="admin-alert" role="status">
{disableErrorMessage}
</div>
) : null}
<button
className="admin-danger-button"
disabled={isDisabling || !disableCode.trim()}
type="submit"
>
<PowerOff size={17} aria-hidden="true" />
<span>{isDisabling ? '停用中' : '停用'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.mode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>Code</dt>
<dd>{result.code}</dd>
</div>
<div>
<dt></dt>
<dd>{result.rewardPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.maxUses}</dd>
</div>
<div>
<dt></dt>
<dd>{result.globalUsedCount}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdBy}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</div>
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}

View File

@@ -0,0 +1,33 @@
import {formatAdminApiError, isAdminApiError} from '../api/adminApiClient';
export function handlePageError(
error: unknown,
onUnauthorized: (message?: string) => void,
setError: (message: string) => void,
) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
return;
}
setError(formatAdminApiError(error));
}
export function splitLines(value: string) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
export function formatUnknownJson(value: unknown) {
if (value === null || typeof value === 'undefined') {
return '';
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}