Merge remote-tracking branch 'origin/master' into codex/ddd

# Conflicts:
#	docs/technical/README.md
#	docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
#	docs/technical/SPACETIMEDB_TABLE_CATALOG.md
#	scripts/generate-spacetime-bindings.mjs
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/assets.rs
#	server-rs/crates/api-server/src/big_fish.rs
#	server-rs/crates/api-server/src/custom_world_ai.rs
#	server-rs/crates/api-server/src/llm.rs
#	server-rs/crates/api-server/src/main.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/api-server/src/runtime_profile.rs
#	server-rs/crates/api-server/src/runtime_story/compat/ai.rs
#	server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs
#	server-rs/crates/api-server/src/runtime_story/compat/presentation.rs
#	server-rs/crates/api-server/src/runtime_story/compat/tests.rs
#	server-rs/crates/api-server/src/state.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/module-big-fish/src/lib.rs
#	server-rs/crates/module-custom-world/src/lib.rs
#	server-rs/crates/module-puzzle/src/lib.rs
#	server-rs/crates/module-runtime/src/lib.rs
#	server-rs/crates/spacetime-client/src/big_fish.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/mod.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	server-rs/crates/spacetime-module/src/custom_world/mod.rs
#	server-rs/crates/spacetime-module/src/lib.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	server-rs/crates/spacetime-module/src/puzzle.rs
#	server-rs/crates/spacetime-module/src/runtime/profile.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/aiService.ts
#	src/services/puzzle-runtime/puzzleRuntimeClient.ts
This commit is contained in:
kdletters
2026-05-02 03:35:59 +08:00
513 changed files with 52813 additions and 6013 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+10 -1
View File
@@ -50,6 +50,15 @@ ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
RUST_SERVER_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_DATABASE="xushi-p4wfr"
GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUtQRzVXQlhTTjVCRDAyTjBNSlNONFJCNyIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NjUxMjAyMiwiZXhwIjoxODM5NTg0MDIyfQ.UguSQDajalekrqs9oiUqLZiWjWK7VTgMQfdLVOhBQZpKX0VYUhNMSok9oBMJ4X655_NxV5TUUXZ4ON4HSJZrMMPc9aZyhS1b3i36vqI_zMPwLrAgfb1MqY5o0wNFl6Y0m0UQ3nsu7ZYxmxxgzF4My7So0Pv75QfXFS3-Uq1-QyO7lCxxgQ6vySbP_PEr7FZJsdPkNvAfP7mTaUh0yaV6SI7jXBsZ_mfdcWtElCNuvR9J3hvfAbx1qyeTgCJtgH4kNhiEOEIAYEFMEQkXd4rdLszmEgtlFubYZPsbMgqeZKx73feU6eGxlYhyPiRHF4AdosIfk3x2MAm_WzOd3efXDQ"
GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN=""
# admin
GENARRATIVE_ADMIN_USERNAME=admin
GENARRATIVE_ADMIN_PASSWORD=123456
ADMIN_API_TARGET=http://127.0.0.1:8082
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>百梦后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@genarrative/admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "node ../../scripts/admin-web-build.mjs build",
"typecheck": "node ../../scripts/admin-web-build.mjs typecheck",
"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"
}
}
+238
View File
@@ -0,0 +1,238 @@
import type {
AdminDebugHttpRequest,
AdminDebugHttpResponse,
AdminDisableProfileRedeemCodeRequest,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
ProfileInviteCodeAdminResponse,
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 upsertProfileInviteCode(
token: string,
payload: AdminUpsertProfileInviteCodeRequest,
) {
return request<ProfileInviteCodeAdminResponse>(
'/admin/api/profile/invite-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);
}
+148
View File
@@ -0,0 +1,148 @@
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 AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
metadata?: Record<string, unknown>;
}
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;
}
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
+177
View File
@@ -0,0 +1,177 @@
import {useCallback, useEffect, useState} from 'react';
import {
formatAdminApiError,
getAdminMe,
isAdminApiError,
loginAdmin,
} from '../api/adminApiClient';
import type {
AdminSessionPayload,
ProfileInviteCodeAdminResponse,
ProfileRedeemCodeAdminResponse,
} from '../api/adminApiTypes';
import {
clearStoredAdminToken,
getStoredAdminToken,
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
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 [inviteResult, setInviteResult] =
useState<ProfileInviteCodeAdminResponse | null>(null);
const clearSession = useCallback((message = '') => {
clearStoredAdminToken();
setToken('');
setAdmin(null);
setRedeemResult(null);
setInviteResult(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);
setInviteResult(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}
{routeId === 'invite' ? (
<AdminInviteCodePage
result={inviteResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setInviteResult}
/>
) : null}
</AdminShell>
);
}
+110
View File
@@ -0,0 +1,110 @@
import {
Bug,
LayoutDashboard,
LogOut,
ShieldCheck,
TicketCheck,
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,
invite: TicketCheck,
} 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>
);
}
+30
View File
@@ -0,0 +1,30 @@
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite';
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'},
{id: 'invite', label: '邀请码', hash: '#invite'},
];
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
View File
@@ -0,0 +1,34 @@
export const ADMIN_TOKEN_STORAGE_KEY = 'genarrative_admin_token';
// 管理员 token 与玩家 token 分开保存,避免后台请求误复用玩家登录态。
export function getStoredAdminToken() {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim() || '';
}
export function setStoredAdminToken(token: string) {
if (!canUseLocalStorage()) {
return;
}
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, nextToken);
return;
}
window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
}
export function clearStoredAdminToken() {
setStoredAdminToken('');
}
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
+18
View File
@@ -0,0 +1,18 @@
import './styles/admin.css';
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {AdminApp} from './app/AdminApp';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Missing #root container');
}
createRoot(rootElement).render(
<StrictMode>
<AdminApp />
</StrictMode>,
);
@@ -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>
);
}
@@ -0,0 +1,158 @@
import {Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {upsertProfileInviteCode} from '../api/adminApiClient';
import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminInviteCodePageProps {
token: string;
result: ProfileInviteCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileInviteCodeAdminResponse) => void;
}
export function AdminInviteCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminInviteCodePageProps) {
const [inviteCode, setInviteCode] = useState('');
const [metadataText, setMetadataText] = useState('{}');
const [errorMessage, setErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileInviteCode(token, {
inviteCode: inviteCode.trim(),
metadata: parseMetadata(metadataText),
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field">
<span>Invite Code</span>
<input
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Metadata JSON</span>
<textarea
rows={10}
spellCheck={false}
value={metadataText}
onChange={(event) => setMetadataText(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !inviteCode.trim() || !isMetadataReady(metadataText)}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.inviteCode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>User ID</dt>
<dd>{result.userId}</dd>
</div>
<div>
<dt></dt>
<dd>{result.inviteCode}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
<pre className="admin-code-block">
{JSON.stringify(result.metadata, null, 2)}
</pre>
</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</section>
);
}
function parseMetadata(value: string): Record<string, unknown> {
const trimmed = value.trim();
if (!trimmed) {
return {};
}
const parsed = JSON.parse(trimmed) as unknown;
if (!isRecord(parsed)) {
throw new Error('Metadata 必须是 JSON 对象');
}
return parsed;
}
function isMetadataReady(value: string) {
try {
parseMetadata(value);
return true;
} catch {
return false;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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
View File
@@ -0,0 +1,33 @@
import {formatAdminApiError, isAdminApiError} from '../api/adminApiClient';
export function handlePageError(
error: unknown,
onUnauthorized: (message?: string) => void,
setError: (message: string) => void,
) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
return;
}
setError(formatAdminApiError(error));
}
export function splitLines(value: string) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
export function formatUnknownJson(value: unknown) {
if (value === null || typeof value === 'undefined') {
return '';
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ADMIN_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+43
View File
@@ -0,0 +1,43 @@
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import react from '@vitejs/plugin-react';
import {defineConfig, loadEnv} from 'vite';
const adminWebRoot = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(adminWebRoot, '../..');
export default defineConfig(({mode}) => {
const repoEnv = loadEnv(mode, repoRoot, '');
const appEnv = loadEnv(mode, adminWebRoot, '');
const env = {...repoEnv, ...appEnv};
const apiTarget =
env.ADMIN_API_TARGET ||
env.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
return {
root: adminWebRoot,
envDir: repoRoot,
plugins: [react()],
server: {
proxy: {
'/admin/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
'/healthz': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 600,
},
};
});
+3 -1
View File
@@ -11,7 +11,9 @@
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [参考目录](./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)。
- [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.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);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。

Some files were not shown because too many files have changed in this diff Show More