68c2b129fa
Security: - DeepSeek API moved to server-side proxy with rate limiting (20 req/min) - Whitelist validation for all POST/PUT routes - Cookie security (secure, sameSite, httpOnly in production) - Input validation for messages, tokens, temperature - Sanitized hasOwnProperty to prevent prototype pollution Performance: - Lazy loading for chat messages (sliding window of 20) - Streaming response throttling (50ms batches) - Scroll optimization (only scroll on new messages) - AbortController fix for stop button Code organization: - GamePage refactored from ~1170 to ~750 lines - New hooks: useGameSession, useStreamingResponse, useCharacterDetection, useLazyMessages - New components: MessageList, ChatInput, SessionSelector, CharacterPanel - Fixed ESLint errors Features: - OOC mode button for direct AI instructions - Message versions (aiResponse) now persist to DB - playerId saved in sessions
170 lines
4.8 KiB
TypeScript
170 lines
4.8 KiB
TypeScript
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 {
|
||
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>
|
||
);
|
||
}
|