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

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;
if (userId) {
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 `SELECT id, user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata
FROM messages FROM messages
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at ASC`, ORDER BY created_at ASC`,
[userId] [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,6 +234,8 @@ class EmailBotService {
} }
async start() { async start() {
try {
console.log('[EmailBot] start() called');
logger.info('[EmailBot] start() called'); logger.info('[EmailBot] start() called');
const imapConfig = await this.getImapConfig(); const imapConfig = await this.getImapConfig();
// Логируем IMAP-конфиг (без пароля) // Логируем IMAP-конфиг (без пароля)
@@ -276,7 +284,13 @@ class EmailBotService {
this.imap.connect(); this.imap.connect();
}; };
tryConnect(); tryConnect();
} catch (err) {
console.error('[EmailBot] Ошибка при старте:', err);
logger.error('[EmailBot] Ошибка при старте:', err);
throw err;
}
} }
} }
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

@@ -30,3 +30,12 @@ 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

@@ -7,3 +7,8 @@ export default {
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,12 +1,12 @@
<template> <template>
<BaseLayout>
<div class="contact-details-page"> <div class="contact-details-page">
<Header :isSidebarOpen="isSidebarOpen" @toggle-sidebar="toggleSidebar" />
<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>
<div class="contact-info-block"> <div class="contact-info-block">
<div> <div>
@@ -98,12 +98,13 @@
</el-dialog> </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,5 +1,6 @@
<template> <template>
<BaseLayout> <BaseLayout>
<div class="table-block-wrapper">
<div class="tableview-header-row"> <div class="tableview-header-row">
<button class="nav-btn" @click="goToTables">Таблицы</button> <button class="nav-btn" @click="goToTables">Таблицы</button>
<button class="nav-btn" @click="goToCreate">Создать таблицу</button> <button class="nav-btn" @click="goToCreate">Создать таблицу</button>
@@ -8,6 +9,7 @@
<button class="danger-btn" @click="goToDelete">Удалить</button> <button class="danger-btn" @click="goToDelete">Удалить</button>
</div> </div>
<UserTableView :table-id="Number($route.params.id)" /> <UserTableView :table-id="Number($route.params.id)" />
</div>
</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>
<div class="tables-list-block">
<button class="close-btn" @click="goBack">×</button>
<h2>Список таблиц</h2> <h2>Список таблиц</h2>
<UserTablesList /> <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';
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> </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>