1- import React , { useEffect , useState } from 'react' ;
2- import { Card , Space , DatePicker , Input , Button , Table , Tag , Select } from 'antd' ;
1+ import React , { useCallback , useEffect , useState } from 'react' ;
2+ import {
3+ Card ,
4+ Space ,
5+ DatePicker ,
6+ Input ,
7+ Button ,
8+ Table ,
9+ Tag ,
10+ Select ,
11+ Statistic ,
12+ Row ,
13+ Col ,
14+ } from 'antd' ;
315import type { Dayjs } from 'dayjs' ;
416import { PageContainer } from '@ant-design/pro-components' ;
517import { exportToXLSX } from '@/utils/export' ;
@@ -16,21 +28,36 @@ export default function AnalyticsLevelsPage() {
1628 const [ data , setData ] = useState < LevelsData | null > ( null ) ;
1729 const [ seg , setSeg ] = useState < 'all' | 'new' | 'returning' | 'payer' > ( 'all' ) ;
1830
19- const load = async ( ) => {
31+ const load = useCallback ( async ( ) => {
2032 setLoading ( true ) ;
2133 try {
2234 const params : Record < string , string | number > = { episode } ;
2335 if ( range && range [ 0 ] ) params . start = range [ 0 ] . toISOString ( ) ;
2436 if ( range && range [ 1 ] ) params . end = range [ 1 ] . toISOString ( ) ;
2537 const r = await fetchAnalyticsLevels ( params ) ;
26- setData ( r || { funnel : [ ] , perLevel : [ ] , perLevelSegments : { } } ) ;
38+ const perLevel = ( r ?. levels || [ ] ) . map ( ( item ) => ( {
39+ level : item . levelId ,
40+ players : item . attempts ,
41+ winRate : item . completionRate * 100 ,
42+ avgDurationSec : item . avgDuration ,
43+ avgRetries : item . avgRetries ,
44+ } ) ) ;
45+ setData ( {
46+ perLevel,
47+ perLevelSegments : { } ,
48+ funnel : perLevel . map ( ( item ) => ( {
49+ step : item . level ,
50+ users : item . players ,
51+ rate : item . winRate ,
52+ } ) ) ,
53+ } ) ;
2754 } finally {
2855 setLoading ( false ) ;
2956 }
30- } ;
57+ } , [ episode , range ] ) ;
3158 useEffect ( ( ) => {
32- /* do not auto- load */
33- } , [ ] ) ;
59+ void load ( ) ;
60+ } , [ load ] ) ;
3461
3562 const exportCSV = ( ) => {
3663 try {
@@ -140,11 +167,11 @@ export default function AnalyticsLevelsPage() {
140167 { title : '参与人数' , dataIndex : 'players' } ,
141168 {
142169 title : '胜率' ,
143- dataIndex : 'win_rate ' ,
144- render : ( v ) => ( v != null ? `${ v } %` : '-' ) ,
170+ dataIndex : 'winRate ' ,
171+ render : ( v : number ) => ( v != null ? `${ v . toFixed ( 2 ) } %` : '-' ) ,
145172 } ,
146- { title : '平均通关时长(s)' , dataIndex : 'avg_duration_sec ' } ,
147- { title : '平均复试次数' , dataIndex : 'avg_retries ' } ,
173+ { title : '平均通关时长(s)' , dataIndex : 'avgDurationSec ' } ,
174+ { title : '平均复试次数' , dataIndex : 'avgRetries ' } ,
148175 {
149176 title : '难度' ,
150177 render : ( _ : unknown , r : LevelData ) => {
@@ -305,38 +332,51 @@ const LevelsSegmentsChart: React.FC<{ data: LevelsData | null }> = ({ data }) =>
305332
306333interface EpisodeData {
307334 episode : string ;
308- perLevel ?: LevelData [ ] ;
335+ players : number ;
336+ completionRate : number ;
337+ avgProgress : number ;
309338}
310339
311340interface MapData {
312341 map : string ;
313- perLevel ?: LevelData [ ] ;
342+ heatMap : Array < Record < string , number > > ;
343+ deathSpots : Array < Record < string , number > > ;
314344}
315345
316346const EpisodeFacets : React . FC < { range : [ Dayjs | null , Dayjs | null ] | null } > = ( { range } ) => {
317347 const [ episodes , setEpisodes ] = useState < EpisodeData [ ] > ( [ ] ) ;
318348 const [ loading , setLoading ] = useState ( false ) ;
319349 const [ limit , setLimit ] = useState ( 6 ) ;
320- const load = async ( ) => {
350+ const load = useCallback ( async ( ) => {
321351 setLoading ( true ) ;
322352 try {
323353 const params : Record < string , string | number > = { } ;
324354 if ( range && range [ 0 ] ) params . start = range [ 0 ] . toISOString ( ) ;
325355 if ( range && range [ 1 ] ) params . end = range [ 1 ] . toISOString ( ) ;
326356 const r = await fetchAnalyticsLevelsEpisodes ( params ) ;
327- setEpisodes ( r ?. episodes || [ ] ) ;
357+ setEpisodes (
358+ ( r ?. episodes || [ ] ) . map ( ( item ) => ( {
359+ episode : item . episodeId ,
360+ players : item . players ,
361+ completionRate : item . completionRate * 100 ,
362+ avgProgress : item . avgProgress * 100 ,
363+ } ) ) ,
364+ ) ;
328365 } finally {
329366 setLoading ( false ) ;
330367 }
331- } ;
332- useEffect ( ( ) => { } , [ ] ) ;
368+ } , [ range ] ) ;
369+ useEffect ( ( ) => {
370+ void load ( ) ;
371+ } , [ load ] ) ;
333372 const exportExcel = async ( ) => {
334373 try {
335374 const sheets : { sheet : string ; rows : string [ ] [ ] } [ ] = [ ] ;
336375 ( episodes || [ ] ) . forEach ( ( e ) => {
337- const rows = [ [ 'level' , 'players' , 'win_rate' ] ] . concat (
338- ( e . perLevel || [ ] ) . map ( ( x ) => [ String ( x . level ) , String ( x . players ) , String ( x . winRate ) ] ) ,
339- ) ;
376+ const rows = [
377+ [ 'episode' , 'players' , 'completion_rate' , 'avg_progress' ] ,
378+ [ String ( e . episode ) , String ( e . players ) , String ( e . completionRate ) , String ( e . avgProgress ) ] ,
379+ ] ;
340380 sheets . push ( { sheet : `ep_${ String ( e . episode || '' ) } ` , rows } ) ;
341381 } ) ;
342382 await exportToXLSX ( 'levels_episodes.csv' , sheets ) ;
@@ -382,25 +422,32 @@ const MapFacets: React.FC<{ range: [Dayjs | null, Dayjs | null] | null }> = ({ r
382422 const [ maps , setMaps ] = useState < MapData [ ] > ( [ ] ) ;
383423 const [ loading , setLoading ] = useState ( false ) ;
384424 const [ limit , setLimit ] = useState ( 6 ) ;
385- const load = async ( ) => {
425+ const load = useCallback ( async ( ) => {
386426 setLoading ( true ) ;
387427 try {
388428 const params : Record < string , string | number > = { } ;
389429 if ( range && range [ 0 ] ) params . start = range [ 0 ] . toISOString ( ) ;
390430 if ( range && range [ 1 ] ) params . end = range [ 1 ] . toISOString ( ) ;
391431 const r = await fetchAnalyticsLevelsMaps ( params ) ;
392- setMaps ( r ?. maps || [ ] ) ;
432+ setMaps (
433+ ( r ?. maps || [ ] ) . map ( ( item ) => ( {
434+ map : item . mapId ,
435+ heatMap : Array . isArray ( item . heatMap ) ? item . heatMap : [ ] ,
436+ deathSpots : Array . isArray ( item . deathSpots ) ? item . deathSpots : [ ] ,
437+ } ) ) ,
438+ ) ;
393439 } finally {
394440 setLoading ( false ) ;
395441 }
396- } ;
442+ } , [ range ] ) ;
397443 const exportExcel = async ( ) => {
398444 try {
399445 const sheets : { sheet : string ; rows : string [ ] [ ] } [ ] = [ ] ;
400446 ( maps || [ ] ) . forEach ( ( e ) => {
401- const rows = [ [ 'level' , 'players' , 'win_rate' ] ] . concat (
402- ( e . perLevel || [ ] ) . map ( ( x ) => [ String ( x . level ) , String ( x . players ) , String ( x . winRate ) ] ) ,
403- ) ;
447+ const rows = [
448+ [ 'map' , 'heat_points' , 'death_points' ] ,
449+ [ String ( e . map ) , String ( e . heatMap . length ) , String ( e . deathSpots . length ) ] ,
450+ ] ;
404451 sheets . push ( { sheet : `map_${ String ( e . map || '' ) } ` , rows } ) ;
405452 } ) ;
406453 await exportToXLSX ( 'levels_maps.csv' , sheets ) ;
@@ -443,69 +490,34 @@ const MapFacets: React.FC<{ range: [Dayjs | null, Dayjs | null] | null }> = ({ r
443490} ;
444491
445492const MapFacet : React . FC < { item : MapData } > = ( { item } ) => {
446- try {
447- const arr = item ?. perLevel || [ ] ;
448- if ( ! arr . length ) return null ;
449- const w = 300 ,
450- h = 160 ,
451- left = 30 ,
452- bottom = 20 ,
453- right = 10 ,
454- topm = 16 ;
455- const levels = arr . map ( ( x ) => String ( x . level ) ) ;
456- const maxY = Math . max ( 100 , ...arr . map ( ( x ) => Number ( x . winRate || 0 ) ) ) ;
457- const sx = ( i : number ) => left + ( ( w - left - right ) * i ) / Math . max ( 1 , levels . length - 1 ) ;
458- const sy = ( v : number ) => topm + ( h - topm - bottom ) * ( 1 - v / Math . max ( 1 , maxY ) ) ;
459- const d = arr . map ( ( x , i ) => `${ i ? 'L' : 'M' } ${ sx ( i ) } ,${ sy ( Number ( x . winRate || 0 ) ) } ` ) . join ( ' ' ) ;
460- return (
461- < Card size = "small" title = { String ( item ?. map || '-' ) } >
462- < svg width = { w } height = { h } style = { { display : 'block' } } >
463- < line x1 = { left } y1 = { topm } x2 = { left } y2 = { h - bottom } stroke = "#ddd" />
464- < line x1 = { left } y1 = { h - bottom } x2 = { w - right } y2 = { h - bottom } stroke = "#ddd" />
465- < path d = { d } fill = "none" stroke = "#1677ff" strokeWidth = { 2 } />
466- { levels . map ( ( lv , i ) => (
467- < text key = { lv } x = { sx ( i ) } y = { h - bottom + 12 } fontSize = { 10 } textAnchor = "middle" >
468- { lv }
469- </ text >
470- ) ) }
471- </ svg >
472- </ Card >
473- ) ;
474- } catch {
475- return null ;
476- }
493+ return (
494+ < Card size = "small" title = { item . map || '-' } >
495+ < Row gutter = { 8 } >
496+ < Col span = { 12 } >
497+ < Statistic title = "热力点" value = { item . heatMap . length } />
498+ </ Col >
499+ < Col span = { 12 } >
500+ < Statistic title = "死亡点" value = { item . deathSpots . length } />
501+ </ Col >
502+ </ Row >
503+ </ Card >
504+ ) ;
477505} ;
478506
479507const EpisodeFacet : React . FC < { episode : EpisodeData } > = ( { episode } ) => {
480- try {
481- const arr = episode ?. perLevel || [ ] ;
482- if ( ! arr . length ) return null ;
483- const w = 300 ,
484- h = 160 ,
485- left = 30 ,
486- bottom = 20 ,
487- right = 10 ,
488- topm = 16 ;
489- const levels = arr . map ( ( x ) => String ( x . level ) ) ;
490- const maxY = Math . max ( 100 , ...arr . map ( ( x ) => Number ( x . winRate || 0 ) ) ) ;
491- const sx = ( i : number ) => left + ( ( w - left - right ) * i ) / Math . max ( 1 , levels . length - 1 ) ;
492- const sy = ( v : number ) => topm + ( h - topm - bottom ) * ( 1 - v / Math . max ( 1 , maxY ) ) ;
493- const d = arr . map ( ( x , i ) => `${ i ? 'L' : 'M' } ${ sx ( i ) } ,${ sy ( Number ( x . winRate || 0 ) ) } ` ) . join ( ' ' ) ;
494- return (
495- < Card size = "small" title = { String ( episode ?. episode || '-' ) } >
496- < svg width = { w } height = { h } style = { { display : 'block' } } >
497- < line x1 = { left } y1 = { topm } x2 = { left } y2 = { h - bottom } stroke = "#ddd" />
498- < line x1 = { left } y1 = { h - bottom } x2 = { w - right } y2 = { h - bottom } stroke = "#ddd" />
499- < path d = { d } fill = "none" stroke = "#1677ff" strokeWidth = { 2 } />
500- { levels . map ( ( lv , i ) => (
501- < text key = { lv } x = { sx ( i ) } y = { h - bottom + 12 } fontSize = { 10 } textAnchor = "middle" >
502- { lv }
503- </ text >
504- ) ) }
505- </ svg >
506- </ Card >
507- ) ;
508- } catch {
509- return null ;
510- }
508+ return (
509+ < Card size = "small" title = { episode . episode || '-' } >
510+ < Row gutter = { 8 } >
511+ < Col span = { 8 } >
512+ < Statistic title = "玩家" value = { episode . players } />
513+ </ Col >
514+ < Col span = { 8 } >
515+ < Statistic title = "完成率" value = { episode . completionRate } suffix = "%" precision = { 2 } />
516+ </ Col >
517+ < Col span = { 8 } >
518+ < Statistic title = "平均进度" value = { episode . avgProgress } suffix = "%" precision = { 2 } />
519+ </ Col >
520+ </ Row >
521+ </ Card >
522+ ) ;
511523} ;
0 commit comments