добавление админ панели

This commit is contained in:
Professional 2025-05-25 23:11:02 +07:00
parent 630a415aff
commit d1346c59d1
14 changed files with 2812 additions and 4 deletions

View File

@ -0,0 +1,298 @@
const User = require('../models/User');
const Conversation = require('../models/Conversation');
const Message = require('../models/Message');
const ProfileView = require('../models/ProfileView');
/**
* @desc Получить список всех пользователей
* @route GET /api/admin/users
* @access Admin
*/
const getAllUsers = async (req, res) => {
try {
const { search, page = 1, limit = 20, isActive } = req.query;
const skip = (page - 1) * limit;
// Создание фильтра
const filter = {};
// Добавляем поиск по имени или email, если указан
if (search) {
filter.$or = [
{ name: { $regex: search, $options: 'i' } },
{ email: { $regex: search, $options: 'i' } }
];
}
// Фильтр по статусу активности, если указан
if (isActive !== undefined) {
filter.isActive = isActive === 'true';
}
// Исключаем админа из результатов выдачи
filter.isAdmin = { $ne: true };
// Получаем пользователей с пагинацией
const users = await User.find(filter)
.select('-password')
.skip(skip)
.limit(parseInt(limit))
.sort({ createdAt: -1 });
// Получаем общее количество пользователей для пагинации
const total = await User.countDocuments(filter);
res.json({
users,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('Ошибка при получении списка пользователей:', error);
res.status(500).json({ message: 'Ошибка сервера при получении списка пользователей' });
}
};
/**
* @desc Получить детальную информацию о пользователе
* @route GET /api/admin/users/:id
* @access Admin
*/
const getUserDetails = async (req, res) => {
try {
const userId = req.params.id;
const user = await User.findById(userId)
.select('-password')
.lean();
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден' });
}
// Получаем статистику для пользователя
const messagesCount = await Message.countDocuments({ sender: userId });
const conversationsCount = await Conversation.countDocuments({
participants: userId
});
const profileViewsCount = await ProfileView.countDocuments({
profileOwner: userId
});
const matchesCount = user.matches ? user.matches.length : 0;
const likesGivenCount = user.liked ? user.liked.length : 0;
// Добавляем статистику к данным пользователя
const userWithStats = {
...user,
stats: {
messagesCount,
conversationsCount,
profileViewsCount,
matchesCount,
likesGivenCount
}
};
res.json(userWithStats);
} catch (error) {
console.error('Ошибка при получении информации о пользователе:', error);
res.status(500).json({ message: 'Ошибка сервера при получении информации о пользователе' });
}
};
/**
* @desc Заблокировать/разблокировать пользователя
* @route PUT /api/admin/users/:id/toggle-active
* @access Admin
*/
const toggleUserActive = async (req, res) => {
try {
const userId = req.params.id;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден' });
}
// Проверка, чтобы нельзя было заблокировать админа
if (user.isAdmin) {
return res.status(403).json({ message: 'Невозможно заблокировать администратора' });
}
// Изменяем статус активности на противоположный
user.isActive = !user.isActive;
await user.save();
res.json({
message: user.isActive ? 'Пользователь разблокирован' : 'Пользователь заблокирован',
isActive: user.isActive
});
} catch (error) {
console.error('Ошибка при изменении статуса пользователя:', error);
res.status(500).json({ message: 'Ошибка сервера при изменении статуса пользователя' });
}
};
/**
* @desc Получить статистику приложения
* @route GET /api/admin/statistics
* @access Admin
*/
const getAppStatistics = async (req, res) => {
try {
// Общая статистика пользователей
const totalUsers = await User.countDocuments({ isAdmin: { $ne: true } });
const activeUsers = await User.countDocuments({ isActive: true, isAdmin: { $ne: true } });
const inactiveUsers = await User.countDocuments({ isActive: false, isAdmin: { $ne: true } });
// Статистика по полу
const maleUsers = await User.countDocuments({ gender: 'male' });
const femaleUsers = await User.countDocuments({ gender: 'female' });
const otherGenderUsers = await User.countDocuments({ gender: 'other' });
// Статистика сообщений и диалогов
const totalMessages = await Message.countDocuments();
const totalConversations = await Conversation.countDocuments();
// Статистика просмотров профилей
const totalProfileViews = await ProfileView.countDocuments();
// Средние значения
const messagesPerUser = totalUsers > 0 ? totalMessages / totalUsers : 0;
const conversationsPerUser = totalUsers > 0 ? totalConversations / totalUsers : 0;
// Новые пользователи за последние 30 дней
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const newUsers30Days = await User.countDocuments({
createdAt: { $gte: thirtyDaysAgo },
isAdmin: { $ne: true }
});
res.json({
users: {
total: totalUsers,
active: activeUsers,
inactive: inactiveUsers,
newIn30Days: newUsers30Days,
genderDistribution: {
male: maleUsers,
female: femaleUsers,
other: otherGenderUsers
}
},
activity: {
totalMessages,
totalConversations,
totalProfileViews,
averages: {
messagesPerUser: messagesPerUser.toFixed(2),
conversationsPerUser: conversationsPerUser.toFixed(2)
}
}
});
} catch (error) {
console.error('Ошибка при получении статистики приложения:', error);
res.status(500).json({ message: 'Ошибка сервера при получении статистики приложения' });
}
};
/**
* @desc Получить список всех диалогов
* @route GET /api/admin/conversations
* @access Admin
*/
const getAllConversations = async (req, res) => {
try {
const { page = 1, limit = 20, userId } = req.query;
const skip = (page - 1) * limit;
// Создание фильтра
const filter = {};
// Фильтр по пользователю, если указан
if (userId) {
filter.participants = userId;
}
// Получаем диалоги с пагинацией и данными участников
const conversations = await Conversation.find(filter)
.populate('participants', 'name email photos')
.populate('lastMessage')
.skip(skip)
.limit(parseInt(limit))
.sort({ updatedAt: -1 });
// Получаем общее количество диалогов для пагинации
const total = await Conversation.countDocuments(filter);
res.json({
conversations,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('Ошибка при получении списка диалогов:', error);
res.status(500).json({ message: 'Ошибка сервера при получении списка диалогов' });
}
};
/**
* @desc Получить сообщения диалога
* @route GET /api/admin/conversations/:id/messages
* @access Admin
*/
const getConversationMessages = async (req, res) => {
try {
const conversationId = req.params.id;
const { page = 1, limit = 50 } = req.query;
const skip = (page - 1) * limit;
// Проверяем существование диалога
const conversation = await Conversation.findById(conversationId);
if (!conversation) {
return res.status(404).json({ message: 'Диалог не найден' });
}
// Получаем сообщения с пагинацией
const messages = await Message.find({ conversationId })
.populate('sender', 'name email')
.skip(skip)
.limit(parseInt(limit))
.sort({ createdAt: -1 });
// Получаем общее количество сообщений для пагинации
const total = await Message.countDocuments({ conversationId });
res.json({
messages,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('Ошибка при получении сообщений диалога:', error);
res.status(500).json({ message: 'Ошибка сервера при получении сообщений диалога' });
}
};
module.exports = {
getAllUsers,
getUserDetails,
toggleUserActive,
getAppStatistics,
getAllConversations,
getConversationMessages
};

View File

@ -136,11 +136,18 @@ const loginUser = async (req, res, next) => {
const isMatch = await user.matchPassword(password);
console.log('Результат проверки пароля:', isMatch ? 'Успешно' : 'Неверный пароль');
// Проверка если аккаунт неактивен (заблокирован)
if (isMatch && !user.isActive && !user.isAdmin) {
res.status(403);
throw new Error('Ваш аккаунт заблокирован. Пожалуйста, обратитесь в поддержку.');
}
if (isMatch) {
res.status(200).json({
_id: user._id,
name: user.name,
email: user.email,
isAdmin: user.isAdmin || false, // Добавляем информацию о правах администратора
token: generateToken(user._id),
message: 'Вход выполнен успешно!'
});

View File

@ -0,0 +1,21 @@
/**
* Middleware для проверки, является ли пользователь администратором
* Должен использоваться после middleware авторизации (protect),
* который добавляет req.user
*/
const adminMiddleware = (req, res, next) => {
// Проверяем, есть ли пользователь (должен быть добавлен в middleware авторизации)
if (!req.user) {
return res.status(401).json({ message: 'Не авторизован' });
}
// Проверяем, является ли пользователь администратором
if (!req.user.isAdmin) {
return res.status(403).json({ message: 'Доступ запрещен. Требуются права администратора' });
}
// Если пользователь администратор, продолжаем выполнение запроса
next();
};
module.exports = adminMiddleware;

View File

@ -65,6 +65,10 @@ const userSchema = new mongoose.Schema(
type: Boolean,
default: true,
},
isAdmin: {
type: Boolean,
default: false,
},
lastSeen: {
type: Date,
default: Date.now,

View File

@ -0,0 +1,22 @@
const express = require('express');
const router = express.Router();
const adminController = require('../controllers/adminController');
const { protect } = require('../middleware/authMiddleware');
const adminMiddleware = require('../middleware/adminMiddleware');
// Все маршруты защищены middleware для проверки авторизации и прав администратора
router.use(protect, adminMiddleware);
// Маршруты для управления пользователями
router.get('/users', adminController.getAllUsers);
router.get('/users/:id', adminController.getUserDetails);
router.put('/users/:id/toggle-active', adminController.toggleUserActive);
// Маршруты для просмотра статистики
router.get('/statistics', adminController.getAppStatistics);
// Маршруты для просмотра диалогов и сообщений
router.get('/conversations', adminController.getAllConversations);
router.get('/conversations/:id/messages', adminController.getConversationMessages);
module.exports = router;

View File

@ -13,8 +13,10 @@ const connectDBModule = require('./config/db');
const authRoutes = require('./routes/authRoutes'); // <--- 1. РАСКОММЕНТИРУЙ ИЛИ ДОБАВЬ ЭТОТ ИМПОРТ
const userRoutes = require('./routes/userRoutes'); // 1. Импортируем userRoutes
const actionRoutes = require('./routes/actionRoutes'); // 1. Импортируем actionRoutes
const adminRoutes = require('./routes/adminRoutes'); // Импортируем маршруты админа
const Message = require('./models/Message'); // Импорт модели Message
const Conversation = require('./models/Conversation'); // Импорт модели Conversation
const initAdminAccount = require('./utils/initAdmin'); // Импорт инициализации админ-аккаунта
// Загружаем переменные окружения до их использования
// dotenv.config(); // Перемещено в начало файла
@ -40,7 +42,15 @@ app.set('io', io);
// Подключение к базе данных - проверяем, что импортировали функцию
if (typeof connectDBModule === 'function') {
try {
connectDBModule(); // Вызов функции подключения
connectDBModule() // Вызов функции подключения
.then(() => {
console.log('Успешное подключение к базе данных');
// Инициализируем админ-аккаунт после подключения к БД
initAdminAccount();
})
.catch(err => {
console.error('Ошибка при подключении к базе данных:', err);
});
console.log('Попытка подключения к базе данных была инициирована...'); // Изменил лог для ясности
} catch (err) {
// Этот catch здесь, скорее всего, не поймает асинхронные ошибки из connectDBModule,
@ -64,11 +74,12 @@ app.get('/', (req, res) => {
res.send('API для Dating App работает!');
});
// 2. РАСКОММЕНТИРУЙ ИЛИ ДОБАВЬ ЭТИ СТРОКИ для подключения маршрутов аутентификации
// Подключаем маршруты
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes); // 2. Подключаем userRoutes с префиксом /api/users
app.use('/api/actions', actionRoutes);
app.use('/api/conversations', conversationRoutes);
app.use('/api/admin', adminRoutes); // Подключаем маршруты для админ-панели
// Socket.IO логика
let activeUsers = [];

View File

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

View File

@ -56,6 +56,48 @@ const routes = [
name: 'Preferences',
component: () => import('../views/PreferencesView.vue'),
meta: { requiresAuth: true }
},
// Маршруты для админ-панели
{
path: '/admin',
name: 'AdminDashboard',
component: () => import('../views/admin/AdminDashboard.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
redirect: { name: 'AdminUsers' },
children: [
{
path: 'users',
name: 'AdminUsers',
component: () => import('../views/admin/AdminUsers.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'users/:id',
name: 'AdminUserDetail',
component: () => import('../views/admin/AdminUserDetail.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
props: true
},
{
path: 'conversations',
name: 'AdminConversations',
component: () => import('../views/admin/AdminConversations.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'conversations/:id',
name: 'AdminConversationDetail',
component: () => import('../views/admin/AdminConversationDetail.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
props: true
},
{
path: 'statistics',
name: 'AdminStatistics',
component: () => import('../views/admin/AdminStatistics.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]
}
// ... здесь будут другие маршруты: /profile, /swipe, /chat/:id и т.д.
];
@ -82,6 +124,12 @@ router.beforeEach(async (to, from, next) => {
}
}
// Проверка прав администратора
if (to.meta.requiresAdmin && (!user.value || !user.value.isAdmin)) {
console.log(`[Router Guard] Доступ к ${to.path} запрещен. Требуются права администратора.`);
return next({ name: 'Swipe' }); // Перенаправление на обычную страницу пользователей
}
// Проверяем требования маршрута к авторизации
if (to.meta.requiresAuth) {
if (isAuthenticated.value) {
@ -95,8 +143,13 @@ router.beforeEach(async (to, from, next) => {
}
else if ((to.name === 'Login' || to.name === 'Register') && isAuthenticated.value) {
// Если пользователь уже аутентифицирован и пытается зайти на страницы авторизации
console.log(`[Router Guard] Пользователь аутентифицирован. Перенаправление с ${to.path} на /swipe.`);
next({ name: 'Swipe' }); // Перенаправляем на страницу свайпов вместо главной для лучшего UX
if (user.value && user.value.isAdmin) {
console.log(`[Router Guard] Админ аутентифицирован. Перенаправление с ${to.path} на админ-панель.`);
next({ name: 'AdminDashboard' }); // Перенаправляем на админ-панель для админа
} else {
console.log(`[Router Guard] Пользователь аутентифицирован. Перенаправление с ${to.path} на /swipe.`);
next({ name: 'Swipe' }); // Перенаправляем на страницу свайпов для обычных пользователей
}
}
else {
// Другие страницы, не требующие авторизации

View File

@ -0,0 +1,571 @@
<template>
<div class="admin-conversation-detail">
<div class="header-controls">
<button @click="goBack" class="back-btn">
&laquo; Назад к списку диалогов
</button>
</div>
<div v-if="loading" class="loading-indicator">
Загрузка сообщений...
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="conversation && !loading" class="conversation-container">
<h2>Просмотр диалога</h2>
<div class="conversation-info">
<h3>Информация о диалоге</h3>
<div class="info-grid">
<div class="info-item">
<div class="info-label">ID диалога</div>
<div class="info-value">{{ conversation._id }}</div>
</div>
<div class="info-item">
<div class="info-label">Дата создания</div>
<div class="info-value">{{ formatDate(conversation.createdAt) }}</div>
</div>
<div class="info-item">
<div class="info-label">Последняя активность</div>
<div class="info-value">{{ formatDate(conversation.updatedAt) }}</div>
</div>
</div>
</div>
<div class="participants-section">
<h3>Участники диалога</h3>
<div class="participants-list">
<div
v-for="participant in conversation.participants"
:key="participant._id"
class="participant-card"
>
<div class="participant-photo" v-if="getParticipantPhoto(participant)">
<img :src="getParticipantPhoto(participant)" alt="Фото пользователя">
</div>
<div class="participant-photo placeholder" v-else>
<div class="initials">{{ getInitials(participant.name) }}</div>
</div>
<div class="participant-info">
<div class="participant-name">{{ participant.name }}</div>
<div class="participant-email">{{ participant.email }}</div>
<button @click="viewUser(participant._id)" class="btn user-btn">
Профиль пользователя
</button>
</div>
</div>
</div>
</div>
<div class="messages-section">
<h3>История сообщений</h3>
<div v-if="loadingMessages" class="loading-messages">
Загрузка сообщений...
</div>
<div v-else-if="messages.length === 0" class="no-messages">
В диалоге нет сообщений
</div>
<div v-else class="messages-list">
<div
v-for="message in messages"
:key="message._id"
class="message-item"
:class="{ 'sender-1': message.sender._id === conversation.participants[0]._id, 'sender-2': message.sender._id === conversation.participants[1]._id }"
>
<div class="message-header">
<div class="message-sender">{{ message.sender.name }}</div>
<div class="message-time">{{ formatMessageTime(message.createdAt) }}</div>
</div>
<div class="message-content">{{ message.text }}</div>
<div class="message-status">
{{ getMessageStatus(message.status) }}
<span v-if="message.isEdited" class="edited-tag">(отредактировано)</span>
</div>
</div>
</div>
<div v-if="pagination.pages > 1" class="pagination">
<button
@click="changePage(currentPage - 1)"
:disabled="currentPage === 1"
class="pagination-btn"
>
&laquo; Более новые
</button>
<span class="pagination-info">
Страница {{ currentPage }} из {{ pagination.pages }}
</span>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage === pagination.pages"
class="pagination-btn"
>
Более старые &raquo;
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import axios from 'axios';
export default {
name: 'AdminConversationDetail',
props: {
id: {
type: String,
required: true
}
},
setup(props) {
const router = useRouter();
const route = useRoute();
const conversation = ref(null);
const messages = ref([]);
const loading = ref(false);
const loadingMessages = ref(false);
const error = ref(null);
const currentPage = ref(1);
const pagination = ref({
page: 1,
limit: 50,
total: 0,
pages: 0
});
const loadConversation = async () => {
loading.value = true;
error.value = null;
try {
const token = localStorage.getItem('token');
// Здесь мы используем маршрут для получения данных о диалоге
// Это должен быть запрос к API, который возвращает данные о конкретном диалоге
// На бэкенде такого маршрута может не быть, поэтому используем маршрут для получения списка диалогов
// и фильтруем результаты на клиенте
const response = await axios.get(
`${import.meta.env.VITE_API_URL}/api/admin/conversations`,
{
params: { userId: props.id, limit: 1 }, // Временное решение, по API нужно реализовать маршрут GET /api/admin/conversations/:id
headers: {
Authorization: `Bearer ${token}`
}
}
);
if (response.data.conversations && response.data.conversations.length > 0) {
conversation.value = response.data.conversations.find(c => c._id === props.id);
if (!conversation.value) {
// Если не нашли диалог по ID, возьмем первый из ответа (временное решение)
conversation.value = response.data.conversations[0];
}
// Загружаем сообщения для диалога
loadMessages();
} else {
error.value = 'Диалог не найден';
}
} catch (err) {
console.error('Ошибка при загрузке данных диалога:', err);
error.value = 'Ошибка при загрузке информации о диалоге. Пожалуйста, попробуйте позже.';
} finally {
loading.value = false;
}
};
const loadMessages = async () => {
if (!conversation.value) return;
loadingMessages.value = true;
try {
const token = localStorage.getItem('token');
const response = await axios.get(
`${import.meta.env.VITE_API_URL}/api/admin/conversations/${conversation.value._id}/messages`,
{
params: {
page: currentPage.value,
limit: pagination.value.limit
},
headers: {
Authorization: `Bearer ${token}`
}
}
);
messages.value = response.data.messages;
pagination.value = response.data.pagination;
} catch (err) {
console.error('Ошибка при загрузке сообщений:', err);
error.value = 'Ошибка при загрузке сообщений. Пожалуйста, попробуйте позже.';
} finally {
loadingMessages.value = false;
}
};
// Переход назад к списку диалогов
const goBack = () => {
router.push({ name: 'AdminConversations' });
};
// Переход к профилю пользователя
const viewUser = (userId) => {
router.push({ name: 'AdminUserDetail', params: { id: userId } });
};
// Получение фотографии участника диалога
const getParticipantPhoto = (participant) => {
if (!participant || !participant.photos || participant.photos.length === 0) return null;
const profilePhoto = participant.photos.find(p => p.isProfilePhoto);
return profilePhoto ? profilePhoto.url : participant.photos[0].url;
};
// Получение инициалов пользователя
const getInitials = (name) => {
if (!name) return '?';
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.substring(0, 2);
};
// Форматирование даты
const formatDate = (dateString) => {
if (!dateString) return 'Не указана';
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Форматирование времени сообщения
const formatMessageTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Получение статуса сообщения
const getMessageStatus = (status) => {
switch(status) {
case 'sending': return 'Отправляется';
case 'delivered': return 'Доставлено';
case 'read': return 'Прочитано';
default: return status;
}
};
// Изменение страницы пагинации
const changePage = (page) => {
if (page < 1 || page > pagination.value.pages) return;
currentPage.value = page;
loadMessages();
};
onMounted(() => {
loadConversation();
});
return {
conversation,
messages,
loading,
loadingMessages,
error,
currentPage,
pagination,
goBack,
viewUser,
getParticipantPhoto,
getInitials,
formatDate,
formatMessageTime,
getMessageStatus,
changePage
};
}
}
</script>
<style scoped>
.admin-conversation-detail {
padding-bottom: 2rem;
}
.header-controls {
display: flex;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.back-btn {
padding: 0.5rem 1rem;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
color: #333;
}
.loading-indicator {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
padding: 1rem;
background-color: #ffebee;
color: #d32f2f;
border-radius: 4px;
margin-bottom: 1rem;
}
h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
}
h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
}
.conversation-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.conversation-info {
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: 0.875rem;
color: #666;
margin-bottom: 0.25rem;
}
.info-value {
font-weight: 500;
}
.participants-section {
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.participants-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.participant-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid #eee;
border-radius: 6px;
}
.participant-photo {
width: 60px;
height: 60px;
border-radius: 50%;
overflow: hidden;
}
.participant-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.participant-photo.placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: #ff3e68;
color: white;
font-weight: bold;
}
.participant-info {
flex: 1;
}
.participant-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.participant-email {
font-size: 0.875rem;
color: #666;
margin-bottom: 0.5rem;
}
.btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
}
.user-btn {
background-color: #e3f2fd;
color: #1976d2;
}
.messages-section {
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.loading-messages {
text-align: center;
padding: 2rem;
color: #666;
}
.no-messages {
text-align: center;
padding: 2rem;
color: #666;
}
.messages-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.message-item {
padding: 1rem;
border-radius: 8px;
max-width: 80%;
}
.message-item.sender-1 {
background-color: #e3f2fd;
align-self: flex-start;
}
.message-item.sender-2 {
background-color: #f8bbd0;
align-self: flex-end;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.message-sender {
font-weight: 500;
}
.message-time {
color: #666;
}
.message-content {
margin-bottom: 0.5rem;
white-space: pre-wrap;
}
.message-status {
text-align: right;
font-size: 0.75rem;
color: #666;
}
.edited-tag {
margin-left: 0.5rem;
font-style: italic;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
}
.pagination-btn {
padding: 0.5rem 1rem;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
color: #666;
}
</style>

View File

@ -0,0 +1,333 @@
<template>
<div class="admin-conversations">
<h2>Управление диалогами</h2>
<div class="search-bar">
<input
type="text"
v-model="searchUserId"
placeholder="Поиск диалогов по ID пользователя..."
/>
<button @click="loadConversations" class="search-btn">Поиск</button>
</div>
<div v-if="loading" class="loading-indicator">
Загрузка диалогов...
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="!loading && !error" class="conversations-table-container">
<table class="conversations-table">
<thead>
<tr>
<th>ID диалога</th>
<th>Участники</th>
<th>Последнее сообщение</th>
<th>Дата создания</th>
<th>Последняя активность</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="conversation in conversations" :key="conversation._id">
<td>{{ shortenId(conversation._id) }}</td>
<td class="participants-cell">
<div v-for="participant in conversation.participants" :key="participant._id" class="participant">
{{ participant.name }} ({{ participant.email }})
</div>
</td>
<td>
{{ conversation.lastMessage ? truncateText(conversation.lastMessage.text, 30) : 'Нет сообщений' }}
</td>
<td>{{ formatDate(conversation.createdAt) }}</td>
<td>{{ formatDate(conversation.updatedAt) }}</td>
<td class="actions">
<button @click="viewConversation(conversation._id)" class="btn view-btn">
Просмотр
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="conversations.length === 0" class="no-results">
Диалоги не найдены
</div>
<div v-if="pagination.pages > 1" class="pagination">
<button
@click="changePage(currentPage - 1)"
:disabled="currentPage === 1"
class="pagination-btn"
>
&laquo; Назад
</button>
<span class="pagination-info">
Страница {{ currentPage }} из {{ pagination.pages }}
</span>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage === pagination.pages"
class="pagination-btn"
>
Вперёд &raquo;
</button>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
export default {
name: 'AdminConversations',
setup() {
const router = useRouter();
const conversations = ref([]);
const loading = ref(false);
const error = ref(null);
const searchUserId = ref('');
const currentPage = ref(1);
const pagination = ref({
page: 1,
limit: 20,
total: 0,
pages: 0
});
const loadConversations = async () => {
loading.value = true;
error.value = null;
try {
// Определяем параметры запроса
const params = {
page: currentPage.value,
limit: pagination.value.limit
};
// Добавляем поиск по ID пользователя, если указан
if (searchUserId.value) {
params.userId = searchUserId.value;
}
const token = localStorage.getItem('token');
const response = await axios.get(`${import.meta.env.VITE_API_URL}/api/admin/conversations`, {
params,
headers: {
Authorization: `Bearer ${token}`
}
});
conversations.value = response.data.conversations;
pagination.value = response.data.pagination;
} catch (err) {
console.error('Ошибка при загрузке диалогов:', err);
error.value = 'Ошибка при загрузке списка диалогов. Пожалуйста, попробуйте позже.';
} finally {
loading.value = false;
}
};
// Функция для сокращения ID (показывать только первые 6 символов)
const shortenId = (id) => {
return id ? id.substring(0, 6) + '...' : '';
};
// Форматирование даты
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Обрезка длинного текста
const truncateText = (text, maxLength) => {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
// Переход на страницу детальной информации о диалоге
const viewConversation = (conversationId) => {
router.push({ name: 'AdminConversationDetail', params: { id: conversationId } });
};
// Изменение страницы пагинации
const changePage = (page) => {
if (page < 1 || page > pagination.value.pages) return;
currentPage.value = page;
loadConversations();
};
onMounted(() => {
loadConversations();
});
return {
conversations,
loading,
error,
searchUserId,
currentPage,
pagination,
loadConversations,
shortenId,
formatDate,
truncateText,
viewConversation,
changePage
};
}
}
</script>
<style scoped>
.admin-conversations {
padding-bottom: 2rem;
}
h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
}
.search-bar {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-bar input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.search-btn {
padding: 0 1.5rem;
background-color: #ff3e68;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.loading-indicator {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
padding: 1rem;
background-color: #ffebee;
color: #d32f2f;
border-radius: 4px;
margin-bottom: 1rem;
}
.conversations-table-container {
overflow-x: auto;
}
.conversations-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.conversations-table th, .conversations-table td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.conversations-table th {
background-color: #f5f5f5;
font-weight: 500;
color: #444;
}
.participants-cell {
max-width: 300px;
}
.participant {
margin-bottom: 0.25rem;
}
.participant:last-child {
margin-bottom: 0;
}
.actions {
white-space: nowrap;
}
.btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
}
.view-btn {
background-color: #e3f2fd;
color: #1976d2;
}
.no-results {
text-align: center;
padding: 2rem;
color: #666;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
}
.pagination-btn {
padding: 0.5rem 1rem;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
color: #666;
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<div class="admin-dashboard">
<div class="admin-header">
<h1>Административная панель</h1>
<div class="admin-user-info">
<span>{{ user?.name || 'Администратор' }}</span>
<button @click="logout" class="logout-btn">Выйти</button>
</div>
</div>
<div class="admin-content">
<div class="admin-sidebar">
<nav>
<router-link :to="{ name: 'AdminUsers' }" active-class="active">Пользователи</router-link>
<router-link :to="{ name: 'AdminConversations' }" active-class="active">Диалоги</router-link>
<router-link :to="{ name: 'AdminStatistics' }" active-class="active">Статистика</router-link>
</nav>
</div>
<div class="admin-main-content">
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
import { useAuth } from '../../auth';
import { useRouter } from 'vue-router';
import { computed } from 'vue';
export default {
name: 'AdminDashboard',
setup() {
const { user, logout: authLogout } = useAuth();
const router = useRouter();
const logout = async () => {
try {
await authLogout();
router.push({ name: 'Login' });
} catch (error) {
console.error('Ошибка при выходе:', error);
}
};
return {
user: computed(() => user.value),
logout
};
}
}
</script>
<style scoped>
.admin-dashboard {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background-color: #ff3e68;
color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.admin-header h1 {
font-size: 1.5rem;
margin: 0;
}
.admin-user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.logout-btn {
padding: 0.4rem 1rem;
background-color: white;
color: #ff3e68;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.admin-content {
display: flex;
flex: 1;
overflow: hidden;
}
.admin-sidebar {
width: 200px;
background-color: white;
box-shadow: 1px 0 3px rgba(0,0,0,0.1);
overflow-y: auto;
}
.admin-sidebar nav {
display: flex;
flex-direction: column;
padding: 1rem 0;
}
.admin-sidebar a {
padding: 0.75rem 1.5rem;
color: #333;
text-decoration: none;
transition: background-color 0.3s;
}
.admin-sidebar a:hover {
background-color: #f0f0f0;
}
.admin-sidebar a.active {
background-color: #ff3e68;
color: white;
}
.admin-main-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
</style>

View File

@ -0,0 +1,356 @@
<template>
<div class="admin-statistics">
<h2>Статистика приложения</h2>
<div v-if="loading" class="loading-indicator">
Загрузка статистики...
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="statistics && !loading" class="statistics-container">
<div class="stat-section users-section">
<h3>Пользователи</h3>
<div class="stat-cards">
<div class="stat-card">
<div class="stat-value">{{ statistics.users.total }}</div>
<div class="stat-label">Всего пользователей</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ statistics.users.active }}</div>
<div class="stat-label">Активных пользователей</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ statistics.users.inactive }}</div>
<div class="stat-label">Заблокированных</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ statistics.users.newIn30Days }}</div>
<div class="stat-label">Новых за 30 дней</div>
</div>
</div>
<div class="gender-distribution">
<h4>Распределение по полу</h4>
<div class="gender-chart">
<div class="gender-bar">
<div
class="gender-male"
:style="{
width: calculateGenderPercentage('male') + '%'
}"
>
<span v-if="calculateGenderPercentage('male') > 10">
{{ statistics.users.genderDistribution.male }}
</span>
</div>
<div
class="gender-female"
:style="{
width: calculateGenderPercentage('female') + '%'
}"
>
<span v-if="calculateGenderPercentage('female') > 10">
{{ statistics.users.genderDistribution.female }}
</span>
</div>
<div
class="gender-other"
:style="{
width: calculateGenderPercentage('other') + '%'
}"
>
<span v-if="calculateGenderPercentage('other') > 10">
{{ statistics.users.genderDistribution.other }}
</span>
</div>
</div>
<div class="gender-legend">
<div class="legend-item">
<div class="legend-color male"></div>
<div class="legend-label">Мужчины ({{ calculateGenderPercentage('male') }}%)</div>
</div>
<div class="legend-item">
<div class="legend-color female"></div>
<div class="legend-label">Женщины ({{ calculateGenderPercentage('female') }}%)</div>
</div>
<div class="legend-item">
<div class="legend-color other"></div>
<div class="legend-label">Другое ({{ calculateGenderPercentage('other') }}%)</div>
</div>
</div>
</div>
</div>
</div>
<div class="stat-section activity-section">
<h3>Активность пользователей</h3>
<div class="stat-cards">
<div class="stat-card">
<div class="stat-value">{{ statistics.activity.totalMessages }}</div>
<div class="stat-label">Всего сообщений</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ statistics.activity.totalConversations }}</div>
<div class="stat-label">Всего диалогов</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ statistics.activity.totalProfileViews }}</div>
<div class="stat-label">Просмотров профилей</div>
</div>
</div>
<h4>Средняя активность</h4>
<div class="average-stats">
<div class="average-item">
<div class="average-label">Сообщений на пользователя:</div>
<div class="average-value">{{ statistics.activity.averages.messagesPerUser }}</div>
</div>
<div class="average-item">
<div class="average-label">Диалогов на пользователя:</div>
<div class="average-value">{{ statistics.activity.averages.conversationsPerUser }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import axios from 'axios';
export default {
name: 'AdminStatistics',
setup() {
const statistics = ref(null);
const loading = ref(false);
const error = ref(null);
const loadStatistics = async () => {
loading.value = true;
error.value = null;
try {
const token = localStorage.getItem('token');
const response = await axios.get(
`${import.meta.env.VITE_API_URL}/api/admin/statistics`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
statistics.value = response.data;
} catch (err) {
console.error('Ошибка при загрузке статистики:', err);
error.value = 'Ошибка при загрузке статистики. Пожалуйста, попробуйте позже.';
} finally {
loading.value = false;
}
};
// Расчет процентов распределения пола
const calculateGenderPercentage = (gender) => {
if (!statistics.value) return 0;
const { male, female, other } = statistics.value.users.genderDistribution;
const total = male + female + other;
if (total === 0) return 0;
const genderCount = statistics.value.users.genderDistribution[gender];
return Math.round((genderCount / total) * 100);
};
onMounted(() => {
loadStatistics();
});
return {
statistics,
loading,
error,
calculateGenderPercentage
};
}
}
</script>
<style scoped>
.admin-statistics {
padding-bottom: 2rem;
}
h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
}
h3 {
margin-top: 0;
margin-bottom: 1.25rem;
color: #333;
}
h4 {
margin-top: 1.5rem;
margin-bottom: 1rem;
color: #444;
}
.loading-indicator {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
padding: 1rem;
background-color: #ffebee;
color: #d32f2f;
border-radius: 4px;
margin-bottom: 1rem;
}
.statistics-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.stat-section {
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.stat-card {
background-color: #f9f9f9;
border-radius: 6px;
padding: 1.25rem;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #ff3e68;
margin-bottom: 0.5rem;
}
.stat-label {
color: #666;
}
.gender-distribution {
margin-top: 1.5rem;
}
.gender-chart {
margin-top: 1rem;
}
.gender-bar {
display: flex;
height: 40px;
background-color: #f5f5f5;
border-radius: 4px;
overflow: hidden;
margin-bottom: 1rem;
}
.gender-bar > div {
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 500;
transition: width 0.5s ease;
}
.gender-male {
background-color: #2196f3;
}
.gender-female {
background-color: #e91e63;
}
.gender-other {
background-color: #9c27b0;
}
.gender-legend {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
.legend-color.male {
background-color: #2196f3;
}
.legend-color.female {
background-color: #e91e63;
}
.legend-color.other {
background-color: #9c27b0;
}
.average-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.average-item {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #f9f9f9;
padding: 1rem;
border-radius: 6px;
}
.average-label {
font-weight: 400;
color: #555;
}
.average-value {
font-weight: 600;
color: #ff3e68;
}
</style>

View File

@ -0,0 +1,548 @@
<template>
<div class="admin-user-detail">
<div class="header-controls">
<button @click="goBack" class="back-btn">
&laquo; Назад к списку пользователей
</button>
<button
v-if="user"
@click="toggleUserStatus"
class="status-btn"
:class="user.isActive ? 'block-btn' : 'unblock-btn'"
>
{{ user.isActive ? 'Заблокировать' : 'Разблокировать' }}
</button>
</div>
<div v-if="loading" class="loading-indicator">
Загрузка информации о пользователе...
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="user && !loading" class="user-container">
<h2>Информация о пользователе</h2>
<div class="user-profile">
<div class="user-header">
<div class="user-photo" v-if="userProfilePhoto">
<img :src="userProfilePhoto" alt="Фото профиля">
</div>
<div class="user-photo placeholder" v-else>
<div class="initials">{{ getUserInitials() }}</div>
</div>
<div class="user-name-status">
<h3>{{ user.name }}</h3>
<span class="status-badge" :class="{ 'active': user.isActive, 'blocked': !user.isActive }">
{{ user.isActive ? 'Активен' : 'Заблокирован' }}
</span>
</div>
</div>
<div class="user-details">
<div class="detail-group">
<div class="detail-item">
<div class="detail-label">ID</div>
<div class="detail-value">{{ user._id }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Email</div>
<div class="detail-value">{{ user.email }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Пол</div>
<div class="detail-value">{{ getGenderText(user.gender) }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Дата рождения</div>
<div class="detail-value">{{ formatDate(user.dateOfBirth) }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Возраст</div>
<div class="detail-value">{{ calculateAge(user.dateOfBirth) }} лет</div>
</div>
</div>
<div class="detail-group">
<div class="detail-item">
<div class="detail-label">Город</div>
<div class="detail-value">{{ user.location?.city || 'Не указан' }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Страна</div>
<div class="detail-value">{{ user.location?.country || 'Не указана' }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Дата регистрации</div>
<div class="detail-value">{{ formatDate(user.createdAt) }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Последнее посещение</div>
<div class="detail-value">{{ formatDate(user.lastSeen) }}</div>
</div>
</div>
</div>
<div class="user-bio" v-if="user.bio">
<h4>О себе</h4>
<p>{{ user.bio }}</p>
</div>
</div>
<div class="user-stats">
<h3>Статистика активности</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ user.stats?.messagesCount || 0 }}</div>
<div class="stat-label">Сообщений</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ user.stats?.conversationsCount || 0 }}</div>
<div class="stat-label">Диалогов</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ user.stats?.profileViewsCount || 0 }}</div>
<div class="stat-label">Просмотров профиля</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ user.stats?.matchesCount || 0 }}</div>
<div class="stat-label">Взаимных симпатий</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ user.stats?.likesGivenCount || 0 }}</div>
<div class="stat-label">Выражений симпатии</div>
</div>
</div>
</div>
<div class="user-photos" v-if="user.photos && user.photos.length > 0">
<h3>Фотографии пользователя</h3>
<div class="photos-grid">
<div
v-for="(photo, index) in user.photos"
:key="index"
class="photo-item"
>
<img :src="photo.url" alt="Фото пользователя">
<div v-if="photo.isProfilePhoto" class="photo-badge">Основное фото</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import axios from 'axios';
export default {
name: 'AdminUserDetail',
props: {
id: {
type: String,
required: true
}
},
setup(props) {
const router = useRouter();
const route = useRoute();
const user = ref(null);
const loading = ref(false);
const error = ref(null);
// Получить основное фото пользователя
const userProfilePhoto = computed(() => {
if (!user.value || !user.value.photos) return null;
const profilePhoto = user.value.photos.find(p => p.isProfilePhoto);
return profilePhoto ? profilePhoto.url : (user.value.photos.length > 0 ? user.value.photos[0].url : null);
});
const loadUserDetails = async () => {
loading.value = true;
error.value = null;
try {
const token = localStorage.getItem('token');
const response = await axios.get(
`${import.meta.env.VITE_API_URL}/api/admin/users/${props.id}`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
user.value = response.data;
} catch (err) {
console.error('Ошибка при загрузке данных пользователя:', err);
error.value = 'Ошибка при загрузке информации о пользователе. Пожалуйста, попробуйте позже.';
} finally {
loading.value = false;
}
};
// Переход назад к списку пользователей
const goBack = () => {
router.push({ name: 'AdminUsers' });
};
// Изменение статуса пользователя (блокировка/разблокировка)
const toggleUserStatus = async () => {
try {
loading.value = true;
const token = localStorage.getItem('token');
const response = await axios.put(
`${import.meta.env.VITE_API_URL}/api/admin/users/${props.id}/toggle-active`,
{},
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
// Обновляем статус пользователя
user.value.isActive = response.data.isActive;
// Показываем уведомление (можно добавить компонент уведомлений)
alert(response.data.message);
} catch (err) {
console.error('Ошибка при изменении статуса пользователя:', err);
alert('Ошибка при изменении статуса пользователя');
} finally {
loading.value = false;
}
};
// Получение инициалов пользователя для отображения, если нет фото
const getUserInitials = () => {
if (!user.value || !user.value.name) return '?';
return user.value.name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.substring(0, 2);
};
// Форматирование пола
const getGenderText = (gender) => {
switch(gender) {
case 'male': return 'Мужской';
case 'female': return 'Женский';
case 'other': return 'Другой';
default: return 'Не указан';
}
};
// Расчет возраста
const calculateAge = (dateOfBirthString) => {
if (!dateOfBirthString) return 'Не указан';
const dob = new Date(dateOfBirthString);
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age;
};
// Форматирование даты
const formatDate = (dateString) => {
if (!dateString) return 'Не указана';
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
onMounted(() => {
loadUserDetails();
});
return {
user,
loading,
error,
userProfilePhoto,
goBack,
toggleUserStatus,
getUserInitials,
getGenderText,
calculateAge,
formatDate
};
}
}
</script>
<style scoped>
.admin-user-detail {
padding-bottom: 2rem;
}
.header-controls {
display: flex;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.back-btn {
padding: 0.5rem 1rem;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
color: #333;
}
.status-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.block-btn {
background-color: #ffebee;
color: #d32f2f;
}
.unblock-btn {
background-color: #e8f5e9;
color: #2e7d32;
}
.loading-indicator {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
padding: 1rem;
background-color: #ffebee;
color: #d32f2f;
border-radius: 4px;
margin-bottom: 1rem;
}
h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
}
h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
}
.user-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.user-profile {
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.user-header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.user-photo {
width: 100px;
height: 100px;
border-radius: 50%;
overflow: hidden;
}
.user-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-photo.placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: #ff3e68;
color: white;
font-size: 2rem;
font-weight: bold;
}
.user-name-status {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.user-name-status h3 {
margin: 0;
font-size: 1.5rem;
}
.status-badge {
display: inline-block;
padding: 0.3rem 0.6rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.active {
background-color: #e8f5e9;
color: #2e7d32;
}
.status-badge.blocked {
background-color: #ffebee;
color: #d32f2f;
}
.user-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.detail-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
display: flex;
flex-direction: column;
}
.detail-label {
font-size: 0.875rem;
color: #666;
margin-bottom: 0.25rem;
}
.detail-value {
font-weight: 500;
}
.user-bio {
margin-top: 1.5rem;
}
.user-bio h4 {
margin-top: 0;
margin-bottom: 0.5rem;
color: #333;
}
.user-bio p {
margin: 0;
line-height: 1.5;
color: #444;
white-space: pre-wrap;
}
.user-stats {
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.stat-card {
background-color: #f9f9f9;
border-radius: 6px;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.75rem;
font-weight: bold;
color: #ff3e68;
margin-bottom: 0.5rem;
}
.stat-label {
color: #666;
font-size: 0.9rem;
}
.user-photos {
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.photos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.photo-item {
position: relative;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 1/1;
}
.photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-badge {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0,0,0,0.7);
color: white;
padding: 0.3rem;
font-size: 0.8rem;
text-align: center;
}
</style>

View File

@ -0,0 +1,411 @@
<template>
<div class="admin-users">
<h2>Управление пользователями</h2>
<div class="search-bar">
<input
type="text"
v-model="searchQuery"
placeholder="Поиск по имени или email..."
@input="debounceSearch"
/>
<div class="filter-options">
<label>
<input type="radio" v-model="activeFilter" value="all" @change="loadUsers">
Все
</label>
<label>
<input type="radio" v-model="activeFilter" value="active" @change="loadUsers">
Активные
</label>
<label>
<input type="radio" v-model="activeFilter" value="blocked" @change="loadUsers">
Заблокированные
</label>
</div>
</div>
<div v-if="loading" class="loading-indicator">
Загрузка пользователей...
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="!loading && !error" class="users-table-container">
<table class="users-table">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Email</th>
<th>Дата регистрации</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user._id">
<td>{{ shortenId(user._id) }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ formatDate(user.createdAt) }}</td>
<td>
<span class="status-badge" :class="{ 'active': user.isActive, 'blocked': !user.isActive }">
{{ user.isActive ? 'Активен' : 'Заблокирован' }}
</span>
</td>
<td class="actions">
<button @click="viewUser(user._id)" class="btn view-btn">
Просмотр
</button>
<button
@click="toggleUserStatus(user._id, user.isActive)"
class="btn"
:class="user.isActive ? 'block-btn' : 'unblock-btn'"
>
{{ user.isActive ? 'Заблокировать' : 'Разблокировать' }}
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="users.length === 0" class="no-results">
Пользователи не найдены
</div>
<div v-if="pagination.pages > 1" class="pagination">
<button
@click="changePage(currentPage - 1)"
:disabled="currentPage === 1"
class="pagination-btn"
>
&laquo; Назад
</button>
<span class="pagination-info">
Страница {{ currentPage }} из {{ pagination.pages }}
</span>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage === pagination.pages"
class="pagination-btn"
>
Вперёд &raquo;
</button>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
export default {
name: 'AdminUsers',
setup() {
const router = useRouter();
const users = ref([]);
const loading = ref(false);
const error = ref(null);
const searchQuery = ref('');
const activeFilter = ref('all');
const currentPage = ref(1);
const pagination = ref({
page: 1,
limit: 20,
total: 0,
pages: 0
});
const loadUsers = async () => {
loading.value = true;
error.value = null;
try {
// Определяем параметры запроса
const params = {
page: currentPage.value,
limit: pagination.value.limit
};
// Добавляем поисковый запрос, если он есть
if (searchQuery.value) {
params.search = searchQuery.value;
}
// Добавляем фильтр по статусу активности
if (activeFilter.value !== 'all') {
params.isActive = activeFilter.value === 'active';
}
const token = localStorage.getItem('token');
const response = await axios.get(`${import.meta.env.VITE_API_URL}/api/admin/users`, {
params,
headers: {
Authorization: `Bearer ${token}`
}
});
users.value = response.data.users;
pagination.value = response.data.pagination;
} catch (err) {
console.error('Ошибка при загрузке пользователей:', err);
error.value = 'Ошибка при загрузке списка пользователей. Пожалуйста, попробуйте позже.';
} finally {
loading.value = false;
}
};
// Функция для сокращения ID (показывать только первые 6 символов)
const shortenId = (id) => {
return id ? id.substring(0, 6) + '...' : '';
};
// Форматирование даты
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Переход на страницу детальной информации о пользователе
const viewUser = (userId) => {
router.push({ name: 'AdminUserDetail', params: { id: userId } });
};
// Изменение статуса пользователя (блокировка/разблокировка)
const toggleUserStatus = async (userId, currentStatus) => {
try {
loading.value = true;
const token = localStorage.getItem('token');
const response = await axios.put(
`${import.meta.env.VITE_API_URL}/api/admin/users/${userId}/toggle-active`,
{},
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
// Обновляем статус пользователя в списке
const userIndex = users.value.findIndex(user => user._id === userId);
if (userIndex !== -1) {
users.value[userIndex].isActive = response.data.isActive;
}
// Показываем уведомление (можно добавить компонент уведомлений)
alert(response.data.message);
} catch (err) {
console.error('Ошибка при изменении статуса пользователя:', err);
alert('Ошибка при изменении статуса пользователя');
} finally {
loading.value = false;
}
};
// Изменение страницы пагинации
const changePage = (page) => {
if (page < 1 || page > pagination.value.pages) return;
currentPage.value = page;
loadUsers();
};
// Задержка для поиска (чтобы не отправлять запрос после каждого ввода символа)
let searchTimeout;
const debounceSearch = () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage.value = 1; // Сбрасываем страницу при новом поиске
loadUsers();
}, 300);
};
onMounted(() => {
loadUsers();
});
return {
users,
loading,
error,
searchQuery,
activeFilter,
currentPage,
pagination,
loadUsers,
shortenId,
formatDate,
viewUser,
toggleUserStatus,
changePage,
debounceSearch
};
}
}
</script>
<style scoped>
.admin-users {
padding-bottom: 2rem;
}
h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
}
.search-bar {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-bar input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
}
.filter-options {
display: flex;
gap: 1.5rem;
}
.filter-options label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.loading-indicator {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
padding: 1rem;
background-color: #ffebee;
color: #d32f2f;
border-radius: 4px;
margin-bottom: 1rem;
}
.users-table-container {
overflow-x: auto;
}
.users-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.users-table th, .users-table td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.users-table th {
background-color: #f5f5f5;
font-weight: 500;
color: #444;
}
.status-badge {
padding: 0.3rem 0.6rem;
border-radius: 20px;
font-size: 0.875rem;
}
.status-badge.active {
background-color: #e8f5e9;
color: #2e7d32;
}
.status-badge.blocked {
background-color: #ffebee;
color: #d32f2f;
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
}
.view-btn {
background-color: #e3f2fd;
color: #1976d2;
}
.block-btn {
background-color: #ffebee;
color: #d32f2f;
}
.unblock-btn {
background-color: #e8f5e9;
color: #2e7d32;
}
.no-results {
text-align: center;
padding: 2rem;
color: #666;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
}
.pagination-btn {
padding: 0.5rem 1rem;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
color: #666;
}
</style>