Files
DLE/frontend/src/components/tables/TableCell.vue
2026-03-01 22:03:48 +03:00

1064 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
Copyright (c) 2024-2026 Тарабанов Александр Викторович
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/VC-HB3-Accelerator
-->
<template>
<template v-if="column.type === 'multiselect'">
<div v-if="!editing" @click="canEditData && (editing = true)" class="tags-cell-view">
<span v-if="selectedMultiNames.length">{{ selectedMultiNames.join(', ') }}</span>
<span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="8" y="4" width="2" height="10" rx="1" fill="#4f8cff"/>
<rect x="4" y="8" width="10" height="2" rx="1" fill="#4f8cff"/>
</svg>
</span>
</div>
<div v-else class="tags-cell-edit">
<div class="tags-multiselect">
<div v-for="option in multiOptions" :key="option" class="tag-option">
<input type="checkbox" :id="'cell-multi-' + option + '-' + rowId" :value="option" v-model="editMultiValues" />
<label :for="'cell-multi-' + option + '-' + rowId">{{ option }}</label>
<span class="remove-option" @click.stop="removeMultiOption(option)"></span>
</div>
</div>
<div class="add-multiselect-option">
<input v-model="newMultiOption" @keyup.enter="addMultiOption" placeholder="Новое значение" />
<button class="add-btn" @click="addMultiOption">+</button>
</div>
<button class="save-btn" @click="saveMulti">Сохранить</button>
<button class="cancel-btn" @click="cancelMulti">Отмена</button>
</div>
</template>
<template v-else-if="column.type === 'relation'">
<div v-if="!editing" @click="canEditData && (editing = true)" class="tags-cell-view">
<span v-if="selectedRelationName">{{ selectedRelationName }}</span>
<span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="8" y="4" width="2" height="10" rx="1" fill="#4f8cff"/>
<rect x="4" y="8" width="10" height="2" rx="1" fill="#4f8cff"/>
</svg>
</span>
</div>
<div v-else class="tags-cell-edit">
<select v-model="editRelationValue" class="notion-input">
<option v-for="opt in relationOptions" :key="opt.id" :value="opt.id">{{ opt.display }}</option>
</select>
<button class="save-btn" @click="saveRelation">Сохранить</button>
<button class="cancel-btn" @click="cancelRelation">Отмена</button>
</div>
</template>
<template v-else-if="column.type === 'lookup'">
<div class="lookup-cell-view">
<span v-if="lookupValues.length">{{ lookupValues.join(', ') }}</span>
<span v-else style="color:#bbb"></span>
</div>
</template>
<template v-else-if="column.type === 'multiselect-relation'">
<div v-if="!editing" @click="canEditData && (editing = true)" class="tags-cell-view">
<span v-if="selectedMultiRelationNames.length">{{ selectedMultiRelationNames.join(', ') }}</span>
<span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="8" y="4" width="2" height="10" rx="1" fill="#4f8cff"/>
<rect x="4" y="8" width="10" height="2" rx="1" fill="#4f8cff"/>
</svg>
</span>
</div>
<div v-else class="tags-cell-edit">
<div class="tags-multiselect">
<div v-for="option in multiRelationOptions" :key="option.id" class="tag-option">
<input type="checkbox" :id="'cell-multirel-' + option.id + '-' + rowId" :value="String(option.id)" v-model="editMultiRelationValues" />
<label :for="'cell-multirel-' + option.id + '-' + rowId">{{ option.display }}</label>
<button class="delete-tag-btn" @click.prevent="deleteTag(option.id)" title="Удалить тег">×</button>
</div>
</div>
<div class="add-tag-block">
<button v-if="!showAddTagInput" class="add-tag-btn" @click="showAddTagInput = true">+ Новый тег</button>
<div v-else class="add-tag-form">
<input v-model="newTagName" @keyup.enter="addTag" placeholder="Название тега" />
<button class="add-tag-confirm" @click="addTag">Добавить</button>
<button class="add-tag-cancel" @click="showAddTagInput = false; newTagName = ''">×</button>
</div>
</div>
<div class="action-buttons">
<button class="save-btn" @click="saveMultiRelation">Сохранить</button>
<button class="cancel-btn" @click="cancelMultiRelation">Отмена</button>
</div>
</div>
</template>
<template v-else>
<div v-if="!editing" class="cell-view-value" @click="canEditData && (editing = true)">
<span v-if="isArrayString(localValue)">{{ parseArrayString(localValue).join(', ') }}</span>
<span v-else-if="localValue">{{ localValue }}</span>
<span v-else class="cell-plus-icon" title="Добавить">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="8" fill="#f3f4f6" stroke="#b6c6e6"/>
<rect x="8" y="4" width="2" height="10" rx="1" fill="#4f8cff"/>
<rect x="4" y="8" width="10" height="2" rx="1" fill="#4f8cff"/>
</svg>
</span>
</div>
<textarea
v-else
v-model="localValue"
@blur="saveAndExit"
@keyup.enter="saveAndExit"
:placeholder="column.name"
class="cell-input"
autofocus
ref="textareaRef"
@input="autoResize"
/>
</template>
</template>
<script setup>
import { ref, watch, onMounted, computed, nextTick, onUnmounted } from 'vue';
import tablesService from '../../services/tablesService';
import { useTablesWebSocket } from '../../composables/useTablesWebSocket';
import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
import cacheService from '../../services/cacheService';
import { usePermissions } from '@/composables/usePermissions';
const props = defineProps(['rowId', 'column', 'cellValues']);
const emit = defineEmits(['update']);
const { canEditData } = usePermissions();
const localValue = ref('');
const editing = ref(false);
// const allTags = ref([]); // Все теги из /api/tags
const editTagIds = ref([]); // id выбранных тегов в режиме редактирования
// Для отображения выбранных тегов
const selectedTagNames = ref([]);
const multiOptions = ref([]);
const editMultiValues = ref([]);
const selectedMultiNames = ref([]);
const newMultiOption = ref('');
// relation/lookup
const relationOptions = ref([]);
const editRelationValue = ref(null);
const selectedRelationName = ref('');
const lookupValues = ref([]);
const multiRelationOptions = ref([]);
const editMultiRelationValues = ref([]);
const selectedMultiRelationNames = ref([]);
const showAddTagInput = ref(false);
const newTagName = ref('');
const textareaRef = ref(null);
function autoResize() {
const ta = textareaRef.value;
if (ta) {
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
}
watch(editing, (val) => {
if (val) {
if (props.column.type === 'multiselect-relation') {
loadMultiRelationOptions();
}
nextTick(() => {
if (textareaRef.value) {
autoResize();
setTimeout(() => autoResize(), 0);
}
});
}
});
// Добавляем watch для отслеживания изменений в мультисвязях с дебаунсингом
let debounceTimer = null;
watch(editMultiRelationValues, (newValues, oldValues) => {
// console.log('[editMultiRelationValues] changed from:', oldValues, 'to:', newValues);
// Очищаем предыдущий таймер
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Устанавливаем новый таймер для предотвращения множественных обновлений
debounceTimer = setTimeout(() => {
// Здесь можно добавить дополнительную логику, если нужно
}, 100);
}, { 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;
let lastLoadedOptionsKey = null;
onMounted(async () => {
const startTime = Date.now();
// console.log(`[TableCell] 🚀 Начало монтирования ячейки row:${props.rowId} col:${props.column.id} в ${startTime}`);
if (props.column.type === 'multiselect') {
multiOptions.value = (props.column.options && props.column.options.options) || [];
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
let values = [];
if (cell && cell.value) {
try {
values = JSON.parse(cell.value);
} catch {}
}
editMultiValues.value = Array.isArray(values) ? values : [];
selectedMultiNames.value = multiOptions.value.filter(opt => editMultiValues.value.includes(opt));
} else if (props.column.type === 'relation') {
await loadRelationOptions();
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
editRelationValue.value = cell ? cell.value : null;
selectedRelationName.value = relationOptions.value.find(opt => String(opt.id) === String(editRelationValue.value))?.display || '';
} else if (props.column.type === 'lookup') {
await loadLookupValues();
} else if (props.column.type === 'multiselect-relation') {
// Загружаем опции только один раз
if (!isInitialized) {
await loadMultiRelationOptions();
isInitialized = true;
}
// Загружаем relations только один раз для каждой комбинации rowId + columnId
if (!isMultiRelationValuesLoaded) {
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 loadMultiRelationValues();
});
}
// Инициализация localValue для отображения массива, если нет имен
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
localValue.value = cell ? cell.value : '';
} else {
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
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(
() => [props.rowId, props.column.id, props.cellValues],
async () => {
// Сбрасываем флаги при изменении столбца
if (props.column.type === 'multiselect-relation') {
isMultiRelationValuesLoaded = false;
lastLoadedOptionsKey = null;
isInitialized = false;
}
if (props.column.type === 'multiselect') {
multiOptions.value = (props.column.options && props.column.options.options) || [];
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
let values = [];
if (cell && cell.value) {
try {
values = JSON.parse(cell.value);
} catch {}
}
editMultiValues.value = Array.isArray(values) ? values : [];
selectedMultiNames.value = multiOptions.value.filter(opt => editMultiValues.value.includes(opt));
} else if (props.column.type === 'relation') {
await loadRelationOptions();
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
editRelationValue.value = cell ? cell.value : null;
selectedRelationName.value = relationOptions.value.find(opt => String(opt.id) === String(editRelationValue.value))?.display || '';
} else if (props.column.type === 'lookup') {
await loadLookupValues();
} else if (props.column.type === 'multiselect-relation') {
// Загружаем опции только один раз при изменении колонки
await loadMultiRelationOptions();
// Загружаем значения
await loadMultiRelationValues();
// Инициализация localValue для отображения массива, если нет имен
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
localValue.value = cell ? cell.value : '';
} else {
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
localValue.value = cell ? cell.value : '';
}
},
{ immediate: true }
);
function saveMulti() {
emit('update', JSON.stringify(editMultiValues.value));
editing.value = false;
}
function cancelMulti() {
editing.value = false;
selectedMultiNames.value = multiOptions.value.filter(opt => editMultiValues.value.includes(opt));
}
async function addMultiOption() {
const val = newMultiOption.value.trim();
if (!val) return;
// Если multiselect связан с relation-таблицей (например, Теги клиентов)
if (props.column.options && props.column.options.relatedTableId && props.column.options.relatedColumnId) {
// 1. Создаём строку в relation-таблице
const newRow = await tablesService.addRow(props.column.options.relatedTableId);
// 2. Сохраняем значение в нужную ячейку (название тега)
await tablesService.saveCell({
table_id: props.column.options.relatedTableId,
row_id: newRow.id,
column_id: props.column.options.relatedColumnId,
value: val
});
// 3. Обновляем multiOptions (заново загружаем из relation-таблицы)
const relTable = await tablesService.getTable(props.column.options.relatedTableId);
const colId = props.column.options.relatedColumnId;
multiOptions.value = relTable.rows.map(row => {
const cell = relTable.cellValues.find(c => c.row_id === row.id && c.column_id === colId);
return cell ? cell.value : `ID ${row.id}`;
});
// 4. Добавляем новый тег в выбранные
editMultiValues.value.push(val);
newMultiOption.value = '';
return;
}
// Обычный multiselect (старый вариант)
if (multiOptions.value.includes(val)) return;
const updatedOptions = [...multiOptions.value, val];
await updateMultiOptionsOnServer(updatedOptions);
multiOptions.value = updatedOptions;
newMultiOption.value = '';
}
async function removeMultiOption(option) {
const updatedOptions = multiOptions.value.filter(o => o !== option);
await updateMultiOptionsOnServer(updatedOptions);
multiOptions.value = updatedOptions;
// Если удалили выбранное — убираем из выбранных
editMultiValues.value = editMultiValues.value.filter(v => v !== option);
}
async function updateMultiOptionsOnServer(optionsArr) {
// PATCH /api/tables/column/:columnId
const body = {
options: {
...(props.column.options || {}),
options: optionsArr
}
};
await fetch(`/api/tables/column/${props.column.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
}
async function loadRelationOptions() {
// Получаем данные из связанной таблицы (id и display)
const opts = [];
try {
const rel = props.column.options || {};
if (rel.relatedTableId) {
const res = await fetch(`/api/tables/${rel.relatedTableId}`);
const data = await res.json();
const colId = rel.relatedColumnId || (data.columns[0] && data.columns[0].id);
for (const row of data.rows) {
const cell = data.cellValues.find(c => c.row_id === row.id && c.column_id === colId);
opts.push({ id: row.id, display: cell ? cell.value : `ID ${row.id}` });
}
}
} catch {}
relationOptions.value = opts;
}
function saveRelation() {
emit('update', editRelationValue.value);
editing.value = false;
selectedRelationName.value = relationOptions.value.find(opt => String(opt.id) === String(editRelationValue.value))?.display || '';
}
function cancelRelation() {
editing.value = false;
}
async function loadLookupValues() {
// Получаем связанные rowId через relation-таблицу
lookupValues.value = [];
try {
const rel = props.column.options || {};
if (rel.relatedTableId && rel.relatedColumnId) {
// Получаем связи для текущей строки
const res = await fetch(`/api/tables/${props.column.table_id}/row/${props.rowId}/relations`);
const relations = await res.json();
// Фильтруем по нужному столбцу relation
const relatedRowIds = relations
.filter(r => String(r.column_id) === String(props.column.id) && String(r.to_table_id) === String(rel.relatedTableId))
.map(r => r.to_row_id);
if (relatedRowIds.length) {
// Получаем значения из связанной таблицы
const relTable = await fetch(`/api/tables/${rel.relatedTableId}`);
const relData = await relTable.json();
lookupValues.value = relatedRowIds.map(rowId => {
const cell = relData.cellValues.find(c => c.row_id === rowId && c.column_id === rel.relatedColumnId);
return cell ? cell.value : `ID ${rowId}`;
});
}
}
} catch {}
}
async function loadMultiRelationOptions() {
// Проверяем, не загружены ли уже опции для текущего столбца
const cacheKey = `${props.column.id}_${props.column.options?.relatedTableId}`;
if (multiRelationOptions.value.length > 0 && lastLoadedOptionsKey === cacheKey) {
return;
}
const rel = props.column.options || {};
// Проверяем, что options содержат необходимые данные
if (!rel.relatedTableId || !rel.relatedColumnId) {
console.warn('[loadMultiRelationOptions] Отсутствуют relatedTableId или relatedColumnId в options:', rel);
multiRelationOptions.value = [];
selectedMultiRelationNames.value = [];
return;
}
try {
// Проверяем кэш для данных таблицы
const cachedTableData = cacheService.getTableData(rel.relatedTableId, 'default');
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 = [];
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;
lastLoadedOptionsKey = cacheKey;
console.log('[loadMultiRelationOptions] Загружено опций:', opts.length);
// Обновляем selectedMultiRelationNames на основе текущих значений
if (editMultiRelationValues.value.length > 0) {
selectedMultiRelationNames.value = opts
.filter(opt => editMultiRelationValues.value.includes(String(opt.id)))
.map(opt => opt.display);
} else {
selectedMultiRelationNames.value = [];
}
} catch (e) {
console.error('[loadMultiRelationOptions] Error:', e);
multiRelationOptions.value = [];
selectedMultiRelationNames.value = [];
}
}
// Дебаунсинг для loadMultiRelationValues
let loadMultiRelationValuesTimer = null;
const LOAD_DEBOUNCE_DELAY = 50; // 50ms (уменьшено для ускорения)
async function loadMultiRelationValues() {
// Проверяем, не загружены ли уже данные
if (isMultiRelationValuesLoaded) {
return;
}
// Очищаем предыдущий таймер
if (loadMultiRelationValuesTimer) {
clearTimeout(loadMultiRelationValuesTimer);
}
// Устанавливаем новый таймер
loadMultiRelationValuesTimer = setTimeout(async () => {
// Получаем связи для текущей строки
// console.log('[loadMultiRelationValues] called for row:', props.rowId, 'column:', props.column.id);
try {
const rel = props.column.options || {};
if (rel.relatedTableId && rel.relatedColumnId) {
// Проверяем кэш для relations
let relations;
let tableData;
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 к строке для корректного сравнения
const relatedRowIds = relations
.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));
// console.log('[loadMultiRelationValues] filtered related row ids:', 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-значения
selectedMultiRelationNames.value = multiRelationOptions.value
.filter(opt => relatedRowIds.includes(String(opt.id)))
.map(opt => opt.display);
// Отмечаем, что данные загружены
isMultiRelationValuesLoaded = true;
}
} catch (e) {
// console.error('[loadMultiRelationValues] Error:', e);
}
}, LOAD_DEBOUNCE_DELAY);
}
async function saveMultiRelation() {
// console.log('[saveMultiRelation] called');
const rel = props.column.options || {};
// console.log('[saveMultiRelation] editMultiRelationValues:', editMultiRelationValues.value);
try {
const payload = {
column_id: props.column.id,
to_table_id: rel.relatedTableId,
to_row_ids: editMultiRelationValues.value
};
// console.log('[saveMultiRelation] POST payload:', payload);
// console.log('[TableCell] Отправляем запрос на обновление relations для строки:', props.rowId);
// console.log('[TableCell] Данные запроса:', payload);
const response = await fetch(`/api/tables/${props.column.table_id}/row/${props.rowId}/relations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json().catch(() => ({}));
// console.log('[TableCell] Ответ сервера для строки:', props.rowId, 'статус:', response.status, 'результат:', result);
if (response.ok) {
// console.log('[TableCell] Успешно сохранены теги для строки:', props.rowId);
} else {
// console.error('[TableCell] Ошибка сохранения тегов для строки:', props.rowId, 'статус:', response.status);
}
editing.value = false;
await loadMultiRelationValues();
// console.log('[saveMultiRelation] emitting update with:', editMultiRelationValues.value);
emit('update', editMultiRelationValues.value);
} catch (e) {
// console.error('[saveMultiRelation] Ошибка при сохранении мультисвязи:', e);
}
}
async function addTag() {
if (!newTagName.value.trim()) return;
const rel = props.column.options || {};
try {
// console.log('[addTag] Добавляем новый тег:', newTagName.value);
// 1. Создаем новую пустую строку в связанной таблице
const rowResponse = await fetch(`/api/tables/${rel.relatedTableId}/rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const newRow = await rowResponse.json();
// console.log('[addTag] Новая строка создана:', newRow);
// 2. Добавляем значение в ячейку через POST /cell
const cellResponse = await fetch(`/api/tables/cell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
row_id: newRow.id,
column_id: rel.relatedColumnId,
value: newTagName.value
})
});
const cellResult = await cellResponse.json();
// console.log('[addTag] Значение ячейки сохранено:', cellResult);
// Очищаем форму
newTagName.value = '';
showAddTagInput.value = false;
// Обновляем список опций и добавляем тег в выбранные параллельно
await Promise.all([
loadMultiRelationOptions(),
Promise.resolve(editMultiRelationValues.value.push(String(newRow.id)))
]);
// console.log('[addTag] Тег добавлен в выбранные:', editMultiRelationValues.value);
// Сохраняем изменения, чтобы отправить WebSocket уведомление
await saveMultiRelation();
} catch (e) {
// console.error('[addTag] Ошибка при добавлении тега:', e);
}
}
async function deleteTag(tagId) {
const rel = props.column.options || {};
if (!confirm('Удалить этот тег?')) return;
try {
// console.log('[deleteTag] Удаляем тег с ID:', tagId);
// Удаляем тег из связанной таблицы
const response = await fetch(`/api/tables/row/${tagId}`, { method: 'DELETE' });
const result = await response.json();
// console.log('[deleteTag] Ответ сервера:', response.status, result);
// Убираем тег из выбранных значений, если он был выбран
editMultiRelationValues.value = editMultiRelationValues.value.filter(id => String(id) !== String(tagId));
// Обновляем список опций
await loadMultiRelationOptions();
// console.log('[deleteTag] Тег удален:', tagId);
// Сохраняем изменения, чтобы отправить WebSocket уведомление
await saveMultiRelation();
} catch (e) {
// console.error('[deleteTag] Ошибка при удалении тега:', e);
}
}
function cancelMultiRelation() {
// Сбрасываем форму добавления тега
showAddTagInput.value = false;
newTagName.value = '';
// Закрываем режим редактирования
editing.value = false;
// Перезагружаем исходные значения
loadMultiRelationValues();
}
function save() {
emit('update', localValue.value);
}
function saveAndExit() {
save();
editing.value = false;
}
function isArrayString(val) {
if (typeof val !== 'string') return false;
try {
const arr = JSON.parse(val);
return Array.isArray(arr);
} catch {
return false;
}
}
function parseArrayString(val) {
if (typeof val !== 'string') return [];
// Пробуем как JSON
try {
const arr = JSON.parse(val);
if (Array.isArray(arr)) return arr.map(String);
} catch {}
// Пробуем как PostgreSQL-массив
if (/^\{.*\}$/.test(val)) {
return val.replace(/[{}\s"]/g, '').split(',').filter(Boolean);
}
// Если просто строка
if (val.trim().length > 0) return [val.trim()];
return [];
}
function prettyDisplay(val, optionsArr) {
const arr = parseArrayString(val);
if (!arr.length) return '—';
if (optionsArr && Array.isArray(optionsArr)) {
// Для relation/multiselect-relation ищу display по id
return arr.map(id => {
const found = optionsArr.find(opt => String(opt.id) === String(id) || String(opt) === String(id));
return found ? (found.display || found) : id;
}).join(', ');
}
return arr.join(', ');
}
</script>
<style scoped>
.cell-input {
border: none !important;
outline: none !important;
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
resize: none !important;
width: 100% !important;
min-height: 32px;
font: inherit;
color: inherit;
overflow: hidden;
}
.cell-input:focus {
border: 1.5px solid #2ecc40;
outline: none;
}
.tags-cell-view, .tags-cell-edit, .lookup-cell-view, .tag-option, .multi-relation-option, .add-multiselect-option, .add-tag-form, .multi-relation-options, .multi-relation-edit, .multi-relation-actions, .action-buttons {
white-space: normal !important;
word-break: break-word !important;
height: auto !important;
min-height: 1.7em;
align-items: flex-start !important;
vertical-align: top !important;
overflow: visible !important;
}
.tags-cell-view {
min-height: 1.7em;
cursor: pointer;
padding: 0.2em 0.1em;
}
.tags-cell-edit {
background: #f8f8f8;
border-radius: 6px;
padding: 0.3em 0.2em 0.5em 0.2em;
}
.tags-multiselect {
display: flex;
flex-wrap: wrap;
gap: 0.5em 1.2em;
margin-bottom: 0.7em;
}
.tag-option {
display: flex;
align-items: center;
gap: 0.3em;
}
.save-btn {
background: #2ecc40;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.3em 1em;
font-weight: 600;
cursor: pointer;
margin-right: 0.7em;
transition: background 0.2s;
}
.save-btn:hover {
background: #27ae38;
}
.cancel-btn {
background: #eaeaea;
color: #333;
border: none;
border-radius: 8px;
padding: 0.3em 1em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.cancel-btn:hover {
background: #d5d5d5;
}
.add-multiselect-option {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0.7em;
}
.add-multiselect-option input {
flex: 1;
padding: 0.2em 0.5em;
border: 1px solid #e0e0e0;
border-radius: 5px;
font-size: 1em;
}
.add-btn {
background: #2ecc40;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.3em 1em;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover {
background: #27ae38;
}
.remove-option {
color: #e74c3c;
font-size: 1.1em;
margin-left: 0.4em;
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
.remove-option:hover {
color: #c0392b;
}
.lookup-cell-view {
min-height: 1.7em;
padding: 0.2em 0.1em;
color: #222;
}
.multi-relation-edit {
padding: 8px 0;
}
.multi-relation-options {
max-height: 180px;
overflow-y: auto;
margin-bottom: 8px;
}
.multi-relation-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
}
.delete-tag-btn {
background: none;
border: none;
color: #e53e3e;
font-size: 1.1em;
cursor: pointer;
padding: 0 4px;
}
.multi-relation-actions {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.save-btn {
background: #4f8cff;
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
}
.cancel-btn {
background: #f3f4f6;
color: #333;
border: none;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
}
.add-tag-block {
margin-top: 8px;
}
.add-tag-btn {
background: #f3f4f6;
color: #4f8cff;
border: none;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
}
.add-tag-form {
display: flex;
gap: 6px;
align-items: center;
}
.add-tag-form input {
padding: 3px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
}
.add-tag-confirm {
background: #4f8cff;
color: #fff;
border: none;
border-radius: 4px;
padding: 3px 10px;
cursor: pointer;
}
.add-tag-cancel {
background: none;
border: none;
color: #e53e3e;
font-size: 1.1em;
cursor: pointer;
padding: 0 4px;
}
.action-buttons {
display: flex;
gap: 0.5em;
margin-top: 0.7em;
}
.delete-tag-btn:hover {
color: #c0392b;
}
.add-tag-btn:hover {
background: #e2e8f0;
color: #3182ce;
}
.add-tag-confirm:hover {
background: #3182ce;
}
.add-tag-block {
margin: 0.7em 0;
}
.cell-view-value {
display: block;
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
width: 100%;
cursor: pointer;
transition: background 0.15s;
min-height: 32px;
}
.cell-view-value:hover {
background: #f3f4f6;
}
.cell-plus-icon {
color: #b6c6e6;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
transition: color 0.15s;
vertical-align: middle;
}
.cell-plus-icon:hover {
color: #4f8cff;
}
</style>