Files
ReSekai/src/pages/AdminPage.tsx
T
Alexej Wolff 68c2b129fa Major refactor: security, performance, and code organization
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
2026-05-05 23:41:52 +02:00

170 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}