Fix admin SQL count parsing for local SpacetimeDB
This commit is contained in:
11
.env.local
11
.env.local
@@ -50,6 +50,15 @@ ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
|
|||||||
RUST_SERVER_TARGET="http://127.0.0.1:3100"
|
RUST_SERVER_TARGET="http://127.0.0.1:3100"
|
||||||
GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
|
GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
|
||||||
|
|
||||||
|
GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101"
|
||||||
|
GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr"
|
||||||
|
GENARRATIVE_SPACETIME_TOKEN=""
|
||||||
|
|
||||||
GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com"
|
GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com"
|
||||||
GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr"
|
GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr"
|
||||||
GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUtQRzVXQlhTTjVCRDAyTjBNSlNONFJCNyIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NjUxMjAyMiwiZXhwIjoxODM5NTg0MDIyfQ.UguSQDajalekrqs9oiUqLZiWjWK7VTgMQfdLVOhBQZpKX0VYUhNMSok9oBMJ4X655_NxV5TUUXZ4ON4HSJZrMMPc9aZyhS1b3i36vqI_zMPwLrAgfb1MqY5o0wNFl6Y0m0UQ3nsu7ZYxmxxgzF4My7So0Pv75QfXFS3-Uq1-QyO7lCxxgQ6vySbP_PEr7FZJsdPkNvAfP7mTaUh0yaV6SI7jXBsZ_mfdcWtElCNuvR9J3hvfAbx1qyeTgCJtgH4kNhiEOEIAYEFMEQkXd4rdLszmEgtlFubYZPsbMgqeZKx73feU6eGxlYhyPiRHF4AdosIfk3x2MAm_WzOd3efXDQ"
|
GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUsyN05YUjBaQkRUVEVCNlFQQjFXNzU2MiIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NzU1NTQ1NiwiZXhwIjoxODQwNjI3NDU2fQ.iy5qN-3lGPQnkya-wsABtqEgRk1VM2XGxTfxuLV5-eTMfX8cR20sWSx7pnoZcLEwYOkz6cEOb4krhMJmTeBax9Z114o_iwISau3wjjHbeKL9or-039zfYfKb3TtJo3_DZaJSu-ECcMZNl4P1zLmtoRSwl-_AMET4sGzPw0_qR-e49_QGDJz1EEhr7aphybl1xCejCebM8XiJjaRz48vL7-lkwBl90uP-0h7Xx8ToTT2h1egmlcYAvaJalVLHIQqzyYxPUT_Zw9TW7VYExZLhJWdGpQzEm0aXZ2fbch9qVrKpZP2xQ9YjppuLxUFFJeQwhmFf6yc67s6J7LqNvL2-ZA"
|
||||||
|
|
||||||
|
# admin
|
||||||
|
GENARRATIVE_ADMIN_USERNAME=admin
|
||||||
|
GENARRATIVE_ADMIN_PASSWORD=123456
|
||||||
|
ADMIN_API_TARGET=http://127.0.0.1:8082
|
||||||
12
apps/admin-web/index.html
Normal file
12
apps/admin-web/index.html
Normal 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>
|
||||||
24
apps/admin-web/package.json
Normal file
24
apps/admin-web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
222
apps/admin-web/src/api/adminApiClient.ts
Normal file
222
apps/admin-web/src/api/adminApiClient.ts
Normal 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);
|
||||||
|
}
|
||||||
135
apps/admin-web/src/api/adminApiTypes.ts
Normal file
135
apps/admin-web/src/api/adminApiTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/admin-web/src/auth/adminAuthStore.ts
Normal file
34
apps/admin-web/src/auth/adminAuthStore.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/admin-web/src/main.tsx
Normal file
18
apps/admin-web/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
661
apps/admin-web/src/styles/admin.css
Normal file
661
apps/admin-web/src/styles/admin.css
Normal 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
9
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_ADMIN_API_BASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
19
apps/admin-web/tsconfig.json
Normal file
19
apps/admin-web/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
43
apps/admin-web/vite.config.ts
Normal file
43
apps/admin-web/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
|
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
|
||||||
- [参考目录](./reference/README.md):脚本/Function 速查入口。
|
- [参考目录](./reference/README.md):脚本/Function 速查入口。
|
||||||
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
|
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
|
||||||
- [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)。
|
- [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)。
|
||||||
|
|
||||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和 Jenkins 数据库迁移流水线见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md)。
|
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和 Jenkins 数据库迁移流水线见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||||
|
|
||||||
## 推荐阅读顺序
|
## 推荐阅读顺序
|
||||||
|
|
||||||
|
|||||||
153
docs/prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md
Normal file
153
docs/prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# 后台管理独立前端工程 PRD
|
||||||
|
|
||||||
|
日期:`2026-04-30`
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
本 PRD 定义当前项目的后台管理独立前端工程 v1。目标不是重做一套后端,也不是把玩家端前端改造成后台,而是在 monorepo 内新增一个独立管理端应用,正式承接已经从 `api-server` 内嵌页面移出的后台 UI。
|
||||||
|
|
||||||
|
首版目标:
|
||||||
|
|
||||||
|
1. 后台 UI 独立落在 `apps/admin-web`,不再写入 Rust 源码字符串。
|
||||||
|
2. 管理数据、业务规则、权限校验和写操作继续统一走 `server-rs/crates/api-server`。
|
||||||
|
3. v1 只接管已有管理能力:管理员登录、当前管理员信息、服务/数据库概览、受控 API 调试、兑换码管理。
|
||||||
|
4. 保持管理端清爽、可扫读、移动端可用,不在界面堆大段规则说明。
|
||||||
|
|
||||||
|
## 2. 用户与使用场景
|
||||||
|
|
||||||
|
### 2.1 目标用户
|
||||||
|
|
||||||
|
1. 项目开发者:用于本地或测试环境快速查看 `api-server` 与 SpacetimeDB 连接状态。
|
||||||
|
2. 运维/运营人员:用于创建、更新、停用兑换码,以及确认后台接口是否可用。
|
||||||
|
3. 后续后台功能开发者:以本工程作为后续用户、作品、资产、审核等管理模块的承载壳。
|
||||||
|
|
||||||
|
### 2.2 首版高频场景
|
||||||
|
|
||||||
|
1. 管理员打开后台登录页,输入环境变量配置的管理员用户名和密码。
|
||||||
|
2. 登录后进入总览页,确认当前服务监听、JWT issuer、SpacetimeDB server/database、schema 表数量和关键表统计。
|
||||||
|
3. 在 API 调试页对当前 `api-server` 的同源相对路径发起受控请求,排查接口状态。
|
||||||
|
4. 在兑换码管理页创建或更新公共码、唯一码、私有码,并在需要时停用兑换码。
|
||||||
|
|
||||||
|
## 3. 功能范围
|
||||||
|
|
||||||
|
### 3.1 包含
|
||||||
|
|
||||||
|
1. 独立前端工程骨架:`apps/admin-web`。
|
||||||
|
2. 登录页:
|
||||||
|
- 调用 `POST /admin/api/login`。
|
||||||
|
- 登录成功后保存管理员 Bearer token。
|
||||||
|
- 登录失败直接展示后端返回的错误消息。
|
||||||
|
3. 管理端 Shell:
|
||||||
|
- 顶部显示当前管理员、登录状态和退出按钮。
|
||||||
|
- 侧边或底部导航包含“总览”“API 调试”“兑换码”。
|
||||||
|
- 移动端导航改为底部或顶部紧凑标签。
|
||||||
|
4. 总览页:
|
||||||
|
- 调用 `GET /admin/api/me` 恢复管理员会话。
|
||||||
|
- 调用 `GET /admin/api/overview` 展示服务和数据库概览。
|
||||||
|
- 表统计允许部分失败,并直接展示失败项。
|
||||||
|
5. API 调试页:
|
||||||
|
- 调用 `POST /admin/api/debug/http`。
|
||||||
|
- 只允许填写同源相对路径,不提供绝对 URL 输入暗示。
|
||||||
|
- 调试结果在独立结果面板展示状态码、响应头、响应文本和 JSON 预览。
|
||||||
|
6. 兑换码管理页:
|
||||||
|
- 调用 `POST /admin/api/profile/redeem-codes` 创建/更新兑换码。
|
||||||
|
- 调用 `POST /admin/api/profile/redeem-codes/disable` 停用兑换码。
|
||||||
|
- 支持 `public`、`unique`、`private` 三种模式。
|
||||||
|
- 私有码支持输入内部 `userId` 和公开陶泥号,提交给后端统一解析。
|
||||||
|
|
||||||
|
### 3.2 不包含
|
||||||
|
|
||||||
|
1. 不新建后台专用后端服务、BFF、Express 服务或 PostgreSQL 数据面。
|
||||||
|
2. 不让管理端直接连接 SpacetimeDB TypeScript SDK。
|
||||||
|
3. 不新增 SpacetimeDB 表结构。
|
||||||
|
4. 不实现完整用户管理、作品审核、资产审核、充值订单后台。
|
||||||
|
5. 不实现多角色权限体系、管理员 refresh session、多端会话管理。
|
||||||
|
6. 不保留 `GET /admin` 同源内嵌页面作为正式后台入口。
|
||||||
|
|
||||||
|
## 4. 页面与交互要求
|
||||||
|
|
||||||
|
### 4.1 登录页
|
||||||
|
|
||||||
|
登录页只保留必要输入和状态反馈:
|
||||||
|
|
||||||
|
1. 用户名输入框。
|
||||||
|
2. 密码输入框。
|
||||||
|
3. 登录按钮。
|
||||||
|
4. 登录失败状态。
|
||||||
|
5. 后台未启用状态。
|
||||||
|
|
||||||
|
后台未启用时,前端根据 `POST /admin/api/login` 的 `503` 响应展示不可登录状态,不额外引导用户在页面配置环境变量。
|
||||||
|
|
||||||
|
### 4.2 总览页
|
||||||
|
|
||||||
|
总览页必须能快速扫读:
|
||||||
|
|
||||||
|
1. 服务状态:监听地址、端口、JWT issuer。
|
||||||
|
2. SpacetimeDB 配置:server URL、database。
|
||||||
|
3. 数据库元信息:database identity、owner identity、host type。
|
||||||
|
4. 表清单与表统计:展示成功计数和失败原因。
|
||||||
|
|
||||||
|
总览页不展示任意 SQL 输入框。
|
||||||
|
|
||||||
|
### 4.3 API 调试页
|
||||||
|
|
||||||
|
API 调试页是受控接口调试台,不是通用代理工具:
|
||||||
|
|
||||||
|
1. method 使用选择控件。
|
||||||
|
2. path 只接受 `/xxx` 形式。
|
||||||
|
3. headers 使用结构化键值编辑。
|
||||||
|
4. body 使用文本框。
|
||||||
|
5. 结果展示在固定结果面板,不在按钮下方临时插入内容。
|
||||||
|
|
||||||
|
### 4.4 兑换码管理页
|
||||||
|
|
||||||
|
兑换码管理页首版采用一个创建/更新表单和一个停用表单:
|
||||||
|
|
||||||
|
1. code:提交前可在前端 trim,但最终标准化以服务端为准。
|
||||||
|
2. mode:`public`、`unique`、`private`。
|
||||||
|
3. rewardPoints:必须为正整数,最终校验以服务端为准。
|
||||||
|
4. maxUses:必须为正整数,最终校验以服务端为准。
|
||||||
|
5. enabled:创建/更新时可切换。
|
||||||
|
6. allowedUserIds:私有码允许的内部用户 ID 列表。
|
||||||
|
7. allowedPublicUserCodes:私有码允许的公开陶泥号列表。
|
||||||
|
|
||||||
|
提交成功后展示后端返回的兑换码记录;失败时展示后端错误消息。
|
||||||
|
|
||||||
|
## 5. 数据与权限边界
|
||||||
|
|
||||||
|
1. 管理端所有请求必须经过 `/admin/api/*`。
|
||||||
|
2. 管理端持有的管理员 token 只用于后台接口,不复用玩家登录 token。
|
||||||
|
3. 普通玩家 token 访问管理接口应被后端拒绝。
|
||||||
|
4. 管理端不持久化密码。
|
||||||
|
5. token 首版可保存在 localStorage;退出登录时立即清除。
|
||||||
|
6. SpacetimeDB 表、reducer、procedure、迁移和权限判断均不由管理端直接处理。
|
||||||
|
|
||||||
|
## 6. 验收标准
|
||||||
|
|
||||||
|
1. `apps/admin-web` 可以独立启动和构建。
|
||||||
|
2. 登录页可完成管理员登录,错误和未启用状态展示清楚。
|
||||||
|
3. 登录后刷新页面可通过 `GET /admin/api/me` 恢复会话。
|
||||||
|
4. 总览页可展示 `GET /admin/api/overview` 的服务与数据库数据。
|
||||||
|
5. API 调试页可通过后端调试接口访问 `/healthz`。
|
||||||
|
6. 兑换码管理页可创建/更新、停用兑换码,并展示返回记录。
|
||||||
|
7. `GET /admin` 保持 404,不恢复旧内嵌页面。
|
||||||
|
8. `npm run check:encoding` 通过。
|
||||||
|
|
||||||
|
## 7. 首版任务拆解
|
||||||
|
|
||||||
|
首版编码按以下模块收口,避免独立后台工程继续扩散成未定义功能:
|
||||||
|
|
||||||
|
1. 工程骨架:创建 `apps/admin-web`,补齐 Vite、TypeScript、React 入口和独立构建脚本。
|
||||||
|
2. API client:只封装 `/admin/api/*`,统一处理 Bearer token、统一 envelope 和后端错误消息。
|
||||||
|
3. 登录与会话:实现登录、启动恢复、`401` 清理、退出登录四个状态流。
|
||||||
|
4. 页面 Shell:实现桌面侧栏/顶部栏与移动端紧凑导航,不在未登录状态渲染后台导航。
|
||||||
|
5. 总览页:展示 `service`、`database`、`tableStats`、`fetchErrors`,部分失败不阻断整页。
|
||||||
|
6. API 调试页:实现 method、path、headers、body 输入和独立结果面板。
|
||||||
|
7. 兑换码页:实现创建/更新、停用、三种 mode 切换和私有码用户列表输入。
|
||||||
|
8. 验证:完成后台工程 typecheck/build、根工程编码检查,以及 `/admin` 仍为 404 的后端验证。
|
||||||
|
|
||||||
|
## 8. 交互修正记录
|
||||||
|
|
||||||
|
### 8.1 兑换码记录页签保持
|
||||||
|
|
||||||
|
兑换码管理页的“记录”面板展示最近一次创建、更新或停用接口返回的兑换码记录。该记录在管理端会话内切换“总览”“API 调试”“兑换码”页签时必须保留,避免运营人员切页核对信息后丢失刚返回的结果。退出登录、登录新会话或刷新浏览器页面时可以清空该前端会话态;持久化查询能力后续由新的后端查询接口承接,不在首版用前端伪造历史列表。
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
日期:`2026-04-23`
|
日期:`2026-04-23`
|
||||||
|
|
||||||
|
更新:`2026-04-30`
|
||||||
|
|
||||||
|
> 状态说明:本文件中的管理员鉴权、`/admin/api/*` 管理接口、数据库概览与受控 API 调试设计继续有效;同源内嵌 HTML/CSS/JS 后台页面已废弃。后续后台 UI 迁移到独立前端工程,当前 `api-server` 不再挂载 `GET /admin` 页面入口。独立后台前端的产品边界见 [`../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md`](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),技术方案见 [`ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md`](./ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
为当前 Rust `api-server` 增加一套同源后台管理服务,满足以下首版目标:
|
为当前 Rust `api-server` 增加一套同源后台管理服务,满足以下首版目标:
|
||||||
@@ -10,7 +14,7 @@
|
|||||||
2. 支持独立的管理员鉴权,不允许普通玩家 JWT 越权访问。
|
2. 支持独立的管理员鉴权,不允许普通玩家 JWT 越权访问。
|
||||||
3. 支持在后台查看当前服务与数据库概览信息。
|
3. 支持在后台查看当前服务与数据库概览信息。
|
||||||
4. 支持在后台测试当前 `api-server` 已挂载接口。
|
4. 支持在后台测试当前 `api-server` 已挂载接口。
|
||||||
5. 保持首版工程足够轻量,不新建额外独立服务进程,不引入第二套前端工程。
|
5. 保持管理能力继续收口在 `server-rs`,管理 UI 由独立后台前端工程承接。
|
||||||
|
|
||||||
## 2. 背景与约束
|
## 2. 背景与约束
|
||||||
|
|
||||||
@@ -24,19 +28,20 @@
|
|||||||
|
|
||||||
1. 后端统一落在 `server-rs`,不回退到 `server-node`。
|
1. 后端统一落在 `server-rs`,不回退到 `server-node`。
|
||||||
2. 不额外新起独立管理服务进程。
|
2. 不额外新起独立管理服务进程。
|
||||||
3. 首版以“一个受保护管理域 + 一个同源后台页面”为落地形态。
|
3. 管理 API 继续作为受保护管理域挂载在 `api-server`。
|
||||||
4. 数据库信息必须尽量读取真实数据库侧信息,不能只展示硬编码假数据。
|
4. 数据库信息必须尽量读取真实数据库侧信息,不能只展示硬编码假数据。
|
||||||
|
|
||||||
## 3. 首版范围
|
## 3. 首版范围
|
||||||
|
|
||||||
### 3.1 包含
|
### 3.1 包含
|
||||||
|
|
||||||
1. `GET /admin`:后台管理页面入口。
|
1. `POST /admin/api/login`:管理员用户名密码登录。
|
||||||
2. `POST /admin/api/login`:管理员用户名密码登录。
|
2. `GET /admin/api/me`:当前管理员会话信息。
|
||||||
3. `GET /admin/api/me`:当前管理员会话信息。
|
3. `GET /admin/api/overview`:服务与数据库概览。
|
||||||
4. `GET /admin/api/overview`:服务与数据库概览。
|
4. `POST /admin/api/debug/http`:受控 HTTP 接口调试。
|
||||||
5. `POST /admin/api/debug/http`:受控 HTTP 接口调试。
|
5. `POST /admin/api/profile/redeem-codes`:兑换码创建/更新。
|
||||||
6. 基于 Bearer JWT 的管理员鉴权中间件。
|
6. `POST /admin/api/profile/redeem-codes/disable`:兑换码停用。
|
||||||
|
7. 基于 Bearer JWT 的管理员鉴权中间件。
|
||||||
|
|
||||||
### 3.2 不包含
|
### 3.2 不包含
|
||||||
|
|
||||||
@@ -44,7 +49,7 @@
|
|||||||
2. 管理员 refresh cookie / 多端会话管理。
|
2. 管理员 refresh cookie / 多端会话管理。
|
||||||
3. 后台直接写库、删库、执行 reducer。
|
3. 后台直接写库、删库、执行 reducer。
|
||||||
4. 任意 SQL 执行器。
|
4. 任意 SQL 执行器。
|
||||||
5. 新建独立 React/Vite 管理端工程。
|
5. `api-server` 内嵌 HTML/CSS/JS 后台页面。
|
||||||
|
|
||||||
## 4. 总体方案
|
## 4. 总体方案
|
||||||
|
|
||||||
@@ -60,13 +65,13 @@
|
|||||||
|
|
||||||
### 4.2 页面形态
|
### 4.2 页面形态
|
||||||
|
|
||||||
后台管理页面采用 `api-server` 直接返回一份内嵌 HTML/CSS/JS 的管理页。
|
后台管理页面不再由 `api-server` 直接返回内嵌 HTML/CSS/JS。`api-server` 仅保留管理 API,页面由独立后台前端工程调用这些接口。
|
||||||
|
|
||||||
原因:
|
原因:
|
||||||
|
|
||||||
1. 首版目标是“可用的后台能力”,不是新建一套复杂前端基建。
|
1. 管理 UI 需要独立演进,不应继续堆在 Rust 源码字符串中。
|
||||||
2. 管理页面交互相对简单,直接内嵌更易随服务端一起部署。
|
2. `server-rs` 继续负责鉴权、聚合和写操作,符合前端只做表现的工程约束。
|
||||||
3. 可以避免新增构建链和静态资源发布路径。
|
3. 删除 `GET /admin` 后,当前服务访问该路径应返回 `404`。
|
||||||
|
|
||||||
### 4.3 数据库信息来源
|
### 4.3 数据库信息来源
|
||||||
|
|
||||||
@@ -129,7 +134,7 @@ claims 设计:
|
|||||||
|
|
||||||
## 6. 后台页面设计
|
## 6. 后台页面设计
|
||||||
|
|
||||||
首版页面包含三个主区域:
|
本节已由独立后台前端工程方案接管。历史同源页面曾包含三个主区域:
|
||||||
|
|
||||||
1. 登录卡片。
|
1. 登录卡片。
|
||||||
2. 数据库概览面板。
|
2. 数据库概览面板。
|
||||||
@@ -223,7 +228,7 @@ claims 设计:
|
|||||||
|
|
||||||
默认策略:
|
默认策略:
|
||||||
|
|
||||||
1. 若未配置用户名或密码,则后台登录接口返回 `503`,后台页面显示“后台未启用”。
|
1. 若未配置用户名或密码,则后台登录接口返回 `503`,独立后台前端自行展示未启用状态。
|
||||||
2. 默认管理员 token TTL 为 `4` 小时。
|
2. 默认管理员 token TTL 为 `4` 小时。
|
||||||
|
|
||||||
## 10. 测试要求
|
## 10. 测试要求
|
||||||
@@ -240,21 +245,27 @@ claims 设计:
|
|||||||
|
|
||||||
## 11. 路由清单
|
## 11. 路由清单
|
||||||
|
|
||||||
首版新增路由:
|
当前保留的管理 API 路由:
|
||||||
|
|
||||||
1. `GET /admin`
|
1. `POST /admin/api/login`
|
||||||
2. `POST /admin/api/login`
|
2. `GET /admin/api/me`
|
||||||
3. `GET /admin/api/me`
|
3. `GET /admin/api/overview`
|
||||||
4. `GET /admin/api/overview`
|
4. `POST /admin/api/debug/http`
|
||||||
5. `POST /admin/api/debug/http`
|
5. `POST /admin/api/profile/redeem-codes`
|
||||||
|
6. `POST /admin/api/profile/redeem-codes/disable`
|
||||||
|
|
||||||
|
`GET /admin` 已取消挂载,后续由独立后台前端工程承接页面入口。
|
||||||
|
|
||||||
## 12. 完成定义
|
## 12. 完成定义
|
||||||
|
|
||||||
满足以下条件时,本任务视为完成:
|
当前管理 API 保留与内嵌页面移除满足以下条件时,本任务视为完成:
|
||||||
|
|
||||||
1. `api-server` 内存在受保护后台管理域。
|
1. `api-server` 内存在受保护后台管理域。
|
||||||
2. 管理员用户名密码可登录。
|
2. 管理员用户名密码可登录。
|
||||||
3. 普通用户 token 无法访问后台接口。
|
3. 普通用户 token 无法访问后台接口。
|
||||||
4. 后台能看到服务和数据库真实概览。
|
4. 后台能看到服务和数据库真实概览。
|
||||||
5. 后台能调试当前服务 HTTP 接口。
|
5. 后台能调试当前服务 HTTP 接口。
|
||||||
6. 路由索引与技术文档已同步更新。
|
6. 兑换码管理 API 可由管理员 token 调用。
|
||||||
|
7. `GET /admin` 不再挂载,访问返回 `404`。
|
||||||
|
8. 独立后台前端 PRD 与技术方案已补齐。
|
||||||
|
9. 路由索引与技术文档已同步更新。
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# 后台管理独立前端工程技术方案
|
||||||
|
|
||||||
|
日期:`2026-04-30`
|
||||||
|
|
||||||
|
对应 PRD:[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md)
|
||||||
|
|
||||||
|
落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程,包含登录、总览、API 调试和兑换码管理首版页面;根工程已补 `admin-web:*` 转发脚本。
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染;所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`。
|
||||||
|
|
||||||
|
本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,旧 `GET /admin` 不再挂载。后续后台入口由独立前端工程部署产物承接。
|
||||||
|
|
||||||
|
## 2. 工程结构
|
||||||
|
|
||||||
|
建议首版结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
└─ admin-web/
|
||||||
|
├─ index.html
|
||||||
|
├─ package.json
|
||||||
|
├─ tsconfig.json
|
||||||
|
├─ vite.config.ts
|
||||||
|
└─ src/
|
||||||
|
├─ main.tsx
|
||||||
|
├─ app/
|
||||||
|
│ ├─ AdminApp.tsx
|
||||||
|
│ ├─ AdminShell.tsx
|
||||||
|
│ └─ adminRoutes.ts
|
||||||
|
├─ api/
|
||||||
|
│ ├─ adminApiClient.ts
|
||||||
|
│ └─ adminApiTypes.ts
|
||||||
|
├─ auth/
|
||||||
|
│ └─ adminAuthStore.ts
|
||||||
|
├─ pages/
|
||||||
|
│ ├─ AdminLoginPage.tsx
|
||||||
|
│ ├─ AdminOverviewPage.tsx
|
||||||
|
│ ├─ AdminDebugHttpPage.tsx
|
||||||
|
│ └─ AdminRedeemCodePage.tsx
|
||||||
|
└─ styles/
|
||||||
|
└─ admin.css
|
||||||
|
```
|
||||||
|
|
||||||
|
首版可使用独立 `package.json`,不要求立刻把根工程改成 npm workspace。后续如果根工程统一 workspace,再把 `apps/admin-web` 纳入统一脚本。
|
||||||
|
|
||||||
|
## 3. 技术栈
|
||||||
|
|
||||||
|
1. React + TypeScript + Vite。
|
||||||
|
2. 图标使用 `lucide-react`。
|
||||||
|
3. 样式首版使用普通 CSS 或 CSS Modules,不引入新的大型 UI 组件库。
|
||||||
|
4. 请求使用浏览器 `fetch` 封装,不新增状态管理库。
|
||||||
|
5. 不引入 SpacetimeDB TypeScript SDK;管理端不直连 SpacetimeDB。
|
||||||
|
|
||||||
|
## 4. API 边界
|
||||||
|
|
||||||
|
### 4.1 基础约定
|
||||||
|
|
||||||
|
所有管理端请求使用同一个 `adminApiClient`:
|
||||||
|
|
||||||
|
1. base URL 由 `VITE_ADMIN_API_BASE_URL` 配置。
|
||||||
|
2. 未配置时默认同源空前缀。
|
||||||
|
3. 有 token 时附加 `Authorization: Bearer <token>`。
|
||||||
|
4. 后端统一响应 envelope 时,前端读取 `data`;错误优先读取 `error.details.message`,再读 `error.message`,最后回退到 HTTP 状态。
|
||||||
|
|
||||||
|
前端统一按以下响应形状解析,不在页面组件里重复拆 envelope:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ApiSuccessEnvelope<T> {
|
||||||
|
data: T;
|
||||||
|
meta?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorEnvelope {
|
||||||
|
error?: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
details?: {
|
||||||
|
message?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
meta?: unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`adminApiClient` 暴露 `request<T>()`、`get<T>()`、`post<T>()` 三层即可。页面只拿到成功数据或抛出的中文错误消息,不直接处理 `Response`。
|
||||||
|
|
||||||
|
### 4.2 已有管理接口
|
||||||
|
|
||||||
|
| 功能 | 方法与路径 | 鉴权 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 管理员登录 | `POST /admin/api/login` | 无 |
|
||||||
|
| 当前管理员 | `GET /admin/api/me` | 管理员 Bearer |
|
||||||
|
| 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer |
|
||||||
|
| 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer |
|
||||||
|
| 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer |
|
||||||
|
| 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer |
|
||||||
|
|
||||||
|
### 4.3 前端类型命名
|
||||||
|
|
||||||
|
后台前端首版不引入自动生成 contract。为了避免字段漂移,`apps/admin-web/src/api/adminApiTypes.ts` 必须按 `shared-contracts` 的 camelCase JSON 字段命名:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface AdminSessionPayload {
|
||||||
|
subject: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
|
issuedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginResponse {
|
||||||
|
token: string;
|
||||||
|
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 interface AdminDebugHttpRequest {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
headers?: AdminDebugHeaderInput[];
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDebugHttpResponse {
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: AdminDebugHeaderInput[];
|
||||||
|
bodyText: string;
|
||||||
|
bodyJson: unknown | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兑换码类型同样保持 camelCase:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 登录 contract
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "root",
|
||||||
|
"password": "secret123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "<admin bearer token>",
|
||||||
|
"admin": {
|
||||||
|
"subject": "admin:root",
|
||||||
|
"username": "root",
|
||||||
|
"displayName": "root",
|
||||||
|
"roles": ["admin"],
|
||||||
|
"issuedAt": "2026-04-30T00:00:00Z",
|
||||||
|
"expiresAt": "2026-04-30T04:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`503` 表示后台未启用;`401` 表示用户名或密码错误。
|
||||||
|
|
||||||
|
### 4.5 总览 contract
|
||||||
|
|
||||||
|
`GET /admin/api/overview` 返回:
|
||||||
|
|
||||||
|
1. `service`:`bindHost`、`bindPort`、`jwtIssuer`、`adminEnabled`、`spacetimeServerUrl`、`spacetimeDatabase`。
|
||||||
|
2. `database`:`databaseIdentity`、`ownerIdentity`、`hostType`、`schemaTableNames`、`tableStats`、`fetchErrors`。
|
||||||
|
|
||||||
|
后端读取 SpacetimeDB schema 时必须请求 `/v1/database/{database}/schema?version=9`。SpacetimeDB 2.x schema HTTP API 缺少 `version` query 会返回 `400 missing field version`,后台页面只能展示读取异常,不能拿到真实表名。
|
||||||
|
|
||||||
|
后端读取表行数时必须按 SpacetimeDB 2.x `/sql` 响应解析:接口返回 statement result 数组,单条结果内的 `schema.elements` 描述列名,`rows` 是按列顺序排列的数组行,例如 `rows: [[0]]`。后台服务不能再假设响应是 `{ rows: [{ row_count: 0 }] }` 的对象行形状;为了兼容小版本差异,可保留对象行兜底解析。
|
||||||
|
|
||||||
|
`tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。
|
||||||
|
|
||||||
|
### 4.6 API 调试 contract
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/healthz",
|
||||||
|
"headers": [],
|
||||||
|
"body": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
限制由后端执行:
|
||||||
|
|
||||||
|
1. `path` 只允许同源相对路径。
|
||||||
|
2. 禁止绝对 URL。
|
||||||
|
3. 禁止调试 `/admin/api/login`。
|
||||||
|
4. 禁止覆盖危险请求头。
|
||||||
|
5. 请求体大小和超时由后端收口。
|
||||||
|
|
||||||
|
### 4.7 兑换码管理 contract
|
||||||
|
|
||||||
|
创建/更新请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "WELCOME2026",
|
||||||
|
"mode": "public",
|
||||||
|
"rewardPoints": 100,
|
||||||
|
"maxUses": 1,
|
||||||
|
"enabled": true,
|
||||||
|
"allowedUserIds": [],
|
||||||
|
"allowedPublicUserCodes": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
停用请求:
|
||||||
|
|
||||||
|
兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "WELCOME2026"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回兑换码记录:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "WELCOME2026",
|
||||||
|
"mode": "public",
|
||||||
|
"rewardPoints": 100,
|
||||||
|
"maxUses": 1,
|
||||||
|
"globalUsedCount": 0,
|
||||||
|
"enabled": true,
|
||||||
|
"allowedUserIds": [],
|
||||||
|
"createdBy": "admin:root",
|
||||||
|
"createdAt": "2026-04-30T00:00:00Z",
|
||||||
|
"updatedAt": "2026-04-30T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。
|
||||||
|
|
||||||
|
## 5. 鉴权与会话
|
||||||
|
|
||||||
|
1. token key 固定为 `genarrative_admin_token`。
|
||||||
|
2. token 首版存 localStorage。
|
||||||
|
3. 应用启动时如果存在 token,先调用 `GET /admin/api/me`。
|
||||||
|
4. `401` 时清空 token 并回到登录页。
|
||||||
|
5. `403` 时展示无权限状态,不自动重试。
|
||||||
|
6. 退出登录只清理本地 token;首版没有后台 refresh session 和服务端会话吊销。
|
||||||
|
|
||||||
|
## 6. 页面实现要点
|
||||||
|
|
||||||
|
1. `AdminShell` 承载导航、当前管理员、退出按钮和页面容器。
|
||||||
|
2. 登录页不进入 `AdminShell`,避免未登录时展示后台导航。
|
||||||
|
3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`。
|
||||||
|
4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`。
|
||||||
|
5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。
|
||||||
|
6. 所有按钮的 loading 状态必须锁定重复提交。
|
||||||
|
7. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
|
||||||
|
|
||||||
|
## 7. 部署与联调
|
||||||
|
|
||||||
|
### 7.1 本地联调
|
||||||
|
|
||||||
|
1. 启动后端:`npm run api-server:maincloud`。
|
||||||
|
2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev`。
|
||||||
|
3. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到 `ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100`。
|
||||||
|
4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:<api-server-port>`,并重启后台前端 dev server。
|
||||||
|
5. `GENARRATIVE_API_PORT` 控制 Rust `api-server` 监听端口;`ADMIN_API_TARGET` 只控制后台前端 dev proxy 目标,二者需要指向同一个端口。
|
||||||
|
|
||||||
|
### 7.2 构建部署
|
||||||
|
|
||||||
|
首版构建产物由独立后台工程输出到 `apps/admin-web/dist`。部署可以选择:
|
||||||
|
|
||||||
|
1. 独立静态站点域名,例如 `https://admin.example.com`。
|
||||||
|
2. 与主站同域不同路径,由网关把后台静态资源和 `/admin/api/*` 分别路由到正确目标。
|
||||||
|
|
||||||
|
无论哪种方式,`server-rs` 仍然是唯一管理 API 后端。
|
||||||
|
|
||||||
|
### 7.3 后台工程脚本
|
||||||
|
|
||||||
|
`apps/admin-web/package.json` 首版至少提供以下脚本:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果后续接入根 npm workspace,再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。
|
||||||
|
|
||||||
|
当前根工程同步提供以下转发脚本:
|
||||||
|
|
||||||
|
1. `npm run admin-web:dev`
|
||||||
|
2. `npm run admin-web:typecheck`
|
||||||
|
3. `npm run admin-web:build`
|
||||||
|
4. `npm run admin-web:preview`
|
||||||
|
|
||||||
|
## 8. 测试计划
|
||||||
|
|
||||||
|
1. `apps/admin-web`:
|
||||||
|
- 登录成功和失败。
|
||||||
|
- token 恢复、过期清理、退出登录。
|
||||||
|
- 总览页正常数据、部分表统计失败、整体请求失败。
|
||||||
|
- API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。
|
||||||
|
- 兑换码 public/unique/private 表单提交和停用。
|
||||||
|
2. 根工程:
|
||||||
|
- `npm run check:encoding`。
|
||||||
|
- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。
|
||||||
|
3. 后端:
|
||||||
|
- 继续保留 `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`。
|
||||||
|
- 修改后端管理 API 后必须运行 `npm run api-server:maincloud` 并手动验证 `/admin` 为 404、`/admin/api/login` 可用。
|
||||||
|
|
||||||
|
## 9. 后续扩展边界
|
||||||
|
|
||||||
|
后续新增用户管理、作品审核、资产审核、订单/充值管理时,必须先补对应 PRD 和技术方案,并在 `server-rs` 增加受保护管理 API。不要让 `apps/admin-web` 直接读取 SpacetimeDB 或复制业务规则。
|
||||||
|
|
||||||
|
## 10. 实施顺序
|
||||||
|
|
||||||
|
1. 先创建 `apps/admin-web` 工程骨架,确保空应用可 `dev/build`。
|
||||||
|
2. 再实现 `adminApiTypes` 与 `adminApiClient`,用 `/admin/api/login` 做第一条真实链路。
|
||||||
|
3. 接入 `adminAuthStore` 和启动恢复逻辑,确认 `401` 会清理本地 token。
|
||||||
|
4. 完成 `AdminShell` 与三页路由,再分别接入总览、API 调试和兑换码接口。
|
||||||
|
5. 最后补测试、运行 `npm run check:encoding`,并确认 `GET /admin` 仍由 `api-server` 返回 `404`。
|
||||||
|
|
||||||
|
当前实现已完成第 1 至第 4 步。验证以实际命令输出为准。
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
- [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。
|
- [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。
|
||||||
- [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。
|
- [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。
|
||||||
- [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。
|
- [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。
|
||||||
|
- [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md):冻结后台管理独立前端工程 `apps/admin-web` 的技术方案,明确管理端只做表现、全部数据和写操作走 `server-rs` 的 `/admin/api/*`,并接管旧 `GET /admin` 内嵌页面的 UI 职责。
|
||||||
- [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client,角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`。
|
- [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client,角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`。
|
||||||
- [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。
|
- [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。
|
||||||
- [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs` 的 `/api/runtime/custom-world/profile` 生成世界底稿。
|
- [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs` 的 `/api/runtime/custom-world/profile` 生成世界底稿。
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
- [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。
|
- [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。
|
||||||
|
|
||||||
- [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。
|
- [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。
|
||||||
- [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理服务首版方案,明确管理员用户名密码登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台与同源管理页面的落地边界。
|
- [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理 API 首版方案;同源内嵌页面已取消,管理员登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台和兑换码管理 API 保留给独立后台前端工程复用。
|
||||||
- [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。
|
- [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。
|
||||||
- [CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md](./CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md):冻结 `draft_foundation` 从 SpacetimeDB 内部规则编译迁到 `api-server + platform-llm` 的边界,明确草稿必须由 `api-server` 真实调 LLM 生成,SpacetimeDB 只负责落库。
|
- [CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md](./CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md):冻结 `draft_foundation` 从 SpacetimeDB 内部规则编译迁到 `api-server + platform-llm` 的边界,明确草稿必须由 `api-server` 真实调 LLM 生成,SpacetimeDB 只负责落库。
|
||||||
- [CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md](./CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md):恢复 Custom World Agent 聊天必须走大模型推理的 Rust 落地方案,冻结 submit/finalize 两阶段职责、旧 Node 提示词原样搬运、SSE 流式回复与 session 回写边界。
|
- [CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md](./CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md):恢复 Custom World Agent 聊天必须走大模型推理的 Rust 落地方案,冻结 submit/finalize 两阶段职责、旧 Node 提示词原样搬运、SSE 流式回复与 session 回写边界。
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本、发布包覆盖策略,以及部署阶段 SpacetimeDB schema 冲突自动导出、清库发布、导入回灌能力。
|
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本、发布包覆盖策略,以及部署阶段 SpacetimeDB schema 冲突自动导出、清库发布、导入回灌能力。
|
||||||
- [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。
|
- [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。
|
||||||
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust`、`npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传、安全清库开关,以及发布包内 schema 冲突自动迁移脚本。
|
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust`、`npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传、安全清库开关,以及发布包内 schema 冲突自动迁移脚本。
|
||||||
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 101 条 Axum 路由,并补充管理后台入口与管理接口索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
|
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 Axum 路由,并补充管理 API 索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
|
||||||
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。
|
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。
|
||||||
- [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md):`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。
|
- [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md):`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。
|
||||||
- [API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md](./API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md):`api-server` 接入 `platform-llm` 的最小代理设计,冻结 `/api/llm/chat/completions` 的配置、状态注入与首版非流式兼容边界。
|
- [API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md](./API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md):`api-server` 接入 `platform-llm` 的最小代理设计,冻结 `/api/llm/chat/completions` 的配置、状态注入与首版非流式兼容边界。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Rust API Server 路由索引(2026-04-23)
|
# Rust API Server 路由索引(2026-04-23)
|
||||||
|
|
||||||
更新时间:`2026-04-23`
|
更新时间:`2026-04-30`
|
||||||
|
|
||||||
## 1. 文档目标
|
## 1. 文档目标
|
||||||
|
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
|
|
||||||
## 2. 当前统计
|
## 2. 当前统计
|
||||||
|
|
||||||
当前 Rust `api-server` 从 `app.rs` 可抽取到 `101` 条路由:
|
当前 Rust `api-server` 从 `app.rs` 可抽取到 `102` 条路由:
|
||||||
|
|
||||||
1. 管理后台接口:`5` 条。
|
1. 管理后台 API:`6` 条。
|
||||||
2. 内部鉴权调试接口:`2` 条。
|
2. 内部鉴权调试接口:`2` 条。
|
||||||
3. AI task 接口:`9` 条。
|
3. AI task 接口:`9` 条。
|
||||||
4. assets / OSS 接口:`15` 条。
|
4. assets / OSS 接口:`15` 条。
|
||||||
@@ -26,13 +26,16 @@
|
|||||||
|
|
||||||
## 3. 路由清单
|
## 3. 路由清单
|
||||||
|
|
||||||
### 3.1 管理后台
|
### 3.1 管理后台 API
|
||||||
|
|
||||||
1. `GET /admin`
|
1. `POST /admin/api/login`
|
||||||
2. `POST /admin/api/login`
|
2. `GET /admin/api/me`
|
||||||
3. `GET /admin/api/me`
|
3. `GET /admin/api/overview`
|
||||||
4. `GET /admin/api/overview`
|
4. `POST /admin/api/debug/http`
|
||||||
5. `POST /admin/api/debug/http`
|
5. `POST /admin/api/profile/redeem-codes`
|
||||||
|
6. `POST /admin/api/profile/redeem-codes/disable`
|
||||||
|
|
||||||
|
`GET /admin` 同源内嵌页面入口已取消挂载,后台 UI 后续由独立前端工程承接。
|
||||||
|
|
||||||
### 3.2 内部鉴权调试
|
### 3.2 内部鉴权调试
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
"dev:rust": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh",
|
"dev:rust": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh",
|
||||||
"dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
"dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||||
"dev:web": "node scripts/dev-web-rust.mjs",
|
"dev:web": "node scripts/dev-web-rust.mjs",
|
||||||
|
"admin-web:dev": "npm --prefix apps/admin-web run dev --",
|
||||||
|
"admin-web:build": "npm --prefix apps/admin-web run build --",
|
||||||
|
"admin-web:typecheck": "npm --prefix apps/admin-web run typecheck --",
|
||||||
|
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
|
||||||
"spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh",
|
"spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh",
|
||||||
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
|
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
|
||||||
"api-server:maincloud": "node scripts/api-server-maincloud.mjs",
|
"api-server:maincloud": "node scripts/api-server-maincloud.mjs",
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
|
||||||
extract::{Extension, Request, State},
|
extract::{Extension, Request, State},
|
||||||
http::{
|
http::{
|
||||||
HeaderMap, HeaderName, HeaderValue, Method, StatusCode,
|
|
||||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
header::{AUTHORIZATION, CONTENT_TYPE},
|
||||||
|
HeaderMap, HeaderName, HeaderValue, Method, StatusCode,
|
||||||
},
|
},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{Html, Response},
|
response::Response,
|
||||||
|
Json,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -21,7 +21,7 @@ use shared_contracts::admin::{
|
|||||||
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse,
|
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse,
|
||||||
AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
|
AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
|
||||||
};
|
};
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
@@ -76,6 +76,9 @@ const DATABASE_OVERVIEW_TABLES: &[&str] = &[
|
|||||||
"asset_object",
|
"asset_object",
|
||||||
"asset_entity_binding",
|
"asset_entity_binding",
|
||||||
];
|
];
|
||||||
|
// SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。
|
||||||
|
// 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。
|
||||||
|
const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9";
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AuthenticatedAdmin {
|
pub struct AuthenticatedAdmin {
|
||||||
@@ -100,17 +103,6 @@ struct SpacetimeSchemaTable {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct SpacetimeSqlRow {
|
|
||||||
#[serde(flatten)]
|
|
||||||
columns: serde_json::Map<String, Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct SpacetimeSqlResponse {
|
|
||||||
rows: Option<Vec<SpacetimeSqlRow>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthenticatedAdmin {
|
impl AuthenticatedAdmin {
|
||||||
pub fn new(session: AdminSessionPayload) -> Self {
|
pub fn new(session: AdminSessionPayload) -> Self {
|
||||||
Self { session }
|
Self { session }
|
||||||
@@ -121,10 +113,6 @@ impl AuthenticatedAdmin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn admin_console_page() -> Html<&'static str> {
|
|
||||||
Html(ADMIN_CONSOLE_HTML)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn admin_login(
|
pub async fn admin_login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
@@ -287,7 +275,7 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
|||||||
|
|
||||||
let schema = fetch_spacetime_json::<SpacetimeSchemaResponse>(
|
let schema = fetch_spacetime_json::<SpacetimeSchemaResponse>(
|
||||||
&client,
|
&client,
|
||||||
&format!("{server_root}/v1/database/{database}/schema"),
|
&build_spacetime_schema_url(server_root, database),
|
||||||
token,
|
token,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -353,6 +341,10 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_spacetime_schema_url(server_root: &str, database: &str) -> String {
|
||||||
|
format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}")
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_spacetime_json<T>(
|
async fn fetch_spacetime_json<T>(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -409,17 +401,63 @@ async fn fetch_spacetime_sql_count(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let payload = response
|
let payload = response
|
||||||
.json::<SpacetimeSqlResponse>()
|
.json::<Value>()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("SQL 响应解析失败:{error}"))?;
|
.map_err(|error| format!("SQL 响应解析失败:{error}"))?;
|
||||||
let row = payload
|
parse_spacetime_sql_count_response(payload)
|
||||||
.rows
|
|
||||||
.and_then(|rows| rows.into_iter().next())
|
|
||||||
.ok_or_else(|| "SQL 结果为空".to_string())?;
|
|
||||||
extract_sql_count(row.columns)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_sql_count(columns: serde_json::Map<String, Value>) -> Result<u64, String> {
|
fn parse_spacetime_sql_count_response(payload: Value) -> Result<u64, String> {
|
||||||
|
match payload {
|
||||||
|
// SpacetimeDB 2.x /sql 返回 statement result 数组,每个 result 内含 schema 与 rows。
|
||||||
|
Value::Array(statements) => {
|
||||||
|
let statement = statements
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "SQL 结果为空".to_string())?;
|
||||||
|
extract_sql_count_from_statement(statement)
|
||||||
|
}
|
||||||
|
// 保留兼容旧对象形状,便于本地/远端 API 小版本差异时仍能读取计数。
|
||||||
|
Value::Object(statement) => extract_sql_count_from_statement(Value::Object(statement)),
|
||||||
|
_ => Err("SQL 响应格式非法".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_sql_count_from_statement(statement: Value) -> Result<u64, String> {
|
||||||
|
let Value::Object(mut statement) = statement else {
|
||||||
|
return Err("SQL statement 结果格式非法".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let schema = statement.remove("schema");
|
||||||
|
let rows = statement
|
||||||
|
.remove("rows")
|
||||||
|
.ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?;
|
||||||
|
extract_sql_count_from_rows(rows, schema.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_sql_count_from_rows(rows: Value, schema: Option<&Value>) -> Result<u64, String> {
|
||||||
|
let Value::Array(rows) = rows else {
|
||||||
|
return Err("SQL rows 字段格式非法".to_string());
|
||||||
|
};
|
||||||
|
let row = rows.first().ok_or_else(|| "SQL 结果为空".to_string())?;
|
||||||
|
extract_sql_count_from_row(row, schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_sql_count_from_row(row: &Value, schema: Option<&Value>) -> Result<u64, String> {
|
||||||
|
match row {
|
||||||
|
Value::Object(columns) => extract_sql_count(columns),
|
||||||
|
Value::Array(values) => {
|
||||||
|
let count_index = schema.and_then(find_sql_count_column_index).unwrap_or(0);
|
||||||
|
values
|
||||||
|
.get(count_index)
|
||||||
|
.ok_or_else(|| "SQL 结果缺少 count 字段".to_string())
|
||||||
|
.and_then(parse_count_value)
|
||||||
|
}
|
||||||
|
value => parse_count_value(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_sql_count(columns: &serde_json::Map<String, Value>) -> Result<u64, String> {
|
||||||
for key in ["row_count", "count", "COUNT(*)"] {
|
for key in ["row_count", "count", "COUNT(*)"] {
|
||||||
if let Some(value) = columns.get(key) {
|
if let Some(value) = columns.get(key) {
|
||||||
return parse_count_value(value);
|
return parse_count_value(value);
|
||||||
@@ -432,6 +470,25 @@ fn extract_sql_count(columns: serde_json::Map<String, Value>) -> Result<u64, Str
|
|||||||
.and_then(parse_count_value)
|
.and_then(parse_count_value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn find_sql_count_column_index(schema: &Value) -> Option<usize> {
|
||||||
|
let elements = schema.get("elements")?.as_array()?;
|
||||||
|
elements.iter().position(|element| {
|
||||||
|
element
|
||||||
|
.get("name")
|
||||||
|
.and_then(extract_sql_schema_name)
|
||||||
|
.map(|name| matches!(name, "row_count" | "count" | "COUNT(*)"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_sql_schema_name(value: &Value) -> Option<&str> {
|
||||||
|
match value {
|
||||||
|
Value::String(text) => Some(text.as_str()),
|
||||||
|
Value::Object(object) => object.get("some").and_then(Value::as_str),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_count_value(value: &Value) -> Result<u64, String> {
|
fn parse_count_value(value: &Value) -> Result<u64, String> {
|
||||||
match value {
|
match value {
|
||||||
Value::Number(number) => number
|
Value::Number(number) => number
|
||||||
@@ -602,520 +659,14 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首版后台页面内嵌在 api-server,避免新增独立前端工程与静态资源发布链。
|
|
||||||
static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Genarrative 管理后台</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: linear-gradient(180deg, #f4efe5 0%, #e7ddd0 100%);
|
|
||||||
--panel: rgba(255, 249, 240, 0.92);
|
|
||||||
--panel-strong: #fffaf2;
|
|
||||||
--line: rgba(90, 61, 41, 0.14);
|
|
||||||
--text: #2f241d;
|
|
||||||
--muted: #7b6657;
|
|
||||||
--accent: #b45a2f;
|
|
||||||
--accent-strong: #8f431f;
|
|
||||||
--ok: #2c7a54;
|
|
||||||
--danger: #a63f2f;
|
|
||||||
--shadow: 0 20px 50px rgba(70, 41, 19, 0.12);
|
|
||||||
--radius: 20px;
|
|
||||||
--font: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
max-width: 1180px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px 16px 40px;
|
|
||||||
}
|
|
||||||
.hero {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
.hero h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.hero p {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.status-chip {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(180, 90, 47, 0.1);
|
|
||||||
color: var(--accent-strong);
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 340px minmax(0, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
|
||||||
.panel-head {
|
|
||||||
padding: 18px 18px 0;
|
|
||||||
}
|
|
||||||
.panel-head h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
.panel-head p {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.panel-body {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
.form {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
input, textarea, select {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid rgba(78, 53, 37, 0.12);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: var(--panel-strong);
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
padding: 12px 14px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
textarea {
|
|
||||||
min-height: 140px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.btn-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 11px 16px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff8f2;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button.secondary {
|
|
||||||
background: rgba(180, 90, 47, 0.14);
|
|
||||||
color: var(--accent-strong);
|
|
||||||
}
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
.stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.metrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.metric {
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255,255,255,0.45);
|
|
||||||
}
|
|
||||||
.metric .k {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.metric .v {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.data-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 160px 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
align-items: start;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid rgba(78, 53, 37, 0.08);
|
|
||||||
}
|
|
||||||
.row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.row .k {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.table-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
max-height: 420px;
|
|
||||||
overflow: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
.table-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(255,255,255,0.52);
|
|
||||||
border: 1px solid rgba(78, 53, 37, 0.08);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.table-item small {
|
|
||||||
color: var(--muted);
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.count {
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.count.err {
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.result-panel {
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255,255,255,0.55);
|
|
||||||
padding: 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
background: rgba(47, 36, 29, 0.06);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.err-text { color: var(--danger); }
|
|
||||||
.ok-text { color: var(--ok); }
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.grid { grid-template-columns: 1fr; }
|
|
||||||
.hero { flex-direction: column; }
|
|
||||||
.row { grid-template-columns: 1fr; gap: 4px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="shell">
|
|
||||||
<div class="hero">
|
|
||||||
<div>
|
|
||||||
<h1>Genarrative 管理后台</h1>
|
|
||||||
<p>查看服务状态、数据库概览,并对当前 API 做受控调试。</p>
|
|
||||||
</div>
|
|
||||||
<div id="admin-status" class="status-chip">未登录</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="stack">
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>管理员登录</h2>
|
|
||||||
<p>使用配置的后台账号进入管理域。</p>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<form id="login-form" class="form">
|
|
||||||
<label>用户名
|
|
||||||
<input id="login-username" name="username" autocomplete="username" />
|
|
||||||
</label>
|
|
||||||
<label>密码
|
|
||||||
<input id="login-password" name="password" type="password" autocomplete="current-password" />
|
|
||||||
</label>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button id="login-submit" type="submit">登录后台</button>
|
|
||||||
<button id="login-clear" class="secondary" type="button">清空令牌</button>
|
|
||||||
</div>
|
|
||||||
<div id="login-message" class="hint"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>API 调试</h2>
|
|
||||||
<p>对当前服务做同源受控请求。</p>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<form id="debug-form" class="form">
|
|
||||||
<label>方法
|
|
||||||
<select id="debug-method">
|
|
||||||
<option>GET</option>
|
|
||||||
<option>POST</option>
|
|
||||||
<option>PUT</option>
|
|
||||||
<option>DELETE</option>
|
|
||||||
<option>PATCH</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>路径
|
|
||||||
<input id="debug-path" value="/healthz" />
|
|
||||||
</label>
|
|
||||||
<label>附加请求头(JSON 数组)
|
|
||||||
<textarea id="debug-headers">[]</textarea>
|
|
||||||
</label>
|
|
||||||
<label>请求体
|
|
||||||
<textarea id="debug-body"></textarea>
|
|
||||||
</label>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button id="debug-submit" type="submit">发送调试请求</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stack">
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>数据库概览</h2>
|
|
||||||
<p>读取当前服务配置和 SpacetimeDB 数据库真相。</p>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<div class="btn-row" style="margin-bottom:14px;">
|
|
||||||
<button id="refresh-overview" type="button">刷新概览</button>
|
|
||||||
</div>
|
|
||||||
<div id="overview-metrics" class="metrics"></div>
|
|
||||||
<div id="overview-detail" class="data-grid" style="margin-top:14px;"></div>
|
|
||||||
<div id="overview-errors" class="hint err-text" style="margin-top:10px;"></div>
|
|
||||||
<div id="overview-tables" class="table-list" style="margin-top:14px;"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>调试结果</h2>
|
|
||||||
<p>返回状态、响应头和内容预览。</p>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<div id="debug-result" class="result-panel">
|
|
||||||
<div class="hint">尚未执行调试请求。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const TOKEN_KEY = 'genarrative_admin_token';
|
|
||||||
const statusEl = document.getElementById('admin-status');
|
|
||||||
const loginMessageEl = document.getElementById('login-message');
|
|
||||||
const overviewMetricsEl = document.getElementById('overview-metrics');
|
|
||||||
const overviewDetailEl = document.getElementById('overview-detail');
|
|
||||||
const overviewTablesEl = document.getElementById('overview-tables');
|
|
||||||
const overviewErrorsEl = document.getElementById('overview-errors');
|
|
||||||
const debugResultEl = document.getElementById('debug-result');
|
|
||||||
|
|
||||||
function getToken() {
|
|
||||||
return window.localStorage.getItem(TOKEN_KEY) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setToken(token) {
|
|
||||||
if (!token) {
|
|
||||||
window.localStorage.removeItem(TOKEN_KEY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.localStorage.setItem(TOKEN_KEY, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(text, ok) {
|
|
||||||
statusEl.textContent = text;
|
|
||||||
statusEl.style.background = ok ? 'rgba(44,122,84,0.12)' : 'rgba(180,90,47,0.1)';
|
|
||||||
statusEl.style.color = ok ? '#2c7a54' : '#8f431f';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
|
||||||
const headers = new Headers(options.headers || {});
|
|
||||||
const token = getToken();
|
|
||||||
if (token) {
|
|
||||||
headers.set('authorization', `Bearer ${token}`);
|
|
||||||
}
|
|
||||||
if (options.json !== undefined) {
|
|
||||||
headers.set('content-type', 'application/json');
|
|
||||||
options.body = JSON.stringify(options.json);
|
|
||||||
}
|
|
||||||
const response = await fetch(path, { ...options, headers });
|
|
||||||
const text = await response.text();
|
|
||||||
let data = null;
|
|
||||||
try { data = text ? JSON.parse(text) : null; } catch (_) {}
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = data?.error?.message || data?.message || text || `HTTP ${response.status}`;
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
return data?.data ?? data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOverview(overview) {
|
|
||||||
const service = overview.service || {};
|
|
||||||
const database = overview.database || {};
|
|
||||||
const stats = Array.isArray(database.tableStats) ? database.tableStats : [];
|
|
||||||
overviewMetricsEl.innerHTML = `
|
|
||||||
<div class="metric"><div class="k">后台状态</div><div class="v">${service.adminEnabled ? '已启用' : '未启用'}</div></div>
|
|
||||||
<div class="metric"><div class="k">服务监听</div><div class="v">${service.bindHost || '-'}:${service.bindPort || '-'}</div></div>
|
|
||||||
<div class="metric"><div class="k">SpacetimeDB</div><div class="v">${service.spacetimeDatabase || '-'}</div></div>
|
|
||||||
<div class="metric"><div class="k">统计表数</div><div class="v">${stats.length}</div></div>
|
|
||||||
`;
|
|
||||||
overviewDetailEl.innerHTML = `
|
|
||||||
<div class="row"><div class="k">JWT Issuer</div><div>${service.jwtIssuer || '-'}</div></div>
|
|
||||||
<div class="row"><div class="k">Spacetime 服务</div><div>${service.spacetimeServerUrl || '-'}</div></div>
|
|
||||||
<div class="row"><div class="k">数据库 Identity</div><div>${database.databaseIdentity || '-'}</div></div>
|
|
||||||
<div class="row"><div class="k">Owner Identity</div><div>${database.ownerIdentity || '-'}</div></div>
|
|
||||||
<div class="row"><div class="k">Host Type</div><div>${database.hostType || '-'}</div></div>
|
|
||||||
<div class="row"><div class="k">Schema 表数量</div><div>${(database.schemaTableNames || []).length}</div></div>
|
|
||||||
`;
|
|
||||||
overviewTablesEl.innerHTML = stats.map((item) => `
|
|
||||||
<div class="table-item">
|
|
||||||
<div>
|
|
||||||
<strong>${item.tableName}</strong>
|
|
||||||
${item.errorMessage ? `<small class="err-text">${item.errorMessage}</small>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="count ${item.errorMessage ? 'err' : ''}">${item.rowCount ?? '失败'}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
overviewErrorsEl.textContent = (database.fetchErrors || []).join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDebugResult(result) {
|
|
||||||
const headerText = (result.headers || []).map((item) => `${item.name}: ${item.value}`).join('\n');
|
|
||||||
debugResultEl.innerHTML = `
|
|
||||||
<div><strong>状态:</strong><span class="${result.status < 400 ? 'ok-text' : 'err-text'}">${result.status} ${result.statusText}</span></div>
|
|
||||||
<div><strong>响应头</strong><pre>${headerText || '(无)'}</pre></div>
|
|
||||||
<div><strong>响应体预览</strong><pre>${result.bodyText || '(空)'}</pre></div>
|
|
||||||
<div><strong>响应 JSON</strong><pre>${result.bodyJson ? JSON.stringify(result.bodyJson, null, 2) : '(不是 JSON)'}</pre></div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMe() {
|
|
||||||
const token = getToken();
|
|
||||||
if (!token) {
|
|
||||||
setStatus('未登录', false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await request('/admin/api/me');
|
|
||||||
setStatus(`管理员:${result.admin.displayName}`, true);
|
|
||||||
} catch (error) {
|
|
||||||
setToken('');
|
|
||||||
setStatus('未登录', false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOverview() {
|
|
||||||
try {
|
|
||||||
const overview = await request('/admin/api/overview');
|
|
||||||
renderOverview(overview);
|
|
||||||
} catch (error) {
|
|
||||||
overviewMetricsEl.innerHTML = '';
|
|
||||||
overviewDetailEl.innerHTML = '';
|
|
||||||
overviewTablesEl.innerHTML = '';
|
|
||||||
overviewErrorsEl.textContent = error.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('login-form').addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
loginMessageEl.textContent = '正在登录...';
|
|
||||||
try {
|
|
||||||
const result = await request('/admin/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
json: {
|
|
||||||
username: document.getElementById('login-username').value,
|
|
||||||
password: document.getElementById('login-password').value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setToken(result.token);
|
|
||||||
loginMessageEl.textContent = '登录成功';
|
|
||||||
await loadMe();
|
|
||||||
await loadOverview();
|
|
||||||
} catch (error) {
|
|
||||||
loginMessageEl.textContent = error.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('login-clear').addEventListener('click', () => {
|
|
||||||
setToken('');
|
|
||||||
setStatus('未登录', false);
|
|
||||||
loginMessageEl.textContent = '已清空本地令牌';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('refresh-overview').addEventListener('click', async () => {
|
|
||||||
await loadOverview();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('debug-form').addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
debugResultEl.innerHTML = '<div class="hint">正在请求...</div>';
|
|
||||||
try {
|
|
||||||
const headers = JSON.parse(document.getElementById('debug-headers').value || '[]');
|
|
||||||
const result = await request('/admin/api/debug/http', {
|
|
||||||
method: 'POST',
|
|
||||||
json: {
|
|
||||||
method: document.getElementById('debug-method').value,
|
|
||||||
path: document.getElementById('debug-path').value,
|
|
||||||
headers,
|
|
||||||
body: document.getElementById('debug-body').value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
renderDebugResult(result);
|
|
||||||
} catch (error) {
|
|
||||||
debugResultEl.innerHTML = `<div class="err-text">${error.message}</div>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadMe().then(loadOverview);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"#;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{build_body_preview, build_debug_base_url, normalize_debug_path, trim_preview};
|
use super::{
|
||||||
|
build_body_preview, build_debug_base_url, build_spacetime_schema_url, normalize_debug_path,
|
||||||
|
parse_spacetime_sql_count_response, trim_preview,
|
||||||
|
};
|
||||||
use axum::{http::StatusCode, response::IntoResponse};
|
use axum::{http::StatusCode, response::IntoResponse};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_debug_path_rejects_absolute_url() {
|
fn normalize_debug_path_rejects_absolute_url() {
|
||||||
@@ -1161,6 +712,91 @@ mod tests {
|
|||||||
assert_eq!(trim_preview(&text).chars().count(), 4000);
|
assert_eq!(trim_preview(&text).chars().count(), 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_spacetime_schema_url_includes_required_version_query() {
|
||||||
|
let url = build_spacetime_schema_url("http://127.0.0.1:3101", "xushi-p4wfr");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"http://127.0.0.1:3101/v1/database/xushi-p4wfr/schema?version=9"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_spacetime_sql_count_response_accepts_statement_array_rows() {
|
||||||
|
let payload = json!([
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"some": "row_count"
|
||||||
|
},
|
||||||
|
"algebraic_type": {
|
||||||
|
"U64": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": [[7]],
|
||||||
|
"total_duration_micros": 116,
|
||||||
|
"stats": {
|
||||||
|
"rows_inserted": 0,
|
||||||
|
"rows_deleted": 0,
|
||||||
|
"rows_updated": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let count =
|
||||||
|
parse_spacetime_sql_count_response(payload).expect("statement array should parse");
|
||||||
|
|
||||||
|
assert_eq!(count, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_spacetime_sql_count_response_uses_schema_column_index() {
|
||||||
|
let payload = json!([
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"some": "table_name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"some": "row_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": [["runtime_setting", "12"]]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let count =
|
||||||
|
parse_spacetime_sql_count_response(payload).expect("schema column index should parse");
|
||||||
|
|
||||||
|
assert_eq!(count, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_spacetime_sql_count_response_keeps_object_row_compatibility() {
|
||||||
|
let payload = json!({
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"row_count": "3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = parse_spacetime_sql_count_response(payload).expect("object row should parse");
|
||||||
|
|
||||||
|
assert_eq!(count, 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_body_preview_handles_utf8() {
|
fn build_body_preview_handles_utf8() {
|
||||||
let preview = build_body_preview("后台测试".as_bytes());
|
let preview = build_body_preview("后台测试".as_bytes());
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ use tower_http::{
|
|||||||
use tracing::{Level, Span, error, info, info_span, warn};
|
use tracing::{Level, Span, error, info, info_span, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
admin::{
|
admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth},
|
||||||
admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview,
|
|
||||||
require_admin_auth,
|
|
||||||
},
|
|
||||||
ai_tasks::{
|
ai_tasks::{
|
||||||
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
|
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
|
||||||
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
|
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
|
||||||
@@ -131,7 +128,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
|
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/admin", get(admin_console_page))
|
|
||||||
.route("/admin/api/login", post(admin_login))
|
.route("/admin/api/login", post(admin_login))
|
||||||
.route(
|
.route(
|
||||||
"/admin/api/me",
|
"/admin/api/me",
|
||||||
@@ -3166,6 +3162,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_page_route_is_not_mounted() {
|
||||||
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/admin")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("admin page request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("admin page request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_login_returns_token_when_configured() {
|
async fn admin_login_returns_token_when_configured() {
|
||||||
let mut config = AppConfig::default();
|
let mut config = AppConfig::default();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"server": "http://127.0.0.1:3101",
|
"server": "local",
|
||||||
"module-path": "./server-rs/crates/spacetime-module"
|
"module-path": "./server-rs/crates/spacetime-module"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user