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:
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
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ADMIN_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user