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

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

@@ -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"