огромный багфикс
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);
|
res.status(400);
|
||||||
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
|
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
|
||||||
}
|
}
|
||||||
|
@ -108,29 +108,17 @@ const getReportById = async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Валидация ObjectId
|
|
||||||
if (!id || !id.match(/^[0-9a-fA-F]{24}$/)) {
|
|
||||||
const error = new Error('Неверный ID жалобы');
|
|
||||||
error.statusCode = 400;
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[REPORT_CTRL] Поиск жалобы с ID: ${id}`);
|
|
||||||
|
|
||||||
const report = await Report.findById(id)
|
const report = await Report.findById(id)
|
||||||
.populate('reporter', 'name email photos')
|
.populate('reporter', 'name email photos')
|
||||||
.populate('reportedUser', 'name email photos isActive')
|
.populate('reportedUser', 'name email photos isActive')
|
||||||
.populate('reviewedBy', 'name email');
|
.populate('reviewedBy', 'name email');
|
||||||
|
|
||||||
if (!report) {
|
if (!report) {
|
||||||
console.log(`[REPORT_CTRL] Жалоба с ID ${id} не найдена`);
|
|
||||||
const error = new Error('Жалоба не найдена');
|
const error = new Error('Жалоба не найдена');
|
||||||
error.statusCode = 404;
|
error.statusCode = 404;
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[REPORT_CTRL] Жалоба найдена: ${report._id}, статус: ${report.status}`);
|
|
||||||
|
|
||||||
// Преобразуем поля для совместимости с фронтендом
|
// Преобразуем поля для совместимости с фронтендом
|
||||||
const reportResponse = report.toObject();
|
const reportResponse = report.toObject();
|
||||||
|
|
||||||
@ -161,26 +149,8 @@ const updateReportStatus = async (req, res, next) => {
|
|||||||
const { status, adminNotes } = req.body;
|
const { status, adminNotes } = req.body;
|
||||||
const adminId = req.user._id;
|
const adminId = req.user._id;
|
||||||
|
|
||||||
// Валидация ObjectId
|
|
||||||
if (!id || !id.match(/^[0-9a-fA-F]{24}$/)) {
|
|
||||||
const error = new Error('Неверный ID жалобы');
|
|
||||||
error.statusCode = 400;
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Валидация статуса
|
|
||||||
const validStatuses = ['pending', 'reviewed', 'resolved', 'dismissed'];
|
|
||||||
if (!status || !validStatuses.includes(status)) {
|
|
||||||
const error = new Error('Неверный статус жалобы');
|
|
||||||
error.statusCode = 400;
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[REPORT_CTRL] Обновление жалобы ${id} на статус: ${status} администратором ${adminId}`);
|
|
||||||
|
|
||||||
const report = await Report.findById(id);
|
const report = await Report.findById(id);
|
||||||
if (!report) {
|
if (!report) {
|
||||||
console.log(`[REPORT_CTRL] Жалоба с ID ${id} не найдена для обновления`);
|
|
||||||
const error = new Error('Жалоба не найдена');
|
const error = new Error('Жалоба не найдена');
|
||||||
error.statusCode = 404;
|
error.statusCode = 404;
|
||||||
return next(error);
|
return next(error);
|
||||||
@ -200,7 +170,7 @@ const updateReportStatus = async (req, res, next) => {
|
|||||||
|
|
||||||
await report.save();
|
await report.save();
|
||||||
|
|
||||||
console.log(`[REPORT_CTRL] Жалоба ${id} успешно обновлена администратором ${adminId}, новый статус: ${status}`);
|
console.log(`[REPORT_CTRL] Жалоба ${id} обновлена администратором ${adminId}, статус: ${status}`);
|
||||||
|
|
||||||
// Возвращаем обновленную жалобу с заполненными связями
|
// Возвращаем обновленную жалобу с заполненными связями
|
||||||
const updatedReport = await Report.findById(id)
|
const updatedReport = await Report.findById(id)
|
||||||
|
@ -18,7 +18,7 @@ const updateUserProfile = async (req, res, next) => {
|
|||||||
if (user) {
|
if (user) {
|
||||||
// Проверяем имя на запрещённые слова, если оно было изменено
|
// Проверяем имя на запрещённые слова, если оно было изменено
|
||||||
if (req.body.name && req.body.name !== user.name) {
|
if (req.body.name && req.body.name !== user.name) {
|
||||||
if (profanityFilter.hasProfanity(req.body.name)) {
|
if (await profanityFilter.isProfane(req.body.name)) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
|
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
|
||||||
}
|
}
|
||||||
@ -42,7 +42,7 @@ const updateUserProfile = async (req, res, next) => {
|
|||||||
|
|
||||||
// Проверяем bio на запрещённые слова перед установкой
|
// Проверяем bio на запрещённые слова перед установкой
|
||||||
if (req.body.bio !== undefined && req.body.bio !== null) {
|
if (req.body.bio !== undefined && req.body.bio !== null) {
|
||||||
if (req.body.bio !== '' && profanityFilter.hasProfanity(req.body.bio)) {
|
if (req.body.bio !== '' && await profanityFilter.isProfane(req.body.bio)) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
throw new Error('Ваша биография содержит запрещённые слова. Пожалуйста, отредактируйте текст.');
|
throw new Error('Ваша биография содержит запрещённые слова. Пожалуйста, отредактируйте текст.');
|
||||||
}
|
}
|
||||||
@ -162,7 +162,8 @@ const getUsersForSwiping = async (req, res, next) => {
|
|||||||
$ne: currentUserId,
|
$ne: currentUserId,
|
||||||
$nin: [...(currentUser.liked || []), ...(currentUser.passed || [])] // Исключаем уже просмотренных
|
$nin: [...(currentUser.liked || []), ...(currentUser.passed || [])] // Исключаем уже просмотренных
|
||||||
},
|
},
|
||||||
isActive: true // Только активные пользователи
|
isActive: true, // Только активные пользователи
|
||||||
|
isAdmin: { $ne: true } // Не включать администраторов в выборку
|
||||||
};
|
};
|
||||||
|
|
||||||
// Фильтрация по полу согласно предпочтениям
|
// Фильтрация по полу согласно предпочтениям
|
||||||
|
@ -8,13 +8,13 @@ const User = require('../models/User');
|
|||||||
const initAdminAccount = async () => {
|
const initAdminAccount = async () => {
|
||||||
try {
|
try {
|
||||||
// Проверяем, существует ли уже админ
|
// Проверяем, существует ли уже админ
|
||||||
const adminExists = await User.findOne({ email: 'admin@example.com', isAdmin: true });
|
const adminExists = await User.findOne({ email: 'admin', isAdmin: true });
|
||||||
|
|
||||||
if (!adminExists) {
|
if (!adminExists) {
|
||||||
// Создаем админа, если не существует
|
// Создаем админа, если не существует
|
||||||
const admin = new User({
|
const admin = new User({
|
||||||
name: 'Администратор',
|
name: 'Администратор',
|
||||||
email: 'admin@example.com',
|
email: 'admin', // Изменено с 'admin@example.com' на 'admin'
|
||||||
password: 'admin124',
|
password: 'admin124',
|
||||||
dateOfBirth: new Date('1990-01-01'), // Устанавливаем формальную дату рождения
|
dateOfBirth: new Date('1990-01-01'), // Устанавливаем формальную дату рождения
|
||||||
gender: 'other',
|
gender: 'other',
|
||||||
@ -27,9 +27,9 @@ const initAdminAccount = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await admin.save();
|
await admin.save();
|
||||||
console.log('Административный аккаунт успешно создан');
|
console.log('Административный аккаунт успешно создан с email: admin');
|
||||||
} else {
|
} else {
|
||||||
console.log('Административный аккаунт уже существует');
|
console.log('Административный аккаунт с email: admin уже существует');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при инициализации админ-аккаунта:', error);
|
console.error('Ошибка при инициализации админ-аккаунта:', error);
|
||||||
|
@ -2,30 +2,25 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
// Список русских нецензурных слов и других запрещенных слов
|
let filterPromise;
|
||||||
|
|
||||||
|
// Списки пользовательских слов (из ранее прочитанной версии файла)
|
||||||
const russianBadWords = [
|
const russianBadWords = [
|
||||||
'блядь', 'хуй', 'пизда', 'ебать', 'ебал', 'ебло', 'хуесос', 'пидор', 'пидорас',
|
'блядь', 'хуй', 'пизда', 'ебать', 'ебал', 'ебло', 'хуесос', 'пидор', 'пидорас',
|
||||||
'мудак', 'говно', 'залупа', 'хер', 'манда', 'спермоед', 'шлюха', 'соска', 'шалава',
|
'мудак', 'говно', 'залупа', 'хер', 'манда', 'спермоед', 'шлюха', 'соска', 'шалава',
|
||||||
'дебил', 'дрочить', 'дрочка', 'ебанутый', 'выродок', 'шмара', 'сука', 'членосос',
|
'дебил', 'дрочить', 'дрочка', 'ебанутый', 'выродок', 'шмара', 'сука', 'членосос',
|
||||||
'гандон', 'гондон', 'гнида', 'вагина', 'хуи', 'жопа', 'шлюхи', 'проститутка'
|
'гандон', 'гондон', 'гнида', 'вагина', 'хуи', 'жопа', 'шлюхи', 'проститутка'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Список запрещенных слов и фраз для дейтинг-приложения
|
|
||||||
const datingAppBadWords = [
|
const datingAppBadWords = [
|
||||||
'проститутка', 'интим', 'секс за деньги', 'эскорт', 'интим услуги',
|
'проститутка', 'интим', 'секс за деньги', 'эскорт', 'интим услуги',
|
||||||
'заплачу', 'заплати', 'минет', 'массаж с интимом'
|
'заплачу', 'заплати', 'минет', 'массаж с интимом'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Загружает список запрещенных слов из файла
|
|
||||||
* @param {string} filePath - Путь к файлу со списком запрещенных слов
|
|
||||||
* @return {Array<string>} - Массив запрещенных слов
|
|
||||||
*/
|
|
||||||
function loadBadWordsFromFile(filePath) {
|
function loadBadWordsFromFile(filePath) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
const words = content.split('\n')
|
const words = content.split('\\n') // Используем \\n как разделитель строк
|
||||||
.map(word => word.trim())
|
.map(word => word.trim())
|
||||||
.filter(word => word && !word.startsWith('//') && word.length >= 3);
|
.filter(word => word && !word.startsWith('//') && word.length >= 3);
|
||||||
console.log(`Загружено ${words.length} запрещенных слов из файла ${filePath}`);
|
console.log(`Загружено ${words.length} запрещенных слов из файла ${filePath}`);
|
||||||
@ -37,145 +32,69 @@ function loadBadWordsFromFile(filePath) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProfanityFilter {
|
function getInitializedFilter() {
|
||||||
constructor() {
|
if (!filterPromise) {
|
||||||
// Загружаем слова из файлов
|
filterPromise = import('bad-words').then(async BadWordsModule => {
|
||||||
const russianFileWords = loadBadWordsFromFile(path.join(__dirname, 'words.txt'));
|
const FilterConstructor = BadWordsModule.default || BadWordsModule; // .default часто используется для ESM default export
|
||||||
const englishFileWords = loadBadWordsFromFile(path.join(__dirname, 'en.txt'));
|
const instance = new FilterConstructor();
|
||||||
|
console.log('Фильтр bad-words успешно инициализирован.');
|
||||||
// Объединяем все источники запрещенных слов
|
|
||||||
this.badWords = [
|
|
||||||
...russianBadWords,
|
|
||||||
...datingAppBadWords,
|
|
||||||
...russianFileWords,
|
|
||||||
...englishFileWords
|
|
||||||
];
|
|
||||||
|
|
||||||
// Создаем регулярные выражения для проверки производных слов
|
// Загрузка и добавление пользовательских слов
|
||||||
this.generateWordPatterns(this.badWords);
|
const wordsPath = path.join(__dirname, 'words.txt');
|
||||||
|
const enWordsPath = path.join(__dirname, 'en.txt');
|
||||||
console.log(`Всего загружено ${this.badWords.length} запрещенных слов`);
|
|
||||||
}
|
const russianFileWords = loadBadWordsFromFile(wordsPath);
|
||||||
|
const englishFileWords = loadBadWordsFromFile(enWordsPath);
|
||||||
|
|
||||||
|
const allCustomWords = [
|
||||||
|
...russianBadWords,
|
||||||
|
...datingAppBadWords,
|
||||||
|
...russianFileWords,
|
||||||
|
...englishFileWords
|
||||||
|
].filter(word => typeof word === 'string' && word.length > 0);
|
||||||
|
|
||||||
/**
|
if (allCustomWords.length > 0) {
|
||||||
* Создает регулярные выражения для проверки производных слов
|
instance.addWords(...allCustomWords);
|
||||||
* @param {Array<string>} words - Список базовых запрещённых слов
|
console.log(`Добавлено ${allCustomWords.length} пользовательских слов в фильтр bad-words.`);
|
||||||
*/
|
} else {
|
||||||
generateWordPatterns(words) {
|
console.log('Пользовательские слова для добавления в фильтр bad-words не найдены.');
|
||||||
this.wordPatterns = words
|
|
||||||
.filter(word => word && word.length >= 3) // Снижаем минимальную длину до 3 символов
|
|
||||||
.map(word => {
|
|
||||||
// Создаем шаблон с учетом возможных разделителей и замен символов
|
|
||||||
const baseWord = word
|
|
||||||
.replace(/[еёо]/g, '[еёо]') // Учитываем замену е/ё/о
|
|
||||||
.replace(/а/g, '[аa@]') // Учитываем замену а на a латинскую или @
|
|
||||||
.replace(/о/g, '[оo0]') // Учитываем замену о на o латинскую или 0
|
|
||||||
.replace(/е/g, '[еe]') // Учитываем замену е на e латинскую
|
|
||||||
.replace(/с/g, '[сc]') // Учитываем замену с на c латинскую
|
|
||||||
.replace(/р/g, '[рp]') // Учитываем замену р на p латинскую
|
|
||||||
.replace(/х/g, '[хx]') // Учитываем замену х на x латинскую
|
|
||||||
.replace(/у/g, '[уy]') // Учитываем замену у на y латинскую
|
|
||||||
.replace(/и/g, '[иi]'); // Учитываем замену и на i
|
|
||||||
|
|
||||||
// Создаем регулярку, которая учитывает:
|
|
||||||
// - возможные разделители между буквами (точки, пробелы, дефисы)
|
|
||||||
// - возможные окончания слов
|
|
||||||
const chars = baseWord.split('');
|
|
||||||
const patternWithSeparators = chars.join('[\\s\\._\\-]*');
|
|
||||||
|
|
||||||
return new RegExp(`\\b${patternWithSeparators}[а-яёa-z]*\\b`, 'i');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет, содержит ли текст запрещенные слова или их производные
|
|
||||||
* @param {string} text - Текст для проверки
|
|
||||||
* @return {boolean} - true, если текст содержит запрещенные слова, иначе false
|
|
||||||
*/
|
|
||||||
hasProfanity(text) {
|
|
||||||
if (!text) return false;
|
|
||||||
|
|
||||||
// Нормализуем текст, убирая лишние символы и разделители
|
|
||||||
const normalizedText = text.toLowerCase()
|
|
||||||
.replace(/[\s\._\-]+/g, ''); // Удаляем пробелы, точки, подчеркивания, дефисы
|
|
||||||
|
|
||||||
// Проверяем на простое вхождение запрещенных слов
|
|
||||||
for (const word of this.badWords) {
|
|
||||||
if (normalizedText.includes(word.toLowerCase())) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return instance;
|
||||||
// Дополнительная проверка на производные слова через регулярные выражения
|
}).catch(err => {
|
||||||
return this.wordPatterns.some(pattern => pattern.test(text.toLowerCase()));
|
console.error("Критическая ошибка: не удалось загрузить или инициализировать модуль bad-words:", err);
|
||||||
|
// Возвращаем "заглушку" фильтра, чтобы приложение не падало
|
||||||
|
return {
|
||||||
|
isProfane: (text) => { console.warn("Фильтр bad-words недоступен, проверка isProfane пропущена."); return false; },
|
||||||
|
clean: (text) => { console.warn("Фильтр bad-words недоступен, операция clean пропущена."); return text; }
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return filterPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
// Экспортируем объект с асинхронными функциями, которые используют инициализированный фильтр
|
||||||
* Проверяет, содержит ли объект запрещенные слова в указанных полях
|
module.exports = {
|
||||||
* @param {Object} obj - Объект для проверки
|
isProfane: async (text) => {
|
||||||
* @param {Array<string>} fields - Массив имен полей для проверки
|
// Добавляем проверку, что text является строкой и не пустой
|
||||||
* @return {Object} - Объект с результатами проверки { isProfane: boolean, field: string|null }
|
if (typeof text !== 'string' || text.trim() === '') return false;
|
||||||
*/
|
const filter = await getInitializedFilter();
|
||||||
validateObject(obj, fields) {
|
return filter.isProfane(text);
|
||||||
|
},
|
||||||
|
clean: async (text) => {
|
||||||
|
if (typeof text !== 'string') return ''; // Возвращаем пустую строку, если text не строка
|
||||||
|
const filter = await getInitializedFilter();
|
||||||
|
return filter.clean(text);
|
||||||
|
},
|
||||||
|
validateObject: async (obj, fields) => {
|
||||||
|
const filter = await getInitializedFilter();
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
if (obj[field] && this.hasProfanity(obj[field])) {
|
if (obj && typeof obj[field] === 'string') { // Проверяем, что obj существует и obj[field] является строкой
|
||||||
return { isProfane: true, field };
|
if (filter.isProfane(obj[field])) {
|
||||||
|
return { isProfane: true, field };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { isProfane: false, field: null };
|
return { isProfane: false, field: null };
|
||||||
}
|
}
|
||||||
|
};
|
||||||
/**
|
|
||||||
* Заменяет запрещенные слова в тексте на звездочки
|
|
||||||
* @param {string} text - Исходный текст
|
|
||||||
* @return {string} - Очищенный текст
|
|
||||||
*/
|
|
||||||
clean(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
|
|
||||||
let cleanedText = text;
|
|
||||||
|
|
||||||
// Проверяем и заменяем на звездочки каждое запрещенное слово
|
|
||||||
this.badWords.forEach(word => {
|
|
||||||
const regex = new RegExp(`\\b${word}\\b`, 'gi');
|
|
||||||
cleanedText = cleanedText.replace(regex, match => '*'.repeat(match.length));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Затем проверяем на производные слова и заменяем их
|
|
||||||
this.wordPatterns.forEach(pattern => {
|
|
||||||
cleanedText = cleanedText.replace(pattern, match => '*'.repeat(match.length));
|
|
||||||
});
|
|
||||||
|
|
||||||
return cleanedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Добавляет новое запрещенное слово в фильтр
|
|
||||||
* @param {string|Array<string>} words - Слово или массив слов для добавления
|
|
||||||
*/
|
|
||||||
addWords(words) {
|
|
||||||
// Добавляем слова в список запрещенных слов
|
|
||||||
if (Array.isArray(words)) {
|
|
||||||
this.badWords.push(...words);
|
|
||||||
this.generateWordPatterns(words);
|
|
||||||
} else {
|
|
||||||
// Если передано одно слово
|
|
||||||
this.badWords.push(words);
|
|
||||||
this.generateWordPatterns([words]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет является ли слово запрещенным
|
|
||||||
* @param {string} word - Слово для проверки
|
|
||||||
* @return {boolean} - true, если слово запрещено, иначе false
|
|
||||||
*/
|
|
||||||
isProfane(word) {
|
|
||||||
return this.hasProfanity(word);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Экспортируем экземпляр фильтра для использования в приложении
|
|
||||||
const profanityFilter = new ProfanityFilter();
|
|
||||||
|
|
||||||
module.exports = profanityFilter;
|
|
283
src/App.vue
283
src/App.vue
@ -9,29 +9,53 @@
|
|||||||
</router-view>
|
</router-view>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Мобильная навигация внизу экрана (не показывается в админ-панели) -->
|
<!-- Мобильная навигация внизу экрана -->
|
||||||
<nav v-if="authState && !isAdminRoute" class="mobile-nav">
|
<nav v-if="authState" class="mobile-nav">
|
||||||
<router-link to="/swipe" class="nav-item" active-class="active">
|
<!-- Вкладки для администратора -->
|
||||||
<div class="nav-icon">
|
<template v-if="currentUserIsAdmin">
|
||||||
<i class="bi-search"></i>
|
<router-link to="/admin/users" class="nav-item" active-class="active">
|
||||||
</div>
|
<div class="nav-icon">
|
||||||
<span class="nav-text">Поиск</span>
|
<i class="bi-people"></i>
|
||||||
</router-link>
|
</div>
|
||||||
<router-link to="/chats" class="nav-item" active-class="active">
|
<span class="nav-text">Пользователи</span>
|
||||||
<div class="nav-icon">
|
</router-link>
|
||||||
<i class="bi-chat-dots"></i>
|
<router-link to="/admin/conversations" class="nav-item" active-class="active">
|
||||||
<span v-if="totalUnread > 0" class="unread-badge">
|
<div class="nav-icon">
|
||||||
{{ totalUnread < 100 ? totalUnread : '99+' }}
|
<i class="bi-chat-square-dots"></i>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<span class="nav-text">Диалоги</span>
|
||||||
<span class="nav-text">Чаты</span>
|
</router-link>
|
||||||
</router-link>
|
<router-link to="/admin/statistics" class="nav-item" active-class="active">
|
||||||
<router-link to="/profile" class="nav-item" active-class="active">
|
<div class="nav-icon">
|
||||||
<div class="nav-icon">
|
<i class="bi-bar-chart-line"></i>
|
||||||
<i class="bi-person"></i>
|
</div>
|
||||||
</div>
|
<span class="nav-text">Статистика</span>
|
||||||
<span class="nav-text">Профиль</span>
|
</router-link>
|
||||||
</router-link>
|
</template>
|
||||||
|
<!-- Вкладки для обычного пользователя (не показываются в админ-панели, если isAdminRoute true) -->
|
||||||
|
<template v-else-if="!isAdminRoute">
|
||||||
|
<router-link to="/swipe" class="nav-item" active-class="active">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<i class="bi-search"></i>
|
||||||
|
</div>
|
||||||
|
<span class="nav-text">Поиск</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/chats" class="nav-item" active-class="active">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<i class="bi-chat-dots"></i>
|
||||||
|
<span v-if="totalUnread > 0" class="unread-badge">
|
||||||
|
{{ totalUnread < 100 ? totalUnread : '99+' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="nav-text">Чаты</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/profile" class="nav-item" active-class="active">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<i class="bi-person"></i>
|
||||||
|
</div>
|
||||||
|
<span class="nav-text">Профиль</span>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -46,7 +70,6 @@ import { getSocket, connectSocket } from '@/services/socketService';
|
|||||||
const { user, isAuthenticated, logout, fetchUser } = useAuth();
|
const { user, isAuthenticated, logout, fetchUser } = useAuth();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
// Используем локальные переменные для отслеживания состояния
|
|
||||||
const userData = ref(null);
|
const userData = ref(null);
|
||||||
const authState = ref(false);
|
const authState = ref(false);
|
||||||
const conversations = ref([]);
|
const conversations = ref([]);
|
||||||
@ -54,194 +77,163 @@ const totalUnread = computed(() => {
|
|||||||
return conversations.value.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0);
|
return conversations.value.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проверяем, находится ли пользователь в админ-панели
|
const currentUserIsAdmin = computed(() => {
|
||||||
|
return !!user.value && !!user.value.isAdmin;
|
||||||
|
});
|
||||||
|
|
||||||
const isAdminRoute = computed(() => {
|
const isAdminRoute = computed(() => {
|
||||||
return route.path.startsWith('/admin');
|
return route.path.startsWith('/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
let socket = null;
|
let socket = null;
|
||||||
|
|
||||||
// Получение списка диалогов для отображения счетчика непрочитанных сообщений
|
|
||||||
const fetchConversations = async () => {
|
const fetchConversations = async () => {
|
||||||
if (!isAuthenticated.value) return;
|
if (!isAuthenticated.value || currentUserIsAdmin.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.getUserConversations();
|
const response = await api.getUserConversations();
|
||||||
conversations.value = response.data;
|
conversations.value = response.data;
|
||||||
console.log('[App] Загружено диалогов для счетчика уведомлений:', conversations.value.length);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[App] Ошибка при загрузке диалогов:', err);
|
console.error('[App] Ошибка при загрузке диалогов:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик новых сообщений
|
|
||||||
const handleNewMessage = (message) => {
|
const handleNewMessage = (message) => {
|
||||||
console.log('[App] Получено новое сообщение:', {
|
if (currentUserIsAdmin.value || !user.value) return;
|
||||||
sender: message.sender,
|
|
||||||
currentUser: user.value?._id,
|
|
||||||
conversationId: message.conversationId
|
|
||||||
});
|
|
||||||
|
|
||||||
// ВАЖНО: Правильно сравниваем ID отправителя
|
|
||||||
// message.sender может быть как объектом, так и строкой ID
|
|
||||||
const senderId = typeof message.sender === 'object' ? message.sender._id : message.sender;
|
const senderId = typeof message.sender === 'object' ? message.sender._id : message.sender;
|
||||||
|
if (senderId !== user.value._id) {
|
||||||
// ВАЖНО: Увеличиваем счетчик ТОЛЬКО если сообщение пришло от другого пользователя
|
|
||||||
if (senderId !== user.value?._id) {
|
|
||||||
// Добавляем небольшую задержку, чтобы дать время событию messagesRead сработать
|
|
||||||
// если пользователь находится в том же чате
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const conversation = conversations.value.find(c => c._id === message.conversationId);
|
const conversation = conversations.value.find(c => c._id === message.conversationId);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
// Проверяем текущий маршрут - если пользователь находится в этом чате,
|
|
||||||
// то сообщение уже могло быть автоматически помечено как прочитанное
|
|
||||||
const currentRoute = route.path;
|
const currentRoute = route.path;
|
||||||
const isInThisChat = currentRoute === `/chat/${message.conversationId}`;
|
const isInThisChat = currentRoute === `/chat/${message.conversationId}`;
|
||||||
|
|
||||||
if (!isInThisChat) {
|
if (!isInThisChat) {
|
||||||
// Увеличиваем счетчик только если пользователь НЕ находится в этом чате
|
|
||||||
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
|
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
|
||||||
console.log('[App] Увеличен счетчик в навигации для диалога:', message.conversationId);
|
|
||||||
} else {
|
|
||||||
console.log('[App] Пользователь находится в этом чате, счетчик не увеличиваем');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Если диалога нет в списке, обновим весь список
|
|
||||||
fetchConversations();
|
fetchConversations();
|
||||||
}
|
}
|
||||||
}, 100); // Задержка 100ms
|
}, 100);
|
||||||
} else {
|
|
||||||
console.log('[App] Сообщение от текущего пользователя, счетчик в навигации не изменяется');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик прочитанных сообщений
|
|
||||||
const handleMessagesRead = ({ conversationId, readerId }) => {
|
const handleMessagesRead = ({ conversationId, readerId }) => {
|
||||||
console.log('[App] Получено событие messagesRead:', { conversationId, readerId, currentUserId: user.value?._id });
|
if (currentUserIsAdmin.value || !user.value) return;
|
||||||
|
if (readerId === user.value._id) {
|
||||||
// Если текущий пользователь прочитал сообщения
|
|
||||||
if (readerId === user.value?._id) {
|
|
||||||
const conversation = conversations.value.find(c => c._id === conversationId);
|
const conversation = conversations.value.find(c => c._id === conversationId);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
console.log('[App] Сбрасываем счетчик для диалога:', conversationId);
|
|
||||||
conversation.unreadCount = 0;
|
conversation.unreadCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Дополнительный обработчик для синхронизации при переходе в чат
|
watch(() => isAuthenticated.value, (newAuthStatus, oldAuthStatus) => {
|
||||||
const handleRouteChange = () => {
|
authState.value = newAuthStatus;
|
||||||
// Обновляем данные диалогов при изменении маршрута
|
if (newAuthStatus) {
|
||||||
|
console.log('[App.vue watch isAuthenticated] Became authenticated.');
|
||||||
|
// Initial setup for authenticated users is handled by onMounted or watch(user.value)
|
||||||
|
} else if (oldAuthStatus && !newAuthStatus) {
|
||||||
|
// User is now unauthenticated (logged out).
|
||||||
|
console.log('[App.vue watch isAuthenticated] Became unauthenticated. Cleaning up.');
|
||||||
|
conversations.value = [];
|
||||||
|
if (socket) {
|
||||||
|
socket.off('getMessage', handleNewMessage);
|
||||||
|
socket.off('messagesRead', handleMessagesRead);
|
||||||
|
// Use userData.value for _id as user.value is null on logout
|
||||||
|
// userData.value holds the *previous* user data here
|
||||||
|
if (userData.value?._id && !(userData.value?.isAdmin)) {
|
||||||
|
socket.emit('leaveNotificationRoom', userData.value._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userData.value = null; // Clear local userData as well
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => user.value, (newUser, oldUser) => {
|
||||||
|
userData.value = newUser;
|
||||||
if (isAuthenticated.value) {
|
if (isAuthenticated.value) {
|
||||||
fetchConversations();
|
console.log('[App.vue watch user.value] User data changed and authenticated. Initializing/Re-initializing user state.');
|
||||||
|
initializeUserState();
|
||||||
|
} else if (oldUser && !newUser) {
|
||||||
|
console.log('[App.vue watch user.value] User logged out (user.value became null).');
|
||||||
|
// Cleanup is primarily handled by watch(isAuthenticated.value)
|
||||||
}
|
}
|
||||||
};
|
}, { deep: true });
|
||||||
|
|
||||||
// Обновляем локальные переменные при изменении состояния аутентификации
|
|
||||||
watch(() => isAuthenticated.value, (newVal) => {
|
|
||||||
console.log('[App] isAuthenticated изменился:', newVal);
|
|
||||||
authState.value = newVal;
|
|
||||||
|
|
||||||
if (newVal) {
|
|
||||||
fetchConversations();
|
|
||||||
setupSocketConnection();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обновляем локальную копию данных пользователя
|
|
||||||
watch(() => user.value, (newVal) => {
|
|
||||||
console.log('[App] Данные пользователя изменились:', newVal);
|
|
||||||
userData.value = newVal;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Следим за изменением маршрута для обновления счетчика
|
|
||||||
watch(() => route.path, (newPath, oldPath) => {
|
watch(() => route.path, (newPath, oldPath) => {
|
||||||
console.log('[App] Маршрут изменился:', { from: oldPath, to: newPath });
|
if (isAuthenticated.value && !currentUserIsAdmin.value) {
|
||||||
|
if (newPath.startsWith('/chat/') || oldPath?.startsWith('/chat/')) {
|
||||||
// Если переходим в чат или возвращаемся из чата, обновляем счетчики
|
setTimeout(() => {
|
||||||
if (newPath.startsWith('/chat/') || oldPath?.startsWith('/chat/')) {
|
fetchConversations();
|
||||||
setTimeout(() => {
|
}, 500);
|
||||||
fetchConversations();
|
}
|
||||||
}, 500); // Небольшая задержка для завершения операций чтения
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Настройка сокет-соединения
|
|
||||||
const setupSocketConnection = () => {
|
const setupSocketConnection = () => {
|
||||||
|
if (currentUserIsAdmin.value || !user.value?._id) return;
|
||||||
socket = getSocket();
|
socket = getSocket();
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = connectSocket();
|
socket = connectSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
socket.off('getMessage', handleNewMessage);
|
||||||
|
socket.off('messagesRead', handleMessagesRead);
|
||||||
socket.on('getMessage', handleNewMessage);
|
socket.on('getMessage', handleNewMessage);
|
||||||
socket.on('messagesRead', handleMessagesRead);
|
socket.on('messagesRead', handleMessagesRead);
|
||||||
|
socket.emit('joinNotificationRoom', user.value._id);
|
||||||
// Присоединение к комнате для уведомлений
|
console.log('[App.vue setupSocketConnection] Socket connection configured for user:', user.value._id);
|
||||||
if (user.value?._id) {
|
|
||||||
socket.emit('joinNotificationRoom', user.value._id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// При монтировании компонента синхронизируем состояние
|
const initializeUserState = () => {
|
||||||
onMounted(() => {
|
if (isAuthenticated.value) {
|
||||||
console.log('[App] App.vue монтирован, проверка состояния аутентификации');
|
if (!currentUserIsAdmin.value) {
|
||||||
|
console.log('[App.vue initializeUserState] Initializing for regular user.');
|
||||||
|
fetchConversations();
|
||||||
|
setupSocketConnection();
|
||||||
|
} else {
|
||||||
|
console.log('[App.vue initializeUserState] User is Admin. Clearing any regular user socket listeners.');
|
||||||
|
if (socket) { // Ensure any previous user socket listeners are off for admin
|
||||||
|
socket.off('getMessage', handleNewMessage);
|
||||||
|
socket.off('messagesRead', handleMessagesRead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
authState.value = isAuthenticated.value;
|
authState.value = isAuthenticated.value;
|
||||||
userData.value = user.value;
|
userData.value = user.value;
|
||||||
|
|
||||||
// Убедимся, что у нас есть актуальные данные пользователя
|
if (localStorage.getItem('userToken') && !user.value) {
|
||||||
if (localStorage.getItem('userToken') && !isAuthenticated.value) {
|
console.log('[App.vue onMounted] Token found, user data missing. Fetching user...');
|
||||||
fetchUser().then(() => {
|
await fetchUser();
|
||||||
if (isAuthenticated.value) {
|
console.log('[App.vue onMounted] User fetched. isAuthenticated:', isAuthenticated.value, 'isAdmin:', currentUserIsAdmin.value);
|
||||||
console.log('[App] Пользователь загружен, проверка статуса администратора:', user.value?.isAdmin);
|
}
|
||||||
|
|
||||||
// Проверяем, является ли пользователь администратором
|
authState.value = isAuthenticated.value;
|
||||||
if (user.value?.isAdmin) {
|
userData.value = user.value;
|
||||||
console.log('[App] Пользователь является администратором, перенаправление на админ-панель');
|
|
||||||
// Если да, перенаправляем на административную панель
|
if (isAuthenticated.value) {
|
||||||
if (route.path !== '/admin') {
|
if (currentUserIsAdmin.value) {
|
||||||
const router = route.matched[0]?.instances.default?.$router;
|
console.log('[App.vue onMounted] Authenticated user is Admin.');
|
||||||
if (router) {
|
initializeUserState(); // Handles admin case (clears user listeners)
|
||||||
router.push('/admin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Для обычных пользователей
|
|
||||||
fetchConversations();
|
|
||||||
setupSocketConnection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (isAuthenticated.value) {
|
|
||||||
console.log('[App] Пользователь уже аутентифицирован, проверка статуса администратора:', user.value?.isAdmin);
|
|
||||||
|
|
||||||
// Проверяем, является ли пользователь администратором
|
|
||||||
if (user.value?.isAdmin) {
|
|
||||||
console.log('[App] Пользователь является администратором, перенаправление на админ-панель');
|
|
||||||
// Если да, перенаправляем на административную панель
|
|
||||||
if (route.path !== '/admin') {
|
|
||||||
const router = route.matched[0]?.instances.default?.$router;
|
|
||||||
if (router) {
|
|
||||||
router.push('/admin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Для обычных пользователей
|
console.log('[App.vue onMounted] Authenticated user is a regular user. Initializing user state.');
|
||||||
fetchConversations();
|
initializeUserState();
|
||||||
setupSocketConnection();
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[App.vue onMounted] User is not authenticated.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// При размонтировании компонента очищаем обработчики событий
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.off('getMessage', handleNewMessage);
|
socket.off('getMessage', handleNewMessage);
|
||||||
socket.off('messagesRead', handleMessagesRead);
|
socket.off('messagesRead', handleMessagesRead);
|
||||||
|
if (user.value?._id && !currentUserIsAdmin.value) {
|
||||||
if (user.value?._id) {
|
|
||||||
socket.emit('leaveNotificationRoom', user.value._id);
|
socket.emit('leaveNotificationRoom', user.value._id);
|
||||||
|
console.log('[App.vue onUnmounted] Left notification room for user:', user.value._id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -425,9 +417,8 @@ body {
|
|||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Важное изменение - переместить этот селектор за пределы @supports */
|
.app-content { /* Это правило изменяет высоту .app-content, если есть safe-area */
|
||||||
.app-content {
|
height: calc(100% - (var(--nav-height) + var(--safe-area-bottom)));
|
||||||
height: calc(100% - var(--nav-height) - var(--safe-area-bottom));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,93 +2,151 @@
|
|||||||
<div class="admin-report-detail">
|
<div class="admin-report-detail">
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<button @click="goBack" class="back-btn">
|
<button @click="goBack" class="back-btn">
|
||||||
« Назад к списку жалоб
|
<i class="bi-arrow-left"></i>
|
||||||
|
<span class="btn-text">Назад к списку жалоб</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading-indicator">
|
<div v-if="loading" class="loading-indicator">
|
||||||
Загрузка информации о жалобе...
|
<div class="loading-spinner"></div>
|
||||||
|
<span>Загрузка информации о жалобе...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-message">
|
<div v-if="error" class="error-message">
|
||||||
|
<i class="bi-exclamation-triangle"></i>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="report && !loading" class="report-container">
|
<div v-if="report && !loading" class="report-container">
|
||||||
<h2>Детали жалобы</h2>
|
<div class="page-header">
|
||||||
|
<h2>Детали жалобы</h2>
|
||||||
|
<div class="status-display">
|
||||||
|
<span class="status-badge" :class="getStatusClass(report.status)">
|
||||||
|
{{ getStatusText(report.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="report-card">
|
<div class="report-card">
|
||||||
<div class="report-header">
|
<div class="report-header">
|
||||||
<div class="report-info">
|
<div class="report-info">
|
||||||
<h3>Жалоба #{{ report._id.substring(0, 8) }}</h3>
|
<div class="report-id-section">
|
||||||
<span class="status-badge" :class="getStatusClass(report.status)">
|
<i class="bi-file-text"></i>
|
||||||
{{ getStatusText(report.status) }}
|
<div class="id-info">
|
||||||
</span>
|
<h3>Жалоба #{{ report._id.substring(0, 8) }}</h3>
|
||||||
|
<span class="full-id">ID: {{ report._id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="report-date">
|
<div class="report-date">
|
||||||
Подана {{ formatDate(report.createdAt) }}
|
<i class="bi-calendar3"></i>
|
||||||
|
<span>Подана {{ formatDate(report.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="report-content">
|
<div class="report-content">
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-item">
|
<div class="info-section">
|
||||||
<label>Причина жалобы:</label>
|
<div class="section-header">
|
||||||
<span class="reason-badge" :class="getReasonClass(report.reason)">
|
<i class="bi-info-circle"></i>
|
||||||
{{ getReasonText(report.reason) }}
|
<h4>Информация о жалобе</h4>
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Причина жалобы:</label>
|
||||||
|
<span class="reason-badge" :class="getReasonClass(report.reason)">
|
||||||
|
{{ getReasonText(report.reason) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-item" v-if="report.description">
|
||||||
|
<label>Описание:</label>
|
||||||
|
<div class="description-container">
|
||||||
|
<p class="description">{{ report.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item" v-if="report.description">
|
<div class="info-section">
|
||||||
<label>Описание:</label>
|
<div class="section-header">
|
||||||
<p class="description">{{ report.description }}</p>
|
<i class="bi-people"></i>
|
||||||
</div>
|
<h4>Участники</h4>
|
||||||
|
</div>
|
||||||
<div class="info-item">
|
|
||||||
<label>Жалующийся:</label>
|
<div class="users-grid">
|
||||||
<div class="user-info">
|
<div class="user-card reporter">
|
||||||
<span>{{ report.reporter?.name || 'Пользователь удален' }}</span>
|
<div class="user-header">
|
||||||
<span class="email">{{ report.reporter?.email || '' }}</span>
|
<i class="bi-person-exclamation"></i>
|
||||||
|
<span class="user-role">Жалующийся</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-details">
|
||||||
|
<div class="user-name">{{ report.reporter?.name || 'Пользователь удален' }}</div>
|
||||||
|
<div class="user-email">{{ report.reporter?.email || '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="arrow-divider">
|
||||||
|
<i class="bi-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-card reported">
|
||||||
|
<div class="user-header">
|
||||||
|
<i class="bi-person-x"></i>
|
||||||
|
<span class="user-role">Обжалуемый</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-details">
|
||||||
|
<div class="user-name">{{ report.reportedUser?.name || 'Пользователь удален' }}</div>
|
||||||
|
<div class="user-email">{{ report.reportedUser?.email || '' }}</div>
|
||||||
|
<button
|
||||||
|
v-if="report.reportedUser?._id"
|
||||||
|
@click="viewUser(report.reportedUser._id)"
|
||||||
|
class="btn view-user-btn"
|
||||||
|
>
|
||||||
|
<i class="bi-eye"></i>
|
||||||
|
<span>Просмотр профиля</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-item">
|
<div class="info-section" v-if="report.adminNotes || report.resolvedAt">
|
||||||
<label>На пользователя:</label>
|
<div class="section-header">
|
||||||
<div class="user-info">
|
<i class="bi-shield-check"></i>
|
||||||
<span>{{ report.reportedUser?.name || 'Пользователь удален' }}</span>
|
<h4>Административная информация</h4>
|
||||||
<span class="email">{{ report.reportedUser?.email || '' }}</span>
|
</div>
|
||||||
<button
|
|
||||||
v-if="report.reportedUser?._id"
|
<div class="info-item" v-if="report.adminNotes">
|
||||||
@click="viewUser(report.reportedUser._id)"
|
<label>Заметки администратора:</label>
|
||||||
class="btn view-user-btn"
|
<div class="admin-notes-container">
|
||||||
>
|
<p class="admin-notes">{{ report.adminNotes }}</p>
|
||||||
Просмотр профиля
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
<div class="info-item" v-if="report.resolvedAt">
|
||||||
|
<label>Дата рассмотрения:</label>
|
||||||
|
<div class="resolve-date">
|
||||||
|
<i class="bi-check-circle"></i>
|
||||||
|
<span>{{ formatDate(report.resolvedAt) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-item" v-if="report.adminNotes">
|
|
||||||
<label>Заметки администратора:</label>
|
|
||||||
<p class="admin-notes">{{ report.adminNotes }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-item" v-if="report.resolvedAt">
|
|
||||||
<label>Дата рассмотрения:</label>
|
|
||||||
<span>{{ formatDate(report.resolvedAt) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="report-actions" v-if="report.status === 'pending'">
|
<div class="report-actions" v-if="report.status === 'pending'">
|
||||||
<h4>Действия</h4>
|
<div class="actions-header">
|
||||||
|
<i class="bi-gear"></i>
|
||||||
|
<h4>Действия с жалобой</h4>
|
||||||
|
</div>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button @click="showResolveModal = true" class="btn resolve-btn">
|
<button @click="showResolveModal = true" class="btn resolve-btn">
|
||||||
<i class="bi-check-circle"></i>
|
<i class="bi-check-circle"></i>
|
||||||
Рассмотреть
|
<span>Рассмотреть</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="showDismissModal = true" class="btn dismiss-btn">
|
<button @click="showDismissModal = true" class="btn dismiss-btn">
|
||||||
<i class="bi-x-circle"></i>
|
<i class="bi-x-circle"></i>
|
||||||
Отклонить
|
<span>Отклонить</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -98,17 +156,32 @@
|
|||||||
<!-- Модальное окно для рассмотрения жалобы -->
|
<!-- Модальное окно для рассмотрения жалобы -->
|
||||||
<div v-if="showResolveModal" class="modal-overlay" @click="showResolveModal = false">
|
<div v-if="showResolveModal" class="modal-overlay" @click="showResolveModal = false">
|
||||||
<div class="modal" @click.stop>
|
<div class="modal" @click.stop>
|
||||||
<h3>Рассмотрение жалобы</h3>
|
<div class="modal-header">
|
||||||
<p>Вы собираетесь отметить эту жалобу как рассмотренную.</p>
|
<h3>Рассмотрение жалобы</h3>
|
||||||
<div class="form-group">
|
<button @click="showResolveModal = false" class="modal-close">
|
||||||
<label>Заметки администратора (необязательно):</label>
|
<i class="bi-x"></i>
|
||||||
<textarea v-model="adminNotes" placeholder="Укажите принятые меры или комментарии..."></textarea>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<p>Вы собираетесь отметить эту жалобу как рассмотренную.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Заметки администратора (необязательно):</label>
|
||||||
|
<textarea
|
||||||
|
v-model="adminNotes"
|
||||||
|
placeholder="Укажите принятые меры или комментарии..."
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button @click="updateReportStatus('resolved')" class="btn resolve-btn" :disabled="updating">
|
<button @click="updateReportStatus('resolved')" class="btn resolve-btn" :disabled="updating">
|
||||||
{{ updating ? 'Обработка...' : 'Рассмотреть' }}
|
<i class="bi-check-circle"></i>
|
||||||
|
<span>{{ updating ? 'Обработка...' : 'Рассмотреть' }}</span>
|
||||||
|
</button>
|
||||||
|
<button @click="showResolveModal = false" class="btn cancel-btn">
|
||||||
|
<i class="bi-x"></i>
|
||||||
|
<span>Отмена</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="showResolveModal = false" class="btn cancel-btn">Отмена</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -116,17 +189,32 @@
|
|||||||
<!-- Модальное окно для отклонения жалобы -->
|
<!-- Модальное окно для отклонения жалобы -->
|
||||||
<div v-if="showDismissModal" class="modal-overlay" @click="showDismissModal = false">
|
<div v-if="showDismissModal" class="modal-overlay" @click="showDismissModal = false">
|
||||||
<div class="modal" @click.stop>
|
<div class="modal" @click.stop>
|
||||||
<h3>Отклонение жалобы</h3>
|
<div class="modal-header">
|
||||||
<p>Вы собираетесь отклонить эту жалобу.</p>
|
<h3>Отклонение жалобы</h3>
|
||||||
<div class="form-group">
|
<button @click="showDismissModal = false" class="modal-close">
|
||||||
<label>Заметки администратора (необязательно):</label>
|
<i class="bi-x"></i>
|
||||||
<textarea v-model="adminNotes" placeholder="Укажите причину отклонения..."></textarea>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<p>Вы собираетесь отклонить эту жалобу.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Заметки администратора (необязательно):</label>
|
||||||
|
<textarea
|
||||||
|
v-model="adminNotes"
|
||||||
|
placeholder="Укажите причину отклонения..."
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button @click="updateReportStatus('dismissed')" class="btn dismiss-btn" :disabled="updating">
|
<button @click="updateReportStatus('dismissed')" class="btn dismiss-btn" :disabled="updating">
|
||||||
{{ updating ? 'Обработка...' : 'Отклонить' }}
|
<i class="bi-x-circle"></i>
|
||||||
|
<span>{{ updating ? 'Обработка...' : 'Отклонить' }}</span>
|
||||||
|
</button>
|
||||||
|
<button @click="showDismissModal = false" class="btn cancel-btn">
|
||||||
|
<i class="bi-arrow-left"></i>
|
||||||
|
<span>Отмена</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="showDismissModal = false" class="btn cancel-btn">Отмена</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -136,7 +224,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import api from '@/services/api';
|
import axios from 'axios';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AdminReportDetail',
|
name: 'AdminReportDetail',
|
||||||
@ -163,19 +251,17 @@ export default {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[AdminReportDetail] Загружаем жалобу с ID:', props.id);
|
const token = localStorage.getItem('userToken');
|
||||||
const response = await api.getReportById(props.id);
|
const response = await axios.get(`/api/admin/reports/${props.id}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
report.value = response.data;
|
report.value = response.data;
|
||||||
console.log('[AdminReportDetail] Жалоба загружена:', response.data);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при загрузке жалобы:', err);
|
console.error('Ошибка при загрузке жалобы:', err);
|
||||||
if (err.response?.status === 404) {
|
error.value = 'Ошибка при загрузке информации о жалобе.';
|
||||||
error.value = 'Жалоба не найдена.';
|
|
||||||
} else if (err.response?.status === 403) {
|
|
||||||
error.value = 'У вас нет прав для просмотра этой жалобы.';
|
|
||||||
} else {
|
|
||||||
error.value = err.response?.data?.message || 'Ошибка при загрузке информации о жалобе.';
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -186,10 +272,14 @@ export default {
|
|||||||
updating.value = true;
|
updating.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[AdminReportDetail] Обновляем статус жалобы:', status);
|
const token = localStorage.getItem('userToken');
|
||||||
const response = await api.updateReportStatus(props.id, {
|
const response = await axios.put(`/api/admin/reports/${props.id}`, {
|
||||||
status,
|
status,
|
||||||
adminNotes: adminNotes.value
|
adminNotes: adminNotes.value
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обновляем локальные данные
|
// Обновляем локальные данные
|
||||||
@ -200,12 +290,10 @@ export default {
|
|||||||
showDismissModal.value = false;
|
showDismissModal.value = false;
|
||||||
adminNotes.value = '';
|
adminNotes.value = '';
|
||||||
|
|
||||||
console.log('[AdminReportDetail] Статус жалобы обновлен успешно');
|
|
||||||
alert(`Жалоба успешно ${status === 'resolved' ? 'рассмотрена' : 'отклонена'}`);
|
alert(`Жалоба успешно ${status === 'resolved' ? 'рассмотрена' : 'отклонена'}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при обновлении статуса жалобы:', err);
|
console.error('Ошибка при обновлении статуса жалобы:', err);
|
||||||
const errorMessage = err.response?.data?.message || 'Ошибка при обновлении статуса жалобы';
|
alert('Ошибка при обновлении статуса жалобы');
|
||||||
alert(errorMessage);
|
|
||||||
} finally {
|
} finally {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
}
|
}
|
||||||
@ -346,6 +434,21 @@ export default {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top: 4px solid #007bff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
background-color: #f8d7da;
|
background-color: #f8d7da;
|
||||||
color: #721c24;
|
color: #721c24;
|
||||||
@ -353,6 +456,9 @@ export default {
|
|||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
border: 1px solid #f5c6cb;
|
border: 1px solid #f5c6cb;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@ -362,6 +468,19 @@ h2 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.report-card {
|
.report-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
@ -384,15 +503,29 @@ h2 {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-id-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.report-info h3 {
|
.report-info h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.full-id {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.report-date {
|
.report-date {
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-content {
|
.report-content {
|
||||||
@ -404,6 +537,26 @@ h2 {
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.info-item label {
|
.info-item label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -444,6 +597,9 @@ h2 {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-user-btn:hover {
|
.view-user-btn:hover {
|
||||||
@ -478,9 +634,11 @@ h2 {
|
|||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-actions h4 {
|
.actions-header {
|
||||||
margin: 0 0 1rem 0;
|
display: flex;
|
||||||
color: #495057;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
@ -560,8 +718,27 @@ h2 {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal h3 {
|
.modal-header {
|
||||||
margin: 0 0 1rem 0;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -614,10 +791,261 @@ h2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Устройства с вырезом (notch) */
|
@media (max-width: 480px) {
|
||||||
@supports (padding: env(safe-area-inset-bottom)) {
|
|
||||||
.admin-report-detail {
|
.admin-report-detail {
|
||||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
padding: 1rem 0.5rem calc(60px + env(safe-area-inset-bottom, 0px)) 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn .btn-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-id-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-id {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-divider {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 95%;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Маленькие экраны (481px - 767px) */
|
||||||
|
@media (min-width: 481px) and (max-width: 767px) {
|
||||||
|
.admin-report-detail {
|
||||||
|
padding: 1rem calc(60px + env(safe-area-inset-bottom, 0px)) 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-divider {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Средние экраны (768px - 991px) */
|
||||||
|
@media (min-width: 768px) and (max-width: 991px) {
|
||||||
|
.admin-report-detail {
|
||||||
|
padding: 1.5rem 1rem calc(60px + env(safe-area-inset-bottom, 0px)) 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-divider {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Большие экраны (992px - 1199px) */
|
||||||
|
@media (min-width: 992px) and (max-width: 1199px) {
|
||||||
|
.admin-report-detail {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Очень большие экраны (1200px+) */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.admin-report-detail {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-content {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-actions {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Планшеты в ландшафтной ориентации */
|
||||||
|
@media (min-width: 768px) and (max-width: 1024px) and (orientation: landscape) {
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -626,5 +1054,174 @@ h2 {
|
|||||||
.admin-report-detail {
|
.admin-report-detail {
|
||||||
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Высокие экраны */
|
||||||
|
@media (min-height: 800px) {
|
||||||
|
.admin-report-detail {
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшения для сенсорных экранов */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.btn {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-user-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Принтеры */
|
||||||
|
@media print {
|
||||||
|
.admin-report-detail {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls,
|
||||||
|
.report-actions,
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Устройства с вырезом (notch) */
|
||||||
|
@supports (padding: env(safe-area-inset-bottom)) {
|
||||||
|
.admin-report-detail {
|
||||||
|
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-report-detail {
|
||||||
|
padding-bottom: calc(60px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Пользовательские карточки */
|
||||||
|
.users-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-divider {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #007bff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-container,
|
||||||
|
.admin-notes-container {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolve-date {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@ -1,77 +1,130 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="admin-reports">
|
<div class="admin-reports">
|
||||||
<h2>Управление жалобами</h2>
|
<div class="header-section">
|
||||||
|
<h2>Управление жалобами</h2>
|
||||||
<div class="search-bar">
|
|
||||||
<div class="filter-options">
|
<div class="search-bar">
|
||||||
<label>
|
<div class="filter-options">
|
||||||
<input type="radio" v-model="statusFilter" value="all" @change="loadReports">
|
<label>
|
||||||
Все
|
<input type="radio" v-model="statusFilter" value="all" @change="loadReports">
|
||||||
</label>
|
<span>Все</span>
|
||||||
<label>
|
</label>
|
||||||
<input type="radio" v-model="statusFilter" value="pending" @change="loadReports">
|
<label>
|
||||||
На рассмотрении
|
<input type="radio" v-model="statusFilter" value="pending" @change="loadReports">
|
||||||
</label>
|
<span>На рассмотрении</span>
|
||||||
<label>
|
</label>
|
||||||
<input type="radio" v-model="statusFilter" value="resolved" @change="loadReports">
|
<label>
|
||||||
Рассмотренные
|
<input type="radio" v-model="statusFilter" value="resolved" @change="loadReports">
|
||||||
</label>
|
<span>Рассмотренные</span>
|
||||||
<label>
|
</label>
|
||||||
<input type="radio" v-model="statusFilter" value="dismissed" @change="loadReports">
|
<label>
|
||||||
Отклоненные
|
<input type="radio" v-model="statusFilter" value="dismissed" @change="loadReports">
|
||||||
</label>
|
<span>Отклоненные</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading-indicator">
|
<div v-if="loading" class="loading-indicator">
|
||||||
Загрузка жалоб...
|
<div class="loading-spinner"></div>
|
||||||
|
<span>Загрузка жалоб...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-message">
|
<div v-if="error" class="error-message">
|
||||||
|
<i class="bi-exclamation-triangle"></i>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!loading && !error" class="reports-table-container">
|
<div v-if="!loading && !error" class="reports-container">
|
||||||
<table class="reports-table">
|
<!-- Десктопное табличное представление -->
|
||||||
<thead>
|
<div class="desktop-table">
|
||||||
<tr>
|
<div class="reports-table-container">
|
||||||
<th>ID</th>
|
<table class="reports-table">
|
||||||
<th>Причина</th>
|
<thead>
|
||||||
<th>Жалующийся</th>
|
<tr>
|
||||||
<th>На пользователя</th>
|
<th class="id-col">ID</th>
|
||||||
<th class="hide-sm">Дата подачи</th>
|
<th class="reason-col">Причина</th>
|
||||||
<th>Статус</th>
|
<th class="reporter-col">Жалующийся</th>
|
||||||
<th>Действия</th>
|
<th class="reported-col">На пользователя</th>
|
||||||
</tr>
|
<th class="date-col">Дата подачи</th>
|
||||||
</thead>
|
<th class="status-col">Статус</th>
|
||||||
<tbody>
|
<th class="actions-col">Действия</th>
|
||||||
<tr v-for="report in reports" :key="report._id">
|
</tr>
|
||||||
<td>{{ shortenId(report._id) }}</td>
|
</thead>
|
||||||
<td>
|
<tbody>
|
||||||
|
<tr v-for="report in reports" :key="report._id">
|
||||||
|
<td class="id-col">
|
||||||
|
<span class="report-id">{{ shortenId(report._id) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="reason-col">
|
||||||
|
<span class="reason-badge" :class="getReasonClass(report.reason)">
|
||||||
|
{{ getReasonText(report.reason) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="reporter-col">{{ report.reporter?.name || 'Удален' }}</td>
|
||||||
|
<td class="reported-col">{{ report.reportedUser?.name || 'Удален' }}</td>
|
||||||
|
<td class="date-col">{{ formatDate(report.createdAt) }}</td>
|
||||||
|
<td class="status-col">
|
||||||
|
<span class="status-badge" :class="getStatusClass(report.status)">
|
||||||
|
{{ getStatusText(report.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions-col">
|
||||||
|
<button @click="viewReport(report._id)" class="btn view-btn">
|
||||||
|
<span class="btn-text">Просмотр</span>
|
||||||
|
<i class="bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Мобильное карточное представление -->
|
||||||
|
<div class="mobile-cards">
|
||||||
|
<div v-for="report in reports" :key="report._id" class="report-card" @click="viewReport(report._id)">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-id">
|
||||||
|
<i class="bi-file-text"></i>
|
||||||
|
<span>{{ shortenId(report._id) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge" :class="getStatusClass(report.status)">
|
||||||
|
{{ getStatusText(report.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="reason-info">
|
||||||
<span class="reason-badge" :class="getReasonClass(report.reason)">
|
<span class="reason-badge" :class="getReasonClass(report.reason)">
|
||||||
{{ getReasonText(report.reason) }}
|
{{ getReasonText(report.reason) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
<td>{{ report.reporter?.name || 'Удален' }}</td>
|
|
||||||
<td>{{ report.reportedUser?.name || 'Удален' }}</td>
|
<div class="users-info">
|
||||||
<td class="hide-sm">{{ formatDate(report.createdAt) }}</td>
|
<div class="user-item">
|
||||||
<td>
|
<i class="bi-person-exclamation"></i>
|
||||||
<span class="status-badge" :class="getStatusClass(report.status)">
|
<span>{{ report.reporter?.name || 'Удален' }}</span>
|
||||||
{{ getStatusText(report.status) }}
|
</div>
|
||||||
</span>
|
<div class="arrow">→</div>
|
||||||
</td>
|
<div class="user-item">
|
||||||
<td class="actions">
|
<i class="bi-person-x"></i>
|
||||||
<button @click="viewReport(report._id)" class="btn view-btn">
|
<span>{{ report.reportedUser?.name || 'Удален' }}</span>
|
||||||
<span class="btn-text">Просмотр</span>
|
</div>
|
||||||
<i class="bi-eye"></i>
|
</div>
|
||||||
</button>
|
|
||||||
</td>
|
<div class="card-footer">
|
||||||
</tr>
|
<span class="date">{{ formatDate(report.createdAt) }}</span>
|
||||||
</tbody>
|
<i class="bi-chevron-right"></i>
|
||||||
</table>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="reports.length === 0" class="no-results">
|
<div v-if="reports.length === 0" class="no-results">
|
||||||
Жалобы не найдены
|
<i class="bi-inbox"></i>
|
||||||
|
<h3>Жалобы не найдены</h3>
|
||||||
|
<p>Нет жалоб, соответствующих выбранным фильтрам</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="pagination.pages > 1" class="pagination">
|
<div v-if="pagination.pages > 1" class="pagination">
|
||||||
@ -80,19 +133,22 @@
|
|||||||
:disabled="currentPage === 1"
|
:disabled="currentPage === 1"
|
||||||
class="pagination-btn"
|
class="pagination-btn"
|
||||||
>
|
>
|
||||||
Назад
|
<i class="bi-chevron-left"></i>
|
||||||
|
<span class="btn-text-lg">Назад</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="pagination-info">
|
<div class="pagination-info">
|
||||||
Страница {{ currentPage }} из {{ pagination.pages }}
|
<span class="page-text">Страница {{ currentPage }} из {{ pagination.pages }}</span>
|
||||||
</span>
|
<span class="total-text">({{ pagination.total }} жалоб)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="changePage(currentPage + 1)"
|
@click="changePage(currentPage + 1)"
|
||||||
:disabled="currentPage === pagination.pages"
|
:disabled="currentPage === pagination.pages"
|
||||||
class="pagination-btn"
|
class="pagination-btn"
|
||||||
>
|
>
|
||||||
Далее
|
<span class="btn-text-lg">Далее</span>
|
||||||
|
<i class="bi-chevron-right"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -102,7 +158,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import api from '@/services/api';
|
import axios from 'axios';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AdminReports',
|
name: 'AdminReports',
|
||||||
@ -135,19 +191,19 @@ export default {
|
|||||||
params.status = statusFilter.value;
|
params.status = statusFilter.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[AdminReports] Загружаем жалобы с параметрами:', params);
|
const token = localStorage.getItem('userToken');
|
||||||
const response = await api.getReports(params);
|
const response = await axios.get(`/api/admin/reports`, {
|
||||||
|
params,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
reports.value = response.data.reports;
|
reports.value = response.data.reports;
|
||||||
pagination.value = response.data.pagination;
|
pagination.value = response.data.pagination;
|
||||||
console.log('[AdminReports] Жалобы загружены:', response.data);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при загрузке жалоб:', err);
|
console.error('Ошибка при загрузке жалоб:', err);
|
||||||
if (err.response?.status === 403) {
|
error.value = 'Ошибка при загрузке списка жалоб. Пожалуйста, попробуйте позже.';
|
||||||
error.value = 'У вас нет прав для просмотра жалоб.';
|
|
||||||
} else {
|
|
||||||
error.value = err.response?.data?.message || 'Ошибка при загрузке списка жалоб. Пожалуйста, попробуйте позже.';
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -285,8 +341,17 @@ h2::before {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-options {
|
.filter-options {
|
||||||
@ -313,6 +378,24 @@ h2::before {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-top: 3px solid #007bff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
@ -322,6 +405,19 @@ h2::before {
|
|||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
border: 1px solid #f5c6cb;
|
border: 1px solid #f5c6cb;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reports-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-table {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reports-table-container {
|
.reports-table-container {
|
||||||
@ -392,6 +488,9 @@ h2::before {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn {
|
.view-btn {
|
||||||
@ -404,6 +503,16 @@ h2::before {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reports-table .id-col,
|
||||||
|
.reports-table .reason-col,
|
||||||
|
.reports-table .reporter-col,
|
||||||
|
.reports-table .reported-col,
|
||||||
|
.reports-table .date-col,
|
||||||
|
.reports-table .status-col,
|
||||||
|
.reports-table .actions-col {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -422,6 +531,9 @@ h2::before {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
color: #495057;
|
color: #495057;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-btn:hover:not(:disabled) {
|
.pagination-btn:hover:not(:disabled) {
|
||||||
@ -436,42 +548,466 @@ h2::before {
|
|||||||
|
|
||||||
.pagination-info {
|
.pagination-info {
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Мобильные карточки */
|
||||||
|
.mobile-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
background: linear-gradient(to right, #f8f9fa, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-id {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-id i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-info {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-info .reason-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item span {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item i {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #777;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer .date {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #007bff;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card:hover .card-footer i {
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-results {
|
.no-results {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 4rem 2rem;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
font-style: italic;
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптивные стили */
|
.no-results i {
|
||||||
@media (max-width: 767px) {
|
font-size: 3rem;
|
||||||
.hide-sm {
|
margin-bottom: 1rem;
|
||||||
|
color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная пагинация */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:hover:not(:disabled) {
|
||||||
|
background-color: #0056b3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:disabled {
|
||||||
|
background-color: #6c757d;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
color: #495057;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для разных экранов */
|
||||||
|
|
||||||
|
/* Очень маленькие экраны (до 480px) */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-reports {
|
||||||
|
padding: 1rem 0.5rem calc(60px + env(safe-area-inset-bottom, 0px)) 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
max-width: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options label {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options label:has(input:checked) {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options input[type="radio"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
padding: 0.75rem;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text-lg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Маленькие экраны (481px - 767px) */
|
||||||
|
@media (min-width: 481px) and (max-width: 767px) {
|
||||||
|
.admin-reports {
|
||||||
|
padding: 1rem calc(60px + env(safe-area-inset-bottom, 0px)) 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options label {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options label:has(input:checked) {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options input[type="radio"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-info {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Средние экраны (768px - 991px) */
|
||||||
|
@media (min-width: 768px) and (max-width: 991px) {
|
||||||
|
.admin-reports {
|
||||||
|
padding: 1.5rem 1rem calc(60px + env(safe-area-inset-bottom, 0px)) 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-cards {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Большие экраны (992px - 1199px) */
|
||||||
|
@media (min-width: 992px) and (max-width: 1199px) {
|
||||||
|
.admin-reports {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-cards {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-table {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reports-table th,
|
||||||
|
.reports-table td {
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-col { width: 8%; }
|
||||||
|
.reason-col { width: 20%; }
|
||||||
|
.reporter-col { width: 18%; }
|
||||||
|
.reported-col { width: 18%; }
|
||||||
|
.date-col { width: 15%; }
|
||||||
|
.status-col { width: 12%; }
|
||||||
|
.actions-col { width: 9%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Очень большие экраны (1200px+) */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.admin-reports {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-cards {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-table {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reports-table th,
|
||||||
|
.reports-table td {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-col { width: 10%; }
|
||||||
|
.reason-col { width: 20%; }
|
||||||
|
.reporter-col { width: 17%; }
|
||||||
|
.reported-col { width: 17%; }
|
||||||
|
.date-col { width: 16%; }
|
||||||
|
.status-col { width: 12%; }
|
||||||
|
.actions-col { width: 8%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скрываем таблицу на мобильных, показываем карточки */
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.desktop-table {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-cards {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Планшеты в ландшафтной ориентации */
|
||||||
|
@media (min-width: 768px) and (max-width: 1024px) and (orientation: landscape) {
|
||||||
|
.desktop-table {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-cards {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reports-table th,
|
.reports-table th,
|
||||||
.reports-table td {
|
.reports-table td {
|
||||||
padding: 0.6rem 0.5rem;
|
padding: 0.6rem 0.4rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.btn-text {
|
|
||||||
display: none;
|
/* Ландшафтная ориентация на мобильных */
|
||||||
|
@media (max-height: 450px) and (orientation: landscape) {
|
||||||
|
.admin-reports {
|
||||||
|
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-options {
|
.header-section {
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Высокие экраны */
|
||||||
|
@media (min-height: 800px) {
|
||||||
.admin-reports {
|
.admin-reports {
|
||||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
min-height: calc(100vh - 200px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -481,11 +1017,4 @@ h2::before {
|
|||||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ландшафтная ориентация на мобильных */
|
|
||||||
@media (max-height: 450px) and (orientation: landscape) {
|
|
||||||
.admin-reports {
|
|
||||||
padding-bottom: calc(50px + env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
Loading…
x
Reference in New Issue
Block a user