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

This commit is contained in:
2025-07-11 16:45:09 +03:00
parent e0ec889863
commit 584ff401ad
42 changed files with 1945 additions and 1004 deletions

View File

@@ -1,22 +1,75 @@
<template>
<template v-if="column.type === 'tags'">
<template v-if="column.type === 'multiselect'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view">
<span v-if="selectedTagNames.length">{{ selectedTagNames.join(', ') }}</span>
<span v-if="selectedMultiNames.length">{{ selectedMultiNames.join(', ') }}</span>
<span v-else style="color:#bbb"></span>
</div>
<div v-else class="tags-cell-edit">
<div class="tags-multiselect">
<div v-for="tag in allTags" :key="tag.id" class="tag-option">
<input type="checkbox" :id="'cell-tag-' + tag.id + '-' + rowId" :value="tag.id" v-model="editTagIds" />
<label :for="'cell-tag-' + tag.id + '-' + rowId">{{ tag.name }}</label>
<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>
<button class="save-btn" @click="saveTags">Сохранить</button>
<button class="cancel-btn" @click="cancelTags">Отмена</button>
<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="editing = true" class="tags-cell-view">
<span v-if="selectedRelationName">{{ selectedRelationName }}</span>
<span v-else style="color:#bbb"></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="editing = true" class="tags-cell-view">
<span v-if="selectedMultiRelationNames.length">{{ selectedMultiRelationNames.map(prettyDisplay).join(', ') }}</span>
<span v-else>{{ prettyDisplay(localValue) }}</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">{{ prettyDisplay(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>
<span v-if="isArrayString(localValue)">{{ parseArrayString(localValue).join(', ') }}</span>
<input
v-else
v-model="localValue"
@blur="save"
@keyup.enter="save"
@@ -27,46 +80,113 @@
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import { ref, watch, onMounted, computed } from 'vue';
import tablesService from '../../services/tablesService';
const props = defineProps(['rowId', 'column', 'cellValues']);
const emit = defineEmits(['update']);
const localValue = ref('');
const editing = ref(false);
const allTags = ref([]); // Все теги из /api/tags
// 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('');
// Добавляем watch для отслеживания изменений в мультисвязях
watch(editMultiRelationValues, (newValues, oldValues) => {
console.log('[editMultiRelationValues] changed from:', oldValues, 'to:', newValues);
}, { deep: true });
onMounted(async () => {
if (props.column.type === 'tags') {
await loadTags();
updateSelectedTagNames();
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 : '';
}
});
async function loadTags() {
const res = await fetch('/api/tags');
allTags.value = await res.json();
}
watch(
() => [props.rowId, props.column.id, props.cellValues],
() => {
if (props.column.type === 'tags') {
// Значение ячейки — строка с JSON-массивом id тегов
async () => {
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 ids = [];
let values = [];
if (cell && cell.value) {
try {
ids = JSON.parse(cell.value);
values = JSON.parse(cell.value);
} catch {}
}
editTagIds.value = Array.isArray(ids) ? ids : [];
updateSelectedTagNames();
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
@@ -77,26 +197,305 @@ watch(
{ immediate: true }
);
function updateSelectedTagNames() {
if (props.column.type === 'tags') {
selectedTagNames.value = allTags.value
.filter(tag => editTagIds.value.includes(tag.id))
.map(tag => tag.name);
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 rel = props.column.options || {};
if (!rel.relatedTableId) return;
const res = await fetch(`/api/tables/${rel.relatedTableId}`);
const data = await res.json();
// Далее используем data.columns, data.rows, data.cellValues
const colId = rel.relatedColumnId || (data.columns[0] && data.columns[0].id);
const opts = [];
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}` });
}
multiRelationOptions.value = opts;
}
async function loadMultiRelationValues() {
// Получаем связи для текущей строки
console.log('[loadMultiRelationValues] called for row:', props.rowId, 'column:', props.column.id);
editMultiRelationValues.value = [];
selectedMultiRelationNames.value = [];
try {
const rel = props.column.options || {};
if (rel.relatedTableId && rel.relatedColumnId) {
const url = `/api/tables/${props.column.table_id}/row/${props.rowId}/relations`;
console.log('[loadMultiRelationValues] GET request to:', url);
const res = await fetch(url);
const relations = await res.json();
console.log('[loadMultiRelationValues] API response status:', res.status, '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;
// Получаем display-значения
await loadMultiRelationOptions();
selectedMultiRelationNames.value = multiRelationOptions.value
.filter(opt => relatedRowIds.includes(String(opt.id)))
.map(opt => opt.display);
console.log('[loadMultiRelationValues] selectedMultiRelationNames:', selectedMultiRelationNames.value);
}
} catch (e) {
console.error('[loadMultiRelationValues] Error:', e);
}
}
function saveTags() {
emit('update', JSON.stringify(editTagIds.value));
editing.value = false;
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);
const response = await fetch(`/api/tables/${props.column.table_id}/row/${props.rowId}/multirelations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json().catch(() => ({}));
console.log('[saveMultiRelation] API response status:', response.status, 'result:', result);
editing.value = false;
await loadMultiRelationValues();
console.log('[saveMultiRelation] emitting update with:', editMultiRelationValues.value);
emit('update', editMultiRelationValues.value);
} catch (e) {
console.error('[saveMultiRelation] Ошибка при сохранении мультисвязи:', e);
}
}
function cancelTags() {
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 loadMultiRelationOptions();
// Автоматически добавляем новый тег в выбранные
editMultiRelationValues.value.push(String(newRow.id));
console.log('[addTag] Тег добавлен в выбранные:', editMultiRelationValues.value);
} 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);
} catch (e) {
console.error('[deleteTag] Ошибка при удалении тега:', e);
}
}
function cancelMultiRelation() {
// Сбрасываем форму добавления тега
showAddTagInput.value = false;
newTagName.value = '';
// Закрываем режим редактирования
editing.value = false;
updateSelectedTagNames();
// Перезагружаем исходные значения
loadMultiRelationValues();
}
function save() {
emit('update', localValue.value);
}
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) {
try {
const arr = JSON.parse(val);
return Array.isArray(arr) ? arr : [val];
} catch {
return [val];
}
}
function prettyDisplay(val) {
if (isArrayString(val)) {
return parseArrayString(val).join(', ');
}
return val;
}
</script>
<style scoped>
@@ -161,4 +560,150 @@ function save() {
.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;
}
</style>