diff --git a/server/index.js b/server/index.js index 7f7c0f7..d20e1eb 100644 --- a/server/index.js +++ b/server/index.js @@ -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, () => { diff --git a/src/App.tsx b/src/App.tsx index 2239cb4..18da0df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6e28b52..99fc50f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -26,6 +26,9 @@ export function Header() { Персонажи + + 📊 Статистика + )} diff --git a/src/pages/AdminPage.css b/src/pages/AdminPage.css new file mode 100644 index 0000000..5b4d006 --- /dev/null +++ b/src/pages/AdminPage.css @@ -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; + } +} diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx new file mode 100644 index 0000000..fd8c56e --- /dev/null +++ b/src/pages/AdminPage.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
Загрузка статистики...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!stats) { + return ( +
+
Нет данных
+
+ ); + } + + return ( +
+

📊 Статистика

+ +
+
+ 📚 +
+ {stats.totalStories} + Историй +
+
+
+ 🎮 +
+ {stats.totalSessions} + Сессий +
+
+
+ 🔤 +
+ {formatTokens(stats.totalTokens)} + Всего токенов +
+
+
+ +

Статистика по историям

+ +
+ + + + + + + + + + + {stats.stories.length === 0 ? ( + + + + ) : ( + stats.stories.map((story) => ( + + + + + + + )) + )} + +
ИсторияСообщенийТокеновПоследняя игра
Нет историй
{story.title}{story.messageCount}{formatTokens(story.tokens)}{formatDate(story.lastPlayed)}
+
+ +
+

+ 💡 Примечание: Токены рассчитаны приблизительно (1 токен ≈ 3 символа для русского текста). + Реальное потребление может отличаться. +

+
+
+ ); +} diff --git a/src/services/api.ts b/src/services/api.ts index a31540a..3bc39a9 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -300,3 +300,37 @@ export async function deletePlayerCharacter(id: string): Promise { 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 { + 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; + } +}