ваше сообщение коммита
This commit is contained in:
@@ -12,6 +12,9 @@ const aiAssistant = require('./services/ai-assistant'); // Добавляем и
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const messagesRoutes = require('./routes/messages');
|
||||
const userTagsRoutes = require('./routes/userTags');
|
||||
const tagsInitRoutes = require('./routes/tagsInit');
|
||||
const tagsRoutes = require('./routes/tags');
|
||||
|
||||
// Проверка и создание директорий для хранения данных контрактов
|
||||
const ensureDirectoriesExist = () => {
|
||||
@@ -100,7 +103,8 @@ app.use(async (req, res, next) => {
|
||||
|
||||
// Если сессия уже есть, используем её
|
||||
if (req.session.authenticated) {
|
||||
return next();
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем заголовок авторизации
|
||||
@@ -150,14 +154,16 @@ app.use(
|
||||
|
||||
// Логирование запросов
|
||||
app.use((req, res, next) => {
|
||||
console.log('[APP] Глобальный лог:', req.method, req.originalUrl);
|
||||
logger.info(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Маршруты API
|
||||
app.use('/api/tables', tablesRoutes); // ДОЛЖНО БЫТЬ ВЫШЕ!
|
||||
app.use('/api', identitiesRoutes);
|
||||
// app.use('/api', identitiesRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users/:userId/tags', userTagsRoutes);
|
||||
app.use('/api/users', usersRoutes);
|
||||
app.use('/api/chat', chatRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
@@ -167,6 +173,9 @@ app.use('/api/geocoding', geocodingRoutes); // Добавленное испол
|
||||
app.use('/api/dle', dleRoutes); // Добавляем маршрут DLE
|
||||
app.use('/api/settings', settingsRoutes); // Добавляем маршрут настроек
|
||||
app.use('/api/messages', messagesRoutes);
|
||||
app.use('/api/tags', tagsInitRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/identities', identitiesRoutes);
|
||||
|
||||
const nonceStore = new Map(); // или любая другая реализация хранилища nonce
|
||||
|
||||
|
||||
@@ -97,4 +97,4 @@ async function saveGuestMessageToDatabase(message, language, guestId) {
|
||||
}
|
||||
|
||||
// Экспортируем функции для работы с базой данных
|
||||
module.exports = { getQuery, pool, getPool, setPoolChangeCallback };
|
||||
module.exports = { query: pool.query.bind(pool), getQuery, pool, getPool, setPoolChangeCallback };
|
||||
|
||||
13
backend/db/migrations/034_create_tags_and_user_tags.sql
Normal file
13
backend/db/migrations/034_create_tags_and_user_tags.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Создание справочника тегов
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
-- Создание связующей таблицы "пользователь — тег"
|
||||
CREATE TABLE IF NOT EXISTS user_tags (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, tag_id)
|
||||
);
|
||||
61
backend/routes/tags.js
Normal file
61
backend/routes/tags.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
|
||||
// Получить все теги
|
||||
router.get('/', async (req, res) => {
|
||||
console.log('GET /api/tags');
|
||||
try {
|
||||
const query = db.getQuery();
|
||||
const { rows } = await query('SELECT * FROM tags ORDER BY name');
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
console.error('Ошибка в /api/tags:', e);
|
||||
res.status(500).json({ error: e.message, stack: e.stack });
|
||||
}
|
||||
});
|
||||
|
||||
// Создать тег
|
||||
router.post('/', async (req, res) => {
|
||||
console.log('POST /api/tags', req.body);
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
const query = db.getQuery();
|
||||
const result = await query(
|
||||
'INSERT INTO tags (name, description) VALUES ($1, $2) RETURNING *',
|
||||
[name, description]
|
||||
);
|
||||
const row = result && result.rows && result.rows[0] ? result.rows[0] : null;
|
||||
if (row) {
|
||||
res.json(row);
|
||||
} else {
|
||||
res.status(500).json({ error: 'Не удалось создать тег', result });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка в /api/tags (POST):', e);
|
||||
res.status(500).json({ error: e.message, stack: e.stack });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить тег и все его связи с пользователями
|
||||
router.delete('/:tagId', async (req, res) => {
|
||||
console.log('DELETE /api/tags/:id', req.params.tagId);
|
||||
try {
|
||||
const tagId = req.params.tagId;
|
||||
const query = db.getQuery();
|
||||
// Сначала удаляем связи user_tags
|
||||
await query('DELETE FROM user_tags WHERE tag_id = $1', [tagId]);
|
||||
// Затем удаляем сам тег
|
||||
const result = await query('DELETE FROM tags WHERE id = $1 RETURNING *', [tagId]);
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Тег не найден' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка в /api/tags/:id (DELETE):', e);
|
||||
res.status(500).json({ error: e.message, stack: e.stack });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
29
backend/routes/tagsInit.js
Normal file
29
backend/routes/tagsInit.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
|
||||
// Инициализация таблиц тегов
|
||||
router.post('/init', async (req, res) => {
|
||||
console.log('POST /api/tags/init');
|
||||
try {
|
||||
const query = db.getQuery();
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_tags (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, tag_id)
|
||||
);
|
||||
`);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error('Ошибка в /api/tags/init:', e);
|
||||
res.status(500).json({ error: e.message, stack: e.stack });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
91
backend/routes/userTags.js
Normal file
91
backend/routes/userTags.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
|
||||
// Инициализация таблиц тегов (если нужно)
|
||||
router.post('/init', async (req, res) => {
|
||||
console.log('POST /api/users/tags/init');
|
||||
try {
|
||||
const query = db.getQuery();
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_tags (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, tag_id)
|
||||
);
|
||||
`);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error('Ошибка в /api/users/tags/init:', e);
|
||||
res.status(500).json({ error: e.message, stack: e.stack });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Работа с тегами пользователя ---
|
||||
|
||||
// Получить теги пользователя
|
||||
router.get('/:userId/tags', async (req, res) => {
|
||||
console.log('GET /api/users/:id/tags', req.params.userId);
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const query = db.getQuery();
|
||||
const result = await query(
|
||||
`SELECT t.* FROM tags t
|
||||
JOIN user_tags ut ON ut.tag_id = t.id
|
||||
WHERE ut.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
const rows = result && result.rows ? result.rows : [];
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
console.error('Ошибка в /api/users/:id/tags (GET):', e);
|
||||
res.status(500).json({ error: e.message, stack: e.stack });
|
||||
}
|
||||
});
|
||||
|
||||
// Добавить тег пользователю
|
||||
router.post('/:userId/tags', async (req, res) => {
|
||||
console.log('POST /api/users/:id/tags', req.params.userId, req.body);
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const { tag_id } = req.body;
|
||||
const query = db.getQuery();
|
||||
await query(
|
||||
'INSERT INTO user_tags (user_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[userId, tag_id]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error('Ошибка в /api/users/:id/tags (POST):', e);
|
||||
res.status(500).json({ error: e.message, stack: e.stack });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить тег у пользователя
|
||||
router.delete('/:userId/tags/:tagId', async (req, res) => {
|
||||
console.log('DELETE /api/users/:id/tags/:tagId', req.params.userId, req.params.tagId);
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const tagId = req.params.tagId;
|
||||
const query = db.getQuery();
|
||||
const result = await query(
|
||||
'DELETE FROM user_tags WHERE user_id = $1 AND tag_id = $2 RETURNING *',
|
||||
[userId, tagId]
|
||||
);
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Связь не найдена' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка в /api/users/:id/tags/:tagId (DELETE):', e);
|
||||
res.status(500).json({ error: e.message, stack: e.stack });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,62 +3,22 @@ const router = express.Router();
|
||||
const db = require('../db');
|
||||
const logger = require('../utils/logger');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { deleteUserById } = require('../services/userDeleteService');
|
||||
const { broadcastContactsUpdate } = require('../wsHub');
|
||||
// const userService = require('../services/userService');
|
||||
|
||||
console.log('[users.js] ROUTER LOADED');
|
||||
|
||||
router.use((req, res, next) => {
|
||||
console.log('[users.js] ROUTER REQUEST:', req.method, req.originalUrl);
|
||||
next();
|
||||
});
|
||||
|
||||
// Получение списка пользователей
|
||||
// router.get('/', (req, res) => {
|
||||
// res.json({ message: 'Users API endpoint' });
|
||||
// });
|
||||
|
||||
// Получение информации о пользователе
|
||||
router.get('/:address', (req, res) => {
|
||||
const { address } = req.params;
|
||||
res.json({
|
||||
address,
|
||||
message: 'User details endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
// Маршрут для обновления языка пользователя
|
||||
router.post('/update-language', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { language } = req.body;
|
||||
const userId = req.session.userId;
|
||||
const validLanguages = ['ru', 'en'];
|
||||
if (!validLanguages.includes(language)) {
|
||||
return res.status(400).json({ error: 'Неподдерживаемый язык' });
|
||||
}
|
||||
await db.getQuery()('UPDATE users SET preferred_language = $1 WHERE id = $2', [language, userId]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating language:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Маршрут для обновления имени и фамилии пользователя
|
||||
router.post('/update-profile', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { firstName, lastName } = req.body;
|
||||
const userId = req.session.userId;
|
||||
if (firstName && firstName.length > 255) {
|
||||
return res.status(400).json({ error: 'Имя слишком длинное (максимум 255 символов)' });
|
||||
}
|
||||
if (lastName && lastName.length > 255) {
|
||||
return res.status(400).json({ error: 'Фамилия слишком длинная (максимум 255 символов)' });
|
||||
}
|
||||
await db.getQuery()('UPDATE users SET first_name = $1, last_name = $2 WHERE id = $3', [
|
||||
firstName || null,
|
||||
lastName || null,
|
||||
userId,
|
||||
]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating user profile:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Получить профиль текущего пользователя
|
||||
/*
|
||||
router.get('/profile', requireAuth, async (req, res) => {
|
||||
@@ -172,21 +132,51 @@ router.patch('/:id', async (req, res) => {
|
||||
|
||||
// DELETE /api/users/:id — удалить контакт и все связанные данные
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const client = await db.getPool().connect();
|
||||
console.log('[users.js] DELETE HANDLER', req.params.id);
|
||||
const userId = Number(req.params.id);
|
||||
console.log('[ROUTER] Перед вызовом deleteUserById для userId:', userId);
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM user_identities WHERE user_id = $1', [userId]);
|
||||
await client.query('DELETE FROM messages WHERE user_id = $1', [userId]);
|
||||
// Добавьте другие связанные таблицы, если нужно
|
||||
await client.query('DELETE FROM users WHERE id = $1', [userId]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true });
|
||||
const deletedCount = await deleteUserById(userId);
|
||||
console.log('[ROUTER] deleteUserById вернул:', deletedCount);
|
||||
if (deletedCount === 0) {
|
||||
return res.status(404).json({ success: false, deleted: 0, error: 'User not found' });
|
||||
}
|
||||
broadcastContactsUpdate();
|
||||
res.json({ success: true, deleted: deletedCount });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('[DELETE] Ошибка при удалении пользователя:', e);
|
||||
res.status(500).json({ error: 'DB error', details: e.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Получить пользователя по id
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
const userId = req.params.id;
|
||||
try {
|
||||
const query = db.getQuery();
|
||||
// Получаем пользователя
|
||||
const userResult = await query('SELECT id, first_name, last_name, created_at, preferred_language FROM users WHERE id = $1', [userId]);
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
const user = userResult.rows[0];
|
||||
// Получаем идентификаторы
|
||||
const identitiesResult = await query('SELECT provider, provider_id FROM user_identities WHERE user_id = $1', [userId]);
|
||||
const identityMap = {};
|
||||
for (const id of identitiesResult.rows) {
|
||||
identityMap[id.provider] = id.provider_id;
|
||||
}
|
||||
res.json({
|
||||
id: user.id,
|
||||
name: [user.first_name, user.last_name].filter(Boolean).join(' ') || null,
|
||||
email: identityMap.email || null,
|
||||
telegram: identityMap.telegram || null,
|
||||
wallet: identityMap.wallet || null,
|
||||
created_at: user.created_at,
|
||||
preferred_language: user.preferred_language || []
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { ethers } = require('ethers');
|
||||
const session = require('express-session');
|
||||
const { app, nonceStore } = require('./app');
|
||||
const usersRouter = require('./routes/users');
|
||||
const authRouter = require('./routes/auth');
|
||||
const identitiesRouter = require('./routes/identities');
|
||||
const chatRouter = require('./routes/chat');
|
||||
const { pool } = require('./db');
|
||||
const helmet = require('helmet');
|
||||
const { getBot, stopBot } = require('./services/telegramBot');
|
||||
const pgSession = require('connect-pg-simple')(session);
|
||||
const authService = require('./services/auth-service');
|
||||
const http = require('http');
|
||||
const { initWSS } = require('./wsHub');
|
||||
const logger = require('./utils/logger');
|
||||
const EmailBotService = require('./services/emailBot.js');
|
||||
const tablesRouter = require('./routes/tables');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
|
||||
const PORT = process.env.PORT || 8000;
|
||||
|
||||
@@ -28,85 +14,18 @@ console.log('Используемый порт:', process.env.PORT || 8000);
|
||||
async function initServices() {
|
||||
try {
|
||||
console.log('Инициализация сервисов...');
|
||||
|
||||
// Останавливаем предыдущий экземпляр бота
|
||||
console.log('Перед stopBot');
|
||||
await stopBot();
|
||||
console.log('После stopBot, перед getBot');
|
||||
getBot();
|
||||
console.log('После getBot, перед созданием EmailBotService');
|
||||
|
||||
// Добавляем обработку ошибок при запуске бота
|
||||
try {
|
||||
console.log('Пробуем создать экземпляр EmailBotService');
|
||||
|
||||
// Запуск email-бота
|
||||
console.log('Создаём экземпляр EmailBotService');
|
||||
const emailBot = new EmailBotService();
|
||||
await emailBot.start();
|
||||
|
||||
// Добавляем graceful shutdown
|
||||
process.once('SIGINT', async () => {
|
||||
await stopBot();
|
||||
process.exit(0);
|
||||
});
|
||||
process.once('SIGTERM', async () => {
|
||||
await stopBot();
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 409) {
|
||||
logger.warn(
|
||||
'Another instance of Telegram bot is running. This is normal during development with nodemon'
|
||||
);
|
||||
// Просто логируем ошибку и продолжаем работу
|
||||
// Бот будет запущен при следующем перезапуске
|
||||
} else {
|
||||
logger.error('Error launching Telegram bot:', error);
|
||||
console.error('Ошибка при запуске Telegram-бота:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Здесь может быть инициализация ботов, email-сервисов и т.д.
|
||||
// ...
|
||||
console.log('Все сервисы успешно инициализированы');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при инициализации сервисов:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Настройка сессий
|
||||
app.use(
|
||||
session({
|
||||
store: new pgSession({
|
||||
pool: pool,
|
||||
tableName: 'session',
|
||||
}),
|
||||
secret: process.env.SESSION_SECRET || 'hb3atoken',
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 дней
|
||||
},
|
||||
})
|
||||
);
|
||||
const server = http.createServer(app);
|
||||
initWSS(server);
|
||||
|
||||
// Маршруты API
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/identities', identitiesRouter);
|
||||
app.use('/api/chat', chatRouter);
|
||||
app.use('/api/tables', tablesRouter);
|
||||
|
||||
// Эндпоинт для проверки состояния сервера
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Для отладки
|
||||
// const host = app.get('host');
|
||||
// console.log('host:', host);
|
||||
app.listen(PORT, async () => {
|
||||
server.listen(PORT, async () => {
|
||||
try {
|
||||
await initServices();
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
@@ -125,6 +44,4 @@ process.on('uncaughtException', (err) => {
|
||||
logger.error('Uncaught Exception:', err);
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
module.exports = app;
|
||||
|
||||
26
backend/services/userDeleteService.js
Normal file
26
backend/services/userDeleteService.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const db = require('../db');
|
||||
|
||||
async function deleteUserById(userId) {
|
||||
console.log('[DELETE] Вызван deleteUserById для userId:', userId);
|
||||
const query = db.getQuery();
|
||||
try {
|
||||
await query('BEGIN');
|
||||
console.log('[DELETE] Начинаем удаление user_identities для userId:', userId);
|
||||
const resIdentities = await query('DELETE FROM user_identities WHERE user_id = $1', [userId]);
|
||||
console.log('[DELETE] Удалено user_identities:', resIdentities.rowCount);
|
||||
console.log('[DELETE] Начинаем удаление messages для userId:', userId);
|
||||
const resMessages = await query('DELETE FROM messages WHERE user_id = $1', [userId]);
|
||||
console.log('[DELETE] Удалено messages:', resMessages.rowCount);
|
||||
console.log('[DELETE] Начинаем удаление пользователя из users:', userId);
|
||||
const result = await query('DELETE FROM users WHERE id = $1 RETURNING *', [userId]);
|
||||
console.log('[DELETE] Результат удаления пользователя:', result.rowCount, result.rows);
|
||||
await query('COMMIT');
|
||||
return result.rowCount;
|
||||
} catch (e) {
|
||||
await query('ROLLBACK');
|
||||
console.error('[DELETE] Ошибка при удалении пользователя:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { deleteUserById };
|
||||
22
backend/wsHub.js
Normal file
22
backend/wsHub.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const WebSocket = require('ws');
|
||||
|
||||
let wss = null;
|
||||
const wsClients = new Set();
|
||||
|
||||
function initWSS(server) {
|
||||
wss = new WebSocket.Server({ server });
|
||||
wss.on('connection', (ws) => {
|
||||
wsClients.add(ws);
|
||||
ws.on('close', () => wsClients.delete(ws));
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastContactsUpdate() {
|
||||
for (const ws of wsClients) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'contacts-updated' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initWSS, broadcastContactsUpdate };
|
||||
@@ -20,6 +20,7 @@
|
||||
"buffer": "^6.0.3",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"element-plus": "^2.9.11",
|
||||
"ethers": "6.13.5",
|
||||
"marked": "^15.0.7",
|
||||
"siwe": "^2.1.4",
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
<template>
|
||||
<div class="contact-details-modal">
|
||||
<div class="contact-details-header">
|
||||
<h2>Детали контакта</h2>
|
||||
<button class="close-btn" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="contact-info-block">
|
||||
<div>
|
||||
<strong>Имя:</strong>
|
||||
<input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
|
||||
<span v-if="isSavingName" class="saving">Сохранение...</span>
|
||||
</div>
|
||||
<div><strong>Email:</strong> {{ contact.email || '-' }}</div>
|
||||
<div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div>
|
||||
<div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div>
|
||||
<div>
|
||||
<strong>Язык:</strong>
|
||||
<div class="multi-select">
|
||||
<div class="selected-langs">
|
||||
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
|
||||
{{ getLanguageLabel(lang) }}
|
||||
<span class="remove-tag" @click="removeLanguage(lang)">×</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="langInput"
|
||||
@focus="showLangDropdown = true"
|
||||
@input="showLangDropdown = true"
|
||||
@keydown.enter.prevent="addLanguageFromInput"
|
||||
class="lang-input"
|
||||
placeholder="Добавить язык..."
|
||||
/>
|
||||
</div>
|
||||
<ul v-if="showLangDropdown" class="lang-dropdown">
|
||||
<li
|
||||
v-for="lang in filteredLanguages"
|
||||
:key="lang.value"
|
||||
@mousedown.prevent="addLanguage(lang.value)"
|
||||
:class="{ selected: selectedLanguages.includes(lang.value) }"
|
||||
>
|
||||
{{ lang.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span v-if="isSavingLangs" class="saving">Сохранение...</span>
|
||||
</div>
|
||||
<div><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</div>
|
||||
<div><strong>Дата последнего сообщения:</strong> {{ formatDate(lastMessageDate) }}</div>
|
||||
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
|
||||
</div>
|
||||
<div class="messages-block">
|
||||
<h3>История сообщений</h3>
|
||||
<div v-if="isLoading" class="loading">Загрузка...</div>
|
||||
<div v-else-if="messages.length === 0" class="empty">Нет сообщений</div>
|
||||
<div v-else class="messages-list">
|
||||
<Message v-for="msg in messages" :key="msg.id" :message="msg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
import messagesService from '../services/messagesService';
|
||||
import contactsService from '../services/contactsService';
|
||||
const props = defineProps({
|
||||
contact: { type: Object, required: true }
|
||||
});
|
||||
const emit = defineEmits(['close', 'contact-deleted', 'contact-updated']);
|
||||
const messages = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const lastMessageDate = ref(null);
|
||||
const editableName = ref(props.contact.name || '');
|
||||
const isSavingName = ref(false);
|
||||
const isSavingLangs = ref(false);
|
||||
|
||||
// --- Языки ---
|
||||
const allLanguages = [
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'other', label: 'Другое' }
|
||||
];
|
||||
const selectedLanguages = ref(Array.isArray(props.contact.preferred_language) ? props.contact.preferred_language : (props.contact.preferred_language ? [props.contact.preferred_language] : []));
|
||||
const langInput = ref('');
|
||||
const showLangDropdown = ref(false);
|
||||
const filteredLanguages = computed(() => {
|
||||
const input = langInput.value.toLowerCase();
|
||||
return allLanguages.filter(
|
||||
l => !selectedLanguages.value.includes(l.value) && l.label.toLowerCase().includes(input)
|
||||
);
|
||||
});
|
||||
function getLanguageLabel(val) {
|
||||
const found = allLanguages.find(l => l.value === val);
|
||||
return found ? found.label : val;
|
||||
}
|
||||
function addLanguage(lang) {
|
||||
if (!selectedLanguages.value.includes(lang)) {
|
||||
selectedLanguages.value.push(lang);
|
||||
saveLanguages();
|
||||
}
|
||||
langInput.value = '';
|
||||
showLangDropdown.value = false;
|
||||
}
|
||||
function addLanguageFromInput() {
|
||||
const found = filteredLanguages.value[0];
|
||||
if (found) addLanguage(found.value);
|
||||
}
|
||||
function removeLanguage(lang) {
|
||||
selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang);
|
||||
saveLanguages();
|
||||
}
|
||||
function saveLanguages() {
|
||||
isSavingLangs.value = true;
|
||||
contactsService.updateContact(props.contact.id, { language: selectedLanguages.value })
|
||||
.then(() => emit('contact-updated'))
|
||||
.finally(() => { isSavingLangs.value = false; });
|
||||
}
|
||||
|
||||
// --- Имя ---
|
||||
function saveName() {
|
||||
if (editableName.value !== props.contact.name) {
|
||||
isSavingName.value = true;
|
||||
contactsService.updateContact(props.contact.id, { name: editableName.value })
|
||||
.then(() => emit('contact-updated'))
|
||||
.finally(() => { isSavingName.value = false; });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Удаление ---
|
||||
function deleteContact() {
|
||||
if (confirm('Удалить контакт?')) {
|
||||
contactsService.deleteContact(props.contact.id)
|
||||
.then(() => emit('contact-deleted', props.contact.id))
|
||||
.catch(() => alert('Ошибка удаления контакта'));
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
async function loadMessages() {
|
||||
if (!props.contact || !props.contact.id) return;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
messages.value = await messagesService.getMessagesByUserId(props.contact.id);
|
||||
if (messages.value.length > 0) {
|
||||
lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
|
||||
} else {
|
||||
lastMessageDate.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
messages.value = [];
|
||||
lastMessageDate.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(loadMessages);
|
||||
watch(() => props.contact, loadMessages);
|
||||
watch(() => props.contact.preferred_language, (newVal) => {
|
||||
selectedLanguages.value = Array.isArray(newVal) ? newVal : (newVal ? [newVal] : []);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-details-modal {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
|
||||
padding: 32px 24px 24px 24px;
|
||||
max-width: 700px;
|
||||
margin: 40px auto;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.contact-details-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: #bbb;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
.contact-info-block {
|
||||
margin-bottom: 18px;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.edit-input {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 1rem;
|
||||
margin-left: 8px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.saving {
|
||||
color: #17a2b8;
|
||||
font-size: 0.95rem;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.delete-btn {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 7px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 18px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background: #b52a37;
|
||||
}
|
||||
.multi-select {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: 220px;
|
||||
}
|
||||
.selected-langs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.lang-tag {
|
||||
background: #e6f7ff;
|
||||
color: #138496;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.97rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.remove-tag {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
font-weight: bold;
|
||||
}
|
||||
.remove-tag:hover {
|
||||
color: #dc3545;
|
||||
}
|
||||
.lang-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
min-width: 80px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.lang-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
z-index: 10;
|
||||
min-width: 180px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.lang-dropdown li {
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.lang-dropdown li.selected {
|
||||
background: #e6f7ff;
|
||||
color: #138496;
|
||||
}
|
||||
.lang-dropdown li:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.messages-block {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.messages-list {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.loading, .empty {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -32,17 +32,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { defineProps } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, required: true }
|
||||
});
|
||||
const emit = defineEmits(['show-details']);
|
||||
const router = useRouter();
|
||||
function formatDate(date) {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
function showDetails(contact) {
|
||||
emit('show-details', contact);
|
||||
router.push({ name: 'contact-details', params: { id: contact.id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@ import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import axios from 'axios';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
|
||||
// Настройка axios
|
||||
// В Docker контейнере localhost:8000 не работает, поэтому используем явное значение
|
||||
const apiUrl =
|
||||
window.location.hostname === 'localhost' ? 'http://localhost:8000' : import.meta.env.VITE_API_URL;
|
||||
window.location.hostname === 'localhost'
|
||||
? 'http://localhost:8000'
|
||||
: 'http://dapp-backend:8000'; // имя контейнера backend
|
||||
axios.defaults.baseURL = apiUrl;
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
@@ -17,6 +21,7 @@ axios.defaults.withCredentials = true;
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(router);
|
||||
app.use(ElementPlus);
|
||||
|
||||
// Не используем заглушки, так как сервер работает
|
||||
// if (import.meta.env.DEV) {
|
||||
|
||||
@@ -92,6 +92,18 @@ const routes = [
|
||||
component: () => import('../views/tables/DeleteTableView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/contacts/:id',
|
||||
name: 'contact-details',
|
||||
component: () => import('../views/contacts/ContactDetailsView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/contacts/:id/delete',
|
||||
name: 'contact-delete-confirm',
|
||||
component: () => import('../views/contacts/ContactDeleteConfirm.vue'),
|
||||
props: true
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -13,7 +13,20 @@ export default {
|
||||
return res.data;
|
||||
},
|
||||
async deleteContact(id) {
|
||||
try {
|
||||
const res = await api.delete(`/api/users/${id}`);
|
||||
console.log('Ответ на удаление контакта:', res.status, res.data);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении контакта:', err.response?.status, err.response?.data, err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
async getContactById(id) {
|
||||
const res = await api.get(`/api/users/${id}`);
|
||||
if (res.data && res.data.id) {
|
||||
return res.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -21,7 +21,6 @@
|
||||
</button>
|
||||
</div>
|
||||
<ContactTable v-if="showContacts" :contacts="contacts" @close="showContacts = false" @show-details="openContactDetails" />
|
||||
<ContactDetails v-if="showContactDetails" :contact="selectedContact" @close="showContactDetails = false" @contact-deleted="onContactDeleted" />
|
||||
<div class="crm-tables-block">
|
||||
<h2>Таблицы</h2>
|
||||
<button class="btn btn-info" @click="goToTables">
|
||||
@@ -43,7 +42,6 @@ import dleService from '../services/dleService';
|
||||
import ContactTable from '../components/ContactTable.vue';
|
||||
import contactsService from '../services/contactsService.js';
|
||||
import DleManagement from '../components/DleManagement.vue';
|
||||
import ContactDetails from '../components/ContactDetails.vue';
|
||||
|
||||
// Определяем props
|
||||
const props = defineProps({
|
||||
@@ -70,6 +68,33 @@ const selectedContact = ref(null);
|
||||
const showContactDetails = ref(false);
|
||||
const showTables = ref(false);
|
||||
|
||||
let ws = null;
|
||||
|
||||
function connectWebSocket() {
|
||||
if (ws) ws.close();
|
||||
ws = new WebSocket('ws://localhost:8000');
|
||||
ws.onopen = () => {
|
||||
console.log('[CRM] WebSocket соединение установлено');
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'contacts-updated') {
|
||||
console.log('[CRM] Получено событие contacts-updated, обновляем контакты');
|
||||
loadContacts();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CRM] Ошибка обработки сообщения WebSocket:', e);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
console.log('[CRM] WebSocket соединение закрыто');
|
||||
};
|
||||
ws.onerror = (e) => {
|
||||
console.error('[CRM] WebSocket ошибка:', e);
|
||||
};
|
||||
}
|
||||
|
||||
// Функция для перехода на домашнюю страницу и открытия боковой панели
|
||||
const goToHomeAndShowSidebar = () => {
|
||||
setToStorage('showWalletSidebar', true);
|
||||
@@ -122,6 +147,8 @@ onMounted(() => {
|
||||
|
||||
// Подписка на события авторизации
|
||||
unsubscribe = eventBus.on('auth-state-changed', handleAuthEvent);
|
||||
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -129,6 +156,8 @@ onBeforeUnmount(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
if (ws) ws.close();
|
||||
});
|
||||
|
||||
async function loadContacts() {
|
||||
|
||||
128
frontend/src/views/contacts/ContactDeleteConfirm.vue
Normal file
128
frontend/src/views/contacts/ContactDeleteConfirm.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="delete-confirm-page">
|
||||
<h2>Подтверждение удаления контакта</h2>
|
||||
<div v-if="isLoading">Загрузка...</div>
|
||||
<div v-else-if="!contact">Контакт не найден</div>
|
||||
<div v-else class="contact-info">
|
||||
<p><strong>Имя:</strong> {{ contact.name || '-' }}</p>
|
||||
<p><strong>Email:</strong> {{ contact.email || '-' }}</p>
|
||||
<p><strong>Telegram:</strong> {{ contact.telegram || '-' }}</p>
|
||||
<p><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</p>
|
||||
<p><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="delete-btn" @click="deleteContact" :disabled="isDeleting">Удалить</button>
|
||||
<button class="cancel-btn" @click="cancelDelete" :disabled="isDeleting">Отменить</button>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import contactsService from '../../services/contactsService.js';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const contact = ref(null);
|
||||
const isLoading = ref(true);
|
||||
const isDeleting = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
|
||||
async function loadContact() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
contact.value = await contactsService.getContactById(route.params.id);
|
||||
} catch (e) {
|
||||
contact.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact() {
|
||||
if (!contact.value) return;
|
||||
isDeleting.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await contactsService.deleteContact(contact.value.id);
|
||||
router.push({ name: 'crm' });
|
||||
} catch (e) {
|
||||
error.value = 'Ошибка при удалении контакта';
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
router.push({ name: 'contact-details', params: { id: route.params.id } });
|
||||
}
|
||||
|
||||
onMounted(loadContact);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.delete-confirm-page {
|
||||
max-width: 500px;
|
||||
margin: 60px auto;
|
||||
padding: 32px 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.contact-info {
|
||||
margin-top: 18px;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.confirm-actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
.delete-btn {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 22px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.delete-btn:disabled {
|
||||
background: #e6a6ad;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.delete-btn:hover:not(:disabled) {
|
||||
background: #b52a37;
|
||||
}
|
||||
.cancel-btn {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 8px 22px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.cancel-btn:disabled {
|
||||
background: #eee;
|
||||
color: #aaa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cancel-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.error {
|
||||
color: #dc3545;
|
||||
margin-top: 18px;
|
||||
}
|
||||
</style>
|
||||
481
frontend/src/views/contacts/ContactDetailsView.vue
Normal file
481
frontend/src/views/contacts/ContactDetailsView.vue
Normal file
@@ -0,0 +1,481 @@
|
||||
<template>
|
||||
<div class="contact-details-page">
|
||||
<Header :isSidebarOpen="isSidebarOpen" @toggle-sidebar="toggleSidebar" />
|
||||
<div v-if="isLoading">Загрузка...</div>
|
||||
<div v-else-if="!contact">Контакт не найден</div>
|
||||
<div v-else class="contact-details-content">
|
||||
<div class="contact-details-header">
|
||||
<h2>Детали контакта</h2>
|
||||
<router-link class="back-btn" :to="{ name: 'crm' }">← Назад к списку</router-link>
|
||||
</div>
|
||||
<div class="contact-info-block">
|
||||
<div>
|
||||
<strong>Имя:</strong>
|
||||
<input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
|
||||
<span v-if="isSavingName" class="saving">Сохранение...</span>
|
||||
</div>
|
||||
<div><strong>Email:</strong> {{ contact.email || '-' }}</div>
|
||||
<div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div>
|
||||
<div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div>
|
||||
<div>
|
||||
<strong>Язык:</strong>
|
||||
<div class="multi-select">
|
||||
<div class="selected-langs">
|
||||
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
|
||||
{{ getLanguageLabel(lang) }}
|
||||
<span class="remove-tag" @click="removeLanguage(lang)">×</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="langInput"
|
||||
@focus="showLangDropdown = true"
|
||||
@input="showLangDropdown = true"
|
||||
@keydown.enter.prevent="addLanguageFromInput"
|
||||
class="lang-input"
|
||||
placeholder="Добавить язык..."
|
||||
/>
|
||||
</div>
|
||||
<ul v-if="showLangDropdown" class="lang-dropdown">
|
||||
<li
|
||||
v-for="lang in filteredLanguages"
|
||||
:key="lang.value"
|
||||
@mousedown.prevent="addLanguage(lang.value)"
|
||||
:class="{ selected: selectedLanguages.includes(lang.value) }"
|
||||
>
|
||||
{{ lang.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span v-if="isSavingLangs" class="saving">Сохранение...</span>
|
||||
</div>
|
||||
<div><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</div>
|
||||
<div><strong>Дата последнего сообщения:</strong> {{ formatDate(lastMessageDate) }}</div>
|
||||
<div class="user-tags-block">
|
||||
<strong>Теги пользователя:</strong>
|
||||
<span v-for="tag in userTags" :key="tag.id" class="user-tag">
|
||||
{{ tag.name }}
|
||||
<span class="remove-tag" @click="removeUserTag(tag.id)">×</span>
|
||||
</span>
|
||||
<button class="add-tag-btn" @click="openTagModal">Добавить тег</button>
|
||||
</div>
|
||||
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
|
||||
</div>
|
||||
<div class="messages-block">
|
||||
<h3>История сообщений</h3>
|
||||
<div v-if="isLoadingMessages" class="loading">Загрузка...</div>
|
||||
<div v-else-if="messages.length === 0" class="empty">Нет сообщений</div>
|
||||
<div v-else class="messages-list">
|
||||
<Message v-for="msg in messages" :key="msg.id" :message="msg" />
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="showTagModal" title="Добавить тег пользователю">
|
||||
<div v-if="allTags.length">
|
||||
<el-select
|
||||
v-model="selectedTags"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="Выберите теги"
|
||||
@change="addTagsToUser"
|
||||
>
|
||||
<el-option
|
||||
v-for="tag in allTags"
|
||||
:key="tag.id"
|
||||
:label="tag.name"
|
||||
:value="tag.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div style="margin-top: 1em; color: #888; font-size: 0.95em;">
|
||||
<strong>Существующие теги:</strong>
|
||||
<span v-for="tag in allTags" :key="'list-' + tag.id" style="margin-right: 0.7em;">
|
||||
{{ tag.name }}<span v-if="tag.description"> ({{ tag.description }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 1em;">
|
||||
<el-input v-model="newTagName" placeholder="Новый тег" />
|
||||
<el-input v-model="newTagDescription" placeholder="Описание" />
|
||||
<el-button type="primary" @click="createTag">Создать тег</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Header from '../../components/Header.vue';
|
||||
import Message from '../../components/Message.vue';
|
||||
import contactsService from '../../services/contactsService.js';
|
||||
import messagesService from '../../services/messagesService.js';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userId = computed(() => route.params.id);
|
||||
const contact = ref(null);
|
||||
const isLoading = ref(true);
|
||||
const isLoadingMessages = ref(false);
|
||||
const lastMessageDate = ref(null);
|
||||
const editableName = ref('');
|
||||
const isSavingName = ref(false);
|
||||
const isSavingLangs = ref(false);
|
||||
const userTags = ref([]);
|
||||
const allTags = ref([]);
|
||||
const selectedTags = ref([]);
|
||||
const showTagModal = ref(false);
|
||||
const newTagName = ref('');
|
||||
const newTagDescription = ref('');
|
||||
const messages = ref([]);
|
||||
const isSidebarOpen = ref(false);
|
||||
|
||||
function toggleSidebar() {
|
||||
isSidebarOpen.value = !isSidebarOpen.value;
|
||||
}
|
||||
|
||||
// --- Языки ---
|
||||
const allLanguages = [
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'other', label: 'Другое' }
|
||||
];
|
||||
const selectedLanguages = ref([]);
|
||||
const langInput = ref('');
|
||||
const showLangDropdown = ref(false);
|
||||
const filteredLanguages = computed(() => {
|
||||
const input = langInput.value.toLowerCase();
|
||||
return allLanguages.filter(
|
||||
l => !selectedLanguages.value.includes(l.value) && l.label.toLowerCase().includes(input)
|
||||
);
|
||||
});
|
||||
function getLanguageLabel(val) {
|
||||
const found = allLanguages.find(l => l.value === val);
|
||||
return found ? found.label : val;
|
||||
}
|
||||
function addLanguage(lang) {
|
||||
if (!selectedLanguages.value.includes(lang)) {
|
||||
selectedLanguages.value.push(lang);
|
||||
saveLanguages();
|
||||
}
|
||||
langInput.value = '';
|
||||
showLangDropdown.value = false;
|
||||
}
|
||||
function addLanguageFromInput() {
|
||||
const found = filteredLanguages.value[0];
|
||||
if (found) addLanguage(found.value);
|
||||
}
|
||||
function removeLanguage(lang) {
|
||||
selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang);
|
||||
saveLanguages();
|
||||
}
|
||||
function saveLanguages() {
|
||||
isSavingLangs.value = true;
|
||||
contactsService.updateContact(contact.value.id, { language: selectedLanguages.value })
|
||||
.then(() => reloadContact())
|
||||
.finally(() => { isSavingLangs.value = false; });
|
||||
}
|
||||
|
||||
// --- Имя ---
|
||||
function saveName() {
|
||||
if (editableName.value !== contact.value.name) {
|
||||
isSavingName.value = true;
|
||||
contactsService.updateContact(contact.value.id, { name: editableName.value })
|
||||
.then(() => reloadContact())
|
||||
.finally(() => { isSavingName.value = false; });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Удаление ---
|
||||
function deleteContact() {
|
||||
router.push({ name: 'contact-delete-confirm', params: { id: contact.value.id } });
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
async function loadMessages() {
|
||||
if (!contact.value || !contact.value.id) return;
|
||||
isLoadingMessages.value = true;
|
||||
try {
|
||||
messages.value = await messagesService.getMessagesByUserId(contact.value.id);
|
||||
if (messages.value.length > 0) {
|
||||
lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
|
||||
} else {
|
||||
lastMessageDate.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
messages.value = [];
|
||||
lastMessageDate.value = null;
|
||||
} finally {
|
||||
isLoadingMessages.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserTags() {
|
||||
if (!contact.value) return;
|
||||
const res = await fetch(`/api/users/${contact.value.id}/tags`);
|
||||
userTags.value = await res.json();
|
||||
selectedTags.value = userTags.value.map(t => t.id);
|
||||
}
|
||||
|
||||
async function openTagModal() {
|
||||
await fetch('/api/tags/init', { method: 'POST' })
|
||||
const res = await fetch('/api/tags')
|
||||
allTags.value = await res.json()
|
||||
await loadUserTags()
|
||||
showTagModal.value = true
|
||||
}
|
||||
|
||||
async function createTag() {
|
||||
const res = await fetch('/api/tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newTagName.value, description: newTagDescription.value })
|
||||
});
|
||||
const newTag = await res.json();
|
||||
await fetch(`/api/users/${contact.value.id}/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tag_id: newTag.id })
|
||||
});
|
||||
const tagsRes = await fetch('/api/tags');
|
||||
allTags.value = await tagsRes.json();
|
||||
await loadUserTags();
|
||||
newTagName.value = '';
|
||||
newTagDescription.value = '';
|
||||
}
|
||||
|
||||
async function addTagsToUser() {
|
||||
for (const tagId of selectedTags.value) {
|
||||
await fetch(`/api/users/${contact.value.id}/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tag_id: tagId })
|
||||
})
|
||||
}
|
||||
await loadUserTags()
|
||||
}
|
||||
|
||||
async function removeUserTag(tagId) {
|
||||
await fetch(`/api/users/${contact.value.id}/tags/${tagId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await loadUserTags();
|
||||
}
|
||||
|
||||
async function reloadContact() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
contact.value = await contactsService.getContactById(userId.value);
|
||||
editableName.value = contact.value?.name || '';
|
||||
selectedLanguages.value = Array.isArray(contact.value?.preferred_language)
|
||||
? contact.value.preferred_language
|
||||
: (contact.value?.preferred_language ? [contact.value.preferred_language] : []);
|
||||
} catch (e) {
|
||||
contact.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await reloadContact();
|
||||
await loadMessages();
|
||||
await loadUserTags();
|
||||
});
|
||||
watch(userId, async () => {
|
||||
await reloadContact();
|
||||
await loadMessages();
|
||||
await loadUserTags();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-details-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
.contact-details-content {
|
||||
padding: 32px 24px 24px 24px;
|
||||
max-width: 700px;
|
||||
margin: 40px auto;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.contact-details-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #17a2b8;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
.back-btn:hover {
|
||||
color: #138496;
|
||||
}
|
||||
.contact-info-block {
|
||||
margin-bottom: 18px;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.edit-input {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 1rem;
|
||||
margin-left: 8px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.saving {
|
||||
color: #17a2b8;
|
||||
font-size: 0.95rem;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.delete-btn {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 7px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 18px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background: #b52a37;
|
||||
}
|
||||
.multi-select {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: 220px;
|
||||
}
|
||||
.selected-langs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.lang-tag {
|
||||
background: #e6f7ff;
|
||||
color: #138496;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.97rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.remove-tag {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
font-weight: bold;
|
||||
}
|
||||
.remove-tag:hover {
|
||||
color: #dc3545;
|
||||
}
|
||||
.lang-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
min-width: 80px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.lang-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
z-index: 10;
|
||||
min-width: 180px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.lang-dropdown li {
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.lang-dropdown li.selected {
|
||||
background: #e6f7ff;
|
||||
color: #138496;
|
||||
}
|
||||
.lang-dropdown li:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.messages-block {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.messages-list {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.loading, .empty {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.user-tags-block {
|
||||
margin: 1em 0;
|
||||
}
|
||||
.user-tag {
|
||||
display: inline-block;
|
||||
background: #e0f7fa;
|
||||
color: #00796b;
|
||||
border-radius: 6px;
|
||||
padding: 0.2em 0.7em;
|
||||
margin-right: 0.5em;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.add-tag-btn {
|
||||
margin-left: 1em;
|
||||
background: #2ecc40;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.3em 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.add-tag-btn:hover {
|
||||
background: #27ae38;
|
||||
}
|
||||
</style>
|
||||
@@ -38,10 +38,10 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '127.0.0.1',
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
target: 'http://dapp-backend:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
credentials: true,
|
||||
|
||||
@@ -46,6 +46,16 @@
|
||||
resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz#2cbcf822bf3764c9658c4d2e568bd0c0cb748016"
|
||||
integrity sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw==
|
||||
|
||||
"@ctrl/tinycolor@^3.4.1":
|
||||
version "3.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31"
|
||||
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
|
||||
|
||||
"@element-plus/icons-vue@^2.3.1":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz#1f635ad5fdd5c85ed936481525570e82b5a8307a"
|
||||
integrity sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==
|
||||
|
||||
"@esbuild/aix-ppc64@0.25.4":
|
||||
version "0.25.4"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz#830d6476cbbca0c005136af07303646b419f1162"
|
||||
@@ -237,6 +247,26 @@
|
||||
"@eslint/core" "^0.13.0"
|
||||
levn "^0.4.1"
|
||||
|
||||
"@floating-ui/core@^1.7.1":
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.1.tgz#1abc6b157d4a936174f9dbd078278c3a81c8bc6b"
|
||||
integrity sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.9"
|
||||
|
||||
"@floating-ui/dom@^1.0.1":
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.1.tgz#76a4e3cbf7a08edf40c34711cf64e0cc8053d912"
|
||||
integrity sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.7.1"
|
||||
"@floating-ui/utils" "^0.2.9"
|
||||
|
||||
"@floating-ui/utils@^0.2.9":
|
||||
version "0.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429"
|
||||
integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==
|
||||
|
||||
"@humanfs/core@^0.19.1":
|
||||
version "0.19.1"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77"
|
||||
@@ -350,6 +380,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.4.tgz#d897170a2b0ba51f78a099edccd968f7b103387c"
|
||||
integrity sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==
|
||||
|
||||
"@popperjs/core@npm:@sxzz/popperjs-es@^2.11.7":
|
||||
version "2.11.7"
|
||||
resolved "https://registry.yarnpkg.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz#a7f69e3665d3da9b115f9e71671dae1b97e13671"
|
||||
integrity sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==
|
||||
|
||||
"@rollup/plugin-inject@^5.0.1":
|
||||
version "5.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz#616f3a73fe075765f91c5bec90176608bed277a3"
|
||||
@@ -513,6 +548,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
|
||||
"@types/lodash-es@^4.17.6":
|
||||
version "4.17.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"
|
||||
integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash@*", "@types/lodash@^4.14.182":
|
||||
version "4.17.17"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.17.tgz#fb85a04f47e9e4da888384feead0de05f7070355"
|
||||
integrity sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==
|
||||
|
||||
"@types/minimist@^1.2.0":
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
|
||||
@@ -540,6 +587,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
|
||||
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
|
||||
|
||||
"@types/web-bluetooth@^0.0.16":
|
||||
version "0.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz#1d12873a8e49567371f2a75fe3e7f7edca6662d8"
|
||||
integrity sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==
|
||||
|
||||
"@vitejs/plugin-vue@^5.2.1":
|
||||
version "5.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz#9e8a512eb174bfc2a333ba959bbf9de428d89ad8"
|
||||
@@ -638,6 +690,28 @@
|
||||
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f"
|
||||
integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==
|
||||
|
||||
"@vueuse/core@^9.1.0":
|
||||
version "9.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.13.0.tgz#2f69e66d1905c1e4eebc249a01759cf88ea00cf4"
|
||||
integrity sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==
|
||||
dependencies:
|
||||
"@types/web-bluetooth" "^0.0.16"
|
||||
"@vueuse/metadata" "9.13.0"
|
||||
"@vueuse/shared" "9.13.0"
|
||||
vue-demi "*"
|
||||
|
||||
"@vueuse/metadata@9.13.0":
|
||||
version "9.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.13.0.tgz#bc25a6cdad1b1a93c36ce30191124da6520539ff"
|
||||
integrity sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==
|
||||
|
||||
"@vueuse/shared@9.13.0":
|
||||
version "9.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-9.13.0.tgz#089ff4cc4e2e7a4015e57a8f32e4b39d096353b9"
|
||||
integrity sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==
|
||||
dependencies:
|
||||
vue-demi "*"
|
||||
|
||||
accepts@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895"
|
||||
@@ -718,6 +792,11 @@ astral-regex@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
|
||||
integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
|
||||
|
||||
async-validator@^4.2.5:
|
||||
version "4.2.5"
|
||||
resolved "https://registry.yarnpkg.com/async-validator/-/async-validator-4.2.5.tgz#c96ea3332a521699d0afaaceed510a54656c6339"
|
||||
integrity sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==
|
||||
|
||||
asynckit@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
@@ -947,6 +1026,11 @@ csstype@^3.1.3:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
||||
dayjs@^1.11.13:
|
||||
version "1.11.13"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c"
|
||||
integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
|
||||
|
||||
debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a"
|
||||
@@ -1040,6 +1124,27 @@ ee-first@1.1.1:
|
||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
|
||||
|
||||
element-plus@^2.9.11:
|
||||
version "2.9.11"
|
||||
resolved "https://registry.yarnpkg.com/element-plus/-/element-plus-2.9.11.tgz#c939a8d945330f596b7a35aae0e501ea170874a2"
|
||||
integrity sha512-x4L/6YC8de4JtuE3vpaEugJdQIeHQaHtIYKyk67IeF6dTIiVax45aX4nWOygnh+xX+0gTvL6xO+9BZhPA3G82w==
|
||||
dependencies:
|
||||
"@ctrl/tinycolor" "^3.4.1"
|
||||
"@element-plus/icons-vue" "^2.3.1"
|
||||
"@floating-ui/dom" "^1.0.1"
|
||||
"@popperjs/core" "npm:@sxzz/popperjs-es@^2.11.7"
|
||||
"@types/lodash" "^4.14.182"
|
||||
"@types/lodash-es" "^4.17.6"
|
||||
"@vueuse/core" "^9.1.0"
|
||||
async-validator "^4.2.5"
|
||||
dayjs "^1.11.13"
|
||||
escape-html "^1.0.3"
|
||||
lodash "^4.17.21"
|
||||
lodash-es "^4.17.21"
|
||||
lodash-unified "^1.0.2"
|
||||
memoize-one "^6.0.0"
|
||||
normalize-wheel-es "^1.2.0"
|
||||
|
||||
emoji-regex@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
@@ -1885,6 +1990,16 @@ locate-path@^6.0.0:
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
|
||||
lodash-unified@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/lodash-unified/-/lodash-unified-1.0.3.tgz#80b1eac10ed2eb02ed189f08614a29c27d07c894"
|
||||
integrity sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==
|
||||
|
||||
lodash.merge@^4.6.2:
|
||||
version "4.6.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||
@@ -1944,6 +2059,11 @@ media-typer@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561"
|
||||
integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==
|
||||
|
||||
memoize-one@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
|
||||
integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==
|
||||
|
||||
meow@^9.0.0:
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364"
|
||||
@@ -2070,6 +2190,11 @@ normalize-path@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
||||
|
||||
normalize-wheel-es@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz#0fa2593d619f7245a541652619105ab076acf09e"
|
||||
integrity sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==
|
||||
|
||||
nth-check@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
|
||||
@@ -3004,6 +3129,11 @@ vite@^6.2.3:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
vue-demi@*:
|
||||
version "0.14.10"
|
||||
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
|
||||
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
|
||||
|
||||
vue-eslint-parser@^9.4.3:
|
||||
version "9.4.3"
|
||||
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz#9b04b22c71401f1e8bca9be7c3e3416a4bde76a8"
|
||||
|
||||
Reference in New Issue
Block a user