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

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);
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
}

View File

@ -108,29 +108,17 @@ const getReportById = async (req, res, next) => {
try {
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)
.populate('reporter', 'name email photos')
.populate('reportedUser', 'name email photos isActive')
.populate('reviewedBy', 'name email');
if (!report) {
console.log(`[REPORT_CTRL] Жалоба с ID ${id} не найдена`);
const error = new Error('Жалоба не найдена');
error.statusCode = 404;
return next(error);
}
console.log(`[REPORT_CTRL] Жалоба найдена: ${report._id}, статус: ${report.status}`);
// Преобразуем поля для совместимости с фронтендом
const reportResponse = report.toObject();
@ -161,26 +149,8 @@ const updateReportStatus = async (req, res, next) => {
const { status, adminNotes } = req.body;
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);
if (!report) {
console.log(`[REPORT_CTRL] Жалоба с ID ${id} не найдена для обновления`);
const error = new Error('Жалоба не найдена');
error.statusCode = 404;
return next(error);
@ -200,7 +170,7 @@ const updateReportStatus = async (req, res, next) => {
await report.save();
console.log(`[REPORT_CTRL] Жалоба ${id} успешно обновлена администратором ${adminId}, новый статус: ${status}`);
console.log(`[REPORT_CTRL] Жалоба ${id} обновлена администратором ${adminId}, статус: ${status}`);
// Возвращаем обновленную жалобу с заполненными связями
const updatedReport = await Report.findById(id)

View File

@ -18,7 +18,7 @@ const updateUserProfile = async (req, res, next) => {
if (user) {
// Проверяем имя на запрещённые слова, если оно было изменено
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);
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
}
@ -42,7 +42,7 @@ const updateUserProfile = async (req, res, next) => {
// Проверяем bio на запрещённые слова перед установкой
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);
throw new Error('Ваша биография содержит запрещённые слова. Пожалуйста, отредактируйте текст.');
}
@ -162,7 +162,8 @@ const getUsersForSwiping = async (req, res, next) => {
$ne: currentUserId,
$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 () => {
try {
// Проверяем, существует ли уже админ
const adminExists = await User.findOne({ email: 'admin@example.com', isAdmin: true });
const adminExists = await User.findOne({ email: 'admin', isAdmin: true });
if (!adminExists) {
// Создаем админа, если не существует
const admin = new User({
name: 'Администратор',
email: 'admin@example.com',
email: 'admin', // Изменено с 'admin@example.com' на 'admin'
password: 'admin124',
dateOfBirth: new Date('1990-01-01'), // Устанавливаем формальную дату рождения
gender: 'other',
@ -27,9 +27,9 @@ const initAdminAccount = async () => {
});
await admin.save();
console.log('Административный аккаунт успешно создан');
console.log('Административный аккаунт успешно создан с email: admin');
} else {
console.log('Административный аккаунт уже существует');
console.log('Административный аккаунт с email: admin уже существует');
}
} catch (error) {
console.error('Ошибка при инициализации админ-аккаунта:', error);

View File

@ -2,30 +2,25 @@
const fs = require('fs');
const path = require('path');
// Список русских нецензурных слов и других запрещенных слов
let filterPromise;
// Списки пользовательских слов (из ранее прочитанной версии файла)
const russianBadWords = [
'блядь', 'хуй', 'пизда', 'ебать', 'ебал', 'ебло', 'хуесос', 'пидор', 'пидорас',
'мудак', 'говно', 'залупа', 'хер', 'манда', 'спермоед', 'шлюха', 'соска', 'шалава',
'дебил', 'дрочить', 'дрочка', 'ебанутый', 'выродок', 'шмара', 'сука', 'членосос',
'гандон', 'гондон', 'гнида', 'вагина', 'хуи', 'жопа', 'шлюхи', 'проститутка'
];
// Список запрещенных слов и фраз для дейтинг-приложения
const datingAppBadWords = [
'проститутка', 'интим', 'секс за деньги', 'эскорт', 'интим услуги',
'заплачу', 'заплати', 'минет', 'массаж с интимом'
];
/**
* Загружает список запрещенных слов из файла
* @param {string} filePath - Путь к файлу со списком запрещенных слов
* @return {Array<string>} - Массив запрещенных слов
*/
function loadBadWordsFromFile(filePath) {
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8');
const words = content.split('\n')
const words = content.split('\\n') // Используем \\n как разделитель строк
.map(word => word.trim())
.filter(word => word && !word.startsWith('//') && word.length >= 3);
console.log(`Загружено ${words.length} запрещенных слов из файла ${filePath}`);
@ -37,145 +32,69 @@ function loadBadWordsFromFile(filePath) {
return [];
}
class ProfanityFilter {
constructor() {
// Загружаем слова из файлов
const russianFileWords = loadBadWordsFromFile(path.join(__dirname, 'words.txt'));
const englishFileWords = loadBadWordsFromFile(path.join(__dirname, 'en.txt'));
function getInitializedFilter() {
if (!filterPromise) {
filterPromise = import('bad-words').then(async BadWordsModule => {
const FilterConstructor = BadWordsModule.default || BadWordsModule; // .default часто используется для ESM default export
const instance = new FilterConstructor();
console.log('Фильтр bad-words успешно инициализирован.');
// Объединяем все источники запрещенных слов
this.badWords = [
// Загрузка и добавление пользовательских слов
const wordsPath = path.join(__dirname, 'words.txt');
const enWordsPath = path.join(__dirname, 'en.txt');
const russianFileWords = loadBadWordsFromFile(wordsPath);
const englishFileWords = loadBadWordsFromFile(enWordsPath);
const allCustomWords = [
...russianBadWords,
...datingAppBadWords,
...russianFileWords,
...englishFileWords
];
].filter(word => typeof word === 'string' && word.length > 0);
// Создаем регулярные выражения для проверки производных слов
this.generateWordPatterns(this.badWords);
console.log(`Всего загружено ${this.badWords.length} запрещенных слов`);
if (allCustomWords.length > 0) {
instance.addWords(...allCustomWords);
console.log(`Добавлено ${allCustomWords.length} пользовательских слов в фильтр bad-words.`);
} else {
console.log('Пользовательские слова для добавления в фильтр bad-words не найдены.');
}
/**
* Создает регулярные выражения для проверки производных слов
* @param {Array<string>} words - Список базовых запрещённых слов
*/
generateWordPatterns(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');
return instance;
}).catch(err => {
console.error("Критическая ошибка: не удалось загрузить или инициализировать модуль bad-words:", err);
// Возвращаем "заглушку" фильтра, чтобы приложение не падало
return {
isProfane: (text) => { console.warn("Фильтр bad-words недоступен, проверка isProfane пропущена."); return false; },
clean: (text) => { console.warn("Фильтр bad-words недоступен, операция clean пропущена."); return text; }
};
});
}
/**
* Проверяет, содержит ли текст запрещенные слова или их производные
* @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 filterPromise;
}
// Дополнительная проверка на производные слова через регулярные выражения
return this.wordPatterns.some(pattern => pattern.test(text.toLowerCase()));
}
/**
* Проверяет, содержит ли объект запрещенные слова в указанных полях
* @param {Object} obj - Объект для проверки
* @param {Array<string>} fields - Массив имен полей для проверки
* @return {Object} - Объект с результатами проверки { isProfane: boolean, field: string|null }
*/
validateObject(obj, fields) {
// Экспортируем объект с асинхронными функциями, которые используют инициализированный фильтр
module.exports = {
isProfane: async (text) => {
// Добавляем проверку, что text является строкой и не пустой
if (typeof text !== 'string' || text.trim() === '') return false;
const filter = await getInitializedFilter();
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) {
if (obj[field] && this.hasProfanity(obj[field])) {
if (obj && typeof obj[field] === 'string') { // Проверяем, что obj существует и obj[field] является строкой
if (filter.isProfane(obj[field])) {
return { isProfane: true, field };
}
}
}
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,8 +9,31 @@
</router-view>
</main>
<!-- Мобильная навигация внизу экрана (не показывается в админ-панели) -->
<nav v-if="authState && !isAdminRoute" class="mobile-nav">
<!-- Мобильная навигация внизу экрана -->
<nav v-if="authState" class="mobile-nav">
<!-- Вкладки для администратора -->
<template v-if="currentUserIsAdmin">
<router-link to="/admin/users" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-people"></i>
</div>
<span class="nav-text">Пользователи</span>
</router-link>
<router-link to="/admin/conversations" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-chat-square-dots"></i>
</div>
<span class="nav-text">Диалоги</span>
</router-link>
<router-link to="/admin/statistics" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-bar-chart-line"></i>
</div>
<span class="nav-text">Статистика</span>
</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>
@ -32,6 +55,7 @@
</div>
<span class="nav-text">Профиль</span>
</router-link>
</template>
</nav>
</div>
</template>
@ -46,7 +70,6 @@ import { getSocket, connectSocket } from '@/services/socketService';
const { user, isAuthenticated, logout, fetchUser } = useAuth();
const route = useRoute();
// Используем локальные переменные для отслеживания состояния
const userData = ref(null);
const authState = ref(false);
const conversations = ref([]);
@ -54,194 +77,163 @@ const totalUnread = computed(() => {
return conversations.value.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0);
});
// Проверяем, находится ли пользователь в админ-панели
const currentUserIsAdmin = computed(() => {
return !!user.value && !!user.value.isAdmin;
});
const isAdminRoute = computed(() => {
return route.path.startsWith('/admin');
});
let socket = null;
// Получение списка диалогов для отображения счетчика непрочитанных сообщений
const fetchConversations = async () => {
if (!isAuthenticated.value) return;
if (!isAuthenticated.value || currentUserIsAdmin.value) return;
try {
const response = await api.getUserConversations();
conversations.value = response.data;
console.log('[App] Загружено диалогов для счетчика уведомлений:', conversations.value.length);
} catch (err) {
console.error('[App] Ошибка при загрузке диалогов:', err);
}
};
// Обработчик новых сообщений
const handleNewMessage = (message) => {
console.log('[App] Получено новое сообщение:', {
sender: message.sender,
currentUser: user.value?._id,
conversationId: message.conversationId
});
// ВАЖНО: Правильно сравниваем ID отправителя
// message.sender может быть как объектом, так и строкой ID
if (currentUserIsAdmin.value || !user.value) return;
const senderId = typeof message.sender === 'object' ? message.sender._id : message.sender;
// ВАЖНО: Увеличиваем счетчик ТОЛЬКО если сообщение пришло от другого пользователя
if (senderId !== user.value?._id) {
// Добавляем небольшую задержку, чтобы дать время событию messagesRead сработать
// если пользователь находится в том же чате
if (senderId !== user.value._id) {
setTimeout(() => {
const conversation = conversations.value.find(c => c._id === message.conversationId);
if (conversation) {
// Проверяем текущий маршрут - если пользователь находится в этом чате,
// то сообщение уже могло быть автоматически помечено как прочитанное
const currentRoute = route.path;
const isInThisChat = currentRoute === `/chat/${message.conversationId}`;
if (!isInThisChat) {
// Увеличиваем счетчик только если пользователь НЕ находится в этом чате
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
console.log('[App] Увеличен счетчик в навигации для диалога:', message.conversationId);
} else {
console.log('[App] Пользователь находится в этом чате, счетчик не увеличиваем');
}
} else {
// Если диалога нет в списке, обновим весь список
fetchConversations();
}
}, 100); // Задержка 100ms
} else {
console.log('[App] Сообщение от текущего пользователя, счетчик в навигации не изменяется');
}, 100);
}
};
// Обработчик прочитанных сообщений
const handleMessagesRead = ({ conversationId, readerId }) => {
console.log('[App] Получено событие messagesRead:', { conversationId, readerId, currentUserId: user.value?._id });
// Если текущий пользователь прочитал сообщения
if (readerId === user.value?._id) {
if (currentUserIsAdmin.value || !user.value) return;
if (readerId === user.value._id) {
const conversation = conversations.value.find(c => c._id === conversationId);
if (conversation) {
console.log('[App] Сбрасываем счетчик для диалога:', conversationId);
conversation.unreadCount = 0;
}
}
};
// Дополнительный обработчик для синхронизации при переходе в чат
const handleRouteChange = () => {
// Обновляем данные диалогов при изменении маршрута
watch(() => isAuthenticated.value, (newAuthStatus, oldAuthStatus) => {
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) {
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) => {
console.log('[App] Маршрут изменился:', { from: oldPath, to: newPath });
// Если переходим в чат или возвращаемся из чата, обновляем счетчики
if (isAuthenticated.value && !currentUserIsAdmin.value) {
if (newPath.startsWith('/chat/') || oldPath?.startsWith('/chat/')) {
setTimeout(() => {
fetchConversations();
}, 500); // Небольшая задержка для завершения операций чтения
}, 500);
}
}
});
// Настройка сокет-соединения
const setupSocketConnection = () => {
if (currentUserIsAdmin.value || !user.value?._id) return;
socket = getSocket();
if (!socket) {
socket = connectSocket();
}
if (socket) {
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
socket.on('getMessage', handleNewMessage);
socket.on('messagesRead', handleMessagesRead);
// Присоединение к комнате для уведомлений
if (user.value?._id) {
socket.emit('joinNotificationRoom', user.value._id);
console.log('[App.vue setupSocketConnection] Socket connection configured for user:', user.value._id);
}
};
const initializeUserState = () => {
if (isAuthenticated.value) {
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(() => {
console.log('[App] App.vue монтирован, проверка состояния аутентификации');
onMounted(async () => {
authState.value = isAuthenticated.value;
userData.value = user.value;
if (localStorage.getItem('userToken') && !user.value) {
console.log('[App.vue onMounted] Token found, user data missing. Fetching user...');
await fetchUser();
console.log('[App.vue onMounted] User fetched. isAuthenticated:', isAuthenticated.value, 'isAdmin:', currentUserIsAdmin.value);
}
authState.value = isAuthenticated.value;
userData.value = user.value;
// Убедимся, что у нас есть актуальные данные пользователя
if (localStorage.getItem('userToken') && !isAuthenticated.value) {
fetchUser().then(() => {
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');
}
if (currentUserIsAdmin.value) {
console.log('[App.vue onMounted] Authenticated user is Admin.');
initializeUserState(); // Handles admin case (clears user listeners)
} else {
console.log('[App.vue onMounted] Authenticated user is a regular user. Initializing user state.');
initializeUserState();
}
} 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 {
// Для обычных пользователей
fetchConversations();
setupSocketConnection();
}
console.log('[App.vue onMounted] User is not authenticated.');
}
});
// При размонтировании компонента очищаем обработчики событий
onUnmounted(() => {
if (socket) {
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
if (user.value?._id) {
if (user.value?._id && !currentUserIsAdmin.value) {
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;
}
/* Важное изменение - переместить этот селектор за пределы @supports */
.app-content {
height: calc(100% - var(--nav-height) - var(--safe-area-bottom));
.app-content { /* Это правило изменяет высоту .app-content, если есть safe-area */
height: calc(100% - (var(--nav-height) + var(--safe-area-bottom)));
}
}

View File

@ -2,36 +2,56 @@
<div class="admin-report-detail">
<div class="header-controls">
<button @click="goBack" class="back-btn">
&laquo; Назад к списку жалоб
<i class="bi-arrow-left"></i>
<span class="btn-text">Назад к списку жалоб</span>
</button>
</div>
<div v-if="loading" class="loading-indicator">
Загрузка информации о жалобе...
<div class="loading-spinner"></div>
<span>Загрузка информации о жалобе...</span>
</div>
<div v-if="error" class="error-message">
<i class="bi-exclamation-triangle"></i>
{{ error }}
</div>
<div v-if="report && !loading" class="report-container">
<div class="page-header">
<h2>Детали жалобы</h2>
<div class="report-card">
<div class="report-header">
<div class="report-info">
<h3>Жалоба #{{ report._id.substring(0, 8) }}</h3>
<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-header">
<div class="report-info">
<div class="report-id-section">
<i class="bi-file-text"></i>
<div class="id-info">
<h3>Жалоба #{{ report._id.substring(0, 8) }}</h3>
<span class="full-id">ID: {{ report._id }}</span>
</div>
</div>
</div>
<div class="report-date">
Подана {{ formatDate(report.createdAt) }}
<i class="bi-calendar3"></i>
<span>Подана {{ formatDate(report.createdAt) }}</span>
</div>
</div>
<div class="report-content">
<div class="info-grid">
<div class="info-section">
<div class="section-header">
<i class="bi-info-circle"></i>
<h4>Информация о жалобе</h4>
</div>
<div class="info-item">
<label>Причина жалобы:</label>
<span class="reason-badge" :class="getReasonClass(report.reason)">
@ -41,54 +61,92 @@
<div class="info-item" v-if="report.description">
<label>Описание:</label>
<div class="description-container">
<p class="description">{{ report.description }}</p>
</div>
<div class="info-item">
<label>Жалующийся:</label>
<div class="user-info">
<span>{{ report.reporter?.name || 'Пользователь удален' }}</span>
<span class="email">{{ report.reporter?.email || '' }}</span>
</div>
</div>
<div class="info-item">
<label>На пользователя:</label>
<div class="user-info">
<span>{{ report.reportedUser?.name || 'Пользователь удален' }}</span>
<span class="email">{{ report.reportedUser?.email || '' }}</span>
<div class="info-section">
<div class="section-header">
<i class="bi-people"></i>
<h4>Участники</h4>
</div>
<div class="users-grid">
<div class="user-card reporter">
<div class="user-header">
<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 class="info-section" v-if="report.adminNotes || report.resolvedAt">
<div class="section-header">
<i class="bi-shield-check"></i>
<h4>Административная информация</h4>
</div>
<div class="info-item" v-if="report.adminNotes">
<label>Заметки администратора:</label>
<div class="admin-notes-container">
<p class="admin-notes">{{ report.adminNotes }}</p>
</div>
</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>
<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">
<button @click="showResolveModal = true" class="btn resolve-btn">
<i class="bi-check-circle"></i>
Рассмотреть
<span>Рассмотреть</span>
</button>
<button @click="showDismissModal = true" class="btn dismiss-btn">
<i class="bi-x-circle"></i>
Отклонить
<span>Отклонить</span>
</button>
</div>
</div>
@ -98,17 +156,32 @@
<!-- Модальное окно для рассмотрения жалобы -->
<div v-if="showResolveModal" class="modal-overlay" @click="showResolveModal = false">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>Рассмотрение жалобы</h3>
<button @click="showResolveModal = false" class="modal-close">
<i class="bi-x"></i>
</button>
</div>
<div class="modal-content">
<p>Вы собираетесь отметить эту жалобу как рассмотренную.</p>
<div class="form-group">
<label>Заметки администратора (необязательно):</label>
<textarea v-model="adminNotes" placeholder="Укажите принятые меры или комментарии..."></textarea>
<textarea
v-model="adminNotes"
placeholder="Укажите принятые меры или комментарии..."
rows="4"
></textarea>
</div>
</div>
<div class="modal-actions">
<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 @click="showResolveModal = false" class="btn cancel-btn">Отмена</button>
</div>
</div>
</div>
@ -116,17 +189,32 @@
<!-- Модальное окно для отклонения жалобы -->
<div v-if="showDismissModal" class="modal-overlay" @click="showDismissModal = false">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>Отклонение жалобы</h3>
<button @click="showDismissModal = false" class="modal-close">
<i class="bi-x"></i>
</button>
</div>
<div class="modal-content">
<p>Вы собираетесь отклонить эту жалобу.</p>
<div class="form-group">
<label>Заметки администратора (необязательно):</label>
<textarea v-model="adminNotes" placeholder="Укажите причину отклонения..."></textarea>
<textarea
v-model="adminNotes"
placeholder="Укажите причину отклонения..."
rows="4"
></textarea>
</div>
</div>
<div class="modal-actions">
<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 @click="showDismissModal = false" class="btn cancel-btn">Отмена</button>
</div>
</div>
</div>
@ -136,7 +224,7 @@
<script>
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import api from '@/services/api';
import axios from 'axios';
export default {
name: 'AdminReportDetail',
@ -163,19 +251,17 @@ export default {
error.value = null;
try {
console.log('[AdminReportDetail] Загружаем жалобу с ID:', props.id);
const response = await api.getReportById(props.id);
const token = localStorage.getItem('userToken');
const response = await axios.get(`/api/admin/reports/${props.id}`, {
headers: {
Authorization: `Bearer ${token}`
}
});
report.value = response.data;
console.log('[AdminReportDetail] Жалоба загружена:', response.data);
} catch (err) {
console.error('Ошибка при загрузке жалобы:', err);
if (err.response?.status === 404) {
error.value = 'Жалоба не найдена.';
} else if (err.response?.status === 403) {
error.value = 'У вас нет прав для просмотра этой жалобы.';
} else {
error.value = err.response?.data?.message || 'Ошибка при загрузке информации о жалобе.';
}
error.value = 'Ошибка при загрузке информации о жалобе.';
} finally {
loading.value = false;
}
@ -186,10 +272,14 @@ export default {
updating.value = true;
try {
console.log('[AdminReportDetail] Обновляем статус жалобы:', status);
const response = await api.updateReportStatus(props.id, {
const token = localStorage.getItem('userToken');
const response = await axios.put(`/api/admin/reports/${props.id}`, {
status,
adminNotes: adminNotes.value
}, {
headers: {
Authorization: `Bearer ${token}`
}
});
// Обновляем локальные данные
@ -200,12 +290,10 @@ export default {
showDismissModal.value = false;
adminNotes.value = '';
console.log('[AdminReportDetail] Статус жалобы обновлен успешно');
alert(`Жалоба успешно ${status === 'resolved' ? 'рассмотрена' : 'отклонена'}`);
} catch (err) {
console.error('Ошибка при обновлении статуса жалобы:', err);
const errorMessage = err.response?.data?.message || 'Ошибка при обновлении статуса жалобы';
alert(errorMessage);
alert('Ошибка при обновлении статуса жалобы');
} finally {
updating.value = false;
}
@ -346,6 +434,21 @@ export default {
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 {
background-color: #f8d7da;
color: #721c24;
@ -353,6 +456,9 @@ export default {
border-radius: 0.375rem;
border: 1px solid #f5c6cb;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
h2 {
@ -362,6 +468,19 @@ h2 {
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 {
background: white;
border-radius: 0.75rem;
@ -384,15 +503,29 @@ h2 {
gap: 1rem;
}
.report-id-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.report-info h3 {
margin: 0;
color: #333;
font-size: 1.25rem;
}
.full-id {
color: #6c757d;
font-size: 0.9rem;
}
.report-date {
color: #6c757d;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.report-content {
@ -404,6 +537,26 @@ h2 {
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 {
display: block;
font-weight: 600;
@ -444,6 +597,9 @@ h2 {
margin-top: 0.5rem;
align-self: flex-start;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.view-user-btn:hover {
@ -478,9 +634,11 @@ h2 {
background: #f8f9fa;
}
.report-actions h4 {
margin: 0 0 1rem 0;
color: #495057;
.actions-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.action-buttons {
@ -560,8 +718,27 @@ h2 {
overflow-y: auto;
}
.modal h3 {
margin: 0 0 1rem 0;
.modal-header {
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;
}
@ -614,10 +791,261 @@ h2 {
}
}
/* Устройства с вырезом (notch) */
@supports (padding: env(safe-area-inset-bottom)) {
@media (max-width: 480px) {
.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 {
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>

View File

@ -1,66 +1,75 @@
<template>
<div class="admin-reports">
<div class="header-section">
<h2>Управление жалобами</h2>
<div class="search-bar">
<div class="filter-options">
<label>
<input type="radio" v-model="statusFilter" value="all" @change="loadReports">
Все
<span>Все</span>
</label>
<label>
<input type="radio" v-model="statusFilter" value="pending" @change="loadReports">
На рассмотрении
<span>На рассмотрении</span>
</label>
<label>
<input type="radio" v-model="statusFilter" value="resolved" @change="loadReports">
Рассмотренные
<span>Рассмотренные</span>
</label>
<label>
<input type="radio" v-model="statusFilter" value="dismissed" @change="loadReports">
Отклоненные
<span>Отклоненные</span>
</label>
</div>
</div>
</div>
<div v-if="loading" class="loading-indicator">
Загрузка жалоб...
<div class="loading-spinner"></div>
<span>Загрузка жалоб...</span>
</div>
<div v-if="error" class="error-message">
<i class="bi-exclamation-triangle"></i>
{{ error }}
</div>
<div v-if="!loading && !error" class="reports-table-container">
<div v-if="!loading && !error" class="reports-container">
<!-- Десктопное табличное представление -->
<div class="desktop-table">
<div class="reports-table-container">
<table class="reports-table">
<thead>
<tr>
<th>ID</th>
<th>Причина</th>
<th>Жалующийся</th>
<th>На пользователя</th>
<th class="hide-sm">Дата подачи</th>
<th>Статус</th>
<th>Действия</th>
<th class="id-col">ID</th>
<th class="reason-col">Причина</th>
<th class="reporter-col">Жалующийся</th>
<th class="reported-col">На пользователя</th>
<th class="date-col">Дата подачи</th>
<th class="status-col">Статус</th>
<th class="actions-col">Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="report in reports" :key="report._id">
<td>{{ shortenId(report._id) }}</td>
<td>
<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>{{ report.reporter?.name || 'Удален' }}</td>
<td>{{ report.reportedUser?.name || 'Удален' }}</td>
<td class="hide-sm">{{ formatDate(report.createdAt) }}</td>
<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">
<td class="actions-col">
<button @click="viewReport(report._id)" class="btn view-btn">
<span class="btn-text">Просмотр</span>
<i class="bi-eye"></i>
@ -69,9 +78,53 @@
</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)">
{{ getReasonText(report.reason) }}
</span>
</div>
<div class="users-info">
<div class="user-item">
<i class="bi-person-exclamation"></i>
<span>{{ report.reporter?.name || 'Удален' }}</span>
</div>
<div class="arrow"></div>
<div class="user-item">
<i class="bi-person-x"></i>
<span>{{ report.reportedUser?.name || 'Удален' }}</span>
</div>
</div>
<div class="card-footer">
<span class="date">{{ formatDate(report.createdAt) }}</span>
<i class="bi-chevron-right"></i>
</div>
</div>
</div>
</div>
<div v-if="reports.length === 0" class="no-results">
Жалобы не найдены
<i class="bi-inbox"></i>
<h3>Жалобы не найдены</h3>
<p>Нет жалоб, соответствующих выбранным фильтрам</p>
</div>
<div v-if="pagination.pages > 1" class="pagination">
@ -80,19 +133,22 @@
:disabled="currentPage === 1"
class="pagination-btn"
>
Назад
<i class="bi-chevron-left"></i>
<span class="btn-text-lg">Назад</span>
</button>
<span class="pagination-info">
Страница {{ currentPage }} из {{ pagination.pages }}
</span>
<div class="pagination-info">
<span class="page-text">Страница {{ currentPage }} из {{ pagination.pages }}</span>
<span class="total-text">({{ pagination.total }} жалоб)</span>
</div>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage === pagination.pages"
class="pagination-btn"
>
Далее
<span class="btn-text-lg">Далее</span>
<i class="bi-chevron-right"></i>
</button>
</div>
</div>
@ -102,7 +158,7 @@
<script>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '@/services/api';
import axios from 'axios';
export default {
name: 'AdminReports',
@ -135,19 +191,19 @@ export default {
params.status = statusFilter.value;
}
console.log('[AdminReports] Загружаем жалобы с параметрами:', params);
const response = await api.getReports(params);
const token = localStorage.getItem('userToken');
const response = await axios.get(`/api/admin/reports`, {
params,
headers: {
Authorization: `Bearer ${token}`
}
});
reports.value = response.data.reports;
pagination.value = response.data.pagination;
console.log('[AdminReports] Жалобы загружены:', response.data);
} catch (err) {
console.error('Ошибка при загрузке жалоб:', err);
if (err.response?.status === 403) {
error.value = 'У вас нет прав для просмотра жалоб.';
} else {
error.value = err.response?.data?.message || 'Ошибка при загрузке списка жалоб. Пожалуйста, попробуйте позже.';
}
error.value = 'Ошибка при загрузке списка жалоб. Пожалуйста, попробуйте позже.';
} finally {
loading.value = false;
}
@ -285,8 +341,17 @@ h2::before {
border-radius: 3px;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.search-bar {
margin-bottom: 1.5rem;
flex-grow: 1;
max-width: 400px;
}
.filter-options {
@ -313,6 +378,24 @@ h2::before {
padding: 2rem;
color: #666;
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 {
@ -322,6 +405,19 @@ h2::before {
border-radius: 0.375rem;
border: 1px solid #f5c6cb;
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 {
@ -392,6 +488,9 @@ h2::before {
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s ease;
min-width: 36px;
min-height: 36px;
justify-content: center;
}
.view-btn {
@ -404,6 +503,16 @@ h2::before {
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 {
display: flex;
justify-content: space-between;
@ -422,6 +531,9 @@ h2::before {
transition: all 0.2s ease;
color: #495057;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.3rem;
}
.pagination-btn:hover:not(:disabled) {
@ -436,42 +548,466 @@ h2::before {
.pagination-info {
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 {
text-align: center;
padding: 3rem;
padding: 4rem 2rem;
color: #6c757d;
font-style: italic;
background: white;
border-radius: 0.75rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* Адаптивные стили */
@media (max-width: 767px) {
.hide-sm {
.no-results i {
font-size: 3rem;
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;
}
.reports-table th,
.reports-table td {
padding: 0.6rem 0.5rem;
padding: 0.6rem 0.4rem;
font-size: 0.85rem;
}
.btn-text {
display: none;
}
.filter-options {
gap: 0.5rem;
/* Ландшафтная ориентация на мобильных */
@media (max-height: 450px) and (orientation: landscape) {
.admin-reports {
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
}
h2 {
font-size: 1.3rem;
.header-section {
margin-bottom: 1rem;
}
.pagination {
padding: 0.5rem 1rem;
}
}
/* Высокие экраны */
@media (min-height: 800px) {
.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));
}
}
/* Ландшафтная ориентация на мобильных */
@media (max-height: 450px) and (orientation: landscape) {
.admin-reports {
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
}
}
</style>