огромный багфикс

This commit is contained in:
Professional 2025-05-26 01:28:53 +07:00
parent d0f21b566b
commit 7236669154
8 changed files with 1514 additions and 507 deletions

View File

@ -31,7 +31,7 @@ const registerUser = async (req, res, next) => {
} }
// Проверка имени на наличие запрещенных слов // Проверка имени на наличие запрещенных слов
if (profanityFilter.hasProfanity(name)) { if (await profanityFilter.isProfane(name)) {
res.status(400); res.status(400);
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.'); throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
} }

View File

@ -108,29 +108,17 @@ const getReportById = async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Валидация ObjectId
if (!id || !id.match(/^[0-9a-fA-F]{24}$/)) {
const error = new Error('Неверный ID жалобы');
error.statusCode = 400;
return next(error);
}
console.log(`[REPORT_CTRL] Поиск жалобы с ID: ${id}`);
const report = await Report.findById(id) const report = await Report.findById(id)
.populate('reporter', 'name email photos') .populate('reporter', 'name email photos')
.populate('reportedUser', 'name email photos isActive') .populate('reportedUser', 'name email photos isActive')
.populate('reviewedBy', 'name email'); .populate('reviewedBy', 'name email');
if (!report) { if (!report) {
console.log(`[REPORT_CTRL] Жалоба с ID ${id} не найдена`);
const error = new Error('Жалоба не найдена'); const error = new Error('Жалоба не найдена');
error.statusCode = 404; error.statusCode = 404;
return next(error); return next(error);
} }
console.log(`[REPORT_CTRL] Жалоба найдена: ${report._id}, статус: ${report.status}`);
// Преобразуем поля для совместимости с фронтендом // Преобразуем поля для совместимости с фронтендом
const reportResponse = report.toObject(); const reportResponse = report.toObject();
@ -161,26 +149,8 @@ const updateReportStatus = async (req, res, next) => {
const { status, adminNotes } = req.body; const { status, adminNotes } = req.body;
const adminId = req.user._id; const adminId = req.user._id;
// Валидация ObjectId
if (!id || !id.match(/^[0-9a-fA-F]{24}$/)) {
const error = new Error('Неверный ID жалобы');
error.statusCode = 400;
return next(error);
}
// Валидация статуса
const validStatuses = ['pending', 'reviewed', 'resolved', 'dismissed'];
if (!status || !validStatuses.includes(status)) {
const error = new Error('Неверный статус жалобы');
error.statusCode = 400;
return next(error);
}
console.log(`[REPORT_CTRL] Обновление жалобы ${id} на статус: ${status} администратором ${adminId}`);
const report = await Report.findById(id); const report = await Report.findById(id);
if (!report) { if (!report) {
console.log(`[REPORT_CTRL] Жалоба с ID ${id} не найдена для обновления`);
const error = new Error('Жалоба не найдена'); const error = new Error('Жалоба не найдена');
error.statusCode = 404; error.statusCode = 404;
return next(error); return next(error);
@ -200,7 +170,7 @@ const updateReportStatus = async (req, res, next) => {
await report.save(); await report.save();
console.log(`[REPORT_CTRL] Жалоба ${id} успешно обновлена администратором ${adminId}, новый статус: ${status}`); console.log(`[REPORT_CTRL] Жалоба ${id} обновлена администратором ${adminId}, статус: ${status}`);
// Возвращаем обновленную жалобу с заполненными связями // Возвращаем обновленную жалобу с заполненными связями
const updatedReport = await Report.findById(id) const updatedReport = await Report.findById(id)

View File

@ -18,7 +18,7 @@ const updateUserProfile = async (req, res, next) => {
if (user) { if (user) {
// Проверяем имя на запрещённые слова, если оно было изменено // Проверяем имя на запрещённые слова, если оно было изменено
if (req.body.name && req.body.name !== user.name) { if (req.body.name && req.body.name !== user.name) {
if (profanityFilter.hasProfanity(req.body.name)) { if (await profanityFilter.isProfane(req.body.name)) {
res.status(400); res.status(400);
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.'); throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
} }
@ -42,7 +42,7 @@ const updateUserProfile = async (req, res, next) => {
// Проверяем bio на запрещённые слова перед установкой // Проверяем bio на запрещённые слова перед установкой
if (req.body.bio !== undefined && req.body.bio !== null) { if (req.body.bio !== undefined && req.body.bio !== null) {
if (req.body.bio !== '' && profanityFilter.hasProfanity(req.body.bio)) { if (req.body.bio !== '' && await profanityFilter.isProfane(req.body.bio)) {
res.status(400); res.status(400);
throw new Error('Ваша биография содержит запрещённые слова. Пожалуйста, отредактируйте текст.'); throw new Error('Ваша биография содержит запрещённые слова. Пожалуйста, отредактируйте текст.');
} }
@ -162,7 +162,8 @@ const getUsersForSwiping = async (req, res, next) => {
$ne: currentUserId, $ne: currentUserId,
$nin: [...(currentUser.liked || []), ...(currentUser.passed || [])] // Исключаем уже просмотренных $nin: [...(currentUser.liked || []), ...(currentUser.passed || [])] // Исключаем уже просмотренных
}, },
isActive: true // Только активные пользователи isActive: true, // Только активные пользователи
isAdmin: { $ne: true } // Не включать администраторов в выборку
}; };
// Фильтрация по полу согласно предпочтениям // Фильтрация по полу согласно предпочтениям

View File

@ -8,13 +8,13 @@ const User = require('../models/User');
const initAdminAccount = async () => { const initAdminAccount = async () => {
try { try {
// Проверяем, существует ли уже админ // Проверяем, существует ли уже админ
const adminExists = await User.findOne({ email: 'admin@example.com', isAdmin: true }); const adminExists = await User.findOne({ email: 'admin', isAdmin: true });
if (!adminExists) { if (!adminExists) {
// Создаем админа, если не существует // Создаем админа, если не существует
const admin = new User({ const admin = new User({
name: 'Администратор', name: 'Администратор',
email: 'admin@example.com', email: 'admin', // Изменено с 'admin@example.com' на 'admin'
password: 'admin124', password: 'admin124',
dateOfBirth: new Date('1990-01-01'), // Устанавливаем формальную дату рождения dateOfBirth: new Date('1990-01-01'), // Устанавливаем формальную дату рождения
gender: 'other', gender: 'other',
@ -27,9 +27,9 @@ const initAdminAccount = async () => {
}); });
await admin.save(); await admin.save();
console.log('Административный аккаунт успешно создан'); console.log('Административный аккаунт успешно создан с email: admin');
} else { } else {
console.log('Административный аккаунт уже существует'); console.log('Административный аккаунт с email: admin уже существует');
} }
} catch (error) { } catch (error) {
console.error('Ошибка при инициализации админ-аккаунта:', error); console.error('Ошибка при инициализации админ-аккаунта:', error);

View File

@ -2,30 +2,25 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// Список русских нецензурных слов и других запрещенных слов let filterPromise;
// Списки пользовательских слов (из ранее прочитанной версии файла)
const russianBadWords = [ const russianBadWords = [
'блядь', 'хуй', 'пизда', 'ебать', 'ебал', 'ебло', 'хуесос', 'пидор', 'пидорас', 'блядь', 'хуй', 'пизда', 'ебать', 'ебал', 'ебло', 'хуесос', 'пидор', 'пидорас',
'мудак', 'говно', 'залупа', 'хер', 'манда', 'спермоед', 'шлюха', 'соска', 'шалава', 'мудак', 'говно', 'залупа', 'хер', 'манда', 'спермоед', 'шлюха', 'соска', 'шалава',
'дебил', 'дрочить', 'дрочка', 'ебанутый', 'выродок', 'шмара', 'сука', 'членосос', 'дебил', 'дрочить', 'дрочка', 'ебанутый', 'выродок', 'шмара', 'сука', 'членосос',
'гандон', 'гондон', 'гнида', 'вагина', 'хуи', 'жопа', 'шлюхи', 'проститутка' 'гандон', 'гондон', 'гнида', 'вагина', 'хуи', 'жопа', 'шлюхи', 'проститутка'
]; ];
// Список запрещенных слов и фраз для дейтинг-приложения
const datingAppBadWords = [ const datingAppBadWords = [
'проститутка', 'интим', 'секс за деньги', 'эскорт', 'интим услуги', 'проститутка', 'интим', 'секс за деньги', 'эскорт', 'интим услуги',
'заплачу', 'заплати', 'минет', 'массаж с интимом' 'заплачу', 'заплати', 'минет', 'массаж с интимом'
]; ];
/**
* Загружает список запрещенных слов из файла
* @param {string} filePath - Путь к файлу со списком запрещенных слов
* @return {Array<string>} - Массив запрещенных слов
*/
function loadBadWordsFromFile(filePath) { function loadBadWordsFromFile(filePath) {
try { try {
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8'); const content = fs.readFileSync(filePath, 'utf8');
const words = content.split('\n') const words = content.split('\\n') // Используем \\n как разделитель строк
.map(word => word.trim()) .map(word => word.trim())
.filter(word => word && !word.startsWith('//') && word.length >= 3); .filter(word => word && !word.startsWith('//') && word.length >= 3);
console.log(`Загружено ${words.length} запрещенных слов из файла ${filePath}`); console.log(`Загружено ${words.length} запрещенных слов из файла ${filePath}`);
@ -37,145 +32,69 @@ function loadBadWordsFromFile(filePath) {
return []; return [];
} }
class ProfanityFilter { function getInitializedFilter() {
constructor() { if (!filterPromise) {
// Загружаем слова из файлов filterPromise = import('bad-words').then(async BadWordsModule => {
const russianFileWords = loadBadWordsFromFile(path.join(__dirname, 'words.txt')); const FilterConstructor = BadWordsModule.default || BadWordsModule; // .default часто используется для ESM default export
const englishFileWords = loadBadWordsFromFile(path.join(__dirname, 'en.txt')); const instance = new FilterConstructor();
console.log('Фильтр bad-words успешно инициализирован.');
// Объединяем все источники запрещенных слов
this.badWords = [
...russianBadWords,
...datingAppBadWords,
...russianFileWords,
...englishFileWords
];
// Создаем регулярные выражения для проверки производных слов // Загрузка и добавление пользовательских слов
this.generateWordPatterns(this.badWords); const wordsPath = path.join(__dirname, 'words.txt');
const enWordsPath = path.join(__dirname, 'en.txt');
console.log(`Всего загружено ${this.badWords.length} запрещенных слов`);
} const russianFileWords = loadBadWordsFromFile(wordsPath);
const englishFileWords = loadBadWordsFromFile(enWordsPath);
const allCustomWords = [
...russianBadWords,
...datingAppBadWords,
...russianFileWords,
...englishFileWords
].filter(word => typeof word === 'string' && word.length > 0);
/** if (allCustomWords.length > 0) {
* Создает регулярные выражения для проверки производных слов instance.addWords(...allCustomWords);
* @param {Array<string>} words - Список базовых запрещённых слов console.log(`Добавлено ${allCustomWords.length} пользовательских слов в фильтр bad-words.`);
*/ } else {
generateWordPatterns(words) { console.log('Пользовательские слова для добавления в фильтр bad-words не найдены.');
this.wordPatterns = words
.filter(word => word && word.length >= 3) // Снижаем минимальную длину до 3 символов
.map(word => {
// Создаем шаблон с учетом возможных разделителей и замен символов
const baseWord = word
.replace(/[еёо]/g, '[еёо]') // Учитываем замену е/ё/о
.replace(/а/g, '[аa@]') // Учитываем замену а на a латинскую или @
.replace(/о/g, '[оo0]') // Учитываем замену о на o латинскую или 0
.replace(/е/g, '[еe]') // Учитываем замену е на e латинскую
.replace(/с/g, '[сc]') // Учитываем замену с на c латинскую
.replace(/р/g, '[рp]') // Учитываем замену р на p латинскую
.replace(/х/g, '[хx]') // Учитываем замену х на x латинскую
.replace(/у/g, '[уy]') // Учитываем замену у на y латинскую
.replace(/и/g, '[иi]'); // Учитываем замену и на i
// Создаем регулярку, которая учитывает:
// - возможные разделители между буквами (точки, пробелы, дефисы)
// - возможные окончания слов
const chars = baseWord.split('');
const patternWithSeparators = chars.join('[\\s\\._\\-]*');
return new RegExp(`\\b${patternWithSeparators}[а-яёa-z]*\\b`, 'i');
});
}
/**
* Проверяет, содержит ли текст запрещенные слова или их производные
* @param {string} text - Текст для проверки
* @return {boolean} - true, если текст содержит запрещенные слова, иначе false
*/
hasProfanity(text) {
if (!text) return false;
// Нормализуем текст, убирая лишние символы и разделители
const normalizedText = text.toLowerCase()
.replace(/[\s\._\-]+/g, ''); // Удаляем пробелы, точки, подчеркивания, дефисы
// Проверяем на простое вхождение запрещенных слов
for (const word of this.badWords) {
if (normalizedText.includes(word.toLowerCase())) {
return true;
} }
}
return instance;
// Дополнительная проверка на производные слова через регулярные выражения }).catch(err => {
return this.wordPatterns.some(pattern => pattern.test(text.toLowerCase())); console.error("Критическая ошибка: не удалось загрузить или инициализировать модуль bad-words:", err);
// Возвращаем "заглушку" фильтра, чтобы приложение не падало
return {
isProfane: (text) => { console.warn("Фильтр bad-words недоступен, проверка isProfane пропущена."); return false; },
clean: (text) => { console.warn("Фильтр bad-words недоступен, операция clean пропущена."); return text; }
};
});
} }
return filterPromise;
}
/** // Экспортируем объект с асинхронными функциями, которые используют инициализированный фильтр
* Проверяет, содержит ли объект запрещенные слова в указанных полях module.exports = {
* @param {Object} obj - Объект для проверки isProfane: async (text) => {
* @param {Array<string>} fields - Массив имен полей для проверки // Добавляем проверку, что text является строкой и не пустой
* @return {Object} - Объект с результатами проверки { isProfane: boolean, field: string|null } if (typeof text !== 'string' || text.trim() === '') return false;
*/ const filter = await getInitializedFilter();
validateObject(obj, fields) { return filter.isProfane(text);
},
clean: async (text) => {
if (typeof text !== 'string') return ''; // Возвращаем пустую строку, если text не строка
const filter = await getInitializedFilter();
return filter.clean(text);
},
validateObject: async (obj, fields) => {
const filter = await getInitializedFilter();
for (const field of fields) { for (const field of fields) {
if (obj[field] && this.hasProfanity(obj[field])) { if (obj && typeof obj[field] === 'string') { // Проверяем, что obj существует и obj[field] является строкой
return { isProfane: true, field }; if (filter.isProfane(obj[field])) {
return { isProfane: true, field };
}
} }
} }
return { isProfane: false, field: null }; return { isProfane: false, field: null };
} }
};
/**
* Заменяет запрещенные слова в тексте на звездочки
* @param {string} text - Исходный текст
* @return {string} - Очищенный текст
*/
clean(text) {
if (!text) return '';
let cleanedText = text;
// Проверяем и заменяем на звездочки каждое запрещенное слово
this.badWords.forEach(word => {
const regex = new RegExp(`\\b${word}\\b`, 'gi');
cleanedText = cleanedText.replace(regex, match => '*'.repeat(match.length));
});
// Затем проверяем на производные слова и заменяем их
this.wordPatterns.forEach(pattern => {
cleanedText = cleanedText.replace(pattern, match => '*'.repeat(match.length));
});
return cleanedText;
}
/**
* Добавляет новое запрещенное слово в фильтр
* @param {string|Array<string>} words - Слово или массив слов для добавления
*/
addWords(words) {
// Добавляем слова в список запрещенных слов
if (Array.isArray(words)) {
this.badWords.push(...words);
this.generateWordPatterns(words);
} else {
// Если передано одно слово
this.badWords.push(words);
this.generateWordPatterns([words]);
}
}
/**
* Проверяет является ли слово запрещенным
* @param {string} word - Слово для проверки
* @return {boolean} - true, если слово запрещено, иначе false
*/
isProfane(word) {
return this.hasProfanity(word);
}
}
// Экспортируем экземпляр фильтра для использования в приложении
const profanityFilter = new ProfanityFilter();
module.exports = profanityFilter;

View File

@ -9,29 +9,53 @@
</router-view> </router-view>
</main> </main>
<!-- Мобильная навигация внизу экрана (не показывается в админ-панели) --> <!-- Мобильная навигация внизу экрана -->
<nav v-if="authState && !isAdminRoute" class="mobile-nav"> <nav v-if="authState" class="mobile-nav">
<router-link to="/swipe" class="nav-item" active-class="active"> <!-- Вкладки для администратора -->
<div class="nav-icon"> <template v-if="currentUserIsAdmin">
<i class="bi-search"></i> <router-link to="/admin/users" class="nav-item" active-class="active">
</div> <div class="nav-icon">
<span class="nav-text">Поиск</span> <i class="bi-people"></i>
</router-link> </div>
<router-link to="/chats" class="nav-item" active-class="active"> <span class="nav-text">Пользователи</span>
<div class="nav-icon"> </router-link>
<i class="bi-chat-dots"></i> <router-link to="/admin/conversations" class="nav-item" active-class="active">
<span v-if="totalUnread > 0" class="unread-badge"> <div class="nav-icon">
{{ totalUnread < 100 ? totalUnread : '99+' }} <i class="bi-chat-square-dots"></i>
</span> </div>
</div> <span class="nav-text">Диалоги</span>
<span class="nav-text">Чаты</span> </router-link>
</router-link> <router-link to="/admin/statistics" class="nav-item" active-class="active">
<router-link to="/profile" class="nav-item" active-class="active"> <div class="nav-icon">
<div class="nav-icon"> <i class="bi-bar-chart-line"></i>
<i class="bi-person"></i> </div>
</div> <span class="nav-text">Статистика</span>
<span class="nav-text">Профиль</span> </router-link>
</router-link> </template>
<!-- Вкладки для обычного пользователя (не показываются в админ-панели, если isAdminRoute true) -->
<template v-else-if="!isAdminRoute">
<router-link to="/swipe" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-search"></i>
</div>
<span class="nav-text">Поиск</span>
</router-link>
<router-link to="/chats" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-chat-dots"></i>
<span v-if="totalUnread > 0" class="unread-badge">
{{ totalUnread < 100 ? totalUnread : '99+' }}
</span>
</div>
<span class="nav-text">Чаты</span>
</router-link>
<router-link to="/profile" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-person"></i>
</div>
<span class="nav-text">Профиль</span>
</router-link>
</template>
</nav> </nav>
</div> </div>
</template> </template>
@ -46,7 +70,6 @@ import { getSocket, connectSocket } from '@/services/socketService';
const { user, isAuthenticated, logout, fetchUser } = useAuth(); const { user, isAuthenticated, logout, fetchUser } = useAuth();
const route = useRoute(); const route = useRoute();
// Используем локальные переменные для отслеживания состояния
const userData = ref(null); const userData = ref(null);
const authState = ref(false); const authState = ref(false);
const conversations = ref([]); const conversations = ref([]);
@ -54,194 +77,163 @@ const totalUnread = computed(() => {
return conversations.value.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0); return conversations.value.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0);
}); });
// Проверяем, находится ли пользователь в админ-панели const currentUserIsAdmin = computed(() => {
return !!user.value && !!user.value.isAdmin;
});
const isAdminRoute = computed(() => { const isAdminRoute = computed(() => {
return route.path.startsWith('/admin'); return route.path.startsWith('/admin');
}); });
let socket = null; let socket = null;
// Получение списка диалогов для отображения счетчика непрочитанных сообщений
const fetchConversations = async () => { const fetchConversations = async () => {
if (!isAuthenticated.value) return; if (!isAuthenticated.value || currentUserIsAdmin.value) return;
try { try {
const response = await api.getUserConversations(); const response = await api.getUserConversations();
conversations.value = response.data; conversations.value = response.data;
console.log('[App] Загружено диалогов для счетчика уведомлений:', conversations.value.length);
} catch (err) { } catch (err) {
console.error('[App] Ошибка при загрузке диалогов:', err); console.error('[App] Ошибка при загрузке диалогов:', err);
} }
}; };
// Обработчик новых сообщений
const handleNewMessage = (message) => { const handleNewMessage = (message) => {
console.log('[App] Получено новое сообщение:', { if (currentUserIsAdmin.value || !user.value) return;
sender: message.sender,
currentUser: user.value?._id,
conversationId: message.conversationId
});
// ВАЖНО: Правильно сравниваем ID отправителя
// message.sender может быть как объектом, так и строкой ID
const senderId = typeof message.sender === 'object' ? message.sender._id : message.sender; const senderId = typeof message.sender === 'object' ? message.sender._id : message.sender;
if (senderId !== user.value._id) {
// ВАЖНО: Увеличиваем счетчик ТОЛЬКО если сообщение пришло от другого пользователя
if (senderId !== user.value?._id) {
// Добавляем небольшую задержку, чтобы дать время событию messagesRead сработать
// если пользователь находится в том же чате
setTimeout(() => { setTimeout(() => {
const conversation = conversations.value.find(c => c._id === message.conversationId); const conversation = conversations.value.find(c => c._id === message.conversationId);
if (conversation) { if (conversation) {
// Проверяем текущий маршрут - если пользователь находится в этом чате,
// то сообщение уже могло быть автоматически помечено как прочитанное
const currentRoute = route.path; const currentRoute = route.path;
const isInThisChat = currentRoute === `/chat/${message.conversationId}`; const isInThisChat = currentRoute === `/chat/${message.conversationId}`;
if (!isInThisChat) { if (!isInThisChat) {
// Увеличиваем счетчик только если пользователь НЕ находится в этом чате
conversation.unreadCount = (conversation.unreadCount || 0) + 1; conversation.unreadCount = (conversation.unreadCount || 0) + 1;
console.log('[App] Увеличен счетчик в навигации для диалога:', message.conversationId);
} else {
console.log('[App] Пользователь находится в этом чате, счетчик не увеличиваем');
} }
} else { } else {
// Если диалога нет в списке, обновим весь список
fetchConversations(); fetchConversations();
} }
}, 100); // Задержка 100ms }, 100);
} else {
console.log('[App] Сообщение от текущего пользователя, счетчик в навигации не изменяется');
} }
}; };
// Обработчик прочитанных сообщений
const handleMessagesRead = ({ conversationId, readerId }) => { const handleMessagesRead = ({ conversationId, readerId }) => {
console.log('[App] Получено событие messagesRead:', { conversationId, readerId, currentUserId: user.value?._id }); if (currentUserIsAdmin.value || !user.value) return;
if (readerId === user.value._id) {
// Если текущий пользователь прочитал сообщения
if (readerId === user.value?._id) {
const conversation = conversations.value.find(c => c._id === conversationId); const conversation = conversations.value.find(c => c._id === conversationId);
if (conversation) { if (conversation) {
console.log('[App] Сбрасываем счетчик для диалога:', conversationId);
conversation.unreadCount = 0; conversation.unreadCount = 0;
} }
} }
}; };
// Дополнительный обработчик для синхронизации при переходе в чат watch(() => isAuthenticated.value, (newAuthStatus, oldAuthStatus) => {
const handleRouteChange = () => { authState.value = newAuthStatus;
// Обновляем данные диалогов при изменении маршрута if (newAuthStatus) {
console.log('[App.vue watch isAuthenticated] Became authenticated.');
// Initial setup for authenticated users is handled by onMounted or watch(user.value)
} else if (oldAuthStatus && !newAuthStatus) {
// User is now unauthenticated (logged out).
console.log('[App.vue watch isAuthenticated] Became unauthenticated. Cleaning up.');
conversations.value = [];
if (socket) {
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
// Use userData.value for _id as user.value is null on logout
// userData.value holds the *previous* user data here
if (userData.value?._id && !(userData.value?.isAdmin)) {
socket.emit('leaveNotificationRoom', userData.value._id);
}
}
userData.value = null; // Clear local userData as well
}
});
watch(() => user.value, (newUser, oldUser) => {
userData.value = newUser;
if (isAuthenticated.value) { if (isAuthenticated.value) {
fetchConversations(); console.log('[App.vue watch user.value] User data changed and authenticated. Initializing/Re-initializing user state.');
initializeUserState();
} else if (oldUser && !newUser) {
console.log('[App.vue watch user.value] User logged out (user.value became null).');
// Cleanup is primarily handled by watch(isAuthenticated.value)
} }
}; }, { deep: true });
// Обновляем локальные переменные при изменении состояния аутентификации
watch(() => isAuthenticated.value, (newVal) => {
console.log('[App] isAuthenticated изменился:', newVal);
authState.value = newVal;
if (newVal) {
fetchConversations();
setupSocketConnection();
}
});
// Обновляем локальную копию данных пользователя
watch(() => user.value, (newVal) => {
console.log('[App] Данные пользователя изменились:', newVal);
userData.value = newVal;
});
// Следим за изменением маршрута для обновления счетчика
watch(() => route.path, (newPath, oldPath) => { watch(() => route.path, (newPath, oldPath) => {
console.log('[App] Маршрут изменился:', { from: oldPath, to: newPath }); if (isAuthenticated.value && !currentUserIsAdmin.value) {
if (newPath.startsWith('/chat/') || oldPath?.startsWith('/chat/')) {
// Если переходим в чат или возвращаемся из чата, обновляем счетчики setTimeout(() => {
if (newPath.startsWith('/chat/') || oldPath?.startsWith('/chat/')) { fetchConversations();
setTimeout(() => { }, 500);
fetchConversations(); }
}, 500); // Небольшая задержка для завершения операций чтения
} }
}); });
// Настройка сокет-соединения
const setupSocketConnection = () => { const setupSocketConnection = () => {
if (currentUserIsAdmin.value || !user.value?._id) return;
socket = getSocket(); socket = getSocket();
if (!socket) { if (!socket) {
socket = connectSocket(); socket = connectSocket();
} }
if (socket) { if (socket) {
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
socket.on('getMessage', handleNewMessage); socket.on('getMessage', handleNewMessage);
socket.on('messagesRead', handleMessagesRead); socket.on('messagesRead', handleMessagesRead);
socket.emit('joinNotificationRoom', user.value._id);
// Присоединение к комнате для уведомлений console.log('[App.vue setupSocketConnection] Socket connection configured for user:', user.value._id);
if (user.value?._id) {
socket.emit('joinNotificationRoom', user.value._id);
}
} }
}; };
// При монтировании компонента синхронизируем состояние const initializeUserState = () => {
onMounted(() => { if (isAuthenticated.value) {
console.log('[App] App.vue монтирован, проверка состояния аутентификации'); if (!currentUserIsAdmin.value) {
console.log('[App.vue initializeUserState] Initializing for regular user.');
fetchConversations();
setupSocketConnection();
} else {
console.log('[App.vue initializeUserState] User is Admin. Clearing any regular user socket listeners.');
if (socket) { // Ensure any previous user socket listeners are off for admin
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
}
}
}
};
onMounted(async () => {
authState.value = isAuthenticated.value; authState.value = isAuthenticated.value;
userData.value = user.value; userData.value = user.value;
// Убедимся, что у нас есть актуальные данные пользователя if (localStorage.getItem('userToken') && !user.value) {
if (localStorage.getItem('userToken') && !isAuthenticated.value) { console.log('[App.vue onMounted] Token found, user data missing. Fetching user...');
fetchUser().then(() => { await fetchUser();
if (isAuthenticated.value) { console.log('[App.vue onMounted] User fetched. isAuthenticated:', isAuthenticated.value, 'isAdmin:', currentUserIsAdmin.value);
console.log('[App] Пользователь загружен, проверка статуса администратора:', user.value?.isAdmin); }
// Проверяем, является ли пользователь администратором authState.value = isAuthenticated.value;
if (user.value?.isAdmin) { userData.value = user.value;
console.log('[App] Пользователь является администратором, перенаправление на админ-панель');
// Если да, перенаправляем на административную панель if (isAuthenticated.value) {
if (route.path !== '/admin') { if (currentUserIsAdmin.value) {
const router = route.matched[0]?.instances.default?.$router; console.log('[App.vue onMounted] Authenticated user is Admin.');
if (router) { initializeUserState(); // Handles admin case (clears user listeners)
router.push('/admin');
}
}
} else {
// Для обычных пользователей
fetchConversations();
setupSocketConnection();
}
}
});
} else if (isAuthenticated.value) {
console.log('[App] Пользователь уже аутентифицирован, проверка статуса администратора:', user.value?.isAdmin);
// Проверяем, является ли пользователь администратором
if (user.value?.isAdmin) {
console.log('[App] Пользователь является администратором, перенаправление на админ-панель');
// Если да, перенаправляем на административную панель
if (route.path !== '/admin') {
const router = route.matched[0]?.instances.default?.$router;
if (router) {
router.push('/admin');
}
}
} else { } else {
// Для обычных пользователей console.log('[App.vue onMounted] Authenticated user is a regular user. Initializing user state.');
fetchConversations(); initializeUserState();
setupSocketConnection();
} }
} else {
console.log('[App.vue onMounted] User is not authenticated.');
} }
}); });
// При размонтировании компонента очищаем обработчики событий
onUnmounted(() => { onUnmounted(() => {
if (socket) { if (socket) {
socket.off('getMessage', handleNewMessage); socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead); socket.off('messagesRead', handleMessagesRead);
if (user.value?._id && !currentUserIsAdmin.value) {
if (user.value?._id) {
socket.emit('leaveNotificationRoom', user.value._id); socket.emit('leaveNotificationRoom', user.value._id);
console.log('[App.vue onUnmounted] Left notification room for user:', user.value._id);
} }
} }
}); });
@ -425,9 +417,8 @@ body {
padding-top: 8px; padding-top: 8px;
} }
/* Важное изменение - переместить этот селектор за пределы @supports */ .app-content { /* Это правило изменяет высоту .app-content, если есть safe-area */
.app-content { height: calc(100% - (var(--nav-height) + var(--safe-area-bottom)));
height: calc(100% - var(--nav-height) - var(--safe-area-bottom));
} }
} }

View File

@ -2,93 +2,151 @@
<div class="admin-report-detail"> <div class="admin-report-detail">
<div class="header-controls"> <div class="header-controls">
<button @click="goBack" class="back-btn"> <button @click="goBack" class="back-btn">
&laquo; Назад к списку жалоб <i class="bi-arrow-left"></i>
<span class="btn-text">Назад к списку жалоб</span>
</button> </button>
</div> </div>
<div v-if="loading" class="loading-indicator"> <div v-if="loading" class="loading-indicator">
Загрузка информации о жалобе... <div class="loading-spinner"></div>
<span>Загрузка информации о жалобе...</span>
</div> </div>
<div v-if="error" class="error-message"> <div v-if="error" class="error-message">
<i class="bi-exclamation-triangle"></i>
{{ error }} {{ error }}
</div> </div>
<div v-if="report && !loading" class="report-container"> <div v-if="report && !loading" class="report-container">
<h2>Детали жалобы</h2> <div class="page-header">
<h2>Детали жалобы</h2>
<div class="status-display">
<span class="status-badge" :class="getStatusClass(report.status)">
{{ getStatusText(report.status) }}
</span>
</div>
</div>
<div class="report-card"> <div class="report-card">
<div class="report-header"> <div class="report-header">
<div class="report-info"> <div class="report-info">
<h3>Жалоба #{{ report._id.substring(0, 8) }}</h3> <div class="report-id-section">
<span class="status-badge" :class="getStatusClass(report.status)"> <i class="bi-file-text"></i>
{{ getStatusText(report.status) }} <div class="id-info">
</span> <h3>Жалоба #{{ report._id.substring(0, 8) }}</h3>
<span class="full-id">ID: {{ report._id }}</span>
</div>
</div>
</div> </div>
<div class="report-date"> <div class="report-date">
Подана {{ formatDate(report.createdAt) }} <i class="bi-calendar3"></i>
<span>Подана {{ formatDate(report.createdAt) }}</span>
</div> </div>
</div> </div>
<div class="report-content"> <div class="report-content">
<div class="info-grid"> <div class="info-grid">
<div class="info-item"> <div class="info-section">
<label>Причина жалобы:</label> <div class="section-header">
<span class="reason-badge" :class="getReasonClass(report.reason)"> <i class="bi-info-circle"></i>
{{ getReasonText(report.reason) }} <h4>Информация о жалобе</h4>
</span> </div>
<div class="info-item">
<label>Причина жалобы:</label>
<span class="reason-badge" :class="getReasonClass(report.reason)">
{{ getReasonText(report.reason) }}
</span>
</div>
<div class="info-item" v-if="report.description">
<label>Описание:</label>
<div class="description-container">
<p class="description">{{ report.description }}</p>
</div>
</div>
</div> </div>
<div class="info-item" v-if="report.description"> <div class="info-section">
<label>Описание:</label> <div class="section-header">
<p class="description">{{ report.description }}</p> <i class="bi-people"></i>
</div> <h4>Участники</h4>
</div>
<div class="info-item">
<label>Жалующийся:</label> <div class="users-grid">
<div class="user-info"> <div class="user-card reporter">
<span>{{ report.reporter?.name || 'Пользователь удален' }}</span> <div class="user-header">
<span class="email">{{ report.reporter?.email || '' }}</span> <i class="bi-person-exclamation"></i>
<span class="user-role">Жалующийся</span>
</div>
<div class="user-details">
<div class="user-name">{{ report.reporter?.name || 'Пользователь удален' }}</div>
<div class="user-email">{{ report.reporter?.email || '' }}</div>
</div>
</div>
<div class="arrow-divider">
<i class="bi-arrow-right"></i>
</div>
<div class="user-card reported">
<div class="user-header">
<i class="bi-person-x"></i>
<span class="user-role">Обжалуемый</span>
</div>
<div class="user-details">
<div class="user-name">{{ report.reportedUser?.name || 'Пользователь удален' }}</div>
<div class="user-email">{{ report.reportedUser?.email || '' }}</div>
<button
v-if="report.reportedUser?._id"
@click="viewUser(report.reportedUser._id)"
class="btn view-user-btn"
>
<i class="bi-eye"></i>
<span>Просмотр профиля</span>
</button>
</div>
</div>
</div> </div>
</div> </div>
<div class="info-item"> <div class="info-section" v-if="report.adminNotes || report.resolvedAt">
<label>На пользователя:</label> <div class="section-header">
<div class="user-info"> <i class="bi-shield-check"></i>
<span>{{ report.reportedUser?.name || 'Пользователь удален' }}</span> <h4>Административная информация</h4>
<span class="email">{{ report.reportedUser?.email || '' }}</span> </div>
<button
v-if="report.reportedUser?._id" <div class="info-item" v-if="report.adminNotes">
@click="viewUser(report.reportedUser._id)" <label>Заметки администратора:</label>
class="btn view-user-btn" <div class="admin-notes-container">
> <p class="admin-notes">{{ report.adminNotes }}</p>
Просмотр профиля </div>
</button> </div>
<div class="info-item" v-if="report.resolvedAt">
<label>Дата рассмотрения:</label>
<div class="resolve-date">
<i class="bi-check-circle"></i>
<span>{{ formatDate(report.resolvedAt) }}</span>
</div>
</div> </div>
</div>
<div class="info-item" v-if="report.adminNotes">
<label>Заметки администратора:</label>
<p class="admin-notes">{{ report.adminNotes }}</p>
</div>
<div class="info-item" v-if="report.resolvedAt">
<label>Дата рассмотрения:</label>
<span>{{ formatDate(report.resolvedAt) }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="report-actions" v-if="report.status === 'pending'"> <div class="report-actions" v-if="report.status === 'pending'">
<h4>Действия</h4> <div class="actions-header">
<i class="bi-gear"></i>
<h4>Действия с жалобой</h4>
</div>
<div class="action-buttons"> <div class="action-buttons">
<button @click="showResolveModal = true" class="btn resolve-btn"> <button @click="showResolveModal = true" class="btn resolve-btn">
<i class="bi-check-circle"></i> <i class="bi-check-circle"></i>
Рассмотреть <span>Рассмотреть</span>
</button> </button>
<button @click="showDismissModal = true" class="btn dismiss-btn"> <button @click="showDismissModal = true" class="btn dismiss-btn">
<i class="bi-x-circle"></i> <i class="bi-x-circle"></i>
Отклонить <span>Отклонить</span>
</button> </button>
</div> </div>
</div> </div>
@ -98,17 +156,32 @@
<!-- Модальное окно для рассмотрения жалобы --> <!-- Модальное окно для рассмотрения жалобы -->
<div v-if="showResolveModal" class="modal-overlay" @click="showResolveModal = false"> <div v-if="showResolveModal" class="modal-overlay" @click="showResolveModal = false">
<div class="modal" @click.stop> <div class="modal" @click.stop>
<h3>Рассмотрение жалобы</h3> <div class="modal-header">
<p>Вы собираетесь отметить эту жалобу как рассмотренную.</p> <h3>Рассмотрение жалобы</h3>
<div class="form-group"> <button @click="showResolveModal = false" class="modal-close">
<label>Заметки администратора (необязательно):</label> <i class="bi-x"></i>
<textarea v-model="adminNotes" placeholder="Укажите принятые меры или комментарии..."></textarea> </button>
</div>
<div class="modal-content">
<p>Вы собираетесь отметить эту жалобу как рассмотренную.</p>
<div class="form-group">
<label>Заметки администратора (необязательно):</label>
<textarea
v-model="adminNotes"
placeholder="Укажите принятые меры или комментарии..."
rows="4"
></textarea>
</div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button @click="updateReportStatus('resolved')" class="btn resolve-btn" :disabled="updating"> <button @click="updateReportStatus('resolved')" class="btn resolve-btn" :disabled="updating">
{{ updating ? 'Обработка...' : 'Рассмотреть' }} <i class="bi-check-circle"></i>
<span>{{ updating ? 'Обработка...' : 'Рассмотреть' }}</span>
</button>
<button @click="showResolveModal = false" class="btn cancel-btn">
<i class="bi-x"></i>
<span>Отмена</span>
</button> </button>
<button @click="showResolveModal = false" class="btn cancel-btn">Отмена</button>
</div> </div>
</div> </div>
</div> </div>
@ -116,17 +189,32 @@
<!-- Модальное окно для отклонения жалобы --> <!-- Модальное окно для отклонения жалобы -->
<div v-if="showDismissModal" class="modal-overlay" @click="showDismissModal = false"> <div v-if="showDismissModal" class="modal-overlay" @click="showDismissModal = false">
<div class="modal" @click.stop> <div class="modal" @click.stop>
<h3>Отклонение жалобы</h3> <div class="modal-header">
<p>Вы собираетесь отклонить эту жалобу.</p> <h3>Отклонение жалобы</h3>
<div class="form-group"> <button @click="showDismissModal = false" class="modal-close">
<label>Заметки администратора (необязательно):</label> <i class="bi-x"></i>
<textarea v-model="adminNotes" placeholder="Укажите причину отклонения..."></textarea> </button>
</div>
<div class="modal-content">
<p>Вы собираетесь отклонить эту жалобу.</p>
<div class="form-group">
<label>Заметки администратора (необязательно):</label>
<textarea
v-model="adminNotes"
placeholder="Укажите причину отклонения..."
rows="4"
></textarea>
</div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button @click="updateReportStatus('dismissed')" class="btn dismiss-btn" :disabled="updating"> <button @click="updateReportStatus('dismissed')" class="btn dismiss-btn" :disabled="updating">
{{ updating ? 'Обработка...' : 'Отклонить' }} <i class="bi-x-circle"></i>
<span>{{ updating ? 'Обработка...' : 'Отклонить' }}</span>
</button>
<button @click="showDismissModal = false" class="btn cancel-btn">
<i class="bi-arrow-left"></i>
<span>Отмена</span>
</button> </button>
<button @click="showDismissModal = false" class="btn cancel-btn">Отмена</button>
</div> </div>
</div> </div>
</div> </div>
@ -136,7 +224,7 @@
<script> <script>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import api from '@/services/api'; import axios from 'axios';
export default { export default {
name: 'AdminReportDetail', name: 'AdminReportDetail',
@ -163,19 +251,17 @@ export default {
error.value = null; error.value = null;
try { try {
console.log('[AdminReportDetail] Загружаем жалобу с ID:', props.id); const token = localStorage.getItem('userToken');
const response = await api.getReportById(props.id); const response = await axios.get(`/api/admin/reports/${props.id}`, {
headers: {
Authorization: `Bearer ${token}`
}
});
report.value = response.data; report.value = response.data;
console.log('[AdminReportDetail] Жалоба загружена:', response.data);
} catch (err) { } catch (err) {
console.error('Ошибка при загрузке жалобы:', err); console.error('Ошибка при загрузке жалобы:', err);
if (err.response?.status === 404) { error.value = 'Ошибка при загрузке информации о жалобе.';
error.value = 'Жалоба не найдена.';
} else if (err.response?.status === 403) {
error.value = 'У вас нет прав для просмотра этой жалобы.';
} else {
error.value = err.response?.data?.message || 'Ошибка при загрузке информации о жалобе.';
}
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -186,10 +272,14 @@ export default {
updating.value = true; updating.value = true;
try { try {
console.log('[AdminReportDetail] Обновляем статус жалобы:', status); const token = localStorage.getItem('userToken');
const response = await api.updateReportStatus(props.id, { const response = await axios.put(`/api/admin/reports/${props.id}`, {
status, status,
adminNotes: adminNotes.value adminNotes: adminNotes.value
}, {
headers: {
Authorization: `Bearer ${token}`
}
}); });
// Обновляем локальные данные // Обновляем локальные данные
@ -200,12 +290,10 @@ export default {
showDismissModal.value = false; showDismissModal.value = false;
adminNotes.value = ''; adminNotes.value = '';
console.log('[AdminReportDetail] Статус жалобы обновлен успешно');
alert(`Жалоба успешно ${status === 'resolved' ? 'рассмотрена' : 'отклонена'}`); alert(`Жалоба успешно ${status === 'resolved' ? 'рассмотрена' : 'отклонена'}`);
} catch (err) { } catch (err) {
console.error('Ошибка при обновлении статуса жалобы:', err); console.error('Ошибка при обновлении статуса жалобы:', err);
const errorMessage = err.response?.data?.message || 'Ошибка при обновлении статуса жалобы'; alert('Ошибка при обновлении статуса жалобы');
alert(errorMessage);
} finally { } finally {
updating.value = false; updating.value = false;
} }
@ -346,6 +434,21 @@ export default {
font-style: italic; font-style: italic;
} }
.loading-spinner {
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid #007bff;
border-radius: 50%;
width: 2rem;
height: 2rem;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message { .error-message {
background-color: #f8d7da; background-color: #f8d7da;
color: #721c24; color: #721c24;
@ -353,6 +456,9 @@ export default {
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid #f5c6cb; border: 1px solid #f5c6cb;
margin-bottom: 1rem; margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
} }
h2 { h2 {
@ -362,6 +468,19 @@ h2 {
font-weight: 600; font-weight: 600;
} }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.status-display {
display: flex;
align-items: center;
gap: 0.5rem;
}
.report-card { .report-card {
background: white; background: white;
border-radius: 0.75rem; border-radius: 0.75rem;
@ -384,15 +503,29 @@ h2 {
gap: 1rem; gap: 1rem;
} }
.report-id-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.report-info h3 { .report-info h3 {
margin: 0; margin: 0;
color: #333; color: #333;
font-size: 1.25rem; font-size: 1.25rem;
} }
.full-id {
color: #6c757d;
font-size: 0.9rem;
}
.report-date { .report-date {
color: #6c757d; color: #6c757d;
font-size: 0.9rem; font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.25rem;
} }
.report-content { .report-content {
@ -404,6 +537,26 @@ h2 {
gap: 1.5rem; gap: 1.5rem;
} }
.info-section {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 0.375rem;
border: 1px solid #e9ecef;
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.section-header h4 {
margin: 0;
color: #495057;
font-weight: 500;
}
.info-item label { .info-item label {
display: block; display: block;
font-weight: 600; font-weight: 600;
@ -444,6 +597,9 @@ h2 {
margin-top: 0.5rem; margin-top: 0.5rem;
align-self: flex-start; align-self: flex-start;
transition: all 0.2s ease; transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
} }
.view-user-btn:hover { .view-user-btn:hover {
@ -478,9 +634,11 @@ h2 {
background: #f8f9fa; background: #f8f9fa;
} }
.report-actions h4 { .actions-header {
margin: 0 0 1rem 0; display: flex;
color: #495057; align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
} }
.action-buttons { .action-buttons {
@ -560,8 +718,27 @@ h2 {
overflow-y: auto; overflow-y: auto;
} }
.modal h3 { .modal-header {
margin: 0 0 1rem 0; display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-header h3 {
margin: 0;
color: #333;
}
.modal-close {
background: transparent;
border: none;
cursor: pointer;
font-size: 1.25rem;
color: #6c757d;
}
.modal-close:hover {
color: #333; color: #333;
} }
@ -614,10 +791,261 @@ h2 {
} }
} }
/* Устройства с вырезом (notch) */ @media (max-width: 480px) {
@supports (padding: env(safe-area-inset-bottom)) {
.admin-report-detail { .admin-report-detail {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px)); padding: 1rem 0.5rem calc(60px + env(safe-area-inset-bottom, 0px)) 0.5rem;
}
.header-controls {
margin-bottom: 1rem;
}
.back-btn .btn-text {
display: none;
}
.back-btn {
padding: 0.5rem;
min-width: 40px;
min-height: 40px;
justify-content: center;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.page-header h2 {
font-size: 1.1rem;
margin-bottom: 0;
}
.report-header {
padding: 1rem;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.report-id-section {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.id-info h3 {
font-size: 1rem;
}
.full-id {
font-size: 0.8rem;
word-break: break-all;
}
.report-content {
padding: 1rem;
}
.info-section {
padding: 1rem;
}
.users-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.arrow-divider {
transform: rotate(90deg);
margin: 0.5rem 0;
}
.user-card {
padding: 0.75rem;
}
.action-buttons {
flex-direction: column;
gap: 0.75rem;
}
.btn {
width: 100%;
justify-content: center;
padding: 0.75rem;
}
.modal {
width: 95%;
padding: 1rem;
margin: 1rem;
}
.modal-actions {
flex-direction: column;
gap: 0.75rem;
}
.modal-actions .btn {
width: 100%;
}
}
/* Маленькие экраны (481px - 767px) */
@media (min-width: 481px) and (max-width: 767px) {
.admin-report-detail {
padding: 1rem calc(60px + env(safe-area-inset-bottom, 0px)) 1rem;
}
.page-header {
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
}
.page-header h2 {
font-size: 1.2rem;
}
.report-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.users-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.arrow-divider {
transform: rotate(90deg);
margin: 0.5rem 0;
}
.action-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal {
width: 90%;
max-width: 450px;
}
.modal-actions {
flex-direction: row;
gap: 1rem;
}
}
/* Средние экраны (768px - 991px) */
@media (min-width: 768px) and (max-width: 991px) {
.admin-report-detail {
padding: 1.5rem 1rem calc(60px + env(safe-area-inset-bottom, 0px)) 1rem;
}
.report-header {
flex-direction: row;
align-items: center;
}
.users-grid {
grid-template-columns: 1fr auto 1fr;
gap: 1.5rem;
}
.arrow-divider {
transform: none;
}
.info-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.action-buttons {
display: flex;
flex-direction: row;
gap: 1rem;
}
}
/* Большие экраны (992px - 1199px) */
@media (min-width: 992px) and (max-width: 1199px) {
.admin-report-detail {
padding: 2rem 1.5rem;
}
.info-grid {
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.info-section:last-child {
grid-column: 1 / -1;
}
.users-grid {
grid-template-columns: 1fr auto 1fr;
gap: 2rem;
}
}
/* Очень большие экраны (1200px+) */
@media (min-width: 1200px) {
.admin-report-detail {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.info-grid {
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.info-section:last-child {
grid-column: 1 / -1;
}
.users-grid {
grid-template-columns: 1fr auto 1fr;
gap: 2rem;
}
.report-header {
padding: 2rem;
}
.report-content {
padding: 2rem;
}
.report-actions {
padding: 2rem;
}
}
/* Планшеты в ландшафтной ориентации */
@media (min-width: 768px) and (max-width: 1024px) and (orientation: landscape) {
.info-grid {
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.users-grid {
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
}
.action-buttons {
display: flex;
flex-direction: row;
gap: 1rem;
} }
} }
@ -626,5 +1054,174 @@ h2 {
.admin-report-detail { .admin-report-detail {
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
} }
.header-controls {
margin-bottom: 0.75rem;
}
.page-header {
margin-bottom: 1rem;
}
.report-header {
padding: 1rem;
}
.report-content {
padding: 1rem;
}
.info-section {
padding: 1rem;
}
.modal {
max-height: 85vh;
}
}
/* Высокие экраны */
@media (min-height: 800px) {
.admin-report-detail {
min-height: calc(100vh - 200px);
}
.report-card {
min-height: 400px;
}
}
/* Улучшения для сенсорных экранов */
@media (hover: none) and (pointer: coarse) {
.btn {
min-height: 44px;
min-width: 44px;
}
.back-btn {
min-height: 44px;
min-width: 44px;
}
.view-user-btn {
min-height: 44px;
}
.modal-close {
min-height: 44px;
min-width: 44px;
}
}
/* Принтеры */
@media print {
.admin-report-detail {
padding: 1rem;
}
.header-controls,
.report-actions,
.modal-overlay {
display: none;
}
.report-card {
box-shadow: none;
border: 1px solid #000;
}
.page-header {
border-bottom: 2px solid #000;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
}
/* Устройства с вырезом (notch) */
@supports (padding: env(safe-area-inset-bottom)) {
.admin-report-detail {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
@media (max-width: 480px) {
.admin-report-detail {
padding-bottom: calc(60px + env(safe-area-inset-bottom, 0px));
}
}
}
/* Пользовательские карточки */
.users-grid {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: center;
}
.user-card {
background: white;
border: 1px solid #e9ecef;
border-radius: 0.5rem;
padding: 1rem;
transition: all 0.2s ease;
}
.user-card:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.user-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.user-role {
font-weight: 600;
font-size: 0.85rem;
color: #495057;
}
.user-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.user-name {
font-weight: 500;
color: #333;
}
.user-email {
color: #6c757d;
font-size: 0.85rem;
}
.arrow-divider {
display: flex;
justify-content: center;
align-items: center;
color: #007bff;
font-size: 1.5rem;
}
.description-container,
.admin-notes-container {
background: white;
border: 1px solid #e9ecef;
border-radius: 0.375rem;
padding: 1rem;
margin-top: 0.5rem;
}
.resolve-date {
display: flex;
align-items: center;
gap: 0.5rem;
color: #28a745;
font-weight: 500;
} }
</style> </style>

View File

@ -1,77 +1,130 @@
<template> <template>
<div class="admin-reports"> <div class="admin-reports">
<h2>Управление жалобами</h2> <div class="header-section">
<h2>Управление жалобами</h2>
<div class="search-bar">
<div class="filter-options"> <div class="search-bar">
<label> <div class="filter-options">
<input type="radio" v-model="statusFilter" value="all" @change="loadReports"> <label>
Все <input type="radio" v-model="statusFilter" value="all" @change="loadReports">
</label> <span>Все</span>
<label> </label>
<input type="radio" v-model="statusFilter" value="pending" @change="loadReports"> <label>
На рассмотрении <input type="radio" v-model="statusFilter" value="pending" @change="loadReports">
</label> <span>На рассмотрении</span>
<label> </label>
<input type="radio" v-model="statusFilter" value="resolved" @change="loadReports"> <label>
Рассмотренные <input type="radio" v-model="statusFilter" value="resolved" @change="loadReports">
</label> <span>Рассмотренные</span>
<label> </label>
<input type="radio" v-model="statusFilter" value="dismissed" @change="loadReports"> <label>
Отклоненные <input type="radio" v-model="statusFilter" value="dismissed" @change="loadReports">
</label> <span>Отклоненные</span>
</label>
</div>
</div> </div>
</div> </div>
<div v-if="loading" class="loading-indicator"> <div v-if="loading" class="loading-indicator">
Загрузка жалоб... <div class="loading-spinner"></div>
<span>Загрузка жалоб...</span>
</div> </div>
<div v-if="error" class="error-message"> <div v-if="error" class="error-message">
<i class="bi-exclamation-triangle"></i>
{{ error }} {{ error }}
</div> </div>
<div v-if="!loading && !error" class="reports-table-container"> <div v-if="!loading && !error" class="reports-container">
<table class="reports-table"> <!-- Десктопное табличное представление -->
<thead> <div class="desktop-table">
<tr> <div class="reports-table-container">
<th>ID</th> <table class="reports-table">
<th>Причина</th> <thead>
<th>Жалующийся</th> <tr>
<th>На пользователя</th> <th class="id-col">ID</th>
<th class="hide-sm">Дата подачи</th> <th class="reason-col">Причина</th>
<th>Статус</th> <th class="reporter-col">Жалующийся</th>
<th>Действия</th> <th class="reported-col">На пользователя</th>
</tr> <th class="date-col">Дата подачи</th>
</thead> <th class="status-col">Статус</th>
<tbody> <th class="actions-col">Действия</th>
<tr v-for="report in reports" :key="report._id"> </tr>
<td>{{ shortenId(report._id) }}</td> </thead>
<td> <tbody>
<tr v-for="report in reports" :key="report._id">
<td class="id-col">
<span class="report-id">{{ shortenId(report._id) }}</span>
</td>
<td class="reason-col">
<span class="reason-badge" :class="getReasonClass(report.reason)">
{{ getReasonText(report.reason) }}
</span>
</td>
<td class="reporter-col">{{ report.reporter?.name || 'Удален' }}</td>
<td class="reported-col">{{ report.reportedUser?.name || 'Удален' }}</td>
<td class="date-col">{{ formatDate(report.createdAt) }}</td>
<td class="status-col">
<span class="status-badge" :class="getStatusClass(report.status)">
{{ getStatusText(report.status) }}
</span>
</td>
<td class="actions-col">
<button @click="viewReport(report._id)" class="btn view-btn">
<span class="btn-text">Просмотр</span>
<i class="bi-eye"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Мобильное карточное представление -->
<div class="mobile-cards">
<div v-for="report in reports" :key="report._id" class="report-card" @click="viewReport(report._id)">
<div class="card-header">
<div class="card-id">
<i class="bi-file-text"></i>
<span>{{ shortenId(report._id) }}</span>
</div>
<span class="status-badge" :class="getStatusClass(report.status)">
{{ getStatusText(report.status) }}
</span>
</div>
<div class="card-content">
<div class="reason-info">
<span class="reason-badge" :class="getReasonClass(report.reason)"> <span class="reason-badge" :class="getReasonClass(report.reason)">
{{ getReasonText(report.reason) }} {{ getReasonText(report.reason) }}
</span> </span>
</td> </div>
<td>{{ report.reporter?.name || 'Удален' }}</td>
<td>{{ report.reportedUser?.name || 'Удален' }}</td> <div class="users-info">
<td class="hide-sm">{{ formatDate(report.createdAt) }}</td> <div class="user-item">
<td> <i class="bi-person-exclamation"></i>
<span class="status-badge" :class="getStatusClass(report.status)"> <span>{{ report.reporter?.name || 'Удален' }}</span>
{{ getStatusText(report.status) }} </div>
</span> <div class="arrow"></div>
</td> <div class="user-item">
<td class="actions"> <i class="bi-person-x"></i>
<button @click="viewReport(report._id)" class="btn view-btn"> <span>{{ report.reportedUser?.name || 'Удален' }}</span>
<span class="btn-text">Просмотр</span> </div>
<i class="bi-eye"></i> </div>
</button>
</td> <div class="card-footer">
</tr> <span class="date">{{ formatDate(report.createdAt) }}</span>
</tbody> <i class="bi-chevron-right"></i>
</table> </div>
</div>
</div>
</div>
<div v-if="reports.length === 0" class="no-results"> <div v-if="reports.length === 0" class="no-results">
Жалобы не найдены <i class="bi-inbox"></i>
<h3>Жалобы не найдены</h3>
<p>Нет жалоб, соответствующих выбранным фильтрам</p>
</div> </div>
<div v-if="pagination.pages > 1" class="pagination"> <div v-if="pagination.pages > 1" class="pagination">
@ -80,19 +133,22 @@
:disabled="currentPage === 1" :disabled="currentPage === 1"
class="pagination-btn" class="pagination-btn"
> >
Назад <i class="bi-chevron-left"></i>
<span class="btn-text-lg">Назад</span>
</button> </button>
<span class="pagination-info"> <div class="pagination-info">
Страница {{ currentPage }} из {{ pagination.pages }} <span class="page-text">Страница {{ currentPage }} из {{ pagination.pages }}</span>
</span> <span class="total-text">({{ pagination.total }} жалоб)</span>
</div>
<button <button
@click="changePage(currentPage + 1)" @click="changePage(currentPage + 1)"
:disabled="currentPage === pagination.pages" :disabled="currentPage === pagination.pages"
class="pagination-btn" class="pagination-btn"
> >
Далее <span class="btn-text-lg">Далее</span>
<i class="bi-chevron-right"></i>
</button> </button>
</div> </div>
</div> </div>
@ -102,7 +158,7 @@
<script> <script>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import api from '@/services/api'; import axios from 'axios';
export default { export default {
name: 'AdminReports', name: 'AdminReports',
@ -135,19 +191,19 @@ export default {
params.status = statusFilter.value; params.status = statusFilter.value;
} }
console.log('[AdminReports] Загружаем жалобы с параметрами:', params); const token = localStorage.getItem('userToken');
const response = await api.getReports(params); const response = await axios.get(`/api/admin/reports`, {
params,
headers: {
Authorization: `Bearer ${token}`
}
});
reports.value = response.data.reports; reports.value = response.data.reports;
pagination.value = response.data.pagination; pagination.value = response.data.pagination;
console.log('[AdminReports] Жалобы загружены:', response.data);
} catch (err) { } catch (err) {
console.error('Ошибка при загрузке жалоб:', err); console.error('Ошибка при загрузке жалоб:', err);
if (err.response?.status === 403) { error.value = 'Ошибка при загрузке списка жалоб. Пожалуйста, попробуйте позже.';
error.value = 'У вас нет прав для просмотра жалоб.';
} else {
error.value = err.response?.data?.message || 'Ошибка при загрузке списка жалоб. Пожалуйста, попробуйте позже.';
}
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -285,8 +341,17 @@ h2::before {
border-radius: 3px; border-radius: 3px;
} }
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.search-bar { .search-bar {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
flex-grow: 1;
max-width: 400px;
} }
.filter-options { .filter-options {
@ -313,6 +378,24 @@ h2::before {
padding: 2rem; padding: 2rem;
color: #666; color: #666;
font-style: italic; font-style: italic;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.loading-spinner {
width: 1.5rem;
height: 1.5rem;
border: 3px solid rgba(255, 255, 255, 0.6);
border-top: 3px solid #007bff;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
} }
.error-message { .error-message {
@ -322,6 +405,19 @@ h2::before {
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid #f5c6cb; border: 1px solid #f5c6cb;
margin-bottom: 1rem; margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.reports-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.desktop-table {
display: block;
} }
.reports-table-container { .reports-table-container {
@ -392,6 +488,9 @@ h2::before {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
transition: all 0.2s ease; transition: all 0.2s ease;
min-width: 36px;
min-height: 36px;
justify-content: center;
} }
.view-btn { .view-btn {
@ -404,6 +503,16 @@ h2::before {
transform: translateY(-1px); transform: translateY(-1px);
} }
.reports-table .id-col,
.reports-table .reason-col,
.reports-table .reporter-col,
.reports-table .reported-col,
.reports-table .date-col,
.reports-table .status-col,
.reports-table .actions-col {
white-space: nowrap;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -422,6 +531,9 @@ h2::before {
transition: all 0.2s ease; transition: all 0.2s ease;
color: #495057; color: #495057;
font-weight: 500; font-weight: 500;
display: flex;
align-items: center;
gap: 0.3rem;
} }
.pagination-btn:hover:not(:disabled) { .pagination-btn:hover:not(:disabled) {
@ -436,42 +548,466 @@ h2::before {
.pagination-info { .pagination-info {
color: #6c757d; color: #6c757d;
display: flex;
gap: 0.5rem;
align-items: center;
}
.page-text {
font-weight: 600;
font-size: 0.95rem;
}
.total-text {
font-size: 0.8rem;
color: #6c757d;
}
/* Мобильные карточки */
.mobile-cards {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.report-card {
background: white;
border-radius: 0.75rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid #e9ecef;
}
.report-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
border-color: #007bff;
}
.report-card:active {
transform: translateY(0);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e9ecef;
background: linear-gradient(to right, #f8f9fa, #ffffff);
}
.card-id {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #333;
font-size: 0.9rem;
}
.card-id i {
font-size: 1.1rem;
color: #007bff;
}
.card-content {
padding: 1rem;
}
.reason-info {
margin-bottom: 0.75rem;
}
.reason-info .reason-badge {
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
}
.users-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 0.5rem;
}
.user-item {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
color: #555;
flex: 1;
min-width: 0;
}
.user-item span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.user-item i {
flex-shrink: 0;
font-size: 1rem;
}
.arrow {
font-size: 1.2rem;
color: #007bff;
font-weight: bold;
flex-shrink: 0;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: #777;
padding-top: 0.5rem;
border-top: 1px solid #f0f0f0;
}
.card-footer .date {
font-weight: 500;
}
.card-footer i {
font-size: 1.1rem;
color: #007bff;
transition: transform 0.2s ease;
}
.report-card:hover .card-footer i {
transform: translateX(2px);
} }
.no-results { .no-results {
text-align: center; text-align: center;
padding: 3rem; padding: 4rem 2rem;
color: #6c757d; color: #6c757d;
font-style: italic; background: white;
border-radius: 0.75rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
} }
/* Адаптивные стили */ .no-results i {
@media (max-width: 767px) { font-size: 3rem;
.hide-sm { margin-bottom: 1rem;
color: #dee2e6;
}
.no-results h3 {
margin: 0 0 0.5rem 0;
color: #495057;
font-size: 1.25rem;
}
.no-results p {
margin: 0;
font-size: 0.95rem;
}
/* Улучшенная пагинация */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
border: 1px solid #e9ecef;
}
.pagination-btn {
padding: 0.6rem 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.4rem;
min-height: 40px;
}
.pagination-btn:hover:not(:disabled) {
background-color: #0056b3;
transform: translateY(-1px);
}
.pagination-btn:disabled {
background-color: #6c757d;
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.pagination-info {
color: #495057;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
text-align: center;
}
/* Адаптивные стили для разных экранов */
/* Очень маленькие экраны (до 480px) */
@media (max-width: 480px) {
.admin-reports {
padding: 1rem 0.5rem calc(60px + env(safe-area-inset-bottom, 0px)) 0.5rem;
}
.header-section {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.search-bar {
max-width: none;
margin-bottom: 0;
}
.filter-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.filter-options label {
padding: 0.5rem;
background: #f8f9fa;
border-radius: 0.5rem;
border: 1px solid #dee2e6;
transition: all 0.2s ease;
justify-content: center;
}
.filter-options label:has(input:checked) {
background: #007bff;
color: white;
border-color: #007bff;
}
.filter-options input[type="radio"] {
display: none;
}
.card-header {
padding: 0.75rem;
}
.card-content {
padding: 0.75rem;
}
.users-info {
flex-direction: column;
gap: 0.5rem;
}
.user-item {
justify-content: center;
padding: 0.25rem;
}
.arrow {
transform: rotate(90deg);
}
.pagination {
padding: 0.75rem;
flex-direction: column;
gap: 1rem;
}
.pagination-btn {
width: 100%;
justify-content: center;
}
.btn-text-lg {
display: none;
}
h2 {
font-size: 1.1rem;
}
}
/* Маленькие экраны (481px - 767px) */
@media (min-width: 481px) and (max-width: 767px) {
.admin-reports {
padding: 1rem calc(60px + env(safe-area-inset-bottom, 0px)) 1rem;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.filter-options label {
padding: 0.4rem 0.8rem;
background: #f8f9fa;
border-radius: 1rem;
border: 1px solid #dee2e6;
transition: all 0.2s ease;
}
.filter-options label:has(input:checked) {
background: #007bff;
color: white;
border-color: #007bff;
}
.filter-options input[type="radio"] {
display: none;
}
.users-info {
align-items: center;
}
.pagination {
flex-direction: row;
}
}
/* Средние экраны (768px - 991px) */
@media (min-width: 768px) and (max-width: 991px) {
.admin-reports {
padding: 1.5rem 1rem calc(60px + env(safe-area-inset-bottom, 0px)) 1rem;
}
.mobile-cards {
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.header-section {
flex-direction: row;
}
}
/* Большие экраны (992px - 1199px) */
@media (min-width: 992px) and (max-width: 1199px) {
.admin-reports {
padding: 2rem 1.5rem;
}
.mobile-cards {
display: none;
}
.desktop-table {
display: block;
}
.reports-table th,
.reports-table td {
padding: 0.75rem 0.5rem;
font-size: 0.9rem;
}
.id-col { width: 8%; }
.reason-col { width: 20%; }
.reporter-col { width: 18%; }
.reported-col { width: 18%; }
.date-col { width: 15%; }
.status-col { width: 12%; }
.actions-col { width: 9%; }
}
/* Очень большие экраны (1200px+) */
@media (min-width: 1200px) {
.admin-reports {
padding: 2rem;
}
.mobile-cards {
display: none;
}
.desktop-table {
display: block;
}
.reports-table th,
.reports-table td {
padding: 1rem;
}
.id-col { width: 10%; }
.reason-col { width: 20%; }
.reporter-col { width: 17%; }
.reported-col { width: 17%; }
.date-col { width: 16%; }
.status-col { width: 12%; }
.actions-col { width: 8%; }
}
/* Скрываем таблицу на мобильных, показываем карточки */
@media (max-width: 991px) {
.desktop-table {
display: none;
}
.mobile-cards {
display: grid;
}
}
/* Планшеты в ландшафтной ориентации */
@media (min-width: 768px) and (max-width: 1024px) and (orientation: landscape) {
.desktop-table {
display: block;
}
.mobile-cards {
display: none; display: none;
} }
.reports-table th, .reports-table th,
.reports-table td { .reports-table td {
padding: 0.6rem 0.5rem; padding: 0.6rem 0.4rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
}
.btn-text {
display: none; /* Ландшафтная ориентация на мобильных */
@media (max-height: 450px) and (orientation: landscape) {
.admin-reports {
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
} }
.filter-options { .header-section {
gap: 0.5rem;
}
h2 {
font-size: 1.3rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.pagination {
padding: 0.5rem 1rem;
}
}
/* Высокие экраны */
@media (min-height: 800px) {
.admin-reports { .admin-reports {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px)); min-height: calc(100vh - 200px);
} }
} }
@ -481,11 +1017,4 @@ h2::before {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
} }
} }
/* Ландшафтная ориентация на мобильных */
@media (max-height: 450px) and (orientation: landscape) {
.admin-reports {
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
}
}
</style> </style>