Fix admin SQL count parsing for local SpacetimeDB

This commit is contained in:
2026-05-01 00:36:42 +08:00
parent 89e7bdbed6
commit 28b77a5ff5
29 changed files with 3064 additions and 581 deletions

12
apps/admin-web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>陶泥后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{
"name": "@genarrative/admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "~5.8.2"
}
}

View File

@@ -0,0 +1,222 @@
import type {
AdminDebugHttpRequest,
AdminDebugHttpResponse,
AdminDisableProfileRedeemCodeRequest,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminUpsertProfileRedeemCodeRequest,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
ProfileRedeemCodeAdminResponse,
} from './adminApiTypes';
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
const ADMIN_API_BASE_URL = normalizeBaseUrl(
import.meta.env.VITE_ADMIN_API_BASE_URL ?? '',
);
interface AdminRequestOptions {
method?: string;
token?: string;
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class AdminApiError extends Error {
status: number;
code: string;
details: Record<string, unknown> | null;
meta: ApiMeta | null;
responseText: string;
constructor(params: {
message: string;
status: number;
code?: string;
details?: Record<string, unknown> | null;
meta?: ApiMeta | null;
responseText?: string;
}) {
super(params.message);
this.name = 'AdminApiError';
this.status = params.status;
this.code = params.code ?? 'ADMIN_API_ERROR';
this.details = params.details ?? null;
this.meta = params.meta ?? null;
this.responseText = params.responseText ?? '';
}
}
export function isAdminApiError(error: unknown): error is AdminApiError {
return error instanceof AdminApiError;
}
export function formatAdminApiError(error: unknown) {
if (isAdminApiError(error)) {
return error.message;
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return '请求失败';
}
export async function request<T>(
path: string,
options: AdminRequestOptions = {},
): Promise<T> {
const method = options.method ?? 'GET';
const headers: Record<string, string> = {
Accept: 'application/json',
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
...(options.headers ?? {}),
};
const token = options.token?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const init: RequestInit = {
method,
headers,
signal: options.signal,
};
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
}
const response = await fetch(buildRequestUrl(path), init);
const responseText = await response.text();
const payload = parseJsonResponse(responseText);
if (!response.ok) {
throw buildAdminApiError(response, payload, responseText);
}
return unwrapSuccessPayload<T>(payload);
}
export function loginAdmin(username: string, password: string) {
return request<AdminLoginResponse>('/admin/api/login', {
method: 'POST',
body: {username, password},
});
}
export function getAdminMe(token: string) {
return request<AdminMeResponse>('/admin/api/me', {token});
}
export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', {token});
}
export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
method: 'POST',
token,
body: payload,
});
}
export function upsertProfileRedeemCode(
token: string,
payload: AdminUpsertProfileRedeemCodeRequest,
) {
return request<ProfileRedeemCodeAdminResponse>(
'/admin/api/profile/redeem-codes',
{
method: 'POST',
token,
body: payload,
},
);
}
export function disableProfileRedeemCode(
token: string,
payload: AdminDisableProfileRedeemCodeRequest,
) {
return request<ProfileRedeemCodeAdminResponse>(
'/admin/api/profile/redeem-codes/disable',
{
method: 'POST',
token,
body: payload,
},
);
}
function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
function buildRequestUrl(path: string) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${ADMIN_API_BASE_URL}${normalizedPath}`;
}
function parseJsonResponse(responseText: string): unknown {
if (!responseText.trim()) {
return null;
}
try {
return JSON.parse(responseText) as unknown;
} catch {
return null;
}
}
function unwrapSuccessPayload<T>(payload: unknown): T {
if (isRecord(payload) && 'data' in payload) {
return (payload as ApiSuccessEnvelope<T>).data as T;
}
return payload as T;
}
function buildAdminApiError(
response: Response,
payload: unknown,
responseText: string,
) {
const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null;
const errorPayload = envelope?.error;
const details = isRecord(errorPayload?.details)
? errorPayload.details
: null;
const detailsMessage =
typeof details?.message === 'string' ? details.message.trim() : '';
const payloadMessage =
typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : '';
const topLevelMessage =
typeof envelope?.message === 'string' ? envelope.message.trim() : '';
const message =
detailsMessage ||
payloadMessage ||
topLevelMessage ||
response.statusText ||
`HTTP ${response.status}`;
return new AdminApiError({
message,
status: response.status,
code: errorPayload?.code,
details,
meta: envelope?.meta ?? null,
responseText,
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,135 @@
export interface ApiMeta {
apiVersion?: string;
requestId?: string;
routeVersion?: string;
operation?: string;
latencyMs?: number;
timestamp?: string;
}
export interface ApiErrorPayload {
code?: string;
message?: string;
details?: {
message?: string;
[key: string]: unknown;
} | null;
}
export interface ApiSuccessEnvelope<T> {
ok?: boolean;
data?: T;
error?: ApiErrorPayload | null;
meta?: ApiMeta;
}
export interface ApiErrorEnvelope {
ok?: boolean;
data?: unknown;
error?: ApiErrorPayload;
meta?: ApiMeta;
message?: string;
}
export interface AdminSessionPayload {
subject: string;
username: string;
displayName: string;
roles: string[];
issuedAt: string;
expiresAt: string;
}
export interface AdminLoginResponse {
token: string;
admin: AdminSessionPayload;
}
export interface AdminMeResponse {
admin: AdminSessionPayload;
}
export interface AdminOverviewResponse {
service: AdminServiceOverviewPayload;
database: AdminDatabaseOverviewPayload;
}
export interface AdminServiceOverviewPayload {
bindHost: string;
bindPort: number;
jwtIssuer: string;
adminEnabled: boolean;
spacetimeServerUrl: string;
spacetimeDatabase: string;
}
export interface AdminDatabaseOverviewPayload {
databaseIdentity: string | null;
ownerIdentity: string | null;
hostType: string | null;
schemaTableNames: string[];
tableStats: AdminDatabaseTableStatPayload[];
fetchErrors: string[];
}
export interface AdminDatabaseTableStatPayload {
tableName: string;
rowCount: number | null;
errorMessage: string | null;
}
export interface AdminDebugHeaderInput {
name: string;
value: string;
}
export type AdminDebugHttpMethod =
| 'GET'
| 'POST'
| 'PUT'
| 'PATCH'
| 'DELETE';
export interface AdminDebugHttpRequest {
method: AdminDebugHttpMethod;
path: string;
headers?: AdminDebugHeaderInput[];
body?: string;
}
export interface AdminDebugHttpResponse {
status: number;
statusText: string;
headers: AdminDebugHeaderInput[];
bodyText: string;
bodyJson: unknown | null;
}
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
enabled: boolean;
allowedUserIds: string[];
allowedPublicUserCodes: string[];
}
export interface AdminDisableProfileRedeemCodeRequest {
code: string;
}
export interface ProfileRedeemCodeAdminResponse {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
globalUsedCount: number;
enabled: boolean;
allowedUserIds: string[];
createdBy: string;
createdAt: string;
updatedAt: string;
}

View 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>
);
}

View 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>
);
}

View 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'
);
}

View File

@@ -0,0 +1,34 @@
export const ADMIN_TOKEN_STORAGE_KEY = 'genarrative_admin_token';
// 管理员 token 与玩家 token 分开保存,避免后台请求误复用玩家登录态。
export function getStoredAdminToken() {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim() || '';
}
export function setStoredAdminToken(token: string) {
if (!canUseLocalStorage()) {
return;
}
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, nextToken);
return;
}
window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
}
export function clearStoredAdminToken() {
setStoredAdminToken('');
}
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}

View File

@@ -0,0 +1,18 @@
import './styles/admin.css';
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {AdminApp} from './app/AdminApp';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Missing #root container');
}
createRoot(rootElement).render(
<StrictMode>
<AdminApp />
</StrictMode>,
);

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,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);
}
}

View File

@@ -0,0 +1,661 @@
:root {
color: #17212b;
background: #eef3f6;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background: #eef3f6;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
#root {
min-height: 100vh;
}
.admin-loading-screen,
.admin-login-screen {
display: grid;
min-height: 100dvh;
place-items: center;
padding: 20px;
}
.admin-loading-screen {
gap: 12px;
color: #5c6b77;
}
.admin-loading-mark {
width: 24px;
height: 24px;
border: 3px solid #d1dde6;
border-top-color: #126e82;
border-radius: 50%;
animation: admin-spin 0.8s linear infinite;
}
.admin-login-screen {
background:
linear-gradient(145deg, rgba(18, 110, 130, 0.12), transparent 36%),
linear-gradient(315deg, rgba(165, 94, 54, 0.12), transparent 34%),
#eef3f6;
}
.admin-login-panel,
.admin-panel {
border: 1px solid #d8e2e8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 36px rgba(23, 33, 43, 0.08);
}
.admin-login-panel {
display: grid;
width: min(100%, 420px);
gap: 18px;
padding: 28px;
}
.admin-login-brand,
.admin-brand {
display: flex;
align-items: center;
gap: 12px;
}
.admin-login-brand h1 {
margin: 0;
color: #17212b;
font-size: 26px;
line-height: 1.16;
}
.admin-login-brand span,
.admin-brand span {
color: #667682;
font-size: 12px;
}
.admin-brand-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid #bcd2db;
border-radius: 8px;
color: #126e82;
background: #e7f3f5;
}
.admin-brand-icon-large {
width: 48px;
height: 48px;
}
.admin-shell {
display: grid;
min-height: 100dvh;
grid-template-columns: 232px minmax(0, 1fr);
}
.admin-sidebar {
display: flex;
flex-direction: column;
gap: 24px;
border-right: 1px solid #d8e2e8;
background: #ffffff;
padding: 22px 18px;
}
.admin-brand strong {
display: block;
font-size: 17px;
}
.admin-nav {
display: grid;
gap: 8px;
}
.admin-nav-button,
.admin-bottom-nav-button,
.admin-icon-button,
.admin-primary-button,
.admin-secondary-button,
.admin-danger-button,
.admin-ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
white-space: nowrap;
}
.admin-nav-button {
justify-content: flex-start;
gap: 10px;
min-height: 42px;
padding: 0 12px;
color: #52616d;
background: transparent;
}
.admin-nav-button[data-active="true"] {
color: #0f5666;
background: #e7f3f5;
}
.admin-main {
display: grid;
min-width: 0;
grid-template-rows: 64px minmax(0, 1fr);
}
.admin-topbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
border-bottom: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.86);
padding: 0 24px;
}
.admin-user {
display: grid;
justify-items: end;
gap: 2px;
}
.admin-user span {
font-weight: 650;
}
.admin-user small {
color: #667682;
}
.admin-content {
min-width: 0;
padding: 24px;
overflow: auto;
}
.admin-page {
display: grid;
gap: 18px;
max-width: 1180px;
margin: 0 auto;
}
.admin-page-heading,
.admin-panel-heading,
.admin-subsection-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.admin-page-heading h2,
.admin-panel-heading h3 {
margin: 0;
color: #17212b;
}
.admin-page-heading h2 {
font-size: 25px;
}
.admin-page-heading p {
margin: 3px 0 0;
color: #667682;
}
.admin-panel {
display: grid;
gap: 16px;
min-width: 0;
padding: 18px;
}
.admin-panel-heading h3 {
font-size: 16px;
}
.admin-panel-heading span {
color: #667682;
font-size: 13px;
}
.admin-panel-warning {
border-color: #efc894;
background: #fffaf3;
}
.admin-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.admin-two-column {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
gap: 16px;
align-items: start;
}
.admin-two-column-wide {
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
}
.admin-stack,
.admin-form {
display: grid;
gap: 16px;
}
.admin-form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.admin-field {
display: grid;
min-width: 0;
gap: 7px;
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-field-fill {
min-width: 0;
}
.admin-field-compact {
max-width: 160px;
}
.admin-field input,
.admin-field select,
.admin-field textarea {
width: 100%;
min-height: 42px;
border: 1px solid #cbd8e0;
border-radius: 8px;
color: #17212b;
background: #fbfdfe;
padding: 9px 11px;
outline: none;
}
.admin-field textarea {
min-height: 112px;
resize: vertical;
}
.admin-field input:focus,
.admin-field select:focus,
.admin-field textarea:focus {
border-color: #126e82;
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
}
.admin-switch-field {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 42px;
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-switch-field input {
width: 18px;
height: 18px;
accent-color: #126e82;
}
.admin-primary-button,
.admin-secondary-button,
.admin-danger-button,
.admin-icon-button {
gap: 8px;
min-height: 40px;
padding: 0 14px;
font-weight: 700;
}
.admin-primary-button {
color: #ffffff;
background: #126e82;
}
.admin-secondary-button,
.admin-icon-button {
border: 1px solid #cbd8e0;
color: #2f4550;
background: #ffffff;
}
.admin-danger-button {
color: #ffffff;
background: #a44242;
}
.admin-ghost-button {
width: 34px;
height: 34px;
color: #52616d;
background: #eef3f6;
}
.admin-alert {
border: 1px solid #efc0bd;
border-radius: 8px;
color: #8a2f2f;
background: #fff4f3;
padding: 10px 12px;
font-size: 13px;
}
.admin-info-list {
display: grid;
gap: 10px;
margin: 0;
}
.admin-info-list div {
display: grid;
grid-template-columns: minmax(90px, 0.34fr) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
}
.admin-info-list dt {
color: #667682;
font-size: 12px;
}
.admin-info-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #17212b;
font-size: 13px;
font-weight: 650;
}
.admin-table-wrap {
overflow: auto;
}
.admin-table {
width: 100%;
min-width: 620px;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
border-bottom: 1px solid #e4edf2;
padding: 10px;
text-align: left;
vertical-align: top;
}
.admin-table th {
color: #667682;
font-size: 12px;
}
.admin-status {
display: inline-flex;
max-width: 460px;
border-radius: 999px;
padding: 3px 8px;
font-size: 12px;
overflow-wrap: anywhere;
}
.admin-status-ok {
color: #17623c;
background: #e6f5ed;
}
.admin-status-error {
color: #8a2f2f;
background: #fff1ef;
}
.admin-error-list {
display: grid;
gap: 8px;
margin: 0;
padding-left: 18px;
color: #8a5a1b;
overflow-wrap: anywhere;
}
.admin-subsection {
display: grid;
gap: 10px;
}
.admin-subsection-heading {
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-header-editor {
display: grid;
gap: 8px;
}
.admin-header-row {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr) 34px;
gap: 8px;
align-items: center;
}
.admin-header-row input {
min-width: 0;
min-height: 38px;
border: 1px solid #cbd8e0;
border-radius: 8px;
padding: 8px 10px;
}
.admin-result-panel {
min-height: 260px;
}
.admin-code-block {
max-height: 520px;
margin: 0;
overflow: auto;
border: 1px solid #dce6ec;
border-radius: 8px;
background: #17212b;
color: #e9f2f4;
padding: 14px;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.admin-empty-state {
display: grid;
min-height: 140px;
place-items: center;
border: 1px dashed #cbd8e0;
border-radius: 8px;
color: #667682;
background: #fbfdfe;
}
.admin-segmented-control {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
border: 1px solid #d8e2e8;
border-radius: 8px;
background: #eef3f6;
padding: 4px;
}
.admin-segmented-control button {
min-height: 36px;
border: 0;
border-radius: 6px;
color: #52616d;
background: transparent;
font-weight: 700;
}
.admin-segmented-control button[data-active="true"] {
color: #0f5666;
background: #ffffff;
box-shadow: 0 2px 8px rgba(23, 33, 43, 0.08);
}
.admin-bottom-nav {
display: none;
}
@keyframes admin-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
display: none;
}
.admin-main {
grid-template-rows: 58px minmax(0, 1fr);
}
.admin-topbar {
justify-content: space-between;
padding: 0 14px;
}
.admin-content {
padding: 16px 14px 86px;
}
.admin-overview-grid,
.admin-two-column,
.admin-two-column-wide,
.admin-form-row {
grid-template-columns: 1fr;
}
.admin-field-compact {
max-width: none;
}
.admin-bottom-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.94);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
backdrop-filter: blur(10px);
}
.admin-bottom-nav-button {
display: grid;
gap: 4px;
min-height: 48px;
color: #667682;
background: transparent;
font-size: 11px;
font-weight: 700;
}
.admin-bottom-nav-button[data-active="true"] {
color: #0f5666;
background: #e7f3f5;
}
}
@media (max-width: 560px) {
.admin-login-panel,
.admin-panel {
padding: 16px;
}
.admin-login-brand h1,
.admin-page-heading h2 {
font-size: 22px;
}
.admin-info-list div {
grid-template-columns: 1fr;
gap: 3px;
}
.admin-header-row {
grid-template-columns: 1fr;
}
.admin-header-row .admin-ghost-button {
width: 100%;
}
.admin-icon-button span {
display: none;
}
}

9
apps/admin-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ADMIN_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,43 @@
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import react from '@vitejs/plugin-react';
import {defineConfig, loadEnv} from 'vite';
const adminWebRoot = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(adminWebRoot, '../..');
export default defineConfig(({mode}) => {
const repoEnv = loadEnv(mode, repoRoot, '');
const appEnv = loadEnv(mode, adminWebRoot, '');
const env = {...repoEnv, ...appEnv};
const apiTarget =
env.ADMIN_API_TARGET ||
env.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
return {
root: adminWebRoot,
envDir: repoRoot,
plugins: [react()],
server: {
proxy: {
'/admin/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
'/healthz': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 600,
},
};
});