Merge branch 'codex/web-admin'
# Conflicts: # server-rs/crates/api-server/src/admin.rs
This commit is contained in:
214
apps/admin-web/src/pages/AdminDebugHttpPage.tsx
Normal file
214
apps/admin-web/src/pages/AdminDebugHttpPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
apps/admin-web/src/pages/AdminInviteCodePage.tsx
Normal file
158
apps/admin-web/src/pages/AdminInviteCodePage.tsx
Normal 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);
|
||||
}
|
||||
83
apps/admin-web/src/pages/AdminLoginPage.tsx
Normal file
83
apps/admin-web/src/pages/AdminLoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/admin-web/src/pages/AdminOverviewPage.tsx
Normal file
171
apps/admin-web/src/pages/AdminOverviewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
275
apps/admin-web/src/pages/AdminRedeemCodePage.tsx
Normal file
275
apps/admin-web/src/pages/AdminRedeemCodePage.tsx
Normal 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;
|
||||
}
|
||||
33
apps/admin-web/src/pages/pageUtils.ts
Normal file
33
apps/admin-web/src/pages/pageUtils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user