feat: add admin stats page with token usage

This commit is contained in:
Alexej Wolff
2026-02-11 01:00:02 +01:00
parent 6616c2e9a7
commit c404b1e17c
6 changed files with 455 additions and 0 deletions
+60
View File
@@ -472,6 +472,66 @@ app.delete("/api/characters/:id", requireAuth, async (req, res) => {
}
});
// ============ ADMIN STATS ============
// Получить статистику по всем историям и токенам
app.get("/api/admin/stats", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const gameSessions = db.collection("game_sessions");
// Получаем все истории пользователя
const userStories = await stories
.find({ userId: req.session.userId })
.toArray();
// Получаем все сессии пользователя
const userSessions = await gameSessions
.find({ storyId: { $in: userStories.map((s) => s._id.toString()) } })
.toArray();
// Считаем статистику для каждой истории
const storyStats = userStories.map((story) => {
const session = userSessions.find(
(s) => s.storyId === story._id.toString()
);
const messages = session?.messages || [];
const messageCount = messages.length;
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
const totalChars = messages.reduce(
(sum, msg) => sum + (msg.content?.length || 0),
0
);
const tokens = Math.round(totalChars / 3);
return {
id: story._id.toString(),
title: story.title,
messageCount,
tokens,
lastPlayed: session?.updatedAt || null,
};
});
// Сортируем по токенам (больше сверху)
storyStats.sort((a, b) => b.tokens - a.tokens);
// Общая статистика
const totalTokens = storyStats.reduce((sum, s) => sum + s.tokens, 0);
res.json({
totalStories: userStories.length,
totalSessions: userSessions.length,
totalTokens,
stories: storyStats,
});
} catch (error) {
console.error("Get admin stats error:", error);
res.status(500).json({ error: "Failed to get stats" });
}
});
// Запуск сервера
connectDB().then(() => {
app.listen(PORT, () => {
+2
View File
@@ -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>
+3
View File
@@ -26,6 +26,9 @@ export function Header() {
<Link to="/characters" className="nav-link">
Персонажи
</Link>
<Link to="/admin" className="nav-link">
📊 Статистика
</Link>
</>
)}
</nav>
+192
View File
@@ -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;
}
}
+164
View File
@@ -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>
);
}
+34
View File
@@ -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;
}
}