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

This commit is contained in:
2025-07-27 20:55:24 +03:00
parent 6dbaf91121
commit 34be65743b
14 changed files with 1124 additions and 126 deletions

View File

@@ -163,8 +163,8 @@ router.post('/verify', async (req, res) => {
} }
// Создаем SIWE сообщение для проверки подписи // Создаем SIWE сообщение для проверки подписи
const domain = 'localhost:5173'; // Используем тот же домен, что и на frontend
const origin = req.get('origin') || 'http://localhost:5173'; const origin = req.get('origin') || 'http://localhost:5173';
const domain = new URL(origin).host; // Извлекаем домен из origin
const { SiweMessage } = require('siwe'); const { SiweMessage } = require('siwe');
const message = new SiweMessage({ const message = new SiweMessage({
@@ -184,6 +184,9 @@ router.post('/verify', async (req, res) => {
logger.info(`[verify] SIWE message for verification: ${messageToSign}`); logger.info(`[verify] SIWE message for verification: ${messageToSign}`);
logger.info(`[verify] Domain: ${domain}, Origin: ${origin}`); logger.info(`[verify] Domain: ${domain}, Origin: ${origin}`);
logger.info(`[verify] Normalized address: ${normalizedAddress}`); logger.info(`[verify] Normalized address: ${normalizedAddress}`);
logger.info(`[verify] Request headers origin: ${req.get('origin')}`);
logger.info(`[verify] Request headers host: ${req.get('host')}`);
logger.info(`[verify] Request headers referer: ${req.get('referer')}`);
// Проверяем подпись // Проверяем подпись
const isValid = await authService.verifySignature(messageToSign, signature, normalizedAddress); const isValid = await authService.verifySignature(messageToSign, signature, normalizedAddress);

View File

@@ -15,6 +15,7 @@ const router = express.Router();
const { requireAdmin } = require('../middleware/auth'); const { requireAdmin } = require('../middleware/auth');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { ethers } = require('ethers'); const { ethers } = require('ethers');
const db = require('../db');
const rpcProviderService = require('../services/rpcProviderService'); const rpcProviderService = require('../services/rpcProviderService');
const authTokenService = require('../services/authTokenService'); const authTokenService = require('../services/authTokenService');
const aiProviderSettingsService = require('../services/aiProviderSettingsService'); const aiProviderSettingsService = require('../services/aiProviderSettingsService');

View File

@@ -17,6 +17,7 @@ const router = express.Router();
const db = require('../db'); const db = require('../db');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const vectorSearchClient = require('../services/vectorSearchClient'); const vectorSearchClient = require('../services/vectorSearchClient');
const { broadcastTableUpdate, broadcastTableRelationsUpdate } = require('../wsHub');
router.use((req, res, next) => { router.use((req, res, next) => {
console.log('Tables router received:', req.method, req.originalUrl); console.log('Tables router received:', req.method, req.originalUrl);
@@ -32,7 +33,7 @@ router.get('/', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -58,7 +59,7 @@ router.post('/', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -85,7 +86,7 @@ router.get('/rag-sources', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -115,7 +116,7 @@ router.get('/:id', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -123,11 +124,26 @@ router.get('/:id', async (req, res, next) => {
console.error('Error reading encryption key:', keyError); console.error('Error reading encryption key:', keyError);
} }
const tableMetaResult = await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name, decrypt_text(description_encrypted, $2) as description FROM user_tables WHERE id = $1', [tableId, encryptionKey]); // Выполняем все 4 запроса параллельно для ускорения
const [tableMetaResult, columnsResult, rowsResult, cellValuesResult] = await Promise.all([
// 1. Метаданные таблицы
db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name, decrypt_text(description_encrypted, $2) as description FROM user_tables WHERE id = $1', [tableId, encryptionKey]),
// 2. Столбцы
db.getQuery()('SELECT id, table_id, "order", created_at, updated_at, decrypt_text(name_encrypted, $2) as name, decrypt_text(type_encrypted, $2) as type, decrypt_text(placeholder_encrypted, $2) as placeholder_encrypted, options, placeholder FROM user_columns WHERE table_id = $1 ORDER BY "order" ASC, id ASC', [tableId, encryptionKey]),
// 3. Строки
db.getQuery()('SELECT * FROM user_rows WHERE table_id = $1 ORDER BY id', [tableId]),
// 4. Значения ячеек
db.getQuery()('SELECT id, row_id, column_id, created_at, updated_at, decrypt_text(value_encrypted, $2) as value FROM user_cell_values WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1)', [tableId, encryptionKey])
]);
const tableMeta = tableMetaResult.rows[0] || { name: '', description: '' }; const tableMeta = tableMetaResult.rows[0] || { name: '', description: '' };
const columns = (await db.getQuery()('SELECT id, table_id, "order", created_at, updated_at, decrypt_text(name_encrypted, $2) as name, decrypt_text(type_encrypted, $2) as type, decrypt_text(placeholder_encrypted, $2) as placeholder_encrypted, placeholder FROM user_columns WHERE table_id = $1 ORDER BY "order" ASC, id ASC', [tableId, encryptionKey])).rows; const columns = columnsResult.rows;
const rows = (await db.getQuery()('SELECT * FROM user_rows WHERE table_id = $1 ORDER BY id', [tableId])).rows; const rows = rowsResult.rows;
const cellValues = (await db.getQuery()('SELECT id, row_id, column_id, created_at, updated_at, decrypt_text(value_encrypted, $2) as value FROM user_cell_values WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1)', [tableId, encryptionKey])).rows; const cellValues = cellValuesResult.rows;
res.json({ name: tableMeta.name, description: tableMeta.description, columns, rows, cellValues }); res.json({ name: tableMeta.name, description: tableMeta.description, columns, rows, cellValues });
} catch (err) { } catch (err) {
next(err); next(err);
@@ -177,7 +193,7 @@ router.post('/:id/columns', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -194,6 +210,7 @@ router.post('/:id/columns', async (req, res, next) => {
[tableId, name, type, order || 0, placeholder, placeholder, encryptionKey] [tableId, name, type, order || 0, placeholder, placeholder, encryptionKey]
); );
res.json(result.rows[0]); res.json(result.rows[0]);
broadcastTableUpdate(tableId);
} catch (err) { } catch (err) {
next(err); next(err);
} }
@@ -214,7 +231,7 @@ router.post('/:id/rows', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -231,6 +248,7 @@ router.post('/:id/rows', async (req, res, next) => {
} }
console.log('[DEBUG][addRow] res.json:', result.rows[0]); console.log('[DEBUG][addRow] res.json:', result.rows[0]);
res.json(result.rows[0]); res.json(result.rows[0]);
broadcastTableUpdate(tableId);
} catch (err) { } catch (err) {
next(err); next(err);
} }
@@ -247,7 +265,7 @@ router.get('/:id/rows', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -331,7 +349,7 @@ router.patch('/cell/:cellId', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -362,6 +380,7 @@ router.patch('/cell/:cellId', async (req, res, next) => {
} }
} }
res.json(result.rows[0]); res.json(result.rows[0]);
broadcastTableUpdate(tableId);
} catch (err) { } catch (err) {
next(err); next(err);
} }
@@ -377,7 +396,7 @@ router.post('/cell', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -391,10 +410,20 @@ router.post('/cell', async (req, res, next) => {
RETURNING *`, RETURNING *`,
[row_id, column_id, value, encryptionKey] [row_id, column_id, value, encryptionKey]
); );
// Получаем table_id
// Получаем table_id и проверяем, является ли это таблицей тегов
const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [row_id])).rows[0]; const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [row_id])).rows[0];
if (table) { if (table) {
const tableId = table.table_id; const tableId = table.table_id;
// Проверяем, является ли это таблицей "Теги клиентов" - ОТКЛЮЧАЕМ WebSocket
// const tableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [tableId, encryptionKey])).rows[0];
// if (tableName && tableName.name === 'Теги клиентов') {
// // Отправляем WebSocket уведомление об обновлении тегов
// const { broadcastTagsUpdate } = require('../wsHub');
// broadcastTagsUpdate();
// }
// Получаем всю строку для upsert // Получаем всю строку для upsert
const rowData = (await db.getQuery()('SELECT r.id as row_id, decrypt_text(c.value_encrypted, $2) as text, decrypt_text(c2.value_encrypted, $2) as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.id = $1', [row_id, encryptionKey])).rows[0]; const rowData = (await db.getQuery()('SELECT r.id as row_id, decrypt_text(c.value_encrypted, $2) as text, decrypt_text(c2.value_encrypted, $2) as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.id = $1', [row_id, encryptionKey])).rows[0];
if (rowData) { if (rowData) {
@@ -404,7 +433,12 @@ router.post('/cell', async (req, res, next) => {
await vectorSearchClient.upsert(tableId, upsertRows); await vectorSearchClient.upsert(tableId, upsertRows);
} }
} }
// Отправляем WebSocket уведомление об обновлении таблицы
const { broadcastTableUpdate } = require('../wsHub');
broadcastTableUpdate(tableId);
} }
res.json(result.rows[0]); res.json(result.rows[0]);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -417,7 +451,29 @@ router.delete('/row/:rowId', async (req, res, next) => {
const rowId = req.params.rowId; const rowId = req.params.rowId;
// Получаем table_id // Получаем table_id
const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [rowId])).rows[0]; const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [rowId])).rows[0];
// Проверяем, является ли это таблицей тегов перед удалением
let isTagsTable = false;
if (table) {
const fs = require('fs');
const path = require('path');
let encryptionKey = 'default-key';
try {
const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
}
} catch (keyError) {
console.error('Error reading encryption key:', keyError);
}
const tableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [table.table_id, encryptionKey])).rows[0];
isTagsTable = tableName && tableName.name === 'Теги клиентов';
}
await db.getQuery()('DELETE FROM user_rows WHERE id = $1', [rowId]); await db.getQuery()('DELETE FROM user_rows WHERE id = $1', [rowId]);
if (table) { if (table) {
const tableId = table.table_id; const tableId = table.table_id;
// Получаем все строки для rebuild // Получаем все строки для rebuild
@@ -427,7 +483,7 @@ router.delete('/row/:rowId', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -442,6 +498,17 @@ router.delete('/row/:rowId', async (req, res, next) => {
await vectorSearchClient.rebuild(tableId, rebuildRows); await vectorSearchClient.rebuild(tableId, rebuildRows);
} }
} }
// Отправляем WebSocket уведомление, если это была таблица тегов - ОТКЛЮЧАЕМ
// if (isTagsTable) {
// const { broadcastTagsUpdate } = require('../wsHub');
// broadcastTagsUpdate();
// }
// Отправляем WebSocket уведомление об обновлении таблицы
const { broadcastTableUpdate } = require('../wsHub');
broadcastTableUpdate(tableId);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
next(err); next(err);
@@ -471,7 +538,7 @@ router.patch('/column/:columnId', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }
@@ -523,6 +590,7 @@ router.patch('/column/:columnId', async (req, res, next) => {
return res.status(404).json({ error: 'Column not found' }); return res.status(404).json({ error: 'Column not found' });
} }
res.json(result.rows[0]); res.json(result.rows[0]);
broadcastTableUpdate(colInfo.table_id);
} catch (err) { } catch (err) {
next(err); next(err);
} }
@@ -588,21 +656,45 @@ router.post('/:id/rebuild-index', requireAuth, async (req, res, next) => {
if (!req.session.isAdmin) { if (!req.session.isAdmin) {
return res.status(403).json({ error: 'Доступ только для администратора' }); return res.status(403).json({ error: 'Доступ только для администратора' });
} }
// Получаем ключ шифрования
const fs = require('fs');
let encryptionKey = 'default-key';
try {
const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
}
} catch (keyError) {
console.error('Error reading encryption key:', keyError);
}
const tableId = req.params.id; const tableId = req.params.id;
const { questionCol, answerCol } = await getQuestionAnswerColumnIds(tableId); const { questionCol, answerCol } = await getQuestionAnswerColumnIds(tableId);
if (!questionCol || !answerCol) { if (!questionCol || !answerCol) {
return res.status(400).json({ error: 'Не найдены колонки с вопросами и ответами' }); return res.status(400).json({ error: 'Не найдены колонки с вопросами и ответами' });
} }
const rows = (await db.getQuery()( const rows = (await db.getQuery()(
`SELECT r.id as row_id, c.value as text, c2.value as answer `SELECT r.id as row_id,
decrypt_text(c.value_encrypted, $4) as text,
decrypt_text(c2.value_encrypted, $4) as answer
FROM user_rows r FROM user_rows r
LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = $2 LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = $2
LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = $3 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = $3
WHERE r.table_id = $1`, WHERE r.table_id = $1`,
[tableId, questionCol, answerCol] [tableId, questionCol, answerCol, encryptionKey]
)).rows; )).rows;
const rebuildRows = rows.filter(r => r.row_id && r.text).map(r => ({ row_id: r.row_id, text: r.text, metadata: { answer: r.answer } }));
const rebuildRows = rows.filter(r => r.row_id && r.text).map(r => ({
row_id: r.row_id,
text: r.text,
metadata: { answer: r.answer }
}));
console.log('[DEBUG][rebuildRows]', rebuildRows); console.log('[DEBUG][rebuildRows]', rebuildRows);
if (rebuildRows.length > 0) { if (rebuildRows.length > 0) {
await vectorSearchClient.rebuild(tableId, rebuildRows); await vectorSearchClient.rebuild(tableId, rebuildRows);
res.json({ success: true, count: rebuildRows.length }); res.json({ success: true, count: rebuildRows.length });
@@ -708,6 +800,10 @@ router.post('/:tableId/row/:rowId/multirelations', async (req, res, next) => {
[rowId, column_id, to_table_id, to_row_id] [rowId, column_id, to_table_id, to_row_id]
); );
} }
// Отправляем WebSocket уведомление об обновлении связей
broadcastTableRelationsUpdate(tableId, rowId);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
next(err); next(err);
@@ -724,7 +820,7 @@ router.get('/:id/placeholders', async (req, res, next) => {
let encryptionKey = 'default-key'; let encryptionKey = 'default-key';
try { try {
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); const keyPath = '/app/ssl/keys/full_db_encryption.key';
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
} }

View File

@@ -524,6 +524,11 @@ router.patch('/:id/tags', async (req, res) => {
[userId, tagId] [userId, tagId]
); );
} }
// Отправляем WebSocket уведомление об обновлении тегов - ОТКЛЮЧАЕМ
// const { broadcastTagsUpdate } = require('../wsHub');
// broadcastTagsUpdate();
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });
@@ -553,6 +558,11 @@ router.delete('/:id/tags/:tagId', async (req, res) => {
'DELETE FROM user_tag_links WHERE user_id = $1 AND tag_id = $2', 'DELETE FROM user_tag_links WHERE user_id = $1 AND tag_id = $2',
[userId, tagId] [userId, tagId]
); );
// Отправляем WebSocket уведомление об обновлении тегов - ОТКЛЮЧАЕМ
// const { broadcastTagsUpdate } = require('../wsHub');
// broadcastTagsUpdate();
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });

View File

@@ -16,6 +16,10 @@ let wss = null;
// Храним клиентов по userId для персонализированных уведомлений // Храним клиентов по userId для персонализированных уведомлений
const wsClients = new Map(); // userId -> Set of WebSocket connections const wsClients = new Map(); // userId -> Set of WebSocket connections
// Кэш для отслеживания изменений тегов
const tagsChangeCache = new Map();
const TAGS_CACHE_TTL = 5000; // 5 секунд
function initWSS(server) { function initWSS(server) {
wss = new WebSocket.Server({ server, path: '/ws' }); wss = new WebSocket.Server({ server, path: '/ws' });
@@ -172,6 +176,96 @@ function broadcastConversationUpdate(conversationId, targetUserId = null) {
} }
} }
function broadcastTableUpdate(tableId) {
console.log('📢 [WebSocket] Отправка обновления таблицы', tableId);
const payload = { type: 'table-updated', tableId };
for (const [userId, clients] of wsClients.entries()) {
for (const ws of clients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
}
}
function broadcastTableRelationsUpdate(tableId, rowId, targetUserId = null) {
console.log(`📢 [WebSocket] Отправка обновления связей таблицы`, {
tableId,
rowId,
targetUserId
});
const payload = {
type: 'table-relations-updated',
tableId,
rowId
};
if (targetUserId) {
// Отправляем конкретному пользователю
const userClients = wsClients.get(targetUserId.toString());
if (userClients) {
for (const ws of userClients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
}
} else {
// Отправляем всем
for (const [userId, clients] of wsClients.entries()) {
for (const ws of clients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
}
}
}
function broadcastTagsUpdate(targetUserId = null) {
const now = Date.now();
const cacheKey = targetUserId || 'global';
// Проверяем, не отправляли ли мы недавно уведомление
const lastUpdate = tagsChangeCache.get(cacheKey);
if (lastUpdate && (now - lastUpdate) < TAGS_CACHE_TTL) {
console.log(`🏷️ [WebSocket] Пропускаем отправку уведомления о тегах (слишком часто)`, { targetUserId });
return;
}
// Обновляем кэш
tagsChangeCache.set(cacheKey, now);
console.log(`🏷️ [WebSocket] Отправка обновления тегов`, { targetUserId });
const payload = {
type: 'tags-updated',
timestamp: now
};
if (targetUserId) {
// Отправляем конкретному пользователю
const userClients = wsClients.get(targetUserId.toString());
if (userClients) {
for (const ws of userClients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
}
} else {
// Отправляем всем
for (const [userId, clients] of wsClients.entries()) {
for (const ws of clients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
}
}
}
function getConnectedUsers() { function getConnectedUsers() {
const users = []; const users = [];
for (const [userId, clients] of wsClients.entries()) { for (const [userId, clients] of wsClients.entries()) {
@@ -210,6 +304,9 @@ module.exports = {
broadcastMessagesUpdate, broadcastMessagesUpdate,
broadcastChatMessage, broadcastChatMessage,
broadcastConversationUpdate, broadcastConversationUpdate,
broadcastTableUpdate,
broadcastTableRelationsUpdate,
broadcastTagsUpdate,
getConnectedUsers, getConnectedUsers,
getStats getStats
}; };

View File

@@ -104,13 +104,14 @@
</template> </template>
<script setup> <script setup>
import { defineProps, computed, ref, onMounted, watch } from 'vue'; import { defineProps, computed, ref, onMounted, watch, onUnmounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElSelect, ElOption, ElForm, ElFormItem, ElInput, ElDatePicker, ElCheckbox, ElButton, ElMessageBox, ElMessage } from 'element-plus'; import { ElSelect, ElOption, ElForm, ElFormItem, ElInput, ElDatePicker, ElCheckbox, ElButton, ElMessageBox, ElMessage } from 'element-plus';
import ImportContactsModal from './ImportContactsModal.vue'; import ImportContactsModal from './ImportContactsModal.vue';
import BroadcastModal from './BroadcastModal.vue'; import BroadcastModal from './BroadcastModal.vue';
import tablesService from '../services/tablesService'; import tablesService from '../services/tablesService';
import messagesService from '../services/messagesService'; import messagesService from '../services/messagesService';
import { useTagsWebSocket } from '../composables/useTagsWebSocket';
const props = defineProps({ const props = defineProps({
contacts: { type: Array, default: () => [] }, contacts: { type: Array, default: () => [] },
newContacts: { type: Array, default: () => [] }, newContacts: { type: Array, default: () => [] },
@@ -141,38 +142,81 @@ const showBroadcastModal = ref(false);
const selectedIds = ref([]); const selectedIds = ref([]);
const selectAll = ref(false); const selectAll = ref(false);
// WebSocket для тегов - ОТКЛЮЧАЕМ из-за циклических запросов
// const { onTagsUpdate } = useTagsWebSocket();
// let unsubscribeFromTags = null;
let lastTagsHash = ref(''); // Хеш последних загруженных тегов
let tagsUpdateInterval = null; // Интервал для периодического обновления тегов
onMounted(async () => { onMounted(async () => {
await fetchContacts(); await fetchContacts();
await loadAvailableTags(); // ВРЕМЕННО ОТКЛЮЧАЕМ - await loadAvailableTags();
// ВРЕМЕННО ОТКЛЮЧАЕМ - Вместо WebSocket используем периодическое обновление каждые 30 секунд
// tagsUpdateInterval = setInterval(async () => {
// console.log('[ContactTable] Периодическое обновление тегов');
// await loadAvailableTags();
// }, 30000); // 30 секунд
// Подписываемся на обновления тегов - ОТКЛЮЧАЕМ
// unsubscribeFromTags = onTagsUpdate(async () => {
// console.log('[ContactTable] Получено обновление тегов, проверяем необходимость перезагрузки');
// await loadAvailableTags();
// });
}); });
async function loadAvailableTags() { onUnmounted(() => {
try { // Отписываемся от WebSocket при размонтировании - ОТКЛЮЧАЕМ
// Получаем все пользовательские таблицы и ищем "Теги клиентов" // if (unsubscribeFromTags) {
const tables = await tablesService.getTables(); // unsubscribeFromTags();
const tagsTable = tables.find(t => t.name === 'Теги клиентов'); // }
if (tagsTable) { // Очищаем интервал
// Загружаем данные таблицы тегов if (tagsUpdateInterval) {
const table = await tablesService.getTable(tagsTable.id); clearInterval(tagsUpdateInterval);
const nameColumn = table.columns.find(col => col.name === 'Название') || table.columns[0]; tagsUpdateInterval = null;
}
});
if (nameColumn) { // ВРЕМЕННО ОТКЛЮЧАЕМ - async function loadAvailableTags() {
// Формируем список тегов // try {
availableTags.value = table.rows.map(row => { // // Получаем все пользовательские таблицы и ищем "Теги клиентов"
const nameCell = table.cellValues.find(c => c.row_id === row.id && c.column_id === nameColumn.id); // const tables = await tablesService.getTables();
return { // const tagsTable = tables.find(t => t.name === 'Теги клиентов');
id: row.id, //
name: nameCell ? nameCell.value : `Тег ${row.id}` // if (tagsTable) {
}; // // Загружаем данные таблицы тегов
}).filter(tag => tag.name.trim()); // Исключаем пустые названия // const table = await tablesService.getTable(tagsTable.id);
} // const nameColumn = table.columns.find(col => col.name === 'Название') || table.columns[0];
} //
} catch (e) { // if (nameColumn) {
console.error('Ошибка загрузки тегов:', e); // // Формируем список тегов
availableTags.value = []; // const newTags = table.rows.map(row => {
} // const nameCell = table.cellValues.find(c => c.row_id === row.id && c.column_id === nameColumn.id);
} // return {
// id: row.id,
// name: nameCell ? nameCell.value : `Тег ${row.id}`
// };
// }).filter(tag => tag.name.trim()); // Исключаем пустые названия
//
// // Создаем хеш для сравнения
// const newTagsHash = JSON.stringify(newTags.map(t => `${t.id}:${t.name}`).sort());
//
// // Обновляем только если данные действительно изменились
// if (newTagsHash !== lastTagsHash.value) {
// console.log('[ContactTable] Теги изменились, обновляем список');
// availableTags.value = newTags;
// lastTagsHash.value = newTagsHash;
// } else {
// console.log('[ContactTable] Теги не изменились, пропускаем обновление');
// }
// }
// }
// } catch (e) {
// console.error('Ошибка загрузки тегов:', e);
// availableTags.value = [];
// }
// }
function buildQuery() { function buildQuery() {
const params = new URLSearchParams(); const params = new URLSearchParams();

View File

@@ -123,8 +123,11 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, computed, nextTick } from 'vue'; import { ref, watch, onMounted, computed, nextTick, onUnmounted } from 'vue';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
import { useTablesWebSocket } from '../../composables/useTablesWebSocket';
import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
import cacheService from '../../services/cacheService';
const props = defineProps(['rowId', 'column', 'cellValues']); const props = defineProps(['rowId', 'column', 'cellValues']);
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
@@ -174,12 +177,54 @@ watch(editing, (val) => {
} }
}); });
// Добавляем watch для отслеживания изменений в мультисвязях // Добавляем watch для отслеживания изменений в мультисвязях с дебаунсингом
let debounceTimer = null;
watch(editMultiRelationValues, (newValues, oldValues) => { watch(editMultiRelationValues, (newValues, oldValues) => {
console.log('[editMultiRelationValues] changed from:', oldValues, 'to:', newValues); console.log('[editMultiRelationValues] changed from:', oldValues, 'to:', newValues);
// Очищаем предыдущий таймер
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Устанавливаем новый таймер для предотвращения множественных обновлений
debounceTimer = setTimeout(() => {
// Здесь можно добавить дополнительную логику, если нужно
}, 100);
}, { deep: true }); }, { deep: true });
// Флаг для предотвращения циклической загрузки
const isLoadingMultiRelations = ref(false);
const lastLoadedValues = ref(new Map()); // Кэш последних загруженных значений
// WebSocket для обновлений таблиц
const { subscribeToTableRelationsUpdates } = useTablesWebSocket();
let unsubscribeFromWebSocket = null;
// Функция для очистки кэша
function clearCache() {
cacheService.clearAll();
console.log('[TableCell] Кэш очищен');
}
// WebSocket для тегов
const { onTagsUpdate } = useTagsWebSocket();
let unsubscribeFromTags = null;
// Удаляем локальные кэши
// const multiRelationOptionsCache = new Map();
// const multiRelationOptionsCacheTimeout = 30000; // 30 секунд
// const relationsCache = new Map();
// const relationsCacheTimeout = 10000; // 10 секунд
// Флаг для предотвращения повторных вызовов
let isInitialized = false;
let isMultiRelationValuesLoaded = false;
onMounted(async () => { onMounted(async () => {
const startTime = Date.now();
console.log(`[TableCell] 🚀 Начало монтирования ячейки row:${props.rowId} col:${props.column.id} в ${startTime}`);
if (props.column.type === 'multiselect') { if (props.column.type === 'multiselect') {
multiOptions.value = (props.column.options && props.column.options.options) || []; multiOptions.value = (props.column.options && props.column.options.options) || [];
const cell = props.cellValues.find( const cell = props.cellValues.find(
@@ -203,8 +248,46 @@ onMounted(async () => {
} else if (props.column.type === 'lookup') { } else if (props.column.type === 'lookup') {
await loadLookupValues(); await loadLookupValues();
} else if (props.column.type === 'multiselect-relation') { } else if (props.column.type === 'multiselect-relation') {
// Загружаем опции только один раз
if (!isInitialized) {
console.log(`[TableCell] 📥 Загружаем опции для row:${props.rowId} col:${props.column.id}`);
await loadMultiRelationOptions();
isInitialized = true;
}
// Загружаем relations только один раз для каждой комбинации rowId + columnId
if (!isMultiRelationValuesLoaded) {
console.log(`[TableCell] 📥 Загружаем relations для row:${props.rowId} col:${props.column.id}`);
await loadMultiRelationValues();
isMultiRelationValuesLoaded = true;
}
// Подписываемся на обновления таблицы
if (props.column.type === 'multiselect-relation') {
unsubscribeFromWebSocket = subscribeToTableRelationsUpdates(props.column.table_id, async () => {
console.log('[TableCell] Получено обновление таблицы, перезагружаем relations');
// Сбрасываем флаг загрузки
isMultiRelationValuesLoaded = false;
// Очищаем кэш relations для текущей строки
cacheService.clearRelationsCache(props.rowId);
await loadMultiRelationValues();
});
}
// Подписываемся на обновления тегов, если это связанная таблица тегов
if (props.column.options && props.column.options.relatedTableId) {
unsubscribeFromTags = onTagsUpdate(async () => {
console.log('[TableCell] Получено обновление тегов, перезагружаем опции');
// Сбрасываем флаги загрузки
isInitialized = false;
isMultiRelationValuesLoaded = false;
// Очищаем кэш таблицы тегов
cacheService.clearTableCache(props.column.options.relatedTableId);
await loadMultiRelationOptions(); await loadMultiRelationOptions();
await loadMultiRelationValues(); await loadMultiRelationValues();
});
}
// Инициализация localValue для отображения массива, если нет имен // Инициализация localValue для отображения массива, если нет имен
const cell = props.cellValues.find( const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id c => c.row_id === props.rowId && c.column_id === props.column.id
@@ -216,6 +299,28 @@ onMounted(async () => {
); );
localValue.value = cell ? cell.value : ''; localValue.value = cell ? cell.value : '';
} }
const endTime = Date.now();
console.log(`[TableCell] ✅ Завершено монтирование ячейки row:${props.rowId} col:${props.column.id} за ${endTime - startTime}ms`);
});
onUnmounted(() => {
// Отписываемся от WebSocket при размонтировании компонента
if (unsubscribeFromWebSocket) {
unsubscribeFromWebSocket();
unsubscribeFromWebSocket = null;
}
// Отписываемся от обновлений тегов
if (unsubscribeFromTags) {
unsubscribeFromTags();
unsubscribeFromTags = null;
}
// Очищаем таймер дебаунсинга
if (loadMultiRelationValuesTimer) {
clearTimeout(loadMultiRelationValuesTimer);
loadMultiRelationValuesTimer = null;
}
}); });
watch( watch(
@@ -244,7 +349,9 @@ watch(
} else if (props.column.type === 'lookup') { } else if (props.column.type === 'lookup') {
await loadLookupValues(); await loadLookupValues();
} else if (props.column.type === 'multiselect-relation') { } else if (props.column.type === 'multiselect-relation') {
// Загружаем опции только один раз при изменении колонки
await loadMultiRelationOptions(); await loadMultiRelationOptions();
// Загружаем значения
await loadMultiRelationValues(); await loadMultiRelationValues();
// Инициализация localValue для отображения массива, если нет имен // Инициализация localValue для отображения массива, если нет имен
const cell = props.cellValues.find( const cell = props.cellValues.find(
@@ -378,49 +485,133 @@ async function loadLookupValues() {
} }
async function loadMultiRelationOptions() { async function loadMultiRelationOptions() {
// Проверяем, не загружены ли уже опции
if (multiRelationOptions.value.length > 0) {
console.log('[loadMultiRelationOptions] Опции уже загружены, пропускаем');
return;
}
const rel = props.column.options || {}; const rel = props.column.options || {};
if (!rel.relatedTableId) return; if (rel.relatedTableId && rel.relatedColumnId) {
const res = await fetch(`/api/tables/${rel.relatedTableId}`); try {
const data = await res.json(); // Проверяем кэш для данных таблицы
// Далее используем data.columns, data.rows, data.cellValues const cachedTableData = cacheService.getTableData(rel.relatedTableId, 'default');
const colId = rel.relatedColumnId || (data.columns[0] && data.columns[0].id); let tableData;
if (cachedTableData) {
console.log(`[loadMultiRelationOptions] ✅ Используем предварительно загруженные данные таблицы ${rel.relatedTableId}`);
tableData = cachedTableData;
} else {
console.log(`[loadMultiRelationOptions] ⚠️ Данные таблицы ${rel.relatedTableId} не найдены в кэше, загружаем заново`);
const response = await fetch(`/api/tables/${rel.relatedTableId}`);
tableData = await response.json();
// Сохраняем в кэш
cacheService.setTableData(rel.relatedTableId, 'default', tableData);
}
// Формируем опции из данных таблицы
const colId = rel.relatedColumnId || (tableData.columns[0] && tableData.columns[0].id);
const opts = []; const opts = [];
for (const row of data.rows) { for (const row of tableData.rows) {
const cell = data.cellValues.find(c => c.row_id === row.id && c.column_id === colId); const cell = tableData.cellValues.find(c => c.row_id === row.id && c.column_id === colId);
opts.push({ id: row.id, display: cell ? cell.value : `ID ${row.id}` }); opts.push({ id: row.id, display: cell ? cell.value : `ID ${row.id}` });
} }
multiRelationOptions.value = opts; multiRelationOptions.value = opts;
console.log(`[loadMultiRelationOptions] Загружено ${opts.length} опций для таблицы ${rel.relatedTableId}`);
} catch (e) {
console.error('[loadMultiRelationOptions] Error:', e);
}
}
} }
// Дебаунсинг для loadMultiRelationValues
let loadMultiRelationValuesTimer = null;
const LOAD_DEBOUNCE_DELAY = 100; // 100ms
async function loadMultiRelationValues() { async function loadMultiRelationValues() {
// Проверяем, не загружены ли уже данные
if (isMultiRelationValuesLoaded) {
console.log('[loadMultiRelationValues] Данные уже загружены, пропускаем');
return;
}
// Очищаем предыдущий таймер
if (loadMultiRelationValuesTimer) {
clearTimeout(loadMultiRelationValuesTimer);
}
// Устанавливаем новый таймер
loadMultiRelationValuesTimer = setTimeout(async () => {
// Получаем связи для текущей строки // Получаем связи для текущей строки
console.log('[loadMultiRelationValues] called for row:', props.rowId, 'column:', props.column.id); console.log('[loadMultiRelationValues] called for row:', props.rowId, 'column:', props.column.id);
editMultiRelationValues.value = [];
selectedMultiRelationNames.value = [];
try { try {
const rel = props.column.options || {}; const rel = props.column.options || {};
if (rel.relatedTableId && rel.relatedColumnId) { if (rel.relatedTableId && rel.relatedColumnId) {
const url = `/api/tables/${props.column.table_id}/row/${props.rowId}/relations`; // Проверяем кэш для relations
console.log('[loadMultiRelationValues] GET request to:', url); let relations;
const res = await fetch(url); let tableData;
const relations = await res.json();
console.log('[loadMultiRelationValues] API response status:', res.status, 'relations:', relations); const cachedRelations = cacheService.getRelationsData(props.rowId, props.column.id);
if (cachedRelations) {
console.log('[loadMultiRelationValues] ✅ Используем предварительно загруженные relations для строки', props.rowId);
relations = cachedRelations;
} else {
console.log('[loadMultiRelationValues] ⚠️ Relations не найдены в кэше, загружаем заново для строки', props.rowId);
// Выполняем запросы параллельно
const [relationsRes, tableRes] = await Promise.all([
fetch(`/api/tables/${props.column.table_id}/row/${props.rowId}/relations`),
fetch(`/api/tables/${rel.relatedTableId}`)
]);
[relations, tableData] = await Promise.all([
relationsRes.json(),
tableRes.json()
]);
// Сохраняем relations в кэш
cacheService.setRelationsData(props.rowId, props.column.id, relations);
}
console.log('[loadMultiRelationValues] API response status: 200 relations:', relations);
// Приводим все id к строке для корректного сравнения // Приводим все id к строке для корректного сравнения
const relatedRowIds = relations const relatedRowIds = relations
.filter(r => String(r.column_id) === String(props.column.id) && String(r.to_table_id) === String(rel.relatedTableId)) .filter(r => String(r.column_id) === String(props.column.id) && String(r.to_table_id) === String(rel.relatedTableId))
.map(r => String(r.to_row_id)); .map(r => String(r.to_row_id));
console.log('[loadMultiRelationValues] filtered related row ids:', relatedRowIds); console.log('[loadMultiRelationValues] filtered related row ids:', relatedRowIds);
// Обновляем значения
editMultiRelationValues.value = relatedRowIds; editMultiRelationValues.value = relatedRowIds;
// Если tableData не загружена, загружаем её отдельно
if (!tableData) {
const tableRes = await fetch(`/api/tables/${rel.relatedTableId}`);
tableData = await tableRes.json();
}
// Формируем опции из загруженных данных таблицы
const colId = rel.relatedColumnId || (tableData.columns[0] && tableData.columns[0].id);
const opts = [];
for (const row of tableData.rows) {
const cell = tableData.cellValues.find(c => c.row_id === row.id && c.column_id === colId);
opts.push({ id: row.id, display: cell ? cell.value : `ID ${row.id}` });
}
multiRelationOptions.value = opts;
// Получаем display-значения // Получаем display-значения
await loadMultiRelationOptions();
selectedMultiRelationNames.value = multiRelationOptions.value selectedMultiRelationNames.value = multiRelationOptions.value
.filter(opt => relatedRowIds.includes(String(opt.id))) .filter(opt => relatedRowIds.includes(String(opt.id)))
.map(opt => opt.display); .map(opt => opt.display);
console.log('[loadMultiRelationValues] selectedMultiRelationNames:', selectedMultiRelationNames.value); console.log('[loadMultiRelationValues] selectedMultiRelationNames:', selectedMultiRelationNames.value);
// Отмечаем, что данные загружены
isMultiRelationValuesLoaded = true;
} }
} catch (e) { } catch (e) {
console.error('[loadMultiRelationValues] Error:', e); console.error('[loadMultiRelationValues] Error:', e);
} }
}, LOAD_DEBOUNCE_DELAY);
} }
async function saveMultiRelation() { async function saveMultiRelation() {
@@ -484,11 +675,12 @@ async function addTag() {
newTagName.value = ''; newTagName.value = '';
showAddTagInput.value = false; showAddTagInput.value = false;
// Обновляем список опций // Обновляем список опций и добавляем тег в выбранные параллельно
await loadMultiRelationOptions(); await Promise.all([
loadMultiRelationOptions(),
Promise.resolve(editMultiRelationValues.value.push(String(newRow.id)))
]);
// Автоматически добавляем новый тег в выбранные
editMultiRelationValues.value.push(String(newRow.id));
console.log('[addTag] Тег добавлен в выбранные:', editMultiRelationValues.value); console.log('[addTag] Тег добавлен в выбранные:', editMultiRelationValues.value);
} catch (e) { } catch (e) {
console.error('[addTag] Ошибка при добавлении тега:', e); console.error('[addTag] Ошибка при добавлении тега:', e);

View File

@@ -160,13 +160,17 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed, watch } from 'vue'; import { ref, onMounted, computed, watch, onUnmounted } from 'vue';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
import TableCell from './TableCell.vue'; import TableCell from './TableCell.vue';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import axios from 'axios'; import axios from 'axios';
// Импортируем компоненты Element Plus // Импортируем компоненты Element Plus
import { ElSelect, ElOption, ElButton } from 'element-plus'; import { ElSelect, ElOption, ElButton } from 'element-plus';
import websocketService from '../../services/websocketService';
import cacheService from '../../services/cacheService';
let unsubscribeFromTableUpdate = null;
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
const rebuilding = ref(false); const rebuilding = ref(false);
const rebuildStatus = ref(null); const rebuildStatus = ref(null);
@@ -370,18 +374,131 @@ async function fetchFilteredRows() {
// Основная загрузка таблицы // Основная загрузка таблицы
async function fetchTable() { async function fetchTable() {
const startTime = Date.now();
console.log(`[UserTableView] 🚀 Начало загрузки таблицы ${props.tableId} в ${startTime}`);
const data = await tablesService.getTable(props.tableId); const data = await tablesService.getTable(props.tableId);
columns.value = data.columns; columns.value = data.columns;
rows.value = data.rows; rows.value = data.rows;
cellValues.value = data.cellValues; cellValues.value = data.cellValues;
tableMeta.value = { name: data.name, description: data.description }; tableMeta.value = { name: data.name, description: data.description };
await updateRelationFilterDefs();
await fetchFilteredRows(); console.log(`[UserTableView] 📊 Загружено ${rows.value.length} строк, ${columns.value.length} столбцов`);
// Предварительно загружаем все relations для всех строк параллельно
const relationColumns = columns.value.filter(col => col.type === 'multiselect-relation');
if (relationColumns.length > 0) {
console.log(`[UserTableView] 🔄 Предварительно загружаем relations для ${relationColumns.length} столбцов`);
const relationPromises = [];
for (const row of rows.value) {
for (const col of relationColumns) {
const promise = fetch(`/api/tables/${col.table_id}/row/${row.id}/relations`)
.then(res => res.json())
.then(relations => {
// Сохраняем в кэш
cacheService.setRelationsData(row.id, col.id, relations);
return { rowId: row.id, colId: col.id, relations };
})
.catch(error => {
console.error(`[UserTableView] Ошибка загрузки relations для row:${row.id} col:${col.id}:`, error);
return { rowId: row.id, colId: col.id, relations: [] };
});
relationPromises.push(promise);
}
}
// Ждем загрузки всех relations
const results = await Promise.all(relationPromises);
console.log(`[UserTableView] ✅ Предварительно загружено ${results.length} relations`);
}
// Предварительно загружаем данные связанных таблиц для опций
const relatedTableIds = new Set();
for (const col of relationColumns) {
if (col.options && col.options.relatedTableId) {
relatedTableIds.add(col.options.relatedTableId);
}
}
if (relatedTableIds.size > 0) {
console.log(`[UserTableView] 🔄 Предварительно загружаем данные ${relatedTableIds.size} связанных таблиц для опций`);
const tablePromises = Array.from(relatedTableIds).map(tableId =>
fetch(`/api/tables/${tableId}`)
.then(res => res.json())
.then(tableData => {
// Сохраняем в кэш с разными ключами для разных столбцов
cacheService.setTableData(tableId, 'default', tableData);
return { tableId, tableData };
})
.catch(error => {
console.error(`[UserTableView] Ошибка загрузки таблицы ${tableId}:`, error);
return { tableId, tableData: null };
})
);
const tableResults = await Promise.all(tablePromises);
console.log(`[UserTableView] ✅ Предварительно загружено ${tableResults.length} связанных таблиц`);
}
// Выполняем обновление фильтров и фильтрацию строк параллельно
await Promise.all([
updateRelationFilterDefs(),
fetchFilteredRows()
]);
// Выводим статистику кэша для отладки
const cacheStats = cacheService.getStats();
console.log('[UserTableView] Статистика кэша после загрузки таблицы:', {
tableCacheSize: cacheStats.tableCacheSize,
relationsCacheSize: cacheStats.relationsCacheSize,
tableCacheKeys: cacheStats.tableCacheKeys,
relationsCacheKeys: cacheStats.relationsCacheKeys.slice(0, 5) // Показываем только первые 5 ключей
});
const endTime = Date.now();
console.log(`[UserTableView] ✅ Завершена загрузка таблицы ${props.tableId} за ${endTime - startTime}ms`);
} }
async function updateRelationFilterDefs() { async function updateRelationFilterDefs() {
// Для каждого multiselect-relation-столбца формируем опции
const defs = []; const defs = [];
const relatedTableMap = new Map();
// Сначала собираем все уникальные relatedTableId и создаем промисы для параллельной загрузки
for (const col of columns.value) {
if (col.type === 'multiselect-relation' && col.options && col.options.relatedTableId && col.options.relatedColumnId) {
const tableId = col.options.relatedTableId;
if (!relatedTableMap.has(tableId)) {
// Проверяем кэш
const cached = cacheService.getTableData(tableId);
if (cached) {
console.log(`[updateRelationFilterDefs] Используем кэшированные данные таблицы ${tableId}`);
relatedTableMap.set(tableId, Promise.resolve(cached));
} else {
relatedTableMap.set(tableId, tablesService.getTable(tableId));
}
}
}
}
// Загружаем все связанные таблицы параллельно
const relatedTables = await Promise.all(Array.from(relatedTableMap.values()));
// Создаем Map для быстрого доступа к загруженным таблицам
const tableMap = new Map();
let tableIndex = 0;
for (const tableId of relatedTableMap.keys()) {
const tableData = relatedTables[tableIndex++];
tableMap.set(tableId, tableData);
// Сохраняем в кэш, если это новые данные
if (!cacheService.getTableData(tableId)) {
cacheService.setTableData(tableId, 'default', tableData);
}
}
// Теперь формируем опции фильтров
for (const col of columns.value) { for (const col of columns.value) {
if (col.type === 'multiselect-relation' && col.options && col.options.relatedTableId && col.options.relatedColumnId) { if (col.type === 'multiselect-relation' && col.options && col.options.relatedTableId && col.options.relatedColumnId) {
// Собираем все уникальные id из этого столбца по всем строкам // Собираем все уникальные id из этого столбца по всем строкам
@@ -391,8 +508,9 @@ async function updateRelationFilterDefs() {
const arr = parseIfArray(cell ? cell.value : []); const arr = parseIfArray(cell ? cell.value : []);
arr.forEach(val => idsSet.add(val)); arr.forEach(val => idsSet.add(val));
} }
// Получаем значения из связанной таблицы
const relTable = await tablesService.getTable(col.options.relatedTableId); // Получаем значения из связанной таблицы (уже загружена)
const relTable = tableMap.get(col.options.relatedTableId);
const opts = Array.from(idsSet).map(id => { const opts = Array.from(idsSet).map(id => {
const relRow = relTable.rows.find(r => String(r.id) === String(id)); const relRow = relTable.rows.find(r => String(r.id) === String(id));
const cell = relTable.cellValues.find(c => c.row_id === (relRow ? relRow.id : id) && c.column_id === col.options.relatedColumnId); const cell = relTable.cellValues.find(c => c.row_id === (relRow ? relRow.id : id) && c.column_id === col.options.relatedColumnId);
@@ -421,6 +539,19 @@ watch([relationFilters], fetchFilteredRows, { deep: true });
onMounted(() => { onMounted(() => {
fetchTable(); fetchTable();
// Подписка на WebSocket обновления таблицы
unsubscribeFromTableUpdate = websocketService.onTableUpdate(props.tableId, () => {
console.log('[UserTableView] Получено событие table-updated, перезагружаем данные');
// Очищаем кэш текущей таблицы
cacheService.clearTableCache(props.tableId);
fetchTable();
});
});
onUnmounted(() => {
if (unsubscribeFromTableUpdate) {
unsubscribeFromTableUpdate();
}
}); });
// Для редактирования ячеек // Для редактирования ячеек

View File

@@ -14,7 +14,9 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import api from '../api/axios'; import api from '../api/axios';
import { getFromStorage, setToStorage, removeFromStorage } from '../utils/storage'; import { getFromStorage, setToStorage, removeFromStorage } from '../utils/storage';
import { generateUniqueId } from '../utils/helpers'; import { generateUniqueId } from '../utils/helpers';
import websocketService from '../services/websocketService'; import websocketModule from '../services/websocketService';
const { websocketService } = websocketModule;
function initGuestId() { function initGuestId() {
let id = getFromStorage('guestId', ''); let id = getFromStorage('guestId', '');
@@ -44,6 +46,15 @@ export function useChat(auth) {
const guestId = ref(initGuestId()); const guestId = ref(initGuestId());
// Сохраняем ссылки на callback функции WebSocket для правильной отписки
const wsCallbacks = {
chatMessage: null,
conversationUpdated: null,
connected: null,
disconnected: null,
error: null
};
const shouldLoadHistory = computed(() => { const shouldLoadHistory = computed(() => {
return auth.isAuthenticated.value || !!guestId.value; return auth.isAuthenticated.value || !!guestId.value;
}); });
@@ -423,44 +434,69 @@ export function useChat(auth) {
console.log('[useChat] Подключение к WebSocket для пользователя:', auth.user.value.id); console.log('[useChat] Подключение к WebSocket для пользователя:', auth.user.value.id);
websocketService.connect(auth.user.value.id); websocketService.connect(auth.user.value.id);
// Подписываемся на события // Создаем и сохраняем callback функции
websocketService.on('chat-message', (message) => { wsCallbacks.chatMessage = (message) => {
console.log('[useChat] Получено новое сообщение через WebSocket:', message); console.log('[useChat] Получено новое сообщение через WebSocket:', message);
// Проверяем, что сообщение не дублируется // Проверяем, что сообщение не дублируется
const existingMessage = messages.value.find(m => m.id === message.id); const existingMessage = messages.value.find(m => m.id === message.id);
if (!existingMessage) { if (!existingMessage) {
messages.value.push(message); messages.value.push(message);
} }
}); };
websocketService.on('conversation-updated', (conversationId) => { wsCallbacks.conversationUpdated = (conversationId) => {
console.log('[useChat] Обновление диалога через WebSocket:', conversationId); console.log('[useChat] Обновление диалога через WebSocket:', conversationId);
// Можно добавить логику обновления списка диалогов // Можно добавить логику обновления списка диалогов
}); };
websocketService.on('connected', () => { wsCallbacks.connected = () => {
console.log('[useChat] WebSocket подключен'); console.log('[useChat] WebSocket подключен');
}); };
websocketService.on('disconnected', () => { wsCallbacks.disconnected = () => {
console.log('[useChat] WebSocket отключен'); console.log('[useChat] WebSocket отключен');
}); };
websocketService.on('error', (error) => { wsCallbacks.error = (error) => {
console.error('[useChat] WebSocket ошибка:', error); console.error('[useChat] WebSocket ошибка:', error);
}); };
// Подписываемся на события
websocketService.on('chat-message', wsCallbacks.chatMessage);
websocketService.on('conversation-updated', wsCallbacks.conversationUpdated);
websocketService.on('connected', wsCallbacks.connected);
websocketService.on('disconnected', wsCallbacks.disconnected);
websocketService.on('error', wsCallbacks.error);
} else { } else {
console.log('[useChat] WebSocket не подключен: пользователь не аутентифицирован или данные не загружены'); console.log('[useChat] WebSocket не подключен: пользователь не аутентифицирован или данные не загружены');
} }
} }
function cleanupWebSocket() { function cleanupWebSocket() {
websocketService.off('chat-message'); // Отписываемся от всех событий, передавая те же callback функции
websocketService.off('conversation-updated'); if (websocketService) {
websocketService.off('connected'); if (wsCallbacks.chatMessage) {
websocketService.off('disconnected'); websocketService.off('chat-message', wsCallbacks.chatMessage);
websocketService.off('error'); }
if (wsCallbacks.conversationUpdated) {
websocketService.off('conversation-updated', wsCallbacks.conversationUpdated);
}
if (wsCallbacks.connected) {
websocketService.off('connected', wsCallbacks.connected);
}
if (wsCallbacks.disconnected) {
websocketService.off('disconnected', wsCallbacks.disconnected);
}
if (wsCallbacks.error) {
websocketService.off('error', wsCallbacks.error);
}
websocketService.disconnect(); websocketService.disconnect();
// Очищаем ссылки на callback функции
Object.keys(wsCallbacks).forEach(key => {
wsCallbacks[key] = null;
});
}
} }
// --- Инициализация --- // --- Инициализация ---

View File

@@ -0,0 +1,141 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
import { ref, onMounted, onUnmounted } from 'vue';
export function useTablesWebSocket() {
const ws = ref(null);
const isConnected = ref(false);
const tableUpdateCallbacks = ref(new Map()); // tableId -> callback
const tableRelationsUpdateCallbacks = ref(new Map()); // `${tableId}-${rowId}` -> callback
function connect() {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
return; // Уже подключены
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
ws.value = new WebSocket(`${wsProtocol}://${window.location.host}/ws`);
ws.value.onopen = () => {
console.log('[TablesWebSocket] Соединение установлено');
isConnected.value = true;
};
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[TablesWebSocket] Получено сообщение:', data);
if (data.type === 'table-updated') {
const callbacks = tableUpdateCallbacks.value.get(data.tableId);
if (callbacks) {
callbacks.forEach(callback => callback(data));
}
}
if (data.type === 'table-relations-updated') {
const key = `${data.tableId}-${data.rowId}`;
const callbacks = tableRelationsUpdateCallbacks.value.get(key);
if (callbacks) {
callbacks.forEach(callback => callback(data));
}
}
} catch (error) {
console.error('[TablesWebSocket] Ошибка обработки сообщения:', error);
}
};
ws.value.onclose = () => {
console.log('[TablesWebSocket] Соединение закрыто');
isConnected.value = false;
// Переподключение через 3 секунды
setTimeout(() => {
if (!isConnected.value) {
connect();
}
}, 3000);
};
ws.value.onerror = (error) => {
console.error('[TablesWebSocket] Ошибка соединения:', error);
isConnected.value = false;
};
}
function subscribeToTableUpdates(tableId, callback) {
if (!tableUpdateCallbacks.value.has(tableId)) {
tableUpdateCallbacks.value.set(tableId, []);
}
tableUpdateCallbacks.value.get(tableId).push(callback);
// Возвращаем функцию для отписки
return () => {
const callbacks = tableUpdateCallbacks.value.get(tableId);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
if (callbacks.length === 0) {
tableUpdateCallbacks.value.delete(tableId);
}
}
};
}
function subscribeToTableRelationsUpdates(tableId, rowId, callback) {
const key = `${tableId}-${rowId}`;
if (!tableRelationsUpdateCallbacks.value.has(key)) {
tableRelationsUpdateCallbacks.value.set(key, []);
}
tableRelationsUpdateCallbacks.value.get(key).push(callback);
// Возвращаем функцию для отписки
return () => {
const callbacks = tableRelationsUpdateCallbacks.value.get(key);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
if (callbacks.length === 0) {
tableRelationsUpdateCallbacks.value.delete(key);
}
}
};
}
function disconnect() {
if (ws.value) {
ws.value.close();
ws.value = null;
}
isConnected.value = false;
}
onMounted(() => {
connect();
});
onUnmounted(() => {
disconnect();
});
return {
isConnected,
connect,
disconnect,
subscribeToTableUpdates,
subscribeToTableRelationsUpdates
};
}

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
import { ref, onMounted, onUnmounted } from 'vue';
import websocketServiceModule from '../services/websocketService';
const { websocketService } = websocketServiceModule;
export function useTagsWebSocket() {
const tagsUpdateCallbacks = ref([]);
let debounceTimer = null;
const DEBOUNCE_DELAY = 1000; // 1 секунда
function onTagsUpdate(callback) {
tagsUpdateCallbacks.value.push(callback);
// Возвращаем функцию для отписки
return () => {
const index = tagsUpdateCallbacks.value.indexOf(callback);
if (index > -1) {
tagsUpdateCallbacks.value.splice(index, 1);
}
};
}
function handleTagsUpdate(data) {
console.log('🏷️ [useTagsWebSocket] Получено обновление тегов:', data);
// Очищаем предыдущий таймер
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Устанавливаем новый таймер для дебаунсинга
debounceTimer = setTimeout(() => {
console.log('🏷️ [useTagsWebSocket] Выполняем обновление тегов после дебаунсинга');
tagsUpdateCallbacks.value.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('Ошибка в callback обновления тегов:', error);
}
});
}, DEBOUNCE_DELAY);
}
onMounted(() => {
websocketService.on('tags-updated', handleTagsUpdate);
});
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
websocketService.off('tags-updated', handleTagsUpdate);
});
return {
onTagsUpdate
};
}

View File

@@ -0,0 +1,117 @@
/**
* Глобальный сервис кэширования для оптимизации запросов
*/
class CacheService {
constructor() {
this.tableCache = new Map();
this.relationsCache = new Map();
this.tableCacheTimeout = 30000; // 30 секунд для таблиц
this.relationsCacheTimeout = 10000; // 10 секунд для relations
}
// Кэширование данных таблиц
getTableData(tableId, columnId = 'default') {
const cacheKey = `${tableId}_${columnId}`;
const cached = this.tableCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.tableCacheTimeout) {
console.log(`[CacheService] ✅ КЭШ ПОПАДАНИЕ для таблицы ${tableId} (${cacheKey})`);
return cached.data;
}
if (cached) {
console.log(`[CacheService] ⏰ Кэш истек для таблицы ${tableId} (${cacheKey})`);
} else {
console.log(`[CacheService] ❌ Кэш отсутствует для таблицы ${tableId} (${cacheKey})`);
}
return null;
}
setTableData(tableId, columnId, data) {
const cacheKey = `${tableId}_${columnId}`;
this.tableCache.set(cacheKey, {
data,
timestamp: Date.now()
});
console.log(`[CacheService] Сохранены данные таблицы ${tableId} в кэш`);
}
// Кэширование relations
getRelationsData(rowId, columnId) {
const cacheKey = `${rowId}_${columnId}`;
const cached = this.relationsCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.relationsCacheTimeout) {
console.log(`[CacheService] Используем кэшированные relations для строки ${rowId}`);
return cached.data;
}
return null;
}
setRelationsData(rowId, columnId, data) {
const cacheKey = `${rowId}_${columnId}`;
this.relationsCache.set(cacheKey, {
data,
timestamp: Date.now()
});
console.log(`[CacheService] Сохранены relations строки ${rowId} в кэш`);
}
// Очистка кэша
clearTableCache(tableId = null) {
if (tableId) {
// Очищаем кэш для конкретной таблицы
for (const [key] of this.tableCache) {
if (key.startsWith(`${tableId}_`)) {
this.tableCache.delete(key);
}
}
console.log(`[CacheService] Очищен кэш таблицы ${tableId}`);
} else {
// Очищаем весь кэш таблиц
this.tableCache.clear();
console.log('[CacheService] Очищен весь кэш таблиц');
}
}
clearRelationsCache(rowId = null) {
if (rowId) {
// Очищаем кэш для конкретной строки
for (const [key] of this.relationsCache) {
if (key.startsWith(`${rowId}_`)) {
this.relationsCache.delete(key);
}
}
console.log(`[CacheService] Очищен кэш relations строки ${rowId}`);
} else {
// Очищаем весь кэш relations
this.relationsCache.clear();
console.log('[CacheService] Очищен весь кэш relations');
}
}
// Полная очистка всех кэшей
clearAll() {
this.tableCache.clear();
this.relationsCache.clear();
console.log('[CacheService] Очищены все кэши');
}
// Получение статистики кэша
getStats() {
return {
tableCacheSize: this.tableCache.size,
relationsCacheSize: this.relationsCache.size,
tableCacheKeys: Array.from(this.tableCache.keys()),
relationsCacheKeys: Array.from(this.relationsCache.keys())
};
}
}
// Создаем единственный экземпляр
const cacheService = new CacheService();
export default cacheService;

View File

@@ -126,6 +126,18 @@ class WebSocketService {
this.emit('contacts-updated'); this.emit('contacts-updated');
break; break;
case 'tags-updated':
console.log('🏷️ [WebSocket] Обновление тегов клиентов');
this.emit('tags-updated');
break;
case 'table-updated':
console.log('[WebSocket] table-updated:', data.tableId);
if (tableUpdateSubscribers[data.tableId]) {
tableUpdateSubscribers[data.tableId].forEach(cb => cb(data));
}
break;
default: default:
console.log('❓ [WebSocket] Неизвестный тип сообщения:', data.type); console.log('❓ [WebSocket] Неизвестный тип сообщения:', data.type);
this.emit('unknown-message', data); this.emit('unknown-message', data);
@@ -189,4 +201,21 @@ class WebSocketService {
// Создаем единственный экземпляр // Создаем единственный экземпляр
const websocketService = new WebSocketService(); const websocketService = new WebSocketService();
export default websocketService; // Подписчики на обновления таблиц: tableId -> [callback]
const tableUpdateSubscribers = {};
function onTableUpdate(tableId, callback) {
if (!tableUpdateSubscribers[tableId]) {
tableUpdateSubscribers[tableId] = [];
}
tableUpdateSubscribers[tableId].push(callback);
// Возвращаем функцию для отписки
return () => {
tableUpdateSubscribers[tableId] = tableUpdateSubscribers[tableId].filter(cb => cb !== callback);
};
}
export default {
websocketService,
onTableUpdate,
};

View File

@@ -150,7 +150,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue'; import BaseLayout from '../../components/BaseLayout.vue';
import Message from '../../components/Message.vue'; import Message from '../../components/Message.vue';
@@ -160,6 +160,7 @@ import messagesService from '../../services/messagesService.js';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -187,10 +188,15 @@ const conversationId = ref(null);
// id таблицы тегов (будет найден или создан) // id таблицы тегов (будет найден или создан)
const tagsTableId = ref(null); const tagsTableId = ref(null);
// WebSocket для тегов
const { onTagsUpdate } = useTagsWebSocket();
let unsubscribeFromTags = null;
async function ensureTagsTable() { async function ensureTagsTable() {
// Получаем все пользовательские таблицы // Получаем все пользовательские таблицы
const tables = await tablesService.getTables(); const tables = await tablesService.getTables();
let tagsTable = tables.find(t => t.name === 'Теги клиентов'); let tagsTable = tables.find(t => t.name === 'Теги клиентов');
if (!tagsTable) { if (!tagsTable) {
// Если таблицы нет — создаём // Если таблицы нет — создаём
tagsTable = await tablesService.createTable({ tagsTable = await tablesService.createTable({
@@ -198,17 +204,28 @@ async function ensureTagsTable() {
description: 'Справочник тегов для контактов', description: 'Справочник тегов для контактов',
isRagSourceId: 2 // не источник для RAG по умолчанию isRagSourceId: 2 // не источник для RAG по умолчанию
}); });
// Добавляем столбцы
await tablesService.addColumn(tagsTable.id, { name: 'Название', type: 'text' }); // Добавляем столбцы параллельно
await tablesService.addColumn(tagsTable.id, { name: 'Описание', type: 'text' }); await Promise.all([
tablesService.addColumn(tagsTable.id, { name: 'Название', type: 'text' }),
tablesService.addColumn(tagsTable.id, { name: 'Описание', type: 'text' })
]);
} else { } else {
// Проверяем, есть ли нужные столбцы, если таблица уже была создана // Проверяем, есть ли нужные столбцы, если таблица уже была создана
const table = await tablesService.getTable(tagsTable.id); const table = await tablesService.getTable(tagsTable.id);
const hasName = table.columns.some(col => col.name === 'Название'); const hasName = table.columns.some(col => col.name === 'Название');
const hasDesc = table.columns.some(col => col.name === 'Описание'); const hasDesc = table.columns.some(col => col.name === 'Описание');
if (!hasName) await tablesService.addColumn(tagsTable.id, { name: 'Название', type: 'text' });
if (!hasDesc) await tablesService.addColumn(tagsTable.id, { name: 'Описание', type: 'text' }); // Добавляем недостающие столбцы параллельно
const addColumnPromises = [];
if (!hasName) addColumnPromises.push(tablesService.addColumn(tagsTable.id, { name: 'Название', type: 'text' }));
if (!hasDesc) addColumnPromises.push(tablesService.addColumn(tagsTable.id, { name: 'Описание', type: 'text' }));
if (addColumnPromises.length > 0) {
await Promise.all(addColumnPromises);
} }
}
tagsTableId.value = tagsTable.id; tagsTableId.value = tagsTable.id;
return tagsTable.id; return tagsTable.id;
} }
@@ -598,6 +615,20 @@ onMounted(async () => {
await reloadContact(); await reloadContact();
await loadUserTags(); await loadUserTags();
await loadMessages(); await loadMessages();
// Подписываемся на обновления тегов
unsubscribeFromTags = onTagsUpdate(async () => {
console.log('[ContactDetailsView] Получено обновление тегов, перезагружаем списки тегов');
await loadAllTags();
await loadUserTags();
});
});
onUnmounted(() => {
// Отписываемся от WebSocket при размонтировании
if (unsubscribeFromTags) {
unsubscribeFromTags();
}
}); });
watch(userId, async () => { watch(userId, async () => {
await reloadContact(); await reloadContact();