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

This commit is contained in:
2025-06-19 15:24:27 +03:00
parent 75e845b5af
commit 90a088e021
25 changed files with 905 additions and 356 deletions

View File

@@ -163,7 +163,7 @@ app.use((req, res, next) => {
app.use('/api/tables', tablesRoutes); // ДОЛЖНО БЫТЬ ВЫШЕ! app.use('/api/tables', tablesRoutes); // ДОЛЖНО БЫТЬ ВЫШЕ!
// app.use('/api', identitiesRoutes); // app.use('/api', identitiesRoutes);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/users/:userId/tags', userTagsRoutes); app.use('/api/users', userTagsRoutes);
app.use('/api/users', usersRoutes); app.use('/api/users', usersRoutes);
app.use('/api/chat', chatRoutes); app.use('/api/chat', chatRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);

View File

@@ -5,15 +5,23 @@ const db = require('../db');
// GET /api/messages?userId=123 // GET /api/messages?userId=123
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const userId = req.query.userId; const userId = req.query.userId;
if (!userId) return res.status(400).json({ error: 'userId required' });
try { try {
const result = await db.getQuery()( let result;
`SELECT id, user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata if (userId) {
FROM messages result = await db.getQuery()(
WHERE user_id = $1 `SELECT id, user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata
ORDER BY created_at ASC`, FROM messages
[userId] WHERE user_id = $1
); ORDER BY created_at ASC`,
[userId]
);
} else {
result = await db.getQuery()(
`SELECT id, user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata
FROM messages
ORDER BY created_at ASC`
);
}
res.json(result.rows); res.json(result.rows);
} catch (e) { } catch (e) {
res.status(500).json({ error: 'DB error', details: e.message }); res.status(500).json({ error: 'DB error', details: e.message });

View File

@@ -3,6 +3,8 @@ const { app, nonceStore } = require('./app');
const http = require('http'); const http = require('http');
const { initWSS } = require('./wsHub'); const { initWSS } = require('./wsHub');
const logger = require('./utils/logger'); const logger = require('./utils/logger');
const { getBot } = require('./services/telegramBot');
const EmailBotService = require('./services/emailBot');
const PORT = process.env.PORT || 8000; const PORT = process.env.PORT || 8000;
@@ -14,9 +16,26 @@ console.log('Используемый порт:', process.env.PORT || 8000);
async function initServices() { async function initServices() {
try { try {
console.log('Инициализация сервисов...'); console.log('Инициализация сервисов...');
// Здесь может быть инициализация ботов, email-сервисов и т.д. console.log('[initServices] Запуск Email-бота...');
// ... console.log('[initServices] Создаю экземпляр EmailBotService...');
console.log('Все сервисы успешно инициализированы'); let emailBot;
try {
emailBot = new EmailBotService();
console.log('[initServices] Экземпляр EmailBotService создан');
} catch (err) {
console.error('[initServices] Ошибка при создании экземпляра EmailBotService:', err);
throw err;
}
console.log('[initServices] Перед вызовом emailBot.start()');
try {
await emailBot.start();
console.log('[initServices] Email-бот успешно запущен');
} catch (err) {
console.error('[initServices] Ошибка при запуске emailBot:', err);
}
console.log('[initServices] Запуск Telegram-бота...');
await getBot();
console.log('[initServices] Telegram-бот успешно запущен');
} catch (error) { } catch (error) {
console.error('Ошибка при инициализации сервисов:', error); console.error('Ошибка при инициализации сервисов:', error);
} }

View File

@@ -1,3 +1,5 @@
console.log('[ai-assistant] loaded');
const { ChatOllama } = require('@langchain/ollama'); const { ChatOllama } = require('@langchain/ollama');
const { HNSWLib } = require('@langchain/community/vectorstores/hnswlib'); const { HNSWLib } = require('@langchain/community/vectorstores/hnswlib');
const { OpenAIEmbeddings } = require('@langchain/openai'); const { OpenAIEmbeddings } = require('@langchain/openai');

View File

@@ -1,7 +1,6 @@
const db = require('../db'); const db = require('../db');
const OpenAI = require('openai'); const OpenAI = require('openai');
const Anthropic = require('@anthropic-ai/sdk'); const Anthropic = require('@anthropic-ai/sdk');
const { GoogleGenAI } = require('@google/genai');
const TABLE = 'ai_providers_settings'; const TABLE = 'ai_providers_settings';
@@ -48,6 +47,7 @@ async function getProviderModels(provider, { api_key, base_url } = {}) {
return res.data ? res.data.map(m => ({ id: m.id, ...m })) : []; return res.data ? res.data.map(m => ({ id: m.id, ...m })) : [];
} }
if (provider === 'google') { if (provider === 'google') {
const { GoogleGenAI } = await import('@google/genai');
const ai = new GoogleGenAI({ apiKey: api_key, baseUrl: base_url }); const ai = new GoogleGenAI({ apiKey: api_key, baseUrl: base_url });
const pager = await ai.models.list(); const pager = await ai.models.list();
const models = []; const models = [];
@@ -79,6 +79,7 @@ async function verifyProviderKey(provider, { api_key, base_url } = {}) {
return { success: true }; return { success: true };
} }
if (provider === 'google') { if (provider === 'google') {
const { GoogleGenAI } = await import('@google/genai');
const ai = new GoogleGenAI({ apiKey: api_key, baseUrl: base_url }); const ai = new GoogleGenAI({ apiKey: api_key, baseUrl: base_url });
const pager = await ai.models.list(); const pager = await ai.models.list();
for await (const _ of pager) { for await (const _ of pager) {

View File

@@ -1,3 +1,4 @@
console.log('[EmailBot] emailBot.js loaded');
const db = require('../db'); const db = require('../db');
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const Imap = require('imap'); const Imap = require('imap');
@@ -9,6 +10,10 @@ const identityService = require('./identity-service');
const aiAssistant = require('./ai-assistant'); const aiAssistant = require('./ai-assistant');
class EmailBotService { class EmailBotService {
constructor() {
console.log('[EmailBot] constructor called');
}
async getSettingsFromDb() { async getSettingsFromDb() {
const { rows } = await db.getQuery()('SELECT * FROM email_settings ORDER BY id LIMIT 1'); const { rows } = await db.getQuery()('SELECT * FROM email_settings ORDER BY id LIMIT 1');
if (!rows.length) throw new Error('Email settings not found in DB'); if (!rows.length) throw new Error('Email settings not found in DB');
@@ -46,6 +51,7 @@ class EmailBotService {
idleInterval: 300000, idleInterval: 300000,
forceNoop: true, forceNoop: true,
}, },
connTimeout: 30000, // 30 секунд
}; };
} }
@@ -228,55 +234,63 @@ class EmailBotService {
} }
async start() { async start() {
logger.info('[EmailBot] start() called'); try {
const imapConfig = await this.getImapConfig(); console.log('[EmailBot] start() called');
// Логируем IMAP-конфиг (без пароля) logger.info('[EmailBot] start() called');
const safeConfig = { ...imapConfig }; const imapConfig = await this.getImapConfig();
if (safeConfig.password) safeConfig.password = '***'; // Логируем IMAP-конфиг (без пароля)
logger.info('[EmailBot] IMAP config:', safeConfig); const safeConfig = { ...imapConfig };
let attempt = 0; if (safeConfig.password) safeConfig.password = '***';
const maxAttempts = 3; logger.info('[EmailBot] IMAP config:', safeConfig);
this.isChecking = false; let attempt = 0;
const tryConnect = () => { const maxAttempts = 3;
attempt++; this.isChecking = false;
logger.info(`[EmailBot] IMAP connect attempt ${attempt}`); const tryConnect = () => {
this.imap = new Imap(imapConfig); attempt++;
this.imap.once('ready', () => { logger.info(`[EmailBot] IMAP connect attempt ${attempt}`);
logger.info('[EmailBot] IMAP connection ready'); this.imap = new Imap(imapConfig);
this.imap.openBox('INBOX', false, (err, box) => { this.imap.once('ready', () => {
if (err) { logger.info('[EmailBot] IMAP connection ready');
logger.error(`[EmailBot] Error opening INBOX: ${err.message}`); this.imap.openBox('INBOX', false, (err, box) => {
this.imap.end(); if (err) {
return; logger.error(`[EmailBot] Error opening INBOX: ${err.message}`);
} this.imap.end();
logger.info('[EmailBot] INBOX opened successfully'); return;
}
logger.info('[EmailBot] INBOX opened successfully');
});
// После успешного подключения — обычная логика
this.checkEmails();
logger.info('[EmailBot] Email bot started and IMAP connection initiated');
// Периодическая проверка почты
setInterval(async () => {
if (this.isChecking) return;
this.isChecking = true;
try {
await this.checkEmails();
} catch (e) {
logger.error('[EmailBot] Error in periodic checkEmails:', e);
}
this.isChecking = false;
}, 60000); // 60 секунд
}); });
// После успешного подключения — обычная логика this.imap.once('error', (err) => {
this.checkEmails(); logger.error(`[EmailBot] IMAP connection error: ${err.message}`);
logger.info('[EmailBot] Email bot started and IMAP connection initiated'); if (err.message && err.message.toLowerCase().includes('timed out') && attempt < maxAttempts) {
// Периодическая проверка почты logger.warn(`[EmailBot] IMAP reconnecting in 10 seconds (attempt ${attempt + 1})...`);
setInterval(async () => { setTimeout(tryConnect, 10000);
if (this.isChecking) return;
this.isChecking = true;
try {
await this.checkEmails();
} catch (e) {
logger.error('[EmailBot] Error in periodic checkEmails:', e);
} }
this.isChecking = false; });
}, 60000); // 60 секунд this.imap.connect();
}); };
this.imap.once('error', (err) => { tryConnect();
logger.error(`[EmailBot] IMAP connection error: ${err.message}`); } catch (err) {
if (err.message && err.message.toLowerCase().includes('timed out') && attempt < maxAttempts) { console.error('[EmailBot] Ошибка при старте:', err);
logger.warn(`[EmailBot] IMAP reconnecting in 10 seconds (attempt ${attempt + 1})...`); logger.error('[EmailBot] Ошибка при старте:', err);
setTimeout(tryConnect, 10000); throw err;
} }
});
this.imap.connect();
};
tryConnect();
} }
} }
console.log('[EmailBot] module.exports = EmailBotService');
module.exports = EmailBotService; module.exports = EmailBotService;

View File

@@ -1,7 +1,10 @@
console.log('[identity-service] loaded');
const db = require('../db'); const db = require('../db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getLinkedWallet } = require('./wallet-service'); const { getLinkedWallet } = require('./wallet-service');
const { checkAdminRole } = require('./admin-role'); const { checkAdminRole } = require('./admin-role');
const { broadcastContactsUpdate } = require('../wsHub');
/** /**
* Сервис для работы с идентификаторами пользователей * Сервис для работы с идентификаторами пользователей
@@ -541,6 +544,7 @@ class IdentityService {
await this.saveIdentity(userId, provider, providerId, true); await this.saveIdentity(userId, provider, providerId, true);
user = { id: userId, role: 'user' }; user = { id: userId, role: 'user' };
isNew = true; isNew = true;
broadcastContactsUpdate();
} }
// Проверяем связь с кошельком // Проверяем связь с кошельком
const wallet = await getLinkedWallet(user.id); const wallet = await getLinkedWallet(user.id);

View File

@@ -332,8 +332,9 @@ async function getBot() {
} }
}); });
// Запускаем бота // Запуск бота
await botInstance.launch(); await botInstance.launch();
logger.info('[TelegramBot] Бот запущен');
} }
return botInstance; return botInstance;

View File

@@ -53,8 +53,8 @@ function showDetails(contact) {
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12); box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px; padding: 32px 24px 24px 24px;
max-width: 950px; width: 100%;
margin: 40px auto; margin-top: 40px;
position: relative; position: relative;
overflow-x: auto; overflow-x: auto;
} }
@@ -65,6 +65,9 @@ function showDetails(contact) {
margin-bottom: 24px; margin-bottom: 24px;
} }
.close-btn { .close-btn {
position: absolute;
top: 18px;
right: 18px;
background: none; background: none;
border: none; border: none;
font-size: 2rem; font-size: 2rem;

View File

@@ -357,8 +357,8 @@ function uninstallModule(module) {
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12); box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px; padding: 32px 24px 24px 24px;
max-width: 950px; width: 100%;
margin: 40px auto; margin-top: 40px;
position: relative; position: relative;
overflow-x: auto; overflow-x: auto;
} }

View File

@@ -0,0 +1,47 @@
<template>
<div class="messages-table-modal">
<div class="messages-table-header">
<h2>Новые сообщения</h2>
</div>
<div v-if="!messages.length" class="empty">Нет новых сообщений</div>
<div v-else class="messages-list">
<Message v-for="msg in messages" :key="msg.id" :message="msg" />
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
import Message from './Message.vue';
const props = defineProps({
messages: { type: Array, required: true }
});
</script>
<style scoped>
.messages-table-modal {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
width: 100%;
margin-top: 40px;
position: relative;
overflow-x: auto;
}
.messages-table-header {
display: flex;
align-items: center;
margin-bottom: 24px;
}
.empty {
color: #888;
text-align: center;
margin: 20px 0;
}
.messages-list {
display: flex;
flex-direction: column;
gap: 18px;
}
</style>

View File

@@ -402,15 +402,15 @@ h3 {
/* Медиа-запросы для адаптивности */ /* Медиа-запросы для адаптивности */
@media screen and (min-width: 1200px) { @media screen and (min-width: 1200px) {
.wallet-sidebar { .wallet-sidebar {
width: 30%; width: 420px;
max-width: 350px; max-width: 420px;
} }
} }
@media screen and (min-width: 769px) and (max-width: 1199px) { @media screen and (min-width: 769px) and (max-width: 1199px) {
.wallet-sidebar { .wallet-sidebar {
width: 40%; width: 350px;
max-width: 320px; max-width: 350px;
} }
} }

View File

@@ -0,0 +1,285 @@
<template>
<div class="tags-table-wrapper">
<div class="tableview-header-row">
<button class="nav-btn" @click="goToTables">Таблицы</button>
<button class="nav-btn" @click="goToCreate">Создать таблицу</button>
<button class="close-btn" @click="closeTable">Закрыть</button>
<button class="action-btn" disabled>Редактировать</button>
<button class="danger-btn" disabled>Удалить</button>
</div>
<div class="tags-header-row">
<h3>Теги</h3>
</div>
<table class="tags-table">
<thead>
<tr>
<th>Название</th>
<th>Описание</th>
<th style="width:110px;">Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags" :key="tag.id">
<td v-if="editId !== tag.id">{{ tag.name }}</td>
<td v-else><input v-model="editName" class="edit-input" /></td>
<td v-if="editId !== tag.id">{{ tag.description || '' }}</td>
<td v-else><input v-model="editDescription" class="edit-input" /></td>
<td>
<template v-if="editId === tag.id">
<button class="save-btn" @click="saveEdit(tag)">Сохранить</button>
<button class="cancel-btn" @click="cancelEdit">Отмена</button>
</template>
<template v-else>
<button class="edit-btn" @click="startEdit(tag)" title="Редактировать"></button>
<button class="delete-btn" @click="deleteTag(tag)" title="Удалить">🗑</button>
</template>
</td>
</tr>
<tr v-if="tags.length === 0">
<td colspan="3" style="text-align:center; color:#888;">Нет тегов</td>
</tr>
<tr>
<td></td>
<td></td>
<td style="text-align:center;">
<button class="edit-btn" @click="showAddTagModal = true" title="Добавить тег"></button>
</td>
</tr>
</tbody>
</table>
<div v-if="showAddTagModal" class="modal-backdrop">
<div class="modal">
<h4>Добавить тег</h4>
<input v-model="newTagName" class="edit-input" placeholder="Название" />
<input v-model="newTagDescription" class="edit-input" placeholder="Описание" style="margin-top:0.7em;" />
<div class="modal-actions">
<button class="save-btn" @click="createTag">Создать</button>
<button class="cancel-btn" @click="showAddTagModal = false">Отмена</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
const tags = ref([]);
const editId = ref(null);
const editName = ref('');
const editDescription = ref('');
const router = useRouter();
const showAddTagModal = ref(false);
const newTagName = ref('');
const newTagDescription = ref('');
function goToTables() {
router.push({ name: 'tables-list' });
}
function goToCreate() {
router.push({ name: 'create-table' });
}
function closeTable() {
if (window.history.length > 1) {
router.back();
} else {
router.push({ name: 'home' });
}
}
async function loadTags() {
const res = await fetch('/api/tags');
tags.value = await res.json();
}
function startEdit(tag) {
editId.value = tag.id;
editName.value = tag.name;
editDescription.value = tag.description || '';
}
function cancelEdit() {
editId.value = null;
editName.value = '';
editDescription.value = '';
}
async function saveEdit(tag) {
await fetch(`/api/tags/${tag.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName.value, description: editDescription.value })
});
await loadTags();
cancelEdit();
}
async function deleteTag(tag) {
if (!confirm(`Удалить тег "${tag.name}"?`)) return;
await fetch(`/api/tags/${tag.id}`, { method: 'DELETE' });
await loadTags();
}
async function createTag() {
if (!newTagName.value.trim()) return;
await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName.value, description: newTagDescription.value })
});
newTagName.value = '';
newTagDescription.value = '';
showAddTagModal.value = false;
await loadTags();
}
onMounted(loadTags);
</script>
<style scoped>
.tableview-header-row {
display: flex;
justify-content: flex-end;
align-items: center;
margin: 1.2em 0 0.5em 0;
}
.tags-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1em;
}
.add-plus-btn, .edit-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1em;
margin-right: 0.5em;
color: #2ecc40;
transition: color 0.2s;
}
.add-plus-btn:hover, .edit-btn:hover, .save-btn:hover {
color: #138496;
}
.close-btn {
background: #ff4d4f;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 600;
cursor: pointer;
font-size: 1em;
transition: background 0.2s;
}
.close-btn:hover {
background: #d9363e;
}
.action-btn {
background: #2ecc40;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 600;
cursor: pointer;
font-size: 1em;
margin-left: 0.7em;
transition: background 0.2s;
}
.action-btn:hover {
background: #27ae38;
}
.danger-btn {
background: #ff4d4f;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 600;
cursor: pointer;
font-size: 1em;
margin-left: 0.7em;
transition: background 0.2s;
}
.danger-btn:hover {
background: #d9363e;
}
.nav-btn {
background: #eaeaea;
color: #333;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 500;
cursor: pointer;
font-size: 1em;
transition: background 0.2s;
margin-right: 0.7em;
}
.nav-btn:hover {
background: #d5d5d5;
}
.tags-table-wrapper {
margin: 2em 0;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 1.5em 1em;
}
.tags-table {
width: 100%;
border-collapse: collapse;
}
.tags-table th, .tags-table td {
border: 1px solid #ececec;
padding: 0.6em 1em;
font-size: 1.05em;
}
.tags-table th {
background: #f7f7f7;
font-weight: 600;
}
.delete-btn {
color: #dc3545;
}
.cancel-btn {
color: #888;
}
.edit-input {
border: 1px solid #ccc;
border-radius: 6px;
padding: 4px 10px;
font-size: 1em;
min-width: 120px;
}
/* Модалка */
.modal-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.18);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #fff;
border-radius: 12px;
padding: 2em 1.5em;
box-shadow: 0 2px 16px rgba(0,0,0,0.13);
min-width: 260px;
display: flex;
flex-direction: column;
gap: 0.7em;
}
.modal-actions {
display: flex;
gap: 1em;
margin-top: 1em;
justify-content: flex-end;
}
</style>

View File

@@ -1,63 +1,33 @@
<template> <template>
<div class="tables-container"> <div class="tables-container">
<header class="tables-header"> <header class="tables-header">
<!-- <h2>Пользовательские таблицы</h2> -->
<button class="create-btn" @click="createTable">Создать таблицу</button> <button class="create-btn" @click="createTable">Создать таблицу</button>
</header> </header>
<div class="tables-list"> <ul class="tables-list-simple">
<div <!-- Системная таблица tags -->
v-for="table in tables" <li>
:key="table.id" <button class="table-link" @click="goToTagsTable">Теги (tags)</button>
class="table-card" </li>
:class="{ selected: table.id === props.selectedTableId }" <!-- Пользовательские таблицы -->
@click="selectTable(table)" <li v-for="table in tables" :key="table.id">
> <button class="table-link" @click="selectTable(table)">{{ table.name }}</button>
<div class="table-info"> </li>
<div class="table-title">{{ table.name }}</div> <li v-if="!tables.length" class="empty-state">
<div class="table-desc">{{ table.description }}</div>
</div>
<div class="table-actions">
<button @click.stop="renameTable(table)">Переименовать</button>
<button class="danger" @click.stop="confirmDelete(table)">Удалить</button>
</div>
</div>
<div v-if="!tables.length" class="empty-state">
<span>Нет таблиц. Создайте первую!</span> <span>Нет таблиц. Создайте первую!</span>
</div> </li>
</div> </ul>
<div v-if="showDeleteModal" class="modal-backdrop">
<div class="modal">
<p>Удалить таблицу <b>{{ selectedTable?.name }}</b>?</p>
<div class="modal-actions">
<button class="danger" @click="deleteTable(selectedTable)">Удалить</button>
<button @click="showDeleteModal = false">Отмена</button>
</div>
</div>
</div>
<UserTableView
v-if="props.selectedTableId"
:table-id="props.selectedTableId"
@close="emit('update:selected-table-id', null)"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, nextTick } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import UserTableView from './UserTableView.vue';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
const props = defineProps({
selectedTableId: Number
});
const emit = defineEmits(['update:selected-table-id']);
const router = useRouter(); const router = useRouter();
const route = useRoute();
const tables = ref([]); const tables = ref([]);
const showDeleteModal = ref(false);
const selectedTable = ref(null);
async function fetchTables() { async function fetchTables() {
tables.value = await tablesService.getTables(); tables.value = await tablesService.getTables();
@@ -70,38 +40,25 @@ function selectTable(table) {
function createTable() { function createTable() {
router.push({ name: 'create-table' }); router.push({ name: 'create-table' });
} }
function goToTagsTable() {
function renameTable(table) { router.push({ name: 'tags-table-view' });
const name = prompt('Новое имя', table.name);
if (name && name !== table.name) {
tablesService.updateTable(table.id, { name }).then(fetchTables);
}
}
function confirmDelete(table) {
selectedTable.value = table;
showDeleteModal.value = true;
}
function deleteTable(table) {
tablesService.deleteTable(table.id).then(() => {
showDeleteModal.value = false;
fetchTables();
if (props.selectedTableId === table.id) emit('update:selected-table-id', null);
});
} }
</script> </script>
<style scoped> <style scoped>
.tables-container { .tables-container {
max-width: 600px; /* max-width: 600px; */
margin: 2rem auto; /* margin: 2rem auto; */
background: #fff; margin-top: 2rem;
border-radius: 18px; margin-left: 0;
box-shadow: 0 2px 16px rgba(0,0,0,0.07); padding: 2rem 1.5rem 2rem 1.5rem;
padding: 2rem 1.5rem; /* background: #fff; */
/* border-radius: 18px; */
/* box-shadow: 0 2px 16px rgba(0,0,0,0.07); */
} }
.tables-header { .tables-header {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center; align-items: center;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -118,62 +75,37 @@ function deleteTable(table) {
.create-btn:hover { .create-btn:hover {
background: #27ae38; background: #27ae38;
} }
.tables-list { .tables-list-simple {
display: flex; list-style: none;
flex-direction: column; padding: 0;
gap: 1rem; margin: 0;
} }
.table-card { .tables-list-simple li {
display: flex; margin-bottom: 0.5em;
flex-wrap: wrap; border-bottom: 1px solid #f0f0f0;
justify-content: space-between; padding-bottom: 0.5em;
align-items: flex-start;
background: #f8f9fa;
border-radius: 10px;
padding: 1rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.03);
cursor: pointer;
border: 2px solid transparent;
transition: border 0.2s;
} }
.table-card.selected { .tables-list-simple li:last-child {
border: 2px solid #2ecc40; border-bottom: none;
} }
.table-info { .table-link {
flex: 1 1 200px; background: none;
}
.table-title {
font-weight: 600;
font-size: 1.1em;
}
.table-desc {
color: #888;
font-size: 0.95em;
margin-top: 0.2em;
}
.table-actions {
display: flex;
gap: 0.5em;
margin-top: 0.5em;
}
.table-actions button {
background: #eaeaea;
border: none; border: none;
border-radius: 6px; color: #2ecc40;
padding: 0.4em 1em; font-size: 1.1em;
font-weight: 600;
cursor: pointer; cursor: pointer;
font-weight: 500; text-align: left;
transition: background 0.2s; padding: 0.2em 0;
transition: color 0.2s, background 0.2s;
width: 100%;
display: block;
border-radius: 6px;
} }
.table-actions button:hover { .table-link:hover {
background: #d5d5d5; color: #138496;
} background: #f5f7fa;
.table-actions .danger { text-decoration: none;
background: #ff4d4f;
color: #fff;
}
.table-actions .danger:hover {
background: #d9363e;
} }
.empty-state { .empty-state {
text-align: center; text-align: center;
@@ -181,49 +113,4 @@ function deleteTable(table) {
margin: 2em 0; margin: 2em 0;
font-size: 1.1em; font-size: 1.1em;
} }
/* Модалка */
.modal-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.18);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #fff;
border-radius: 12px;
padding: 2em 1.5em;
box-shadow: 0 2px 16px rgba(0,0,0,0.13);
min-width: 260px;
}
.modal-actions {
display: flex;
gap: 1em;
margin-top: 1.5em;
justify-content: flex-end;
}
/* Адаптивность */
@media (max-width: 600px) {
.tables-container {
padding: 1em 0.3em;
}
.table-card {
flex-direction: column;
gap: 0.7em;
padding: 0.7em;
}
.tables-header {
flex-direction: column;
gap: 0.7em;
align-items: flex-start;
}
.table-actions {
flex-wrap: wrap;
gap: 0.4em;
}
}
</style> </style>

View File

@@ -0,0 +1,55 @@
import { ref, onMounted, onUnmounted } from 'vue';
import { getContacts } from '../services/contactsService';
import { getAllMessages } from '../services/messagesService';
export function useContactsAndMessagesWebSocket() {
const contacts = ref([]);
const messages = ref([]);
const newContacts = ref([]);
const newMessages = ref([]);
let ws = null;
let lastContactId = null;
let lastMessageId = null;
async function fetchContacts() {
const all = await getContacts();
contacts.value = all;
if (lastContactId) {
newContacts.value = all.filter(c => c.id > lastContactId);
} else {
newContacts.value = [];
}
if (all.length) lastContactId = Math.max(...all.map(c => c.id));
}
async function fetchMessages() {
const all = await getAllMessages();
messages.value = all;
if (lastMessageId) {
newMessages.value = all.filter(m => m.id > lastMessageId);
} else {
newMessages.value = [];
}
if (all.length) lastMessageId = Math.max(...all.map(m => m.id));
}
onMounted(() => {
fetchContacts();
fetchMessages();
ws = new WebSocket('ws://localhost:8000');
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'contacts-updated') fetchContacts();
if (data.type === 'messages-updated') fetchMessages();
} catch (e) {}
};
});
onUnmounted(() => { if (ws) ws.close(); });
function markContactsAsRead() { newContacts.value = []; }
function markMessagesAsRead() { newMessages.value = []; }
return { contacts, messages, newContacts, newMessages, markContactsAsRead, markMessagesAsRead };
}

View File

@@ -92,6 +92,11 @@ const routes = [
component: () => import('../views/tables/DeleteTableView.vue'), component: () => import('../views/tables/DeleteTableView.vue'),
props: true props: true
}, },
{
path: '/tables/tags',
name: 'tags-table-view',
component: () => import('../views/tables/TagsTableViewPage.vue')
},
{ {
path: '/contacts/:id', path: '/contacts/:id',
name: 'contact-details', name: 'contact-details',
@@ -104,6 +109,16 @@ const routes = [
component: () => import('../views/contacts/ContactDeleteConfirm.vue'), component: () => import('../views/contacts/ContactDeleteConfirm.vue'),
props: true props: true
}, },
{
path: '/contacts-list',
name: 'contacts-list',
component: () => import('../views/ContactsView.vue')
},
{
path: '/dle-management',
name: 'dle-management',
component: () => import('../views/DleManagementView.vue')
},
]; ];
const router = createRouter({ const router = createRouter({

View File

@@ -29,4 +29,13 @@ export default {
} }
return null; return null;
} }
}; };
export async function getContacts() {
const res = await fetch('/api/users');
const data = await res.json();
if (data && data.success) {
return data.contacts;
}
return [];
}

View File

@@ -6,4 +6,9 @@ export default {
const { data } = await axios.get(`/api/messages?userId=${userId}`); const { data } = await axios.get(`/api/messages?userId=${userId}`);
return data; return data;
} }
}; };
export async function getAllMessages() {
const { data } = await axios.get('/api/messages');
return data;
}

View File

@@ -0,0 +1,74 @@
<template>
<BaseLayout>
<div class="contacts-tabs">
<button :class="{active: tab==='all'}" @click="tab='all'">Все контакты</button>
<button :class="{active: tab==='newContacts'}" @click="tab='newContacts'; markContactsAsRead()">
Новые контакты
<span v-if="newContacts.length" class="badge">{{ newContacts.length }}</span>
</button>
<button :class="{active: tab==='newMessages'}" @click="tab='newMessages'; markMessagesAsRead()">
Новые сообщения
<span v-if="newMessages.length" class="badge">{{ newMessages.length }}</span>
</button>
</div>
<ContactTable v-if="tab==='all'" :contacts="contacts" />
<ContactTable v-if="tab==='newContacts'" :contacts="newContacts" />
<MessagesTable v-if="tab==='newMessages'" :messages="newMessages" />
</BaseLayout>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import BaseLayout from '../components/BaseLayout.vue';
import ContactTable from '../components/ContactTable.vue';
import MessagesTable from '../components/MessagesTable.vue';
import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket';
const tab = ref('all');
const {
contacts, newContacts, newMessages,
markContactsAsRead, markMessagesAsRead
} = useContactsAndMessagesWebSocket();
const router = useRouter();
function goBack() {
if (window.history.length > 1) {
router.back();
} else {
router.push({ name: 'crm' });
}
}
</script>
<style scoped>
.contacts-tabs {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.contacts-tabs button {
background: #f5f7fa;
border: none;
border-radius: 8px 8px 0 0;
padding: 10px 22px;
font-size: 1.08rem;
cursor: pointer;
position: relative;
transition: background 0.18s, color 0.18s;
}
.contacts-tabs button.active {
background: #fff;
color: #17a2b8;
font-weight: 600;
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
}
.badge {
background: #dc3545;
color: #fff;
border-radius: 10px;
padding: 2px 8px;
font-size: 0.95em;
margin-left: 7px;
}
</style>

View File

@@ -9,18 +9,16 @@
<div class="crm-view-container"> <div class="crm-view-container">
<div class="dle-management-block"> <div class="dle-management-block">
<h2>Управление DLE</h2> <h2>Управление DLE</h2>
<button class="btn btn-info" @click="showDleManagement = true"> <button class="btn btn-info" @click="goToDleManagement">
<i class="fas fa-cogs"></i> Подробнее <i class="fas fa-cogs"></i> Подробнее
</button> </button>
</div> </div>
<DleManagement v-if="showDleManagement" :dle-list="dleList" :selected-dle-index="selectedDleIndex" @close="showDleManagement = false" />
<div class="crm-contacts-block"> <div class="crm-contacts-block">
<h2>Контакты</h2> <h2>Контакты</h2>
<button class="btn btn-info" @click="showContacts = true"> <button class="btn btn-info" @click="goToContactsList">
<i class="fas fa-address-book"></i> Подробнее <i class="fas fa-address-book"></i> Подробнее
</button> </button>
</div> </div>
<ContactTable v-if="showContacts" :contacts="contacts" @close="showContacts = false" @show-details="openContactDetails" />
<div class="crm-tables-block"> <div class="crm-tables-block">
<h2>Таблицы</h2> <h2>Таблицы</h2>
<button class="btn btn-info" @click="goToTables"> <button class="btn btn-info" @click="goToTables">
@@ -189,6 +187,14 @@ function onContactDeleted() {
function goToTables() { function goToTables() {
router.push({ name: 'tables-list' }); router.push({ name: 'tables-list' });
} }
function goToDleManagement() {
router.push({ name: 'dle-management' });
}
function goToContactsList() {
router.push({ name: 'contacts-list' });
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,47 @@
<template>
<BaseLayout>
<DleManagement
:dle-list="dleList"
:selected-dle-index="selectedDleIndex"
@close="goBack"
class="dle-management-root"
/>
</BaseLayout>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import BaseLayout from '../components/BaseLayout.vue';
import DleManagement from '../components/DleManagement.vue';
import dleService from '../services/dleService';
const dleList = ref([]);
const selectedDleIndex = ref(0);
const router = useRouter();
function goBack() {
if (window.history.length > 1) {
router.back();
} else {
router.push({ name: 'crm' });
}
}
onMounted(async () => {
dleList.value = await dleService.getAllDLEs() || [];
});
</script>
<style scoped>
.dle-management-root {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
width: 100%;
margin-top: 40px;
position: relative;
overflow-x: auto;
}
</style>

View File

@@ -1,109 +1,110 @@
<template> <template>
<div class="contact-details-page"> <BaseLayout>
<Header :isSidebarOpen="isSidebarOpen" @toggle-sidebar="toggleSidebar" /> <div class="contact-details-page">
<div v-if="isLoading">Загрузка...</div> <div v-if="isLoading">Загрузка...</div>
<div v-else-if="!contact">Контакт не найден</div> <div v-else-if="!contact">Контакт не найден</div>
<div v-else class="contact-details-content"> <div v-else class="contact-details-content">
<div class="contact-details-header"> <div class="contact-details-header">
<h2>Детали контакта</h2> <h2>Детали контакта</h2>
<router-link class="back-btn" :to="{ name: 'crm' }"> Назад к списку</router-link> <button class="close-btn" @click="goBack">×</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>
<div><strong>Email:</strong> {{ contact.email || '-' }}</div> <div class="contact-info-block">
<div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div> <div>
<div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div> <strong>Имя:</strong>
<div> <input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
<strong>Язык:</strong> <span v-if="isSavingName" class="saving">Сохранение...</span>
<div class="multi-select"> </div>
<div class="selected-langs"> <div><strong>Email:</strong> {{ contact.email || '-' }}</div>
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag"> <div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div>
{{ getLanguageLabel(lang) }} <div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div>
<span class="remove-tag" @click="removeLanguage(lang)">×</span> <div>
</span> <strong>Язык:</strong>
<input <div class="multi-select">
v-model="langInput" <div class="selected-langs">
@focus="showLangDropdown = true" <span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
@input="showLangDropdown = true" {{ getLanguageLabel(lang) }}
@keydown.enter.prevent="addLanguageFromInput" <span class="remove-tag" @click="removeLanguage(lang)">×</span>
class="lang-input" </span>
placeholder="Добавить язык..." <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> </div>
<ul v-if="showLangDropdown" class="lang-dropdown"> <span v-if="isSavingLangs" class="saving">Сохранение...</span>
<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> </div>
<span v-if="isSavingLangs" class="saving">Сохранение...</span> <div><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</div>
</div> <div><strong>Дата последнего сообщения:</strong> {{ formatDate(lastMessageDate) }}</div>
<div><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</div> <div class="user-tags-block">
<div><strong>Дата последнего сообщения:</strong> {{ formatDate(lastMessageDate) }}</div> <strong>Теги пользователя:</strong>
<div class="user-tags-block"> <span v-for="tag in userTags" :key="tag.id" class="user-tag">
<strong>Теги пользователя:</strong> {{ tag.name }}
<span v-for="tag in userTags" :key="tag.id" class="user-tag"> <span class="remove-tag" @click="removeUserTag(tag.id)">×</span>
{{ 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> </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>
</div> </div>
<div style="margin-top: 1em;"> <el-dialog v-model="showTagModal" title="Добавить тег пользователю">
<el-input v-model="newTagName" placeholder="Новый тег" /> <div v-if="allTags.length">
<el-input v-model="newTagDescription" placeholder="Описание" /> <el-select
<el-button type="primary" @click="createTag">Создать тег</el-button> v-model="selectedTags"
</div> multiple
</el-dialog> 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> </div>
</div> </BaseLayout>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Header from '../../components/Header.vue'; import BaseLayout from '../../components/BaseLayout.vue';
import Message from '../../components/Message.vue'; import Message from '../../components/Message.vue';
import contactsService from '../../services/contactsService.js'; import contactsService from '../../services/contactsService.js';
import messagesService from '../../services/messagesService.js'; import messagesService from '../../services/messagesService.js';
@@ -125,7 +126,6 @@ const showTagModal = ref(false);
const newTagName = ref(''); const newTagName = ref('');
const newTagDescription = ref(''); const newTagDescription = ref('');
const messages = ref([]); const messages = ref([]);
const isSidebarOpen = ref(false);
function toggleSidebar() { function toggleSidebar() {
isSidebarOpen.value = !isSidebarOpen.value; isSidebarOpen.value = !isSidebarOpen.value;
@@ -288,6 +288,14 @@ async function reloadContact() {
} }
} }
function goBack() {
if (window.history.length > 1) {
router.back();
} else {
router.push({ name: 'crm' });
}
}
onMounted(async () => { onMounted(async () => {
await reloadContact(); await reloadContact();
await loadMessages(); await loadMessages();
@@ -302,19 +310,17 @@ watch(userId, async () => {
<style scoped> <style scoped>
.contact-details-page { .contact-details-page {
max-width: 900px;
margin: 0 auto;
padding: 32px 0; padding: 32px 0;
} }
.contact-details-content { .contact-details-content {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px; padding: 32px 24px 24px 24px;
max-width: 700px; width: 100%;
margin: 40px auto; margin-top: 40px;
position: relative; position: relative;
overflow-x: auto; overflow-x: auto;
background: none;
border-radius: 0;
box-shadow: none;
} }
.contact-details-header { .contact-details-header {
display: flex; display: flex;
@@ -322,17 +328,16 @@ watch(userId, async () => {
align-items: center; align-items: center;
margin-bottom: 18px; margin-bottom: 18px;
} }
.back-btn { .close-btn {
background: none; background: none;
border: none; border: none;
color: #17a2b8; font-size: 2rem;
font-size: 1.1rem;
cursor: pointer; cursor: pointer;
text-decoration: underline; color: #bbb;
padding: 0; transition: color 0.2s;
} }
.back-btn:hover { .close-btn:hover {
color: #138496; color: #333;
} }
.contact-info-block { .contact-info-block {
margin-bottom: 18px; margin-bottom: 18px;

View File

@@ -1,13 +1,15 @@
<template> <template>
<BaseLayout> <BaseLayout>
<div class="tableview-header-row"> <div class="table-block-wrapper">
<button class="nav-btn" @click="goToTables">Таблицы</button> <div class="tableview-header-row">
<button class="nav-btn" @click="goToCreate">Создать таблицу</button> <button class="nav-btn" @click="goToTables">Таблицы</button>
<button class="close-btn" @click="closeTable">Закрыть</button> <button class="nav-btn" @click="goToCreate">Создать таблицу</button>
<button class="action-btn" @click="goToEdit">Редактировать</button> <button class="close-btn" @click="closeTable">Закрыть</button>
<button class="danger-btn" @click="goToDelete">Удалить</button> <button class="action-btn" @click="goToEdit">Редактировать</button>
<button class="danger-btn" @click="goToDelete">Удалить</button>
</div>
<UserTableView :table-id="Number($route.params.id)" />
</div> </div>
<UserTableView :table-id="Number($route.params.id)" />
</BaseLayout> </BaseLayout>
</template> </template>
@@ -44,6 +46,16 @@ function goToCreate() {
</script> </script>
<style scoped> <style scoped>
.table-block-wrapper {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
max-width: 950px;
margin: 40px auto;
position: relative;
overflow-x: auto;
}
.tableview-header-row { .tableview-header-row {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -1,11 +1,51 @@
<template> <template>
<BaseLayout> <BaseLayout>
<h2>Список таблиц</h2> <div class="tables-list-block">
<UserTablesList /> <button class="close-btn" @click="goBack">×</button>
<h2>Список таблиц</h2>
<UserTablesList />
</div>
</BaseLayout> </BaseLayout>
</template> </template>
<script setup> <script setup>
import BaseLayout from '../../components/BaseLayout.vue'; import BaseLayout from '../../components/BaseLayout.vue';
import UserTablesList from '../../components/tables/UserTablesList.vue'; import UserTablesList from '../../components/tables/UserTablesList.vue';
</script> import { useRouter } from 'vue-router';
// import TagsTableView from '../../components/tables/TagsTableView.vue'; // больше не используется
const router = useRouter();
function goBack() {
if (window.history.length > 1) {
router.back();
} else {
router.push({ name: 'crm' });
}
}
</script>
<style scoped>
.tables-list-block {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
width: 100%;
margin-top: 40px;
position: relative;
overflow-x: auto;
}
.close-btn {
position: absolute;
top: 18px;
right: 18px;
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #bbb;
transition: color 0.2s;
}
.close-btn:hover {
color: #333;
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<BaseLayout>
<TagsTableView />
</BaseLayout>
</template>
<script setup>
import BaseLayout from '../../components/BaseLayout.vue';
import TagsTableView from '../../components/tables/TagsTableView.vue';
</script>