ваше сообщение коммита

This commit is contained in:
2025-07-04 16:48:56 +03:00
parent 3adb469a37
commit 6182c2ced1
13 changed files with 2364 additions and 27 deletions

View File

@@ -0,0 +1,3 @@
-- Добавление поля is_blocked и blocked_at для блокировки пользователя
ALTER TABLE users ADD COLUMN is_blocked boolean NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN blocked_at timestamp;

File diff suppressed because it is too large Load Diff

View File

@@ -2030,3 +2030,89 @@
{"level":"error","message":"Unhandled Rejection: Cannot use a pool after calling end on the pool","stack":"Error: Cannot use a pool after calling end on the pool\n at /app/node_modules/pg-pool/index.js:45:11\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async /app/app.js:103:20","timestamp":"2025-07-03T21:56:00.872Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-03T22:06:50.565Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-03T22:06:50.566Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.815Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.816Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.817Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.818Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:33:37.818Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:21.027Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:21.028Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:21.028Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:21.028Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:41:36.420Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.267Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.269Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.269Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.269Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:39.269Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:55.822Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:45:55.824Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.103Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.103Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.103Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:49:12.105Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.058Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.060Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.060Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.060Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.061Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.061Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:51:42.061Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.490Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.492Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.492Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.492Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.493Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.493Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.493Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.493Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:54:58.494Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:55:28.951Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T12:55:45.515Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.870Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:17:06.871Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.158Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.158Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.158Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.158Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.159Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:41.159Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T13:39:57.234Z"}
{"level":"error","message":"[EmailBot] IMAP connection error: Timed out while authenticating with server","timestamp":"2025-07-04T13:40:16.048Z"}
{"level":"error","message":"Uncaught Exception: Not authenticated","stack":"Error: Not authenticated\n at Connection.openBox (/app/node_modules/imap/lib/Connection.js:409:11)\n at Connection.<anonymous> (/app/services/emailBot.js:105:19)\n at Object.onceWrapper (node:events:638:28)\n at Connection.emit (node:events:536:35)\n at Connection.<anonymous> (/app/node_modules/imap/lib/Connection.js:1623:12)\n at Connection._resTagged (/app/node_modules/imap/lib/Connection.js:1535:22)\n at Parser.<anonymous> (/app/node_modules/imap/lib/Connection.js:194:10)\n at Parser.emit (node:events:524:28)\n at Parser._resTagged (/app/node_modules/imap/lib/Parser.js:175:10)\n at Parser._parse (/app/node_modules/imap/lib/Parser.js:139:16)","timestamp":"2025-07-04T13:40:45.693Z"}
{"level":"error","message":"IMAP connection error during check: write after end","timestamp":"2025-07-04T13:41:51.223Z"}
{"level":"error","message":"IMAP connection error during check: write after end","timestamp":"2025-07-04T13:41:51.223Z"}
{"level":"error","message":"IMAP connection error during check: write after end","timestamp":"2025-07-04T13:41:51.223Z"}
{"level":"error","message":"IMAP connection error during check: write after end","timestamp":"2025-07-04T13:41:51.223Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.331Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.332Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.332Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.332Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.332Z"}
{"level":"error","message":"IMAP connection error during check: Timed out while authenticating with server","timestamp":"2025-07-04T13:45:08.333Z"}

View File

@@ -61,7 +61,8 @@ router.get('/', requireAuth, async (req, res, next) => {
dateTo = '',
contactType = 'all',
search = '',
newMessages = ''
newMessages = '',
blocked = 'all'
} = req.query;
const adminId = req.user && req.user.id;
@@ -100,9 +101,16 @@ router.get('/', requireAuth, async (req, res, next) => {
idx++;
}
// Фильтр по блокировке
if (blocked === 'blocked') {
where.push(`u.is_blocked = true`);
} else if (blocked === 'unblocked') {
where.push(`u.is_blocked = false`);
}
// --- Основной SQL ---
let sql = `
SELECT u.id, u.first_name, u.last_name, u.created_at, u.preferred_language,
SELECT u.id, u.first_name, u.last_name, u.created_at, u.preferred_language, u.is_blocked,
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'email' LIMIT 1) AS email,
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'telegram' LIMIT 1) AS telegram,
(SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'wallet' LIMIT 1) AS wallet
@@ -169,7 +177,8 @@ router.get('/', requireAuth, async (req, res, next) => {
telegram: u.telegram || null,
wallet: u.wallet || null,
created_at: u.created_at,
preferred_language: u.preferred_language || []
preferred_language: u.preferred_language || [],
is_blocked: u.is_blocked || false
}));
res.json({ success: true, contacts });
@@ -232,34 +241,58 @@ router.post('/mark-contact-read', async (req, res) => {
}
});
// PATCH /api/users/:id — обновить имя и язык
router.patch('/:id', async (req, res) => {
const userId = req.params.id;
const { name, language } = req.body;
if (!name && !language) return res.status(400).json({ error: 'Nothing to update' });
// Заблокировать пользователя
router.patch('/:id/block', requireAuth, async (req, res) => {
try {
const userId = req.params.id;
await db.query('UPDATE users SET is_blocked = true, blocked_at = NOW() WHERE id = $1', [userId]);
res.json({ success: true, message: 'Пользователь заблокирован' });
} catch (e) {
logger.error('Ошибка блокировки пользователя:', e);
res.status(500).json({ success: false, error: e.message });
}
});
// Разблокировать пользователя
router.patch('/:id/unblock', requireAuth, async (req, res) => {
try {
const userId = req.params.id;
await db.query('UPDATE users SET is_blocked = false, blocked_at = NULL WHERE id = $1', [userId]);
res.json({ success: true, message: 'Пользователь разблокирован' });
} catch (e) {
logger.error('Ошибка разблокировки пользователя:', e);
res.status(500).json({ success: false, error: e.message });
}
});
// Обновить пользователя (в том числе is_blocked)
router.patch('/:id', requireAuth, async (req, res) => {
try {
const userId = req.params.id;
const { first_name, last_name, preferred_language, is_blocked } = req.body;
const fields = [];
const values = [];
let idx = 1;
if (name !== undefined) {
// Разделяем имя на first_name и last_name (по пробелу)
const [firstName, ...lastNameArr] = name.split(' ');
fields.push(`first_name = $${idx++}`);
values.push(firstName);
fields.push(`last_name = $${idx++}`);
values.push(lastNameArr.join(' ') || null);
if (first_name !== undefined) { fields.push(`first_name = $${idx++}`); values.push(first_name); }
if (last_name !== undefined) { fields.push(`last_name = $${idx++}`); values.push(last_name); }
if (preferred_language !== undefined) { fields.push(`preferred_language = $${idx++}`); values.push(JSON.stringify(preferred_language)); }
if (is_blocked !== undefined) {
fields.push(`is_blocked = $${idx++}`);
values.push(is_blocked);
if (is_blocked) {
fields.push(`blocked_at = NOW()`);
} else {
fields.push(`blocked_at = NULL`);
}
if (language !== undefined) {
fields.push(`preferred_language = $${idx++}`);
values.push(JSON.stringify(language));
}
if (!fields.length) return res.status(400).json({ success: false, error: 'Нет данных для обновления' });
const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = $${idx}`;
values.push(userId);
const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`;
const result = await db.getQuery()(sql, values);
res.json(result.rows[0]);
await db.query(sql, values);
res.json({ success: true, message: 'Пользователь обновлен' });
} catch (e) {
logger.error('PATCH /api/users/:id error', { error: e, body: req.body, stack: e.stack });
res.status(500).json({ error: 'DB error', details: e.message });
logger.error('Ошибка обновления пользователя:', e);
res.status(500).json({ success: false, error: e.message });
}
});
@@ -329,4 +362,74 @@ router.post('/', async (req, res) => {
}
});
// Массовый импорт контактов
router.post('/import', requireAuth, async (req, res) => {
try {
const contacts = req.body;
if (!Array.isArray(contacts)) {
return res.status(400).json({ success: false, error: 'Ожидается массив контактов' });
}
const dbq = db.getQuery();
let added = 0, updated = 0, errors = [];
for (const [i, c] of contacts.entries()) {
try {
// Имя
let first_name = null, last_name = null;
if (c.name) {
const parts = c.name.trim().split(' ');
first_name = parts[0] || null;
last_name = parts.slice(1).join(' ') || null;
}
// Проверка на существование по email/telegram/wallet
let userId = null;
let foundUser = null;
if (c.email) {
const r = await dbq('SELECT user_id FROM user_identities WHERE provider = $1 AND provider_id = $2', ['email', c.email.toLowerCase()]);
if (r.rows.length) foundUser = r.rows[0].user_id;
}
if (!foundUser && c.telegram) {
const r = await dbq('SELECT user_id FROM user_identities WHERE provider = $1 AND provider_id = $2', ['telegram', c.telegram]);
if (r.rows.length) foundUser = r.rows[0].user_id;
}
if (!foundUser && c.wallet) {
const r = await dbq('SELECT user_id FROM user_identities WHERE provider = $1 AND provider_id = $2', ['wallet', c.wallet]);
if (r.rows.length) foundUser = r.rows[0].user_id;
}
if (foundUser) {
userId = foundUser;
updated++;
// Обновляем имя, если нужно
if (first_name || last_name) {
await dbq('UPDATE users SET first_name = COALESCE($1, first_name), last_name = COALESCE($2, last_name) WHERE id = $3', [first_name, last_name, userId]);
}
} else {
// Создаём нового пользователя
const ins = await dbq('INSERT INTO users (first_name, last_name, created_at) VALUES ($1, $2, NOW()) RETURNING id', [first_name, last_name]);
userId = ins.rows[0].id;
added++;
}
// Добавляем идентификаторы (email, telegram, wallet)
const identities = [
c.email ? { provider: 'email', provider_id: c.email.toLowerCase() } : null,
c.telegram ? { provider: 'telegram', provider_id: c.telegram } : null,
c.wallet ? { provider: 'wallet', provider_id: c.wallet } : null
].filter(Boolean);
for (const idn of identities) {
// Проверяем, есть ли уже такой идентификатор у пользователя
const exists = await dbq('SELECT 1 FROM user_identities WHERE user_id = $1 AND provider = $2 AND provider_id = $3', [userId, idn.provider, idn.provider_id]);
if (!exists.rows.length) {
await dbq('INSERT INTO user_identities (user_id, provider, provider_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', [userId, idn.provider, idn.provider_id]);
}
}
} catch (e) {
errors.push({ row: i + 1, error: e.message });
}
}
broadcastContactsUpdate();
res.json({ success: true, added, updated, errors });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
module.exports = router;

View File

@@ -23,6 +23,7 @@
"element-plus": "^2.9.11",
"ethers": "6.13.5",
"marked": "^15.0.7",
"papaparse": "^5.5.3",
"siwe": "^2.1.4",
"sortablejs": "^1.15.6",
"vue": "^3.2.47",

View File

@@ -0,0 +1,74 @@
<template>
<el-dialog v-model="visible" title="Массовая рассылка" width="700px" @close="$emit('close')">
<div v-if="step === 1">
<div style="margin-bottom:1em;">Вы выбрали {{userIds.length}} пользователей для рассылки.</div>
<ChatInterface
v-model:newMessage="message"
:isAdmin="true"
:messages="[]"
:attachments="attachments"
@update:attachments="val => attachments = val"
@send-message="onSend"
:showSendButton="false"
/>
<el-button type="primary" :disabled="!message.trim()" @click="sendBroadcast" :loading="loading">Отправить</el-button>
<el-button @click="$emit('close')" style="margin-left:1em;">Отмена</el-button>
</div>
<div v-else-if="step === 2">
<div v-if="result.success" style="color:green;">Рассылка завершена успешно!</div>
<div v-if="result.errors && result.errors.length" style="color:red;max-height:120px;overflow:auto;">
Ошибки:
<ul>
<li v-for="err in result.errors" :key="err.userId">ID {{err.userId}}: {{err.error}}</li>
</ul>
</div>
<el-button type="primary" @click="closeAndRefresh">Закрыть</el-button>
</div>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue';
import ChatInterface from './ChatInterface.vue';
import messagesService from '../services/messagesService.js';
import { ElMessage } from 'element-plus';
const props = defineProps({ userIds: { type: Array, required: true } });
const visible = ref(true);
const message = ref('');
const attachments = ref([]);
const loading = ref(false);
const step = ref(1);
const result = ref({});
async function sendBroadcast() {
loading.value = true;
const errors = [];
let successCount = 0;
for (const userId of props.userIds) {
try {
await messagesService.broadcastMessage({ userId, message: message.value });
successCount++;
} catch (e) {
errors.push({ userId, error: e?.message || 'Ошибка отправки' });
}
}
result.value = { success: errors.length === 0, errors };
step.value = 2;
loading.value = false;
}
function onSend() {
sendBroadcast();
}
function closeAndRefresh() {
visible.value = false;
setTimeout(() => {
step.value = 1;
result.value = {};
message.value = '';
attachments.value = [];
loading.value = false;
// Сообщаем родителю об успешной рассылке
emit('close');
}, 300);
}
</script>

View File

@@ -1,7 +1,9 @@
<template>
<div class="contact-table-modal">
<div class="contact-table-header">
<h2>Контакты</h2>
<el-button type="info" :disabled="!selectedIds.length" @click="showBroadcastModal = true" style="margin-right: 1em;">Рассылка</el-button>
<el-button type="danger" :disabled="!selectedIds.length" @click="deleteSelected" style="margin-right: 1em;">Удалить</el-button>
<el-button type="primary" @click="showImportModal = true" style="margin-right: 1em;">Импорт</el-button>
<button class="close-btn" @click="$emit('close')">×</button>
</div>
<el-form :inline="true" class="filters-form" label-position="top">
@@ -28,6 +30,13 @@
<el-option label="Да" value="yes" />
</el-select>
</el-form-item>
<el-form-item label="Блокировка">
<el-select v-model="filterBlocked" placeholder="Все" style="min-width:120px;" @change="onAnyFilterChange">
<el-option label="Все" value="all" />
<el-option label="Только заблокированные" value="blocked" />
<el-option label="Только не заблокированные" value="unblocked" />
</el-select>
</el-form-item>
<el-form-item label="Теги">
<el-select
v-model="selectedTagIds"
@@ -53,6 +62,7 @@
<table class="contact-table">
<thead>
<tr>
<th><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th>
<th>Имя</th>
<th>Email</th>
<th>Telegram</th>
@@ -63,6 +73,7 @@
</thead>
<tbody>
<tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
<td><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td>
<td>{{ contact.name || '-' }}</td>
<td>{{ contact.email || '-' }}</td>
<td>{{ contact.telegram || '-' }}</td>
@@ -75,13 +86,17 @@
</tr>
</tbody>
</table>
<ImportContactsModal v-if="showImportModal" @close="showImportModal = false" @imported="onImported" />
<BroadcastModal v-if="showBroadcastModal" :user-ids="selectedIds" @close="showBroadcastModal = false" />
</div>
</template>
<script setup>
import { defineProps, computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ElSelect, ElOption, ElForm, ElFormItem, ElInput, ElDatePicker, ElCheckbox, ElButton } from 'element-plus';
import { ElSelect, ElOption, ElForm, ElFormItem, ElInput, ElDatePicker, ElCheckbox, ElButton, ElMessageBox, ElMessage } from 'element-plus';
import ImportContactsModal from './ImportContactsModal.vue';
import BroadcastModal from './BroadcastModal.vue';
const props = defineProps({
contacts: { type: Array, default: () => [] },
newContacts: { type: Array, default: () => [] },
@@ -100,11 +115,18 @@ const filterContactType = ref('all');
const filterDateFrom = ref('');
const filterDateTo = ref('');
const filterNewMessages = ref('');
const filterBlocked = ref('all');
// Теги
const allTags = ref([]);
const selectedTagIds = ref([]);
const showImportModal = ref(false);
const showBroadcastModal = ref(false);
const selectedIds = ref([]);
const selectAll = ref(false);
onMounted(async () => {
await loadTags();
await fetchContacts();
@@ -123,6 +145,7 @@ function buildQuery() {
if (filterContactType.value && filterContactType.value !== 'all') params.append('contactType', filterContactType.value);
if (filterSearch.value) params.append('search', filterSearch.value);
if (filterNewMessages.value) params.append('newMessages', filterNewMessages.value);
if (filterBlocked.value && filterBlocked.value !== 'all') params.append('blocked', filterBlocked.value);
return params.toString();
}
@@ -149,6 +172,7 @@ function resetFilters() {
filterDateFrom.value = '';
filterDateTo.value = '';
filterNewMessages.value = '';
filterBlocked.value = 'all';
selectedTagIds.value = [];
fetchContacts();
}
@@ -175,6 +199,45 @@ async function showDetails(contact) {
}
router.push({ name: 'contact-details', params: { id: contact.id } });
}
function onImported() {
showImportModal.value = false;
fetchContacts();
}
function toggleSelectAll() {
if (selectAll.value) {
selectedIds.value = contactsArray.value.map(c => c.id);
} else {
selectedIds.value = [];
}
}
watch(contactsArray, () => {
// Сбросить выбор при обновлении данных
selectedIds.value = [];
selectAll.value = false;
});
async function deleteSelected() {
if (!selectedIds.value.length) return;
try {
await ElMessageBox.confirm(
`Вы действительно хотите удалить ${selectedIds.value.length} контакт(ов)?`,
'Подтверждение удаления',
{ type: 'warning' }
);
for (const id of selectedIds.value) {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
}
ElMessage.success('Контакты удалены');
fetchContacts();
selectedIds.value = [];
selectAll.value = false;
} catch (e) {
// Отмена
}
}
</script>
<style scoped>
@@ -190,9 +253,9 @@ async function showDetails(contact) {
}
.contact-table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
position: relative;
}
.close-btn {
position: absolute;

View File

@@ -0,0 +1,177 @@
<template>
<el-dialog v-model="visible" title="Импорт контактов" width="800px" @close="$emit('close')">
<div v-if="step === 1">
<el-upload
drag
:auto-upload="false"
:show-file-list="false"
accept=".csv,.json"
@change="handleFileChange"
style="width:100%"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">Перетащите файл сюда или <em>нажмите для выбора</em></div>
<div class="el-upload__tip">Поддерживаются форматы CSV и JSON</div>
</el-upload>
</div>
<div v-else-if="step === 2">
<div style="margin-bottom:1em;">Сопоставьте столбцы файла с полями контакта:</div>
<el-table :data="previewRows" border style="width:100%;margin-bottom:1em;">
<el-table-column v-for="(col, idx) in columns" :key="col" :label="col">
<template #header>
<el-select v-model="mapping[col]" placeholder="Выбрать поле" size="small">
<el-option v-for="f in fields" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
</template>
<template #default="scope">
{{ scope.row[col] }}
</template>
</el-table-column>
<el-table-column label="Удалить" width="80">
<template #default="scope">
<el-button type="danger" icon="el-icon-delete" size="small" @click="removeRow(scope.$index)" circle />
</template>
</el-table-column>
</el-table>
<el-button @click="step = 1" style="margin-right:1em;">Назад</el-button>
<el-button type="primary" @click="submitImport" :loading="loading">Импортировать</el-button>
</div>
<div v-else-if="step === 3">
<div v-if="result.success" style="color:green;">Импорт завершён: добавлено {{result.added}}, обновлено {{result.updated}}</div>
<div v-if="result.errors && result.errors.length" style="color:red;max-height:120px;overflow:auto;">
Ошибки:
<ul>
<li v-for="err in result.errors" :key="err.row">Строка {{err.row}}: {{err.error}}</li>
</ul>
</div>
<el-button type="primary" @click="closeAndRefresh">Закрыть</el-button>
</div>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed } from 'vue';
import Papa from 'papaparse';
import { ElMessage } from 'element-plus';
const visible = ref(true);
const step = ref(1);
const file = ref(null);
const rawRows = ref([]);
const columns = ref([]);
const previewRows = ref([]);
const mapping = reactive({});
const loading = ref(false);
const result = ref({});
const fields = [
{ label: 'Имя', value: 'name' },
{ label: 'Email', value: 'email' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Wallet', value: 'wallet' }
];
function handleFileChange(e) {
const f = e.raw || (e.target && e.target.files && e.target.files[0]);
if (!f) return;
file.value = f;
const reader = new FileReader();
reader.onload = (evt) => {
let data = [];
if (f.name.endsWith('.csv')) {
const parsed = Papa.parse(evt.target.result, { header: true });
data = parsed.data.filter(r => Object.values(r).some(Boolean));
} else if (f.name.endsWith('.json')) {
try {
let parsed = JSON.parse(evt.target.result);
let dataCandidate = Array.isArray(parsed) ? parsed : findFirstArray(parsed);
if (!Array.isArray(dataCandidate)) {
throw new Error('JSON должен содержать массив объектов на любом уровне вложенности');
}
data = dataCandidate;
} catch (e) {
ElMessage.error('Ошибка парсинга JSON: ' + e.message);
return;
}
}
if (!data.length) {
ElMessage.error('Файл не содержит данных');
return;
}
rawRows.value = data;
columns.value = Object.keys(data[0]);
previewRows.value = data.slice(0, 10);
// Автоматический маппинг по названию
for (const col of columns.value) {
const lower = col.toLowerCase();
if (lower.includes('mail')) mapping[col] = 'email';
else if (lower.includes('tele')) mapping[col] = 'telegram';
else if (lower.includes('wallet')) mapping[col] = 'wallet';
else if (lower.includes('name')) mapping[col] = 'name';
else mapping[col] = '';
}
step.value = 2;
};
reader.readAsText(f);
}
function removeRow(idx) {
rawRows.value.splice(idx, 1);
previewRows.value = rawRows.value.slice(0, 10);
}
async function submitImport() {
loading.value = true;
// Собираем данные по маппингу
const contacts = rawRows.value.map(row => {
const obj = {};
for (const col of columns.value) {
const field = mapping[col];
if (field) obj[field] = row[col];
}
return obj;
});
try {
const resp = await fetch('/api/users/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contacts)
});
const data = await resp.json();
result.value = data;
step.value = 3;
} catch (e) {
ElMessage.error('Ошибка импорта: ' + e.message);
} finally {
loading.value = false;
}
}
function closeAndRefresh() {
visible.value = false;
setTimeout(() => {
step.value = 1;
result.value = {};
rawRows.value = [];
columns.value = [];
previewRows.value = [];
file.value = null;
Object.keys(mapping).forEach(k => delete mapping[k]);
loading.value = false;
// Сообщаем родителю об успешном импорте
emit('imported');
emit('close');
}, 300);
}
function findFirstArray(obj) {
if (Array.isArray(obj)) return obj;
if (typeof obj === 'object' && obj !== null) {
for (const key in obj) {
const found = findFirstArray(obj[key]);
if (found) return found;
}
}
return null;
}
</script>
<style scoped>
.el-upload {
width: 100%;
margin-bottom: 1em;
}
</style>

View File

@@ -28,6 +28,14 @@ export default {
return res.data;
}
return null;
},
async blockContact(id) {
const res = await api.patch(`/api/users/${id}/block`);
return res.data;
},
async unblockContact(id) {
const res = await api.patch(`/api/users/${id}/unblock`);
return res.data;
}
};

View File

@@ -63,6 +63,27 @@
</span>
<button class="add-tag-btn" @click="openTagModal">Добавить тег</button>
</div>
<div class="block-user-section">
<strong>Статус блокировки:</strong>
<span v-if="contact.is_blocked" class="blocked-status">Заблокирован</span>
<span v-else class="unblocked-status">Не заблокирован</span>
<template v-if="isAdmin">
<el-button
v-if="!contact.is_blocked"
type="danger"
size="small"
@click="blockUser"
style="margin-left: 1em;"
>Заблокировать</el-button>
<el-button
v-else
type="success"
size="small"
@click="unblockUser"
style="margin-left: 1em;"
>Разблокировать</el-button>
</template>
</div>
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
</div>
<div class="messages-block">
@@ -326,6 +347,14 @@ function goBack() {
async function handleSendMessage({ message, attachments }) {
if (!contact.value || !contact.value.id) return;
if (contact.value.is_blocked) {
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert('Пользователь заблокирован. Отправка сообщений невозможна.', 'Ошибка', { type: 'error' });
} else {
alert('Пользователь заблокирован. Отправка сообщений невозможна.');
}
return;
}
// Проверка наличия хотя бы одного идентификатора
const hasAnyId = contact.value.email || contact.value.telegram || contact.value.wallet;
if (!hasAnyId) {
@@ -395,6 +424,36 @@ async function handleAiReply(selectedMessages = []) {
}
}
function showBlockStatusMessage(msg, type = 'info') {
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert(msg, 'Статус блокировки', { type });
} else {
alert(msg);
}
}
async function blockUser() {
if (!contact.value) return;
try {
await contactsService.blockContact(contact.value.id);
contact.value.is_blocked = true;
showBlockStatusMessage('Пользователь заблокирован', 'success');
} catch (e) {
showBlockStatusMessage('Ошибка блокировки пользователя', 'error');
}
}
async function unblockUser() {
if (!contact.value) return;
try {
await contactsService.unblockContact(contact.value.id);
contact.value.is_blocked = false;
showBlockStatusMessage('Пользователь разблокирован', 'success');
} catch (e) {
showBlockStatusMessage('Ошибка разблокировки пользователя', 'error');
}
}
onMounted(async () => {
await reloadContact();
await loadMessages();
@@ -582,4 +641,16 @@ watch(userId, async () => {
.add-tag-btn:hover {
background: #27ae38;
}
.block-user-section {
margin-top: 1em;
margin-bottom: 1em;
}
.blocked-status {
color: #d32f2f;
font-weight: bold;
}
.unblocked-status {
color: #388e3c;
font-weight: bold;
}
</style>

View File

@@ -2039,6 +2039,11 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
papaparse@^5.5.3:
version "5.5.3"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.3.tgz#07f8994dec516c6dab266e952bed68e1de59fa9a"
integrity sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"papaparse": "^5.5.3"
}
}

8
yarn.lock Normal file
View File

@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
papaparse@^5.5.3:
version "5.5.3"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.3.tgz#07f8994dec516c6dab266e952bed68e1de59fa9a"
integrity sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==