Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387a1c26e3 | |||
| 18af49c27f | |||
| 93b27a2307 | |||
| 8febb799f3 | |||
| ed1a972c9e | |||
| a0242e3547 | |||
| 77e931ed96 | |||
| 0253a35e72 | |||
| 947f59c894 | |||
| aecabdacfd | |||
| 94014392ed | |||
| 40684aafcd | |||
| 56d70bf720 | |||
| 9ae3f53989 | |||
| b303605bf2 | |||
| ea8d0ef064 | |||
| cfdba640ee | |||
| 271b153308 | |||
| 25f6027c4a | |||
| 793ec1b6b0 | |||
| 8d1c640eb5 | |||
| 7ed99d7353 | |||
| 2f092c3566 | |||
| 3f3a6c4ab2 | |||
| 5c1434511b | |||
| 9807295a7d |
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"Bash(netstat -ano)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(findstr :8081)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(findstr LISTENING)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(lsof -ti:8081)",
|
||||
"Bash(curl -s http://localhost:8081/health)",
|
||||
"Skill(code-review)",
|
||||
"Skill(code-review:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,20 +14,40 @@ if (hookInput && !isGitCommitCommand(extractShellCommand(hookInput))) {
|
||||
}
|
||||
|
||||
const validationSteps = [
|
||||
{
|
||||
label: 'Rust format check',
|
||||
command: npmCommand,
|
||||
args:
|
||||
process.platform === 'win32'
|
||||
? ['/d', '/s', '/c', 'npm run check:rustfmt']
|
||||
: ['run', 'check:rustfmt'],
|
||||
},
|
||||
{
|
||||
label: 'TypeScript typecheck',
|
||||
command: npmCommand,
|
||||
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run typecheck'] : ['run', 'typecheck'],
|
||||
args:
|
||||
process.platform === 'win32'
|
||||
? ['/d', '/s', '/c', 'npm run typecheck']
|
||||
: ['run', 'typecheck'],
|
||||
},
|
||||
{
|
||||
label: 'Admin web typecheck',
|
||||
command: npmCommand,
|
||||
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] : ['run', 'admin-web:typecheck'],
|
||||
args:
|
||||
process.platform === 'win32'
|
||||
? ['/d', '/s', '/c', 'npm run admin-web:typecheck']
|
||||
: ['run', 'admin-web:typecheck'],
|
||||
},
|
||||
{
|
||||
label: 'Rust api-server compile check',
|
||||
command: 'cargo',
|
||||
args: ['check', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
|
||||
args: [
|
||||
'check',
|
||||
'-p',
|
||||
'api-server',
|
||||
'--manifest-path',
|
||||
'server-rs/Cargo.toml',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -66,7 +86,9 @@ function runStep(step) {
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[codex-hook] ${step.label} 启动失败:${result.error.message}`);
|
||||
console.error(
|
||||
`[codex-hook] ${step.label} 启动失败:${result.error.message}`,
|
||||
);
|
||||
return { ok: false, status: 1 };
|
||||
}
|
||||
|
||||
@@ -104,12 +126,15 @@ function extractShellCommand(input) {
|
||||
input?.command,
|
||||
];
|
||||
|
||||
const command = candidates.find(value => typeof value === 'string' && value.trim().length > 0);
|
||||
const command = candidates.find(
|
||||
(value) => typeof value === 'string' && value.trim().length > 0,
|
||||
);
|
||||
if (command) {
|
||||
return command;
|
||||
}
|
||||
|
||||
const shellCommand = input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
|
||||
const shellCommand =
|
||||
input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
|
||||
if (Array.isArray(shellCommand)) {
|
||||
return shellCommand.join(' ');
|
||||
}
|
||||
|
||||
@@ -318,6 +318,14 @@ For image edit/redraw that should replace an existing canvas layer, pass `projec
|
||||
|
||||
For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation.
|
||||
|
||||
## Successful Responses with Warnings
|
||||
|
||||
Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can return HTTP 2xx with an optional structured `warning`. A 2xx response means the task completed, but it does not guarantee that every requested post-processed derivative exists.
|
||||
|
||||
- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation.
|
||||
- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason.
|
||||
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes.
|
||||
|
||||
@@ -78,6 +78,14 @@ Ask a follow-up only when two routes could both be correct and produce different
|
||||
|
||||
All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`.
|
||||
|
||||
## HTTP 2xx Warning Handling
|
||||
|
||||
Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may return HTTP 2xx while carrying a structured `warning`; completion does not imply that all post-processed derivatives exist.
|
||||
|
||||
- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch.
|
||||
- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning.
|
||||
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. If a malformed response contains both, prioritize the general `warning` over `sliceWarning` defensively.
|
||||
|
||||
## Reference Image Upload
|
||||
|
||||
If the user provides a local file as a reference image, run upload before the generation request:
|
||||
|
||||
@@ -78,7 +78,7 @@ Best practices:
|
||||
- Avoid overlapping queries that duplicate row delivery.
|
||||
- Use indexes for subscribed filters.
|
||||
|
||||
## 2.2.0 to 2.6.0 Delta
|
||||
## 2.2.0 to 2.6.1 Delta
|
||||
|
||||
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
|
||||
@@ -88,6 +88,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
- **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables.
|
||||
- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments.
|
||||
- **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands.
|
||||
- **2.6.1**: procedure contexts again receive the caller `Identity` and `ConnectionId`; generated TypeScript `Option<T>` fields use optional keys; `spacetime init --template` lists available templates when no template argument is supplied.
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ GENARRATIVE_LLM_PROVIDER="ark"
|
||||
GENARRATIVE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
|
||||
GENARRATIVE_LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e"
|
||||
GENARRATIVE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
||||
GENARRATIVE_EDITOR_BGFILTER_BASE_URL="https://u1082648-b442-cd409e05.westx.seetacloud.com:8443"
|
||||
APIMART_BASE_URL="https://api.apimart.ai/v1"
|
||||
APIMART_API_KEY=""
|
||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
|
||||
@@ -36,6 +37,7 @@ DASHSCOPE_SCENE_IMAGE_MODEL="wan2.2-t2i-flash"
|
||||
DASHSCOPE_REFERENCE_IMAGE_MODEL="qwen-image-2.0"
|
||||
DASHSCOPE_COVER_IMAGE_MODEL="wan2.2-t2i-flash"
|
||||
ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS=420000
|
||||
|
||||
# 启用服务端大模型调试日志(记录所有输入输出)
|
||||
LLM_DEBUG_LOG="true"
|
||||
|
||||
|
||||
@@ -24,6 +24,40 @@ module.exports = {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/scripts/**/*.{ts,js,mjs,cjs}'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.{ts,tsx,js,mjs,cjs}'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'unused-imports/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['miniprogram/**/*.js'],
|
||||
globals: {
|
||||
App: 'readonly',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'apps/admin-web/src/pages/*.tsx',
|
||||
'src/components/platform-entry/PlatformMobileHomeWelcomeDialog.tsx',
|
||||
],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/platform-entry/PlatformEntryFlowShellImpl.tsx'],
|
||||
rules: {
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/game-canvas/**/*.tsx'],
|
||||
rules: {
|
||||
@@ -61,7 +95,25 @@ module.exports = {
|
||||
'dist',
|
||||
'dist_check',
|
||||
'dist_check_monster_position',
|
||||
'coverage',
|
||||
'node_modules',
|
||||
'server-rs/target',
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'target',
|
||||
'src/components/CharacterAnimator.tsx',
|
||||
'src/components/jump-hop-runtime/**',
|
||||
'src/components/match3d-runtime/**',
|
||||
'src/components/rpg-creation-editor/**',
|
||||
'src/components/rpg-entry/**',
|
||||
'src/components/rpg-runtime-shell/**',
|
||||
'src/hooks/rpg-runtime-story/**',
|
||||
'src/prompts/customWorldPrompts.ts',
|
||||
'src/services/ai.ts',
|
||||
'src/services/miniGameDraftGenerationProgress.ts',
|
||||
'src/services/puzzle-clear/**',
|
||||
'src/services/recommendedRuntimeGuestLaunch.test.ts',
|
||||
'src/data/sceneEncounterPreviews.ts',
|
||||
'public/Icons',
|
||||
'media',
|
||||
'.codex-logs',
|
||||
|
||||
@@ -42,6 +42,7 @@ temp*build*/
|
||||
/.app/
|
||||
/target/
|
||||
/logs
|
||||
/.claude/settings.local.json
|
||||
/.codegraph/
|
||||
/.playwright-cli/
|
||||
**/.playwright-cli/
|
||||
@@ -50,6 +51,7 @@ temp*build*/
|
||||
.worktrees/
|
||||
.rag/
|
||||
.env.secrets.local
|
||||
nohup.out
|
||||
spacetime.local.json
|
||||
deploy/container/api-server.env
|
||||
deploy/container/worker-smoke/
|
||||
|
||||
@@ -98,21 +98,20 @@ npm run check:content
|
||||
主运行时:
|
||||
|
||||
- [src/App.tsx](./src/App.tsx)
|
||||
- [src/components/GameShell.tsx](./src/components/GameShell.tsx)
|
||||
- [src/AuthenticatedApp.tsx](./src/AuthenticatedApp.tsx)
|
||||
- [src/routing/appRoutes.tsx](./src/routing/appRoutes.tsx)
|
||||
- [src/hooks/useCombatFlow.ts](./src/hooks/useCombatFlow.ts)
|
||||
- [src/hooks/useStoryGeneration.ts](./src/hooks/useStoryGeneration.ts)
|
||||
|
||||
主流程内嵌编辑能力:
|
||||
|
||||
- [src/components/CustomWorldEntityEditorModal.tsx](./src/components/CustomWorldEntityEditorModal.tsx)
|
||||
- [src/components/CustomWorldNpcVisualEditor.tsx](./src/components/CustomWorldNpcVisualEditor.tsx)
|
||||
- [src/components/CustomWorldRoleAssetStudioModal.tsx](./src/components/CustomWorldRoleAssetStudioModal.tsx)
|
||||
- [src/components/image-editor/ImageCanvasEditorView.tsx](./src/components/image-editor/ImageCanvasEditorView.tsx)
|
||||
- [src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx](./src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx)
|
||||
- [src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx](./src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx)
|
||||
|
||||
核心数据:
|
||||
|
||||
- [src/data/scenePresets.ts](./src/data/scenePresets.ts)
|
||||
- [src/data/characterPresets.ts](./src/data/characterPresets.ts)
|
||||
- [src/data/monsterPresets.ts](./src/data/monsterPresets.ts)
|
||||
- [src/data/npcInteractions.ts](./src/data/npcInteractions.ts)
|
||||
- [src/data/treasureInteractions.ts](./src/data/treasureInteractions.ts)
|
||||
|
||||
|
||||
@@ -2,23 +2,21 @@ import type {
|
||||
AdminAccountListResponse,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminDashboardQuery,
|
||||
AdminDashboardResponse,
|
||||
AdminDebugHttpRequest,
|
||||
AdminDebugHttpResponse,
|
||||
AdminDisableProfileRedeemCodeRequest,
|
||||
AdminDisableProfileTaskConfigRequest,
|
||||
AdminDatabaseTableListResponse,
|
||||
AdminDatabaseTableRowsQuery,
|
||||
AdminDatabaseTableRowsResponse,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
|
||||
AdminDebugHttpRequest,
|
||||
AdminDebugHttpResponse,
|
||||
AdminDirectUploadTicketPayload,
|
||||
AdminDisableProfileRedeemCodeRequest,
|
||||
AdminDisableProfileTaskConfigRequest,
|
||||
AdminEditorAssetListQuery,
|
||||
AdminEditorAssetListResponse,
|
||||
AdminDirectUploadTicketPayload,
|
||||
AdminEditorShowcaseAssetResponse,
|
||||
AdminEditorShowcaseCampaignResponse,
|
||||
AdminEditorShowcaseDisplayRequest,
|
||||
@@ -37,14 +35,16 @@ import type {
|
||||
AdminRechargeRefundPreviewRequest,
|
||||
AdminRechargeRefundPreviewResponse,
|
||||
AdminRechargeRefundRegisterRequest,
|
||||
AdminTrackingEventListQuery,
|
||||
AdminTrackingEventKeyListResponse,
|
||||
AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
@@ -53,11 +53,11 @@ import type {
|
||||
AdminUpsertProfileTaskConfigRequest,
|
||||
AdminUpsertProfileWalletConfigRequest,
|
||||
AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
AdminUserDetailQuery,
|
||||
AdminUserDetailResponse,
|
||||
AdminWalletRestrictionRequest,
|
||||
AdminWalletRestrictionResponse,
|
||||
AdminWorkVisibilityListResponse,
|
||||
ApiErrorEnvelope,
|
||||
ApiMeta,
|
||||
ApiSuccessEnvelope,
|
||||
@@ -398,10 +398,11 @@ export function getAdminAssetReadUrl(
|
||||
export function listAdminEditorAssets(
|
||||
token: string,
|
||||
query: AdminEditorAssetListQuery = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return request<AdminEditorAssetListResponse>(
|
||||
`/admin/api/editor-assets${buildEditorAssetListQuery(query)}`,
|
||||
{ token },
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,9 @@ export interface AdminDashboardMetricsPayload {
|
||||
consumedMudPoints: number;
|
||||
totalRegisteredUsers: number;
|
||||
newRegisteredUsers: number;
|
||||
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
|
||||
day1Retention: AdminDashboardRetentionMetricPayload;
|
||||
day7Retention: AdminDashboardRetentionMetricPayload;
|
||||
visitUsers: number;
|
||||
totalVisitUsers: number;
|
||||
visitCount: number;
|
||||
@@ -135,6 +138,18 @@ export interface AdminDashboardMetricsPayload {
|
||||
currentUsers: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardPaymentConversionPayload {
|
||||
paidUsers: number;
|
||||
newRegisteredUsers: number;
|
||||
rateBasisPoints: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardRetentionMetricPayload {
|
||||
eligibleUsers: number;
|
||||
retainedUsers: number;
|
||||
rateBasisPoints: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardChartPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -452,6 +467,7 @@ export interface AdminEditorAssetPayload {
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
groupTaskId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: Record<string, unknown> | null;
|
||||
sourceResourceId?: string | null;
|
||||
@@ -459,6 +475,10 @@ export interface AdminEditorAssetPayload {
|
||||
generationCostMudPoints: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
generator: string;
|
||||
taskGenerator: string;
|
||||
taskCostMudPoints: number;
|
||||
children: AdminEditorAssetPayload[];
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListResponse {
|
||||
|
||||
@@ -17,26 +17,25 @@ import {
|
||||
getStoredAdminToken,
|
||||
setStoredAdminToken,
|
||||
} from '../auth/adminAuthStore';
|
||||
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
|
||||
import {AdminAccountsPage} from '../pages/AdminAccountsPage';
|
||||
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
|
||||
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
|
||||
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
|
||||
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
|
||||
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
|
||||
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
|
||||
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
|
||||
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
|
||||
import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage';
|
||||
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
|
||||
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
import {AdminRechargeOrderPage} from '../pages/AdminRechargeOrderPage';
|
||||
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
|
||||
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
|
||||
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
|
||||
import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage';
|
||||
import {AdminShell} from './AdminShell';
|
||||
import type {AdminRouteId} from './adminRoutes';
|
||||
import {
|
||||
getAccessibleAdminRoutes,
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
resolveAdminRoute,
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
import {AdminShell} from './AdminShell';
|
||||
|
||||
type SessionStatus = 'checking' | 'guest' | 'authenticated';
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import {
|
||||
Activity,
|
||||
Bug,
|
||||
BadgeDollarSign,
|
||||
Bug,
|
||||
Coins,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Database,
|
||||
Eye,
|
||||
GitBranch,
|
||||
Images,
|
||||
Star,
|
||||
WalletCards,
|
||||
ShieldCheck,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
ReceiptText,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Database,
|
||||
Star,
|
||||
Table2,
|
||||
TicketCheck,
|
||||
TicketPercent,
|
||||
ReceiptText,
|
||||
Users,
|
||||
WalletCards,
|
||||
} from 'lucide-react';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
adminTrackingEventDefinitions,
|
||||
buildAdminTrackingEventKeyOptions,
|
||||
filterAdminProfileTaskTrackingEventDefinitions,
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
filterAdminTrackingEventDefinitions,
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from './trackingEventDefinitions';
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -29,6 +36,21 @@ const dashboardResponse: AdminDashboardResponse = {
|
||||
consumedMudPoints: 88,
|
||||
totalRegisteredUsers: 1200,
|
||||
newRegisteredUsers: 16,
|
||||
newUserPaymentConversion: {
|
||||
paidUsers: 5,
|
||||
newRegisteredUsers: 16,
|
||||
rateBasisPoints: 3125,
|
||||
},
|
||||
day1Retention: {
|
||||
eligibleUsers: 12,
|
||||
retainedUsers: 3,
|
||||
rateBasisPoints: 2500,
|
||||
},
|
||||
day7Retention: {
|
||||
eligibleUsers: 0,
|
||||
retainedUsers: 0,
|
||||
rateBasisPoints: 0,
|
||||
},
|
||||
visitUsers: 34,
|
||||
totalVisitUsers: 456,
|
||||
visitCount: 98,
|
||||
@@ -41,7 +63,10 @@ const dashboardResponse: AdminDashboardResponse = {
|
||||
title: '生产素材',
|
||||
unit: '个',
|
||||
total: 12,
|
||||
buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }],
|
||||
buckets: [
|
||||
{ key: '2026-06-23', label: '2026-06-23', value: 12 },
|
||||
{ key: '2026-06-24', label: '2026-06-24', value: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
operations: {
|
||||
@@ -82,8 +107,34 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
|
||||
expect(screen.getByText('本日生产素材数')).toBeTruthy();
|
||||
expect(screen.getByText('总注册用户')).toBeTruthy();
|
||||
expect(screen.getByText('本日新增用户数')).toBeTruthy();
|
||||
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
|
||||
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
|
||||
const paymentRateCard = screen
|
||||
.getByText('本日新增用户付费率')
|
||||
.closest('article');
|
||||
expect(paymentRateCard).toBeTruthy();
|
||||
expect(
|
||||
within(paymentRateCard as HTMLElement).getByText('31.25%'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(paymentRateCard as HTMLElement).getByText('付费人数 / 新增人数'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(paymentRateCard as HTMLElement).getByText('5 / 16 人'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('次日留存')).toBeTruthy();
|
||||
expect(screen.getByText('25%')).toBeTruthy();
|
||||
expect(screen.getByText('3 / 12 人')).toBeTruthy();
|
||||
const day7Card = screen.getByText('七日留存').closest('article');
|
||||
expect(day7Card).toBeTruthy();
|
||||
expect(within(day7Card as HTMLElement).getByText('-')).toBeTruthy();
|
||||
expect(within(day7Card as HTMLElement).getByText('0 / 0 人')).toBeTruthy();
|
||||
expect(screen.getByText('近 5 分钟活跃用户')).toBeTruthy();
|
||||
expect(screen.getByText('生产素材')).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByTitle('2026-06-24: 0 个')
|
||||
.firstElementChild?.getAttribute('style'),
|
||||
).toContain('height: 0%');
|
||||
expect(screen.queryByRole('button', { name: '本时段' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '运营汇总' }));
|
||||
@@ -108,7 +159,7 @@ test('Dashboard 默认日期使用北京时间', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 选择本周时按今天填充整周范围', async () => {
|
||||
test('Dashboard 选择本周时按北京时间今天截断未来日期', async () => {
|
||||
const user = setupUser();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
@@ -123,12 +174,12 @@ test('Dashboard 选择本周时按今天填充整周范围', async () => {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-22',
|
||||
endDate: '2026-06-28',
|
||||
endDate: '2026-06-27',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 选择本月时按今天填充整月范围', async () => {
|
||||
test('Dashboard 选择本月时按北京时间今天截断未来日期', async () => {
|
||||
const user = setupUser();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
@@ -143,7 +194,7 @@ test('Dashboard 选择本月时按今天填充整月范围', async () => {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-06-30',
|
||||
endDate: '2026-06-27',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -186,8 +237,139 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
|
||||
expect(screen.getByText('本时段新增用户付费率')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Dashboard 新增用户付费率分母为零时显示横线', async () => {
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue({
|
||||
...dashboardResponse,
|
||||
metrics: {
|
||||
...dashboardResponse.metrics,
|
||||
newRegisteredUsers: 0,
|
||||
newUserPaymentConversion: {
|
||||
paidUsers: 0,
|
||||
newRegisteredUsers: 0,
|
||||
rateBasisPoints: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
const paymentRateCard = (
|
||||
await screen.findByText('本日新增用户付费率')
|
||||
).closest('article');
|
||||
expect(paymentRateCard).toBeTruthy();
|
||||
expect(within(paymentRateCard as HTMLElement).getByText('-')).toBeTruthy();
|
||||
expect(
|
||||
within(paymentRateCard as HTMLElement).getByText('0 / 0 人'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => {
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('本日生产素材数');
|
||||
const endDateInput = screen.getByLabelText('终止日期') as HTMLInputElement;
|
||||
expect(endDateInput.max).toBe('2026-06-27');
|
||||
|
||||
fireEvent.change(endDateInput, { target: { value: '2026-07-12' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endDateInput.value).toBe('2026-06-27');
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-27',
|
||||
endDate: '2026-06-27',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 四张趋势图共享横向日期窗口', async () => {
|
||||
const baseChart = dashboardResponse.charts[0]!;
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue({
|
||||
...dashboardResponse,
|
||||
charts: [
|
||||
baseChart,
|
||||
{
|
||||
...baseChart,
|
||||
id: 'consumed-mud-points',
|
||||
title: '消耗泥点',
|
||||
unit: '泥点',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
const chartRegions = await screen.findAllByRole('region', {
|
||||
name: /趋势图$/,
|
||||
});
|
||||
const [sourceChart, targetChart] = chartRegions;
|
||||
if (!sourceChart || !targetChart) {
|
||||
throw new Error('趋势图未完整渲染');
|
||||
}
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
setScrollableDimensions(sourceChart, {
|
||||
clientWidth: 100,
|
||||
scrollWidth: 500,
|
||||
scrollLeft: 200,
|
||||
});
|
||||
setScrollableDimensions(targetChart, {
|
||||
clientWidth: 100,
|
||||
scrollWidth: 500,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.scroll(sourceChart);
|
||||
});
|
||||
|
||||
expect(targetChart.scrollLeft).toBe(200);
|
||||
});
|
||||
|
||||
test('Dashboard 访问人数趋势明确区分每日桶与时段去重总数', async () => {
|
||||
const baseChart = dashboardResponse.charts[0]!;
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue({
|
||||
...dashboardResponse,
|
||||
charts: [
|
||||
{
|
||||
...baseChart,
|
||||
id: 'visit-users',
|
||||
title: '每日访问人数',
|
||||
unit: '人',
|
||||
total: 34,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('每日访问人数')).toBeTruthy();
|
||||
expect(screen.getByText('时段去重 34 人')).toBeTruthy();
|
||||
});
|
||||
|
||||
function setupUser() {
|
||||
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
}
|
||||
|
||||
function setScrollableDimensions(
|
||||
element: HTMLElement,
|
||||
dimensions: {
|
||||
clientWidth: number;
|
||||
scrollWidth: number;
|
||||
scrollLeft: number;
|
||||
},
|
||||
) {
|
||||
Object.defineProperties(element, {
|
||||
clientWidth: { configurable: true, value: dimensions.clientWidth },
|
||||
scrollWidth: { configurable: true, value: dimensions.scrollWidth },
|
||||
scrollLeft: {
|
||||
configurable: true,
|
||||
value: dimensions.scrollLeft,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getAdminDashboard } from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -42,24 +42,29 @@ export function AdminDashboardPage({
|
||||
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [chartScrollLeft, setChartScrollLeft] = useState(0);
|
||||
const today = formatBeijingDateInput(new Date());
|
||||
|
||||
const loadDashboard = useCallback(async (range = dateRange) => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await getAdminDashboard(token, {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
});
|
||||
setDashboard(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [dateRange, onUnauthorized, token]);
|
||||
const loadDashboard = useCallback(
|
||||
async (range = dateRange) => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await getAdminDashboard(token, {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
});
|
||||
setDashboard(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[dateRange, onUnauthorized, token],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
@@ -75,6 +80,10 @@ export function AdminDashboardPage({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
setChartScrollLeft(0);
|
||||
}, [dashboard?.range.periodEndDate, dashboard?.range.periodStartDate]);
|
||||
|
||||
const metrics = dashboard?.metrics;
|
||||
const totalMetricCards = useMemo(
|
||||
() => [
|
||||
@@ -98,7 +107,7 @@ export function AdminDashboardPage({
|
||||
},
|
||||
{
|
||||
id: 'current-users',
|
||||
label: '当前使用人数(五分钟统计一次)',
|
||||
label: '近 5 分钟活跃用户',
|
||||
value: metrics?.currentUsers ?? 0,
|
||||
unit: '人',
|
||||
},
|
||||
@@ -154,6 +163,7 @@ export function AdminDashboardPage({
|
||||
<span>起始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
max={today}
|
||||
value={dateRange.startDate}
|
||||
onChange={(event) =>
|
||||
handleDateRangeChange('startDate', event.target.value)
|
||||
@@ -164,6 +174,7 @@ export function AdminDashboardPage({
|
||||
<span>终止日期</span>
|
||||
<input
|
||||
type="date"
|
||||
max={today}
|
||||
value={dateRange.endDate}
|
||||
onChange={(event) =>
|
||||
handleDateRangeChange('endDate', event.target.value)
|
||||
@@ -278,9 +289,51 @@ export function AdminDashboardPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>新增用户转化与留存</h3>
|
||||
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-retention-grid">
|
||||
<RateCard
|
||||
label={`${rangePrefix(granularity)}新增用户付费率`}
|
||||
numeratorLabel="付费人数"
|
||||
denominatorLabel="新增人数"
|
||||
numerator={metrics?.newUserPaymentConversion?.paidUsers}
|
||||
denominator={
|
||||
metrics?.newUserPaymentConversion?.newRegisteredUsers
|
||||
}
|
||||
rateBasisPoints={
|
||||
metrics?.newUserPaymentConversion?.rateBasisPoints
|
||||
}
|
||||
/>
|
||||
<RateCard
|
||||
label="次日留存"
|
||||
numeratorLabel="留存人数"
|
||||
denominatorLabel="可观察新增人数"
|
||||
numerator={metrics?.day1Retention.retainedUsers}
|
||||
denominator={metrics?.day1Retention.eligibleUsers}
|
||||
rateBasisPoints={metrics?.day1Retention.rateBasisPoints}
|
||||
/>
|
||||
<RateCard
|
||||
label="七日留存"
|
||||
numeratorLabel="留存人数"
|
||||
denominatorLabel="可观察新增人数"
|
||||
numerator={metrics?.day7Retention.retainedUsers}
|
||||
denominator={metrics?.day7Retention.eligibleUsers}
|
||||
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-dashboard-chart-grid">
|
||||
{(dashboard?.charts ?? []).map((chart) => (
|
||||
<ChartPanel key={chart.id} chart={chart} />
|
||||
<ChartPanel
|
||||
key={chart.id}
|
||||
chart={chart}
|
||||
scrollLeft={chartScrollLeft}
|
||||
onScrollLeftChange={setChartScrollLeft}
|
||||
/>
|
||||
))}
|
||||
{dashboard && dashboard.charts.length === 0 ? (
|
||||
<div className="admin-empty-state">暂无图表</div>
|
||||
@@ -347,9 +400,10 @@ export function AdminDashboardPage({
|
||||
if (!parseDateValueAsUtc(value)) {
|
||||
return;
|
||||
}
|
||||
const clampedValue = value > today ? today : value;
|
||||
setGranularity('period');
|
||||
setDateRange((current) =>
|
||||
normalizeDateRange({ ...current, [field]: value }),
|
||||
normalizeDateRange({ ...current, [field]: clampedValue }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -377,17 +431,85 @@ function MetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
|
||||
function RateCard({
|
||||
label,
|
||||
numeratorLabel,
|
||||
denominatorLabel,
|
||||
numerator,
|
||||
denominator,
|
||||
rateBasisPoints,
|
||||
}: {
|
||||
label: string;
|
||||
numeratorLabel: string;
|
||||
denominatorLabel: string;
|
||||
numerator?: number;
|
||||
denominator?: number;
|
||||
rateBasisPoints?: number;
|
||||
}) {
|
||||
const hasDenominator = Boolean(denominator);
|
||||
return (
|
||||
<article className="admin-dashboard-retention-card">
|
||||
<span>{label}</span>
|
||||
<strong>
|
||||
{hasDenominator && rateBasisPoints !== undefined
|
||||
? formatRateBasisPoints(rateBasisPoints)
|
||||
: '-'}
|
||||
</strong>
|
||||
<div>
|
||||
<small>
|
||||
{numeratorLabel} / {denominatorLabel}
|
||||
</small>
|
||||
<b>
|
||||
{numerator !== undefined && denominator !== undefined
|
||||
? `${formatNumber(numerator)} / ${formatNumber(denominator)} 人`
|
||||
: '-'}
|
||||
</b>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartPanel({
|
||||
chart,
|
||||
scrollLeft,
|
||||
onScrollLeftChange,
|
||||
}: {
|
||||
chart: AdminDashboardChartPayload;
|
||||
scrollLeft: number;
|
||||
onScrollLeftChange: (scrollLeft: number) => void;
|
||||
}) {
|
||||
const barsRef = useRef<HTMLDivElement>(null);
|
||||
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
|
||||
|
||||
useEffect(() => {
|
||||
const element = barsRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(element.scrollLeft - scrollLeft) > 1) {
|
||||
element.scrollLeft = scrollLeft;
|
||||
}
|
||||
}, [chart.buckets.length, scrollLeft]);
|
||||
|
||||
return (
|
||||
<section className="admin-panel admin-dashboard-chart-card">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>{chart.title}</h3>
|
||||
<span>
|
||||
{chart.id === 'visit-users' ? '时段去重 ' : ''}
|
||||
{formatNumber(chart.total)} {chart.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-bars">
|
||||
<div
|
||||
ref={barsRef}
|
||||
className="admin-dashboard-bars"
|
||||
role="region"
|
||||
aria-label={`${chart.title}趋势图`}
|
||||
tabIndex={0}
|
||||
onScroll={(event) => {
|
||||
onScrollLeftChange(event.currentTarget.scrollLeft);
|
||||
}}
|
||||
>
|
||||
{chart.buckets.map((bucket) => (
|
||||
<div className="admin-dashboard-bar-item" key={bucket.key}>
|
||||
<div
|
||||
@@ -396,7 +518,10 @@ function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
height: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
|
||||
height:
|
||||
bucket.value === 0
|
||||
? '0%'
|
||||
: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -450,6 +575,13 @@ function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN').format(value);
|
||||
}
|
||||
|
||||
function formatRateBasisPoints(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 10_000);
|
||||
}
|
||||
|
||||
function formatBeijingDateInput(date: Date) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
@@ -498,9 +630,7 @@ function buildWeekDateRange(dateValue: string) {
|
||||
const weekday = date.getUTCDay() || 7;
|
||||
const monday = new Date(date);
|
||||
monday.setUTCDate(date.getUTCDate() - weekday + 1);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setUTCDate(monday.getUTCDate() + 6);
|
||||
return { startDate: formatUtcDate(monday), endDate: formatUtcDate(sunday) };
|
||||
return { startDate: formatUtcDate(monday), endDate: dateValue };
|
||||
}
|
||||
|
||||
function buildMonthDateRange(dateValue: string) {
|
||||
@@ -512,12 +642,9 @@ function buildMonthDateRange(dateValue: string) {
|
||||
const firstDate = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1),
|
||||
);
|
||||
const lastDate = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
|
||||
);
|
||||
return {
|
||||
startDate: formatUtcDate(firstDate),
|
||||
endDate: formatUtcDate(lastDate),
|
||||
endDate: dateValue,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
getAdminEditorShowcaseCampaign,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminEditorShowcaseCampaign,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminEditorShowcaseAssetPayload,
|
||||
AdminEditorShowcaseCampaignPayload,
|
||||
|
||||
@@ -10,13 +10,13 @@ import type {
|
||||
AdminTrackingEventKeyPayload,
|
||||
TrackingScopeKind,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import {
|
||||
buildAdminTrackingEventKeyOptions,
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
filterAdminTrackingEventDefinitions,
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminTrackingEventsPageProps {
|
||||
|
||||
@@ -361,6 +361,63 @@ button:disabled {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 112px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card + .admin-dashboard-retention-card {
|
||||
border-left: 1px solid #ead8ca;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > span {
|
||||
color: #8f7868;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > strong {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 2;
|
||||
color: #8f3f27;
|
||||
font-size: 30px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card small {
|
||||
min-width: 0;
|
||||
color: #a38f80;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card b {
|
||||
flex: 0 0 auto;
|
||||
color: #755a49;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operations {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -417,7 +474,10 @@ button:disabled {
|
||||
gap: 8px;
|
||||
min-height: 196px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
padding: 4px 2px 0;
|
||||
scrollbar-color: #bdaea3 #f4e5d7;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item {
|
||||
@@ -661,7 +721,17 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-inline-identity > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-inline-identity small {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-database-user-cell,
|
||||
@@ -750,6 +820,55 @@ button:disabled {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell > div:last-child small {
|
||||
display: block;
|
||||
max-width: 92px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-asset-query-expand-button {
|
||||
display: inline-flex;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #78523e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-asset-query-expand-spacer,
|
||||
.admin-asset-query-child-branch {
|
||||
display: block;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.admin-asset-query-child-row {
|
||||
background: #fffaf5;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell-child {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.admin-asset-query-child-branch {
|
||||
height: 34px;
|
||||
border-bottom: 1px solid #d8c3b3;
|
||||
border-left: 1px solid #d8c3b3;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
@@ -1212,7 +1331,7 @@ button:disabled {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-info-list div {
|
||||
.admin-info-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 0.34fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
@@ -1402,29 +1521,30 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-asset-query-table {
|
||||
min-width: 1080px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(1),
|
||||
.admin-asset-query-table td:nth-child(1) {
|
||||
width: 10%;
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(2),
|
||||
.admin-asset-query-table td:nth-child(2),
|
||||
.admin-asset-query-table th:nth-child(3),
|
||||
.admin-asset-query-table td:nth-child(3) {
|
||||
width: 14%;
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(4),
|
||||
.admin-asset-query-table td:nth-child(4) {
|
||||
width: 34%;
|
||||
width: 27%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(5),
|
||||
.admin-asset-query-table td:nth-child(5) {
|
||||
width: 8%;
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(6),
|
||||
@@ -1432,6 +1552,11 @@ button:disabled {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(7),
|
||||
.admin-asset-query-table td:nth-child(7) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
@@ -1990,10 +2115,19 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
.admin-dashboard-operation-grid,
|
||||
.admin-dashboard-retention-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card:nth-child(odd) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card:nth-child(n + 3) {
|
||||
border-top: 1px solid #ead8ca;
|
||||
}
|
||||
|
||||
.admin-field-compact {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -2126,11 +2260,19 @@ button:disabled {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.admin-info-list div {
|
||||
.admin-info-list > div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-thumb-button {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2139,8 +2281,30 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card + .admin-dashboard-retention-card {
|
||||
border-top: 1px solid #ead8ca;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > strong {
|
||||
grid-row: auto;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
.admin-dashboard-operation-grid,
|
||||
.admin-dashboard-retention-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const configPath = new URL('../src-tauri/tauri.conf.json', import.meta.url);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
@@ -1117,7 +1117,7 @@ function extractDesktopCapabilities(source) {
|
||||
}
|
||||
|
||||
function extractDesktopHandledMethods(source) {
|
||||
const matchBodies = [...source.matchAll(/match request\.method\.as_str\(\) \{([\s\S]*?)\n \}/g)].map(
|
||||
const matchBodies = [...source.matchAll(/match request\.method\.as_str\(\) \{([\s\S]*?)\n {4}\}/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
if (matchBodies.length === 0) {
|
||||
@@ -1152,7 +1152,7 @@ function extractDesktopHostBridgeMethodBody(source, method) {
|
||||
|
||||
const nextMethodMatch = source
|
||||
.slice(methodStart + method.length)
|
||||
.match(/\n (?:"[^"]+"|_)\s*=>/);
|
||||
.match(/\n {8}(?:"[^"]+"|_)\s*=>/);
|
||||
const nextMethodStart = nextMethodMatch
|
||||
? methodStart + method.length + nextMethodMatch.index
|
||||
: -1;
|
||||
@@ -1410,10 +1410,6 @@ function extractTauriInvokeCommands(source) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectRustSourceBasenames(files) {
|
||||
return files.map((file) => file.pathname.split('/').pop()).sort();
|
||||
}
|
||||
|
||||
function collectRustSourceRelativePaths(files) {
|
||||
const rootPath = rustSourceDir.pathname.endsWith('/')
|
||||
? rustSourceDir.pathname
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
|
||||
const shellRoot = new URL('../', import.meta.url);
|
||||
const repoRoot = path.resolve(shellRoot.pathname, '../..');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
@@ -82,11 +82,6 @@ const filesPath = new URL('../src/host-bridge/files.ts', import.meta.url);
|
||||
const filesSource = fs.readFileSync(filesPath, 'utf8');
|
||||
const filesTestPath = new URL('../src/host-bridge/files.test.ts', import.meta.url);
|
||||
const filesTestSource = fs.readFileSync(filesTestPath, 'utf8');
|
||||
const filePayloadsPath = new URL(
|
||||
'../src/host-bridge/filePayloads.ts',
|
||||
import.meta.url,
|
||||
);
|
||||
const filePayloadsSource = fs.readFileSync(filePayloadsPath, 'utf8');
|
||||
const hapticsPath = new URL('../src/host-bridge/haptics.ts', import.meta.url);
|
||||
const hapticsSource = fs.readFileSync(hapticsPath, 'utf8');
|
||||
const hapticsTestPath = new URL(
|
||||
@@ -495,7 +490,7 @@ function extractNumberConstExport(source, exportName) {
|
||||
|
||||
function extractMobileBridgeHandledMethods(source) {
|
||||
const match = source.match(
|
||||
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
|
||||
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n {2}\}/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error('unable to read mobile shell HostBridge handler methods');
|
||||
@@ -506,7 +501,7 @@ function extractMobileBridgeHandledMethods(source) {
|
||||
|
||||
function extractMobileBridgeUnsupportedMethods(source) {
|
||||
const match = source.match(
|
||||
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
|
||||
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n {2}\}/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error('unable to read mobile shell HostBridge unsupported methods');
|
||||
@@ -514,7 +509,7 @@ function extractMobileBridgeUnsupportedMethods(source) {
|
||||
|
||||
const unsupportedMethods = new Set();
|
||||
const casePattern =
|
||||
/case '([^']+)':([\s\S]*?)(?=\n case '|\n default:|\n \})/g;
|
||||
/case '([^']+)':([\s\S]*?)(?=\n {4}case '|\n {4}default:|\n {2}\})/g;
|
||||
for (const entry of match[1].matchAll(casePattern)) {
|
||||
if (entry[2].includes('unsupported(request.method)')) {
|
||||
unsupportedMethods.add(entry[1]);
|
||||
@@ -990,10 +985,6 @@ const sharedEvents = extractStringArrayExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_EVENTS',
|
||||
);
|
||||
const sharedBlockedDownloadProtocols = extractStringArrayExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
|
||||
);
|
||||
const sharedMobileBaseCapabilities = extractStringArrayExport(
|
||||
sharedContractSource,
|
||||
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const shellRoot = new URL('../', import.meta.url);
|
||||
const easConfigPath = new URL('eas.json', shellRoot);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user