@cs2dak/react 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/components/EconomyConversionPanel.tsx +1 -2
- package/src/components/EconomyPanel.tsx +5 -12
- package/src/components/KillFeed.tsx +1 -1
- package/src/components/MatchWorkspace.tsx +2 -17
- package/src/components/SeasonLeaderboard.test.ts +55 -0
- package/src/components/SeasonLeaderboard.tsx +128 -0
- package/src/index.ts +7 -3
- package/src/theme.css +9 -0
- package/src/components/DemoAnalysisDashboard.tsx +0 -178
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cs2dak/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"exports": {
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"lucide-react": "^0.468.0",
|
|
12
|
-
"@cs2dak/
|
|
13
|
-
"@cs2dak/
|
|
14
|
-
"@cs2dak/
|
|
12
|
+
"@cs2dak/contract": "1.0.0",
|
|
13
|
+
"@cs2dak/presentation": "1.0.0",
|
|
14
|
+
"@cs2dak/maps": "0.2.1"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"react": ">=18",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { economyLabelCn } from "@cs2dak/core";
|
|
1
|
+
import { economyLabelCn, type EconomyConversion } from "@cs2dak/presentation";
|
|
3
2
|
|
|
4
3
|
export interface EconomyConversionPanelProps {
|
|
5
4
|
/** One team's per-economy-type conversion (from buildEconomyConversion). */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EconomyPoint } from "@cs2dak/contract";
|
|
2
|
+
import { ECONOMY_LABEL_SHORT } from "@cs2dak/presentation";
|
|
2
3
|
|
|
3
4
|
export interface EconomyPanelProps {
|
|
4
5
|
points: EconomyPoint[];
|
|
@@ -9,14 +10,6 @@ export interface EconomyPanelProps {
|
|
|
9
10
|
const width = 760;
|
|
10
11
|
const height = 260;
|
|
11
12
|
const pad = { left: 48, right: 18, top: 24, bottom: 34 };
|
|
12
|
-
const economyLabels = {
|
|
13
|
-
pistol: "手枪",
|
|
14
|
-
eco: "Eco",
|
|
15
|
-
semi: "半起",
|
|
16
|
-
force: "强起",
|
|
17
|
-
full: "长枪",
|
|
18
|
-
conversion: "长枪"
|
|
19
|
-
} as const;
|
|
20
13
|
const economyColors = {
|
|
21
14
|
pistol: "rgba(255, 198, 77, 0.18)",
|
|
22
15
|
eco: "rgba(104, 115, 129, 0.18)",
|
|
@@ -111,8 +104,8 @@ export function EconomyPanel({ points, teamAName, teamBName }: EconomyPanelProps
|
|
|
111
104
|
{" 每回合装备价值;背景色为每队经济类型"}
|
|
112
105
|
</div>
|
|
113
106
|
<div className="dak-economy-legend" aria-label="经济类型图例">
|
|
114
|
-
{
|
|
115
|
-
<span key={type}><i style={{ background: economyColors[type] }} />{
|
|
107
|
+
{Object.keys(ECONOMY_LABEL_SHORT).map((type) => (
|
|
108
|
+
<span key={type}><i style={{ background: economyColors[type as keyof typeof economyColors] }} />{ECONOMY_LABEL_SHORT[type]}</span>
|
|
116
109
|
))}
|
|
117
110
|
</div>
|
|
118
111
|
<div className="dak-economy-rounds">
|
|
@@ -129,8 +122,8 @@ export function EconomyPanel({ points, teamAName, teamBName }: EconomyPanelProps
|
|
|
129
122
|
{upsetLabels[upsetType]}
|
|
130
123
|
</span>
|
|
131
124
|
)}
|
|
132
|
-
<span className={point.winnerTeamKey === "teamA" ? "dak-economy-winner" : undefined}>{teamAName}: {
|
|
133
|
-
<span className={point.winnerTeamKey === "teamB" ? "dak-economy-winner" : undefined}>{teamBName}: {
|
|
125
|
+
<span className={point.winnerTeamKey === "teamA" ? "dak-economy-winner" : undefined}>{teamAName}: {ECONOMY_LABEL_SHORT[point.teamAEconomy]}</span>
|
|
126
|
+
<span className={point.winnerTeamKey === "teamB" ? "dak-economy-winner" : undefined}>{teamBName}: {ECONOMY_LABEL_SHORT[point.teamBEconomy]}</span>
|
|
134
127
|
</div>
|
|
135
128
|
);
|
|
136
129
|
})}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MatchWorkspaceModel, WorkspaceReplayFrame, WorkspaceReplayRound, WorkspaceSpatialPoint } from "@cs2dak/contract";
|
|
2
|
-
import { displayWeaponName } from "@cs2dak/
|
|
2
|
+
import { displayWeaponName, sideLabel, economyLabelCn } from "@cs2dak/presentation";
|
|
3
3
|
import { getMapCalibration, worldToRadar } from "@cs2dak/maps";
|
|
4
4
|
import { Activity, BarChart3, ChevronLeft, ChevronRight, Crosshair, Film, Gauge, ListChecks, Map, Pause, Play, ShieldCheck, Table2, Users } from "lucide-react";
|
|
5
5
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
@@ -234,7 +234,7 @@ function PlayerStoryPanel({ model }: MatchWorkspaceProps) {
|
|
|
234
234
|
<b className="dak-mono">{fact.kills}/{fact.deaths}/{fact.assists}</b>
|
|
235
235
|
</div>
|
|
236
236
|
<div className="dak-player-round-meta">
|
|
237
|
-
<span>{
|
|
237
|
+
<span>{economyLabelCn(fact.economyType) || "未知经济"}</span>
|
|
238
238
|
<span>{fact.survived ? "存活" : "阵亡"}</span>
|
|
239
239
|
{fact.openingDuel !== "none" && <span>{openingDuelLabel(fact.openingDuel)}</span>}
|
|
240
240
|
</div>
|
|
@@ -565,21 +565,6 @@ function summarizePlayerRoundFacts(facts: MatchWorkspaceModel["players"][number]
|
|
|
565
565
|
);
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
-
function sideLabel(side: string) {
|
|
569
|
-
return side === "t" ? "进攻方" : "防守方";
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
function economyLabel(type: string | null) {
|
|
573
|
-
const labels: Record<string, string> = {
|
|
574
|
-
pistol: "手枪局",
|
|
575
|
-
eco: "ECO",
|
|
576
|
-
semi: "半起",
|
|
577
|
-
force: "强起",
|
|
578
|
-
full: "长枪",
|
|
579
|
-
conversion: "长枪"
|
|
580
|
-
};
|
|
581
|
-
return type ? labels[type] ?? type : "未知经济";
|
|
582
|
-
}
|
|
583
568
|
|
|
584
569
|
function openingDuelLabel(openingDuel: string) {
|
|
585
570
|
if (openingDuel === "won") return "首杀";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import type { SeasonLeaderboardModel } from "@cs2dak/contract";
|
|
5
|
+
import { SeasonLeaderboard } from "./SeasonLeaderboard";
|
|
6
|
+
|
|
7
|
+
// 组件只读 views + rows.metrics/name/playerKey;用最小 fixture 而非重建完整 schema。
|
|
8
|
+
const model = {
|
|
9
|
+
version: "cs2-demo-analysis-kit/leaderboard-0.1",
|
|
10
|
+
weightsVersion: "test",
|
|
11
|
+
matchCount: 1,
|
|
12
|
+
provenance: { cohortVersion: "cs2-demo-analysis-kit/cohort-1.0", sourceSchemaVersion: "cs2-demo-format/2.0", matches: [] },
|
|
13
|
+
views: [
|
|
14
|
+
{
|
|
15
|
+
key: "core",
|
|
16
|
+
label: "Core",
|
|
17
|
+
defaultSort: "rivalhubRR",
|
|
18
|
+
columns: [
|
|
19
|
+
{ key: "rivalhubRR", label: "RR", format: "rating", description: null },
|
|
20
|
+
{ key: "hsPercent", label: "HS%", format: "percent", description: null },
|
|
21
|
+
{ key: "kd", label: "K/D", format: "ratio", description: null }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
rows: [
|
|
26
|
+
{ playerKey: "steam:1", name: "Alice", steamIds: ["1"], externalUserId: null, teamKeys: [], mapCount: 1, confidence: 0.5, prism: null, metrics: { rivalhubRR: 1.234, hsPercent: 55.5, kd: 1.4 } },
|
|
27
|
+
{ playerKey: "steam:2", name: "Bob", steamIds: ["2"], externalUserId: null, teamKeys: [], mapCount: 1, confidence: 0.5, prism: null, metrics: { rivalhubRR: 0.9, hsPercent: 48, kd: null } }
|
|
28
|
+
]
|
|
29
|
+
} as unknown as SeasonLeaderboardModel;
|
|
30
|
+
|
|
31
|
+
describe("SeasonLeaderboard", () => {
|
|
32
|
+
it("formats metrics per column format and renders null as —", () => {
|
|
33
|
+
const html = renderToStaticMarkup(React.createElement(SeasonLeaderboard, { model }));
|
|
34
|
+
expect(html).toContain("Alice");
|
|
35
|
+
expect(html).toContain("Bob");
|
|
36
|
+
expect(html).toContain("1.23"); // rating: 2 位小数
|
|
37
|
+
expect(html).toContain("55.5%"); // percent: 0–100 + %
|
|
38
|
+
expect(html).toContain("—"); // Bob 的 K/D 为 null
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("sorts by the default sort column descending, nulls last", () => {
|
|
42
|
+
const html = renderToStaticMarkup(React.createElement(SeasonLeaderboard, { model }));
|
|
43
|
+
// 默认按 rivalhubRR 降序:Alice(1.234) 在 Bob(0.9) 前
|
|
44
|
+
expect(html.indexOf("Alice")).toBeLessThan(html.indexOf("Bob"));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("marks rows interactive when onPlayerClick is provided", () => {
|
|
48
|
+
const html = renderToStaticMarkup(
|
|
49
|
+
React.createElement(SeasonLeaderboard, { model, onPlayerClick: () => {} })
|
|
50
|
+
);
|
|
51
|
+
expect(html).toContain("dak-row-clickable");
|
|
52
|
+
expect(html).toContain('role="button"');
|
|
53
|
+
expect(html).toContain("查看 Alice 详情");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
LeaderboardFormat,
|
|
4
|
+
LeaderboardMetricKey,
|
|
5
|
+
LeaderboardViewKey,
|
|
6
|
+
SeasonLeaderboardModel
|
|
7
|
+
} from "@cs2dak/contract";
|
|
8
|
+
|
|
9
|
+
export interface SeasonLeaderboardProps {
|
|
10
|
+
model: SeasonLeaderboardModel;
|
|
11
|
+
/**
|
|
12
|
+
* 提供后每行可点击(鼠标 + 键盘),回报该选手的 playerKey。
|
|
13
|
+
* 让嵌入产品(RivalHub)接导航而不必 fork 本组件。
|
|
14
|
+
*/
|
|
15
|
+
onPlayerClick?: (playerKey: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 按 format 把原始值渲染为展示文本;缺失统一显示 “—”(不伪造 0)。 */
|
|
19
|
+
function formatMetric(value: number | null | undefined, format: LeaderboardFormat): string {
|
|
20
|
+
if (value == null) return "—";
|
|
21
|
+
switch (format) {
|
|
22
|
+
case "integer":
|
|
23
|
+
return String(Math.round(value));
|
|
24
|
+
case "rating":
|
|
25
|
+
case "ratio":
|
|
26
|
+
return value.toFixed(2);
|
|
27
|
+
case "adr":
|
|
28
|
+
return value.toFixed(1);
|
|
29
|
+
case "percent":
|
|
30
|
+
return `${value.toFixed(1)}%`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function SeasonLeaderboard({ model, onPlayerClick }: SeasonLeaderboardProps) {
|
|
35
|
+
const [viewKey, setViewKey] = useState<LeaderboardViewKey>(model.views[0]?.key ?? "core");
|
|
36
|
+
const view = model.views.find((v) => v.key === viewKey) ?? model.views[0];
|
|
37
|
+
const [sortKey, setSortKey] = useState<LeaderboardMetricKey>(view.defaultSort);
|
|
38
|
+
|
|
39
|
+
function selectView(next: LeaderboardViewKey) {
|
|
40
|
+
setViewKey(next);
|
|
41
|
+
const nextView = model.views.find((v) => v.key === next);
|
|
42
|
+
if (nextView) setSortKey(nextView.defaultSort);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 排序在客户端:降序,缺失(null)始终排在最后。
|
|
46
|
+
const rows = useMemo(() => {
|
|
47
|
+
return [...model.rows].sort((a, b) => {
|
|
48
|
+
const va = a.metrics[sortKey];
|
|
49
|
+
const vb = b.metrics[sortKey];
|
|
50
|
+
if (va == null && vb == null) return 0;
|
|
51
|
+
if (va == null) return 1;
|
|
52
|
+
if (vb == null) return -1;
|
|
53
|
+
return vb - va;
|
|
54
|
+
});
|
|
55
|
+
}, [model.rows, sortKey]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="dak-leaderboard">
|
|
59
|
+
<div className="dak-tabs" role="tablist">
|
|
60
|
+
{model.views.map((v) => (
|
|
61
|
+
<button
|
|
62
|
+
key={v.key}
|
|
63
|
+
type="button"
|
|
64
|
+
role="tab"
|
|
65
|
+
aria-selected={v.key === viewKey}
|
|
66
|
+
className={v.key === viewKey ? "dak-tab dak-tab-active" : "dak-tab"}
|
|
67
|
+
onClick={() => selectView(v.key)}
|
|
68
|
+
>
|
|
69
|
+
{v.label}
|
|
70
|
+
</button>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<table className="dak-table">
|
|
75
|
+
<thead>
|
|
76
|
+
<tr>
|
|
77
|
+
<th>#</th>
|
|
78
|
+
<th>选手</th>
|
|
79
|
+
{view.columns.map((col) => (
|
|
80
|
+
<th
|
|
81
|
+
key={col.key}
|
|
82
|
+
title={col.description ?? undefined}
|
|
83
|
+
aria-sort={col.key === sortKey ? "descending" : undefined}
|
|
84
|
+
className={col.key === sortKey ? "dak-col-sorted" : "dak-col-sortable"}
|
|
85
|
+
onClick={() => setSortKey(col.key)}
|
|
86
|
+
>
|
|
87
|
+
{col.label}
|
|
88
|
+
</th>
|
|
89
|
+
))}
|
|
90
|
+
</tr>
|
|
91
|
+
</thead>
|
|
92
|
+
<tbody>
|
|
93
|
+
{rows.map((row, i) => (
|
|
94
|
+
<tr
|
|
95
|
+
key={row.playerKey}
|
|
96
|
+
className={onPlayerClick ? "dak-row-clickable" : undefined}
|
|
97
|
+
onClick={onPlayerClick ? () => onPlayerClick(row.playerKey) : undefined}
|
|
98
|
+
onKeyDown={
|
|
99
|
+
onPlayerClick
|
|
100
|
+
? (e) => {
|
|
101
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
onPlayerClick(row.playerKey);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
: undefined
|
|
107
|
+
}
|
|
108
|
+
tabIndex={onPlayerClick ? 0 : undefined}
|
|
109
|
+
role={onPlayerClick ? "button" : undefined}
|
|
110
|
+
aria-label={onPlayerClick ? `查看 ${row.name} 详情` : undefined}
|
|
111
|
+
>
|
|
112
|
+
<td className="dak-mono">{i + 1}</td>
|
|
113
|
+
<td>{row.name}</td>
|
|
114
|
+
{view.columns.map((col) => (
|
|
115
|
+
<td
|
|
116
|
+
key={col.key}
|
|
117
|
+
className={col.key === sortKey ? "dak-mono dak-col-sorted" : "dak-mono"}
|
|
118
|
+
>
|
|
119
|
+
{formatMetric(row.metrics[col.key], col.format)}
|
|
120
|
+
</td>
|
|
121
|
+
))}
|
|
122
|
+
</tr>
|
|
123
|
+
))}
|
|
124
|
+
</tbody>
|
|
125
|
+
</table>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
export { MatchWorkspace
|
|
2
|
-
export { DemoAnalysisDashboard } from "./components/DemoAnalysisDashboard";
|
|
1
|
+
export { MatchWorkspace } from "./components/MatchWorkspace";
|
|
3
2
|
export { ScoreboardTable } from "./components/ScoreboardTable";
|
|
4
3
|
export { RoundTimeline } from "./components/RoundTimeline";
|
|
5
4
|
export { EconomyPanel } from "./components/EconomyPanel";
|
|
6
|
-
export { EconomyConversionPanel } from "./components/EconomyConversionPanel";
|
|
7
5
|
export { HeatmapCanvas } from "./components/HeatmapCanvas";
|
|
8
6
|
export { KillFeed } from "./components/KillFeed";
|
|
9
7
|
export { QaReportPanel } from "./components/QaReportPanel";
|
|
8
|
+
export { SeasonLeaderboard } from "./components/SeasonLeaderboard";
|
|
9
|
+
|
|
10
|
+
// AdminQaWorkspace 和 EconomyConversionPanel 保留在源文件中但不再从公共 API 导出:
|
|
11
|
+
// 仓库内零消费者,等待 DAK Studio 建设时重新决定公共面。
|
|
12
|
+
// import { AdminQaWorkspace } from "./components/MatchWorkspace";
|
|
13
|
+
// import { EconomyConversionPanel } from "./components/EconomyConversionPanel";
|
package/src/theme.css
CHANGED
|
@@ -151,6 +151,15 @@
|
|
|
151
151
|
background: linear-gradient(180deg, rgba(255, 122, 33, 0.2), rgba(255, 122, 33, 0.08));
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
.dak-leaderboard .dak-col-sortable {
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.dak-leaderboard .dak-col-sorted {
|
|
159
|
+
color: var(--dak-fg);
|
|
160
|
+
background: rgba(255, 122, 33, 0.08);
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
.dak-grid {
|
|
155
164
|
display: grid;
|
|
156
165
|
grid-template-columns: minmax(0, 1.24fr) minmax(380px, 0.76fr);
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import type { DemoViewModel } from "@cs2dak/contract";
|
|
2
|
-
import { Activity, Crosshair, Gauge, ListChecks, Map, ShieldAlert, Table2 } from "lucide-react";
|
|
3
|
-
import { useMemo, useState, type ReactNode } from "react";
|
|
4
|
-
import { EconomyPanel } from "./EconomyPanel";
|
|
5
|
-
import { HeatmapCanvas } from "./HeatmapCanvas";
|
|
6
|
-
import { QaReportPanel } from "./QaReportPanel";
|
|
7
|
-
import { RoundTimeline } from "./RoundTimeline";
|
|
8
|
-
import { ScoreboardTable } from "./ScoreboardTable";
|
|
9
|
-
|
|
10
|
-
export interface DemoAnalysisDashboardProps {
|
|
11
|
-
model: DemoViewModel;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
type ViewKey = "overview" | "rounds" | "spatial" | "qa";
|
|
15
|
-
|
|
16
|
-
export function DemoAnalysisDashboard({ model }: DemoAnalysisDashboardProps) {
|
|
17
|
-
const [view, setView] = useState<ViewKey>("overview");
|
|
18
|
-
const topPlayer = model.scoreboard[0];
|
|
19
|
-
const economySwing = useMemo(
|
|
20
|
-
() => model.economy.reduce((max, point) => Math.max(max, Math.abs(point.advantage)), 0),
|
|
21
|
-
[model.economy]
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<main className="dak-shell">
|
|
26
|
-
<div className="dak-workspace">
|
|
27
|
-
<header className="dak-header">
|
|
28
|
-
<div>
|
|
29
|
-
<div className="dak-eyebrow">CS2 Demo Analysis Kit</div>
|
|
30
|
-
<h1 className="dak-title">{model.title}</h1>
|
|
31
|
-
<p className="dak-subtitle">{model.subtitle}</p>
|
|
32
|
-
</div>
|
|
33
|
-
<div className="dak-scoreblock">
|
|
34
|
-
<div className="dak-scoreline">{model.scoreline}</div>
|
|
35
|
-
<div className="dak-mapline">{model.map.name}</div>
|
|
36
|
-
</div>
|
|
37
|
-
</header>
|
|
38
|
-
|
|
39
|
-
<section className="dak-kpi-strip">
|
|
40
|
-
<Metric icon={<Gauge size={16} />} label="最高 V2 RR" value={topPlayer ? topPlayer.accountRR.toFixed(3) : "0.000"} detail={topPlayer?.name ?? "暂无选手"} />
|
|
41
|
-
<Metric icon={<Crosshair size={16} />} label="最高 ADR" value={topPlayer ? topPlayer.adr.toFixed(1) : "0.0"} detail="来自伤害统计" />
|
|
42
|
-
<Metric icon={<Activity size={16} />} label="最大装备差" value={`$${economySwing.toLocaleString()}`} detail={`${model.economy.length} 回合`} />
|
|
43
|
-
<Metric icon={<ShieldAlert size={16} />} label="数据检查" value={model.qa.summary.issueCount.toString()} detail={model.qa.ok ? "通过" : "需要检查"} />
|
|
44
|
-
</section>
|
|
45
|
-
|
|
46
|
-
<nav className="dak-tabs" aria-label="Analysis views">
|
|
47
|
-
<TabButton active={view === "overview"} icon={<Table2 size={16} />} label="总览" onClick={() => setView("overview")} />
|
|
48
|
-
<TabButton active={view === "rounds"} icon={<ListChecks size={16} />} label="回合" onClick={() => setView("rounds")} />
|
|
49
|
-
<TabButton active={view === "spatial"} icon={<Map size={16} />} label="地图" onClick={() => setView("spatial")} />
|
|
50
|
-
<TabButton active={view === "qa"} icon={<ShieldAlert size={16} />} label="检查" onClick={() => setView("qa")} />
|
|
51
|
-
</nav>
|
|
52
|
-
|
|
53
|
-
{view === "overview" && (
|
|
54
|
-
<div className="dak-grid">
|
|
55
|
-
<section className="dak-stack">
|
|
56
|
-
<Panel title="选手数据 / RR">
|
|
57
|
-
<ScoreboardTable rows={model.scoreboard} />
|
|
58
|
-
</Panel>
|
|
59
|
-
<Panel title="经济走势">
|
|
60
|
-
<EconomyPanel
|
|
61
|
-
points={model.economy}
|
|
62
|
-
teamAName={model.teams.teamA.name}
|
|
63
|
-
teamBName={model.teams.teamB.name}
|
|
64
|
-
/>
|
|
65
|
-
</Panel>
|
|
66
|
-
</section>
|
|
67
|
-
|
|
68
|
-
<aside className="dak-stack">
|
|
69
|
-
<Panel title="地图热力图">
|
|
70
|
-
<HeatmapCanvas map={model.map} points={model.heatmap} />
|
|
71
|
-
</Panel>
|
|
72
|
-
<Panel title="回合事件">
|
|
73
|
-
<RoundTimeline events={model.timeline} />
|
|
74
|
-
</Panel>
|
|
75
|
-
</aside>
|
|
76
|
-
</div>
|
|
77
|
-
)}
|
|
78
|
-
|
|
79
|
-
{view === "rounds" && (
|
|
80
|
-
<div className="dak-grid dak-grid-even">
|
|
81
|
-
<Panel title="回合事件">
|
|
82
|
-
<RoundTimeline events={model.timeline} />
|
|
83
|
-
</Panel>
|
|
84
|
-
<Panel title="选手回合明细">
|
|
85
|
-
<div className="dak-roundfact-list">
|
|
86
|
-
{model.playerRoundFacts.slice(0, 80).map((fact) => (
|
|
87
|
-
<div className="dak-roundfact-row" key={`${fact.roundNumber}-${fact.steamId64}`}>
|
|
88
|
-
<span className="dak-badge">R{fact.roundNumber}</span>
|
|
89
|
-
<span>{fact.name}</span>
|
|
90
|
-
<span className="dak-muted">{sideLabel(fact.side)} · {economyLabel(fact.economyType)}</span>
|
|
91
|
-
<span className="dak-mono">{fact.kills}/{fact.deaths}/{fact.assists}</span>
|
|
92
|
-
<span className="dak-tags">{fact.kastTags.length > 0 ? fact.kastTags.map(kastLabel).join("、") : "无贡献回合"}</span>
|
|
93
|
-
</div>
|
|
94
|
-
))}
|
|
95
|
-
</div>
|
|
96
|
-
</Panel>
|
|
97
|
-
</div>
|
|
98
|
-
)}
|
|
99
|
-
|
|
100
|
-
{view === "spatial" && (
|
|
101
|
-
<div className="dak-grid dak-grid-even">
|
|
102
|
-
<Panel title="热力图">
|
|
103
|
-
<HeatmapCanvas map={model.map} points={model.heatmap} />
|
|
104
|
-
</Panel>
|
|
105
|
-
<Panel title="经济走势">
|
|
106
|
-
<EconomyPanel points={model.economy} teamAName={model.teams.teamA.name} teamBName={model.teams.teamB.name} />
|
|
107
|
-
</Panel>
|
|
108
|
-
</div>
|
|
109
|
-
)}
|
|
110
|
-
|
|
111
|
-
{view === "qa" && (
|
|
112
|
-
<Panel title="数据检查">
|
|
113
|
-
<QaReportPanel report={model.qa} />
|
|
114
|
-
</Panel>
|
|
115
|
-
)}
|
|
116
|
-
</div>
|
|
117
|
-
</main>
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function sideLabel(side: string) {
|
|
122
|
-
return side === "t" ? "进攻方" : "防守方";
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function economyLabel(type: string | null) {
|
|
126
|
-
const labels: Record<string, string> = {
|
|
127
|
-
pistol: "手枪局",
|
|
128
|
-
eco: "ECO",
|
|
129
|
-
semi: "半起",
|
|
130
|
-
force: "强起",
|
|
131
|
-
full: "长枪",
|
|
132
|
-
conversion: "长枪"
|
|
133
|
-
};
|
|
134
|
-
return type ? labels[type] ?? type : "未知经济";
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function kastLabel(tag: string) {
|
|
138
|
-
const labels: Record<string, string> = {
|
|
139
|
-
kill: "击杀",
|
|
140
|
-
assist: "助攻",
|
|
141
|
-
survive: "存活",
|
|
142
|
-
trade: "被补枪"
|
|
143
|
-
};
|
|
144
|
-
return labels[tag] ?? tag;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function Metric({ icon, label, value, detail }: { icon: ReactNode; label: string; value: string; detail: string }) {
|
|
148
|
-
return (
|
|
149
|
-
<div className="dak-metric">
|
|
150
|
-
<div className="dak-metric-icon">{icon}</div>
|
|
151
|
-
<div>
|
|
152
|
-
<div className="dak-metric-label">{label}</div>
|
|
153
|
-
<div className="dak-metric-value">{value}</div>
|
|
154
|
-
<div className="dak-metric-detail">{detail}</div>
|
|
155
|
-
</div>
|
|
156
|
-
</div>
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function TabButton({ active, icon, label, onClick }: { active: boolean; icon: ReactNode; label: string; onClick: () => void }) {
|
|
161
|
-
return (
|
|
162
|
-
<button className={active ? "dak-tab dak-tab-active" : "dak-tab"} type="button" onClick={onClick}>
|
|
163
|
-
{icon}
|
|
164
|
-
<span>{label}</span>
|
|
165
|
-
</button>
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function Panel({ title, children }: { title: string; children: ReactNode }) {
|
|
170
|
-
return (
|
|
171
|
-
<section className="dak-panel">
|
|
172
|
-
<div className="dak-panel-header">
|
|
173
|
-
<h2 className="dak-panel-title">{title}</h2>
|
|
174
|
-
</div>
|
|
175
|
-
<div className="dak-panel-body">{children}</div>
|
|
176
|
-
</section>
|
|
177
|
-
);
|
|
178
|
-
}
|