Fix admin SQL count parsing for local SpacetimeDB
This commit is contained in:
163
apps/admin-web/src/app/AdminApp.tsx
Normal file
163
apps/admin-web/src/app/AdminApp.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
import {
|
||||
formatAdminApiError,
|
||||
getAdminMe,
|
||||
isAdminApiError,
|
||||
loginAdmin,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminSessionPayload,
|
||||
ProfileRedeemCodeAdminResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
clearStoredAdminToken,
|
||||
getStoredAdminToken,
|
||||
setStoredAdminToken,
|
||||
} from '../auth/adminAuthStore';
|
||||
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
|
||||
import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
|
||||
import {AdminShell} from './AdminShell';
|
||||
import type {AdminRouteId} from './adminRoutes';
|
||||
import {resolveAdminRoute, routeHash} from './adminRoutes';
|
||||
|
||||
type SessionStatus = 'checking' | 'guest' | 'authenticated';
|
||||
|
||||
export function AdminApp() {
|
||||
const [status, setStatus] = useState<SessionStatus>('checking');
|
||||
const [admin, setAdmin] = useState<AdminSessionPayload | null>(null);
|
||||
const [token, setToken] = useState('');
|
||||
const [routeId, setRouteId] = useState<AdminRouteId>(() =>
|
||||
resolveAdminRoute(window.location.hash),
|
||||
);
|
||||
const [loginNotice, setLoginNotice] = useState('');
|
||||
// 兑换码页会随页签切换卸载,最近操作记录需要放在会话层保留。
|
||||
const [redeemResult, setRedeemResult] =
|
||||
useState<ProfileRedeemCodeAdminResponse | null>(null);
|
||||
|
||||
const clearSession = useCallback((message = '') => {
|
||||
clearStoredAdminToken();
|
||||
setToken('');
|
||||
setAdmin(null);
|
||||
setRedeemResult(null);
|
||||
setStatus('guest');
|
||||
setLoginNotice(message);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const storedToken = getStoredAdminToken();
|
||||
|
||||
if (!storedToken) {
|
||||
setStatus('guest');
|
||||
return;
|
||||
}
|
||||
|
||||
void getAdminMe(storedToken)
|
||||
.then((response) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(storedToken);
|
||||
setAdmin(response.admin);
|
||||
setStatus('authenticated');
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearStoredAdminToken();
|
||||
setToken('');
|
||||
setAdmin(null);
|
||||
setStatus('guest');
|
||||
setLoginNotice(
|
||||
isAdminApiError(error) && error.status === 401
|
||||
? '登录状态已失效'
|
||||
: formatAdminApiError(error),
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
setRouteId(resolveAdminRoute(window.location.hash));
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
const handleRouteChange = useCallback((nextRouteId: AdminRouteId) => {
|
||||
setRouteId(nextRouteId);
|
||||
const nextHash = routeHash(nextRouteId);
|
||||
if (window.location.hash !== nextHash) {
|
||||
window.location.hash = nextHash;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogin = useCallback(async (username: string, password: string) => {
|
||||
const response = await loginAdmin(username, password);
|
||||
setStoredAdminToken(response.token);
|
||||
setToken(response.token);
|
||||
setAdmin(response.admin);
|
||||
setRedeemResult(null);
|
||||
setLoginNotice('');
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const handleUnauthorized = useCallback(
|
||||
(message = '登录状态已失效') => {
|
||||
clearSession(message);
|
||||
},
|
||||
[clearSession],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
clearSession('');
|
||||
}, [clearSession]);
|
||||
|
||||
if (status === 'checking') {
|
||||
return (
|
||||
<main className="admin-loading-screen">
|
||||
<div className="admin-loading-mark" />
|
||||
<span>正在校验会话</span>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'guest' || !admin || !token) {
|
||||
return <AdminLoginPage notice={loginNotice} onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
admin={admin}
|
||||
routeId={routeId}
|
||||
onLogout={handleLogout}
|
||||
onRouteChange={handleRouteChange}
|
||||
>
|
||||
{routeId === 'overview' ? (
|
||||
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{routeId === 'debug' ? (
|
||||
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{routeId === 'redeem' ? (
|
||||
<AdminRedeemCodePage
|
||||
result={redeemResult}
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
onResultChange={setRedeemResult}
|
||||
/>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
108
apps/admin-web/src/app/AdminShell.tsx
Normal file
108
apps/admin-web/src/app/AdminShell.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Bug,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
TicketPercent,
|
||||
} from 'lucide-react';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
import type {AdminSessionPayload} from '../api/adminApiTypes';
|
||||
import type {AdminRouteId} from './adminRoutes';
|
||||
import {adminRoutes} from './adminRoutes';
|
||||
|
||||
interface AdminShellProps {
|
||||
admin: AdminSessionPayload;
|
||||
routeId: AdminRouteId;
|
||||
children: ReactNode;
|
||||
onRouteChange: (routeId: AdminRouteId) => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
const routeIcons = {
|
||||
overview: LayoutDashboard,
|
||||
debug: Bug,
|
||||
redeem: TicketPercent,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
admin,
|
||||
routeId,
|
||||
children,
|
||||
onRouteChange,
|
||||
onLogout,
|
||||
}: AdminShellProps) {
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-brand">
|
||||
<div className="admin-brand-icon">
|
||||
<ShieldCheck size={20} aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<strong>陶泥后台</strong>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="admin-nav" aria-label="后台导航">
|
||||
{adminRoutes.map((route) => {
|
||||
const Icon = routeIcons[route.id];
|
||||
return (
|
||||
<button
|
||||
className="admin-nav-button"
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="admin-main">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-user">
|
||||
<span>{admin.displayName || admin.username}</span>
|
||||
<small>{admin.roles.join(' / ')}</small>
|
||||
</div>
|
||||
<button
|
||||
className="admin-icon-button"
|
||||
title="退出登录"
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut size={18} aria-hidden="true" />
|
||||
<span>退出</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="admin-content">{children}</main>
|
||||
</div>
|
||||
|
||||
<nav className="admin-bottom-nav" aria-label="后台导航">
|
||||
{adminRoutes.map((route) => {
|
||||
const Icon = routeIcons[route.id];
|
||||
return (
|
||||
<button
|
||||
className="admin-bottom-nav-button"
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
>
|
||||
<Icon size={19} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/admin-web/src/app/adminRoutes.ts
Normal file
29
apps/admin-web/src/app/adminRoutes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type AdminRouteId = 'overview' | 'debug' | 'redeem';
|
||||
|
||||
export interface AdminRouteDefinition {
|
||||
id: AdminRouteId;
|
||||
label: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'overview', label: '总览', hash: '#overview'},
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'redeem', label: '兑换码', hash: '#redeem'},
|
||||
];
|
||||
|
||||
export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
const normalizedHash = hash.trim().toLowerCase();
|
||||
return (
|
||||
adminRoutes.find((route) => route.hash === normalizedHash)?.id ??
|
||||
'overview'
|
||||
);
|
||||
}
|
||||
|
||||
export function routeHash(routeId: AdminRouteId) {
|
||||
return (
|
||||
adminRoutes.find((route) => route.id === routeId)?.hash ??
|
||||
adminRoutes[0]?.hash ??
|
||||
'#overview'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user