Skip to content

Commit 5f960b7

Browse files
committed
refactor(dashboard): typed analytics contracts and invoke/directory hardening
- analytics services return typed DTOs (overview/retention/levels/ payments), normalize legacy start/end params to startDate/endDate, and stop swallowing request errors; Behavior auto-loads its event table now that the contract is stable - function directory uses the summary endpoint as the single source of truth with descriptors as optional enrichment instead of a silent descriptors fallback - invoke page validates targeted/hash route prerequisites before dispatching instead of relying on a backend 400 - instances debug dialog keeps the resolved schema in state and runs rjsf ajv8 validation on the payload before execution - function-call history page drops the unimplemented rerun action and its dead imports
1 parent 29aed9d commit 5f960b7

13 files changed

Lines changed: 970 additions & 530 deletions

File tree

web/src/pages/Analytics/Behavior/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ export default function AnalyticsBehaviorPage() {
101101
}
102102
};
103103
useEffect(() => {
104-
/* do not auto-load to avoid 404s before backend ready */
104+
void load();
105+
// The endpoint is part of the analytics contract; an empty event filter means all events.
106+
// eslint-disable-next-line react-hooks/exhaustive-deps
105107
}, []);
106108

107109
const [steps, setSteps] = useState<string[]>([]);

web/src/pages/Analytics/Levels/index.tsx

Lines changed: 100 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
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';
315
import type { Dayjs } from 'dayjs';
416
import { PageContainer } from '@ant-design/pro-components';
517
import { 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

306333
interface EpisodeData {
307334
episode: string;
308-
perLevel?: LevelData[];
335+
players: number;
336+
completionRate: number;
337+
avgProgress: number;
309338
}
310339

311340
interface MapData {
312341
map: string;
313-
perLevel?: LevelData[];
342+
heatMap: Array<Record<string, number>>;
343+
deathSpots: Array<Record<string, number>>;
314344
}
315345

316346
const 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

445492
const 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

479507
const 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

Comments
 (0)