feat: add admin stats page with token usage
This commit is contained in:
@@ -6,6 +6,7 @@ import StoryDetailPage from "./pages/StoryDetailPage";
|
||||
import CreateStoryPage from "./pages/CreateStoryPage";
|
||||
import CharactersPage from "./pages/CharactersPage";
|
||||
import GamePage from "./pages/GamePage";
|
||||
import AdminPage from "./pages/AdminPage";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
@@ -22,6 +23,7 @@ function App() {
|
||||
<Route path="/edit/:id" element={<CreateStoryPage />} />
|
||||
<Route path="/characters" element={<CharactersPage />} />
|
||||
<Route path="/play/:id" element={<GamePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,9 @@ export function Header() {
|
||||
<Link to="/characters" className="nav-link">
|
||||
Персонажи
|
||||
</Link>
|
||||
<Link to="/admin" className="nav-link">
|
||||
📊 Статистика
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
.admin-page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.admin-page h1 {
|
||||
margin-bottom: 2rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.admin-page h2 {
|
||||
margin: 2rem 0 1rem;
|
||||
color: #b0b0b0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.admin-loading,
|
||||
.admin-error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #888;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Stats Overview */
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #2a2a3e 0%, #1e1e2e 100%);
|
||||
border: 1px solid #3a3a5a;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stat-card.highlight {
|
||||
background: linear-gradient(135deg, #2a3a4e 0%, #1e2e3e 100%);
|
||||
border-color: #4a6a8a;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.stat-card.highlight .stat-value {
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Stories Table */
|
||||
.stories-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #3a3a5a;
|
||||
}
|
||||
|
||||
.stories-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
.stories-table th {
|
||||
background: #2a2a3e;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
color: #b0b0b0;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stories-table td {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #2a2a3a;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.stories-table tbody tr:hover {
|
||||
background: #252535;
|
||||
}
|
||||
|
||||
.story-title {
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tokens-cell {
|
||||
color: #64b5f6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 2rem !important;
|
||||
}
|
||||
|
||||
/* Token Info */
|
||||
.token-info {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(100, 181, 246, 0.1);
|
||||
border: 1px solid rgba(100, 181, 246, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.token-info p {
|
||||
margin: 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.token-info strong {
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stories-table th,
|
||||
.stories-table td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.story-title {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { getAdminStats } from "../services/api";
|
||||
import "./AdminPage.css";
|
||||
|
||||
interface StoryStats {
|
||||
id: string;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
tokens: number;
|
||||
lastPlayed: string | null;
|
||||
}
|
||||
|
||||
interface AdminStats {
|
||||
totalStories: number;
|
||||
totalSessions: number;
|
||||
totalTokens: number;
|
||||
stories: StoryStats[];
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getAdminStats();
|
||||
if (data) {
|
||||
setStats(data);
|
||||
} else {
|
||||
setError("Не удалось загрузить статистику");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Ошибка загрузки данных");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadStats();
|
||||
}, [isAuthenticated, authLoading, navigate]);
|
||||
|
||||
const formatTokens = (tokens: number) => {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(2)}M`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
|
||||
return tokens.toString();
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return "Никогда";
|
||||
return new Date(dateStr).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-loading">Загрузка статистики...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-error">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-error">Нет данных</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<h1>📊 Статистика</h1>
|
||||
|
||||
<div className="stats-overview">
|
||||
<div className="stat-card">
|
||||
<span className="stat-icon">📚</span>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.totalStories}</span>
|
||||
<span className="stat-label">Историй</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-icon">🎮</span>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.totalSessions}</span>
|
||||
<span className="stat-label">Сессий</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card highlight">
|
||||
<span className="stat-icon">🔤</span>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{formatTokens(stats.totalTokens)}</span>
|
||||
<span className="stat-label">Всего токенов</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Статистика по историям</h2>
|
||||
|
||||
<div className="stories-table-wrapper">
|
||||
<table className="stories-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>История</th>
|
||||
<th>Сообщений</th>
|
||||
<th>Токенов</th>
|
||||
<th>Последняя игра</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.stories.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="no-data">Нет историй</td>
|
||||
</tr>
|
||||
) : (
|
||||
stats.stories.map((story) => (
|
||||
<tr key={story.id}>
|
||||
<td className="story-title">{story.title}</td>
|
||||
<td>{story.messageCount}</td>
|
||||
<td className="tokens-cell">{formatTokens(story.tokens)}</td>
|
||||
<td className="date-cell">{formatDate(story.lastPlayed)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="token-info">
|
||||
<p>
|
||||
💡 <strong>Примечание:</strong> Токены рассчитаны приблизительно (1 токен ≈ 3 символа для русского текста).
|
||||
Реальное потребление может отличаться.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -300,3 +300,37 @@ export async function deletePlayerCharacter(id: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ ADMIN STATS ============
|
||||
|
||||
interface StoryStats {
|
||||
id: string;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
tokens: number;
|
||||
lastPlayed: string | null;
|
||||
}
|
||||
|
||||
interface AdminStats {
|
||||
totalStories: number;
|
||||
totalSessions: number;
|
||||
totalTokens: number;
|
||||
stories: StoryStats[];
|
||||
}
|
||||
|
||||
export async function getAdminStats(): Promise<AdminStats | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/admin/stats`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to get admin stats:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user