огромный багфикс
This commit is contained in:
parent
d0f21b566b
commit
7236669154
@ -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('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 } // Не включать администраторов в выборку
|
||||
};
|
||||
|
||||
// Фильтрация по полу согласно предпочтениям
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
};
|
227
src/App.vue
227
src/App.vue
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,36 +2,56 @@
|
||||
<div class="admin-report-detail">
|
||||
<div class="header-controls">
|
||||
<button @click="goBack" class="back-btn">
|
||||
« Назад к списку жалоб
|
||||
<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>
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user