ваше сообщение коммита
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onBeforeUnmount, defineProps, defineEmits } from 'vue';
|
||||
import { useAuth } from '../composables/useAuth';
|
||||
import { useAuthContext } from '../composables/useAuth';
|
||||
import { useAuthFlow } from '../composables/useAuthFlow';
|
||||
import { useNotifications } from '../composables/useNotifications';
|
||||
import { getFromStorage, setToStorage, removeFromStorage } from '../utils/storage';
|
||||
@@ -53,7 +53,7 @@ import NotificationDisplay from './NotificationDisplay.vue';
|
||||
// 1. ИСПОЛЬЗОВАНИЕ COMPOSABLES
|
||||
// =====================================================================
|
||||
|
||||
const auth = useAuth();
|
||||
const auth = useAuthContext();
|
||||
const { notifications, showSuccessMessage, showErrorMessage } = useNotifications();
|
||||
|
||||
// Определяем props, которые будут приходить от родительского View
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { useAuth } from '../composables/useAuth';
|
||||
import { useAuthContext } from '../composables/useAuth';
|
||||
import eventBus from '../utils/eventBus';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -35,7 +35,7 @@ const toggleSidebar = () => {
|
||||
};
|
||||
|
||||
// Обработка аутентификации
|
||||
const auth = useAuth();
|
||||
const auth = useAuthContext();
|
||||
const { isAuthenticated } = auth;
|
||||
|
||||
// Мониторинг изменений статуса аутентификации
|
||||
|
||||
@@ -129,7 +129,7 @@ import { useRouter } from 'vue-router';
|
||||
import eventBus from '../utils/eventBus';
|
||||
import EmailConnect from './identity/EmailConnect.vue';
|
||||
import TelegramConnect from './identity/TelegramConnect.vue';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { useAuthContext } from '@/composables/useAuth';
|
||||
|
||||
const router = useRouter();
|
||||
const props = defineProps({
|
||||
@@ -144,7 +144,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'wallet-auth', 'disconnect-wallet', 'telegram-auth', 'email-auth', 'cancel-email-auth']);
|
||||
|
||||
const { deleteIdentity } = useAuth();
|
||||
const { deleteIdentity } = useAuthContext();
|
||||
|
||||
// Обработчики событий
|
||||
const handleWalletAuth = () => {
|
||||
|
||||
12
frontend/src/components/cells/CellCheckbox.vue
Normal file
12
frontend/src/components/cells/CellCheckbox.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<input type="checkbox" :checked="value === true || value === 'true'" disabled />
|
||||
</template>
|
||||
<script setup>
|
||||
defineProps({ value: [Boolean, String, Number] });
|
||||
</script>
|
||||
<style scoped>
|
||||
input[type='checkbox'] {
|
||||
pointer-events: none;
|
||||
accent-color: #1976d2;
|
||||
}
|
||||
</style>
|
||||
16
frontend/src/components/cells/CellDate.vue
Normal file
16
frontend/src/components/cells/CellDate.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<span>{{ formatted }}</span>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({ value: String });
|
||||
const formatted = computed(() => {
|
||||
if (!props.value) return '';
|
||||
const d = new Date(props.value);
|
||||
if (isNaN(d)) return props.value;
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
span { color: #3a3a3a; }
|
||||
</style>
|
||||
9
frontend/src/components/cells/CellNumber.vue
Normal file
9
frontend/src/components/cells/CellNumber.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<span>{{ value }}</span>
|
||||
</template>
|
||||
<script setup>
|
||||
defineProps({ value: [String, Number] });
|
||||
</script>
|
||||
<style scoped>
|
||||
span { font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
20
frontend/src/components/cells/CellSelect.vue
Normal file
20
frontend/src/components/cells/CellSelect.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<span v-if="Array.isArray(value)">
|
||||
<span v-for="(v, i) in value" :key="i" class="select-tag">{{ v }}</span>
|
||||
</span>
|
||||
<span v-else>{{ value }}</span>
|
||||
</template>
|
||||
<script setup>
|
||||
defineProps({ value: [String, Array], options: [Array, Object] });
|
||||
</script>
|
||||
<style scoped>
|
||||
.select-tag {
|
||||
display: inline-block;
|
||||
background: #e0f3ff;
|
||||
color: #1976d2;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
margin-right: 4px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
</style>
|
||||
9
frontend/src/components/cells/CellText.vue
Normal file
9
frontend/src/components/cells/CellText.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<span>{{ value }}</span>
|
||||
</template>
|
||||
<script setup>
|
||||
defineProps({ value: String });
|
||||
</script>
|
||||
<style scoped>
|
||||
span { white-space: pre-line; }
|
||||
</style>
|
||||
@@ -38,10 +38,10 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import axios from '@/api/axios';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { useAuthContext } from '@/composables/useAuth';
|
||||
|
||||
const emit = defineEmits(['close', 'success']);
|
||||
const { linkIdentity } = useAuth();
|
||||
const { linkIdentity } = useAuthContext();
|
||||
|
||||
const email = ref('');
|
||||
const code = ref('');
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { useAuthContext } from '@/composables/useAuth';
|
||||
import { connectWithWallet } from '@/services/wallet';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { linkIdentity } = useAuth();
|
||||
const { linkIdentity } = useAuthContext();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
111
frontend/src/components/tables/CreateTableModal.vue
Normal file
111
frontend/src/components/tables/CreateTableModal.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="create-table-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Создать новую таблицу</h3>
|
||||
<button class="close-btn" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="createTable">
|
||||
<div class="form-group">
|
||||
<label>Название таблицы</label>
|
||||
<input v-model="name" required maxlength="255" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea v-model="description" maxlength="500"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-success" type="submit" :disabled="isLoading">Создать</button>
|
||||
<button class="btn btn-secondary" type="button" @click="$emit('close')">Отмена</button>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import tablesService from '../../services/tablesService';
|
||||
const emit = defineEmits(['close', 'table-created']);
|
||||
const name = ref('');
|
||||
const description = ref('');
|
||||
const isLoading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function createTable() {
|
||||
if (!name.value.trim()) return;
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await tablesService.createTable({ name: name.value, description: description.value });
|
||||
emit('table-created');
|
||||
emit('close');
|
||||
} catch (e) {
|
||||
error.value = 'Ошибка создания таблицы';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-table-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.13);
|
||||
padding: 28px 22px 18px 22px;
|
||||
max-width: 400px;
|
||||
margin: 40px auto;
|
||||
position: relative;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #bbb;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.btn {
|
||||
padding: 7px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #bbb;
|
||||
color: #fff;
|
||||
}
|
||||
.error {
|
||||
color: #dc3545;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
276
frontend/src/components/tables/DynamicTableEditor.vue
Normal file
276
frontend/src/components/tables/DynamicTableEditor.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="dynamic-table-editor">
|
||||
<div class="editor-header">
|
||||
<input v-model="tableName" @blur="saveTableName" class="table-title-input" />
|
||||
<textarea v-model="tableDesc" @blur="saveTableDesc" class="table-desc-input" />
|
||||
<button class="close-btn" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div v-if="isLoading" class="loading">Загрузка...</div>
|
||||
<div v-else>
|
||||
<TableColumnsDraggable
|
||||
:columns="columns"
|
||||
@update-column="updateColumn"
|
||||
@delete-column="deleteColumn"
|
||||
@edit-options="openOptionsEditor"
|
||||
@columns-reordered="reorderColumns"
|
||||
/>
|
||||
<SelectOptionsEditor
|
||||
v-if="showOptionsEditor"
|
||||
:options="editingOptions"
|
||||
@update:options="saveOptions"
|
||||
/>
|
||||
<div class="table-controls">
|
||||
<button class="btn btn-success" @click="addColumn">Добавить столбец</button>
|
||||
<button class="btn btn-success" @click="addRow">Добавить строку</button>
|
||||
</div>
|
||||
<table class="dynamic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="col in columns" :key="col.id">{{ col.name }}</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td v-for="col in columns" :key="col.id">
|
||||
<template v-if="col.type === 'select'">
|
||||
<select v-model="cellEdits[`${row.id}_${col.id}`]" @change="saveCell(row.id, col.id)">
|
||||
<option v-for="opt in col.options || []" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input :value="cellValue(row.id, col.id)" @input="onCellInput(row.id, col.id, $event.target.value)" @blur="saveCell(row.id, col.id)" />
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteRow(row.id)">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!rows.length || !columns.length" class="empty-table">Нет данных. Добавьте столбцы и строки.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import tablesService from '../../services/tablesService';
|
||||
import TableColumnsDraggable from './TableColumnsDraggable.vue';
|
||||
import SelectOptionsEditor from './SelectOptionsEditor.vue';
|
||||
|
||||
const props = defineProps({ tableId: { type: Number, required: true } });
|
||||
const emit = defineEmits(['close']);
|
||||
const isLoading = ref(true);
|
||||
const columns = ref([]);
|
||||
const rows = ref([]);
|
||||
const cellValues = ref([]);
|
||||
const cellEdits = ref({});
|
||||
const tableName = ref('');
|
||||
const tableDesc = ref('');
|
||||
const showOptionsEditor = ref(false);
|
||||
const editingCol = ref(null);
|
||||
const editingOptions = ref([]);
|
||||
|
||||
function loadTable() {
|
||||
isLoading.value = true;
|
||||
tablesService.getTable(props.tableId)
|
||||
.then(res => {
|
||||
columns.value = res.columns;
|
||||
rows.value = res.rows;
|
||||
cellValues.value = res.cellValues;
|
||||
cellEdits.value = {};
|
||||
tableName.value = res.columns.length ? res.columns[0].table_name : '';
|
||||
tableDesc.value = res.columns.length ? res.columns[0].table_description : '';
|
||||
})
|
||||
.finally(() => { isLoading.value = false; });
|
||||
}
|
||||
function addColumn() {
|
||||
const name = prompt('Название столбца:');
|
||||
if (!name) return;
|
||||
tablesService.addColumn(props.tableId, { name, type: 'text' }).then(loadTable);
|
||||
}
|
||||
function addRow() {
|
||||
tablesService.addRow(props.tableId).then(loadTable);
|
||||
}
|
||||
function deleteColumn(colId) {
|
||||
if (!confirm('Удалить столбец?')) return;
|
||||
tablesService.deleteColumn(colId).then(loadTable);
|
||||
}
|
||||
function deleteRow(rowId) {
|
||||
if (!confirm('Удалить строку?')) return;
|
||||
tablesService.deleteRow(rowId).then(loadTable);
|
||||
}
|
||||
function cellValue(rowId, colId) {
|
||||
const key = `${rowId}_${colId}`;
|
||||
if (cellEdits.value[key] !== undefined) return cellEdits.value[key];
|
||||
const found = cellValues.value.find(c => c.row_id === rowId && c.column_id === colId);
|
||||
return found ? found.value : '';
|
||||
}
|
||||
function saveCell(rowId, colId) {
|
||||
const key = `${rowId}_${colId}`;
|
||||
const value = cellEdits.value[key];
|
||||
tablesService.saveCell({ row_id: rowId, column_id: colId, value }).then(loadTable);
|
||||
}
|
||||
function updateColumn(col) {
|
||||
try {
|
||||
// Убеждаемся, что options - это массив или null
|
||||
let options = col.options;
|
||||
if (typeof options === 'string') {
|
||||
try {
|
||||
options = JSON.parse(options);
|
||||
} catch {
|
||||
options = [];
|
||||
}
|
||||
}
|
||||
|
||||
tablesService.updateColumn(col.id, {
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
options: options,
|
||||
order: col.order
|
||||
}).then(loadTable).catch(err => {
|
||||
console.error('Ошибка обновления столбца:', err);
|
||||
alert('Ошибка обновления столбца');
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка updateColumn:', err);
|
||||
}
|
||||
}
|
||||
function reorderColumns(newColumns) {
|
||||
// Сохраняем новый порядок столбцов последовательно
|
||||
const updatePromises = newColumns.map((col, idx) =>
|
||||
tablesService.updateColumn(col.id, { order: idx })
|
||||
);
|
||||
|
||||
Promise.all(updatePromises)
|
||||
.then(() => loadTable())
|
||||
.catch(err => {
|
||||
console.error('Ошибка переупорядочивания столбцов:', err);
|
||||
alert('Ошибка переупорядочивания столбцов');
|
||||
});
|
||||
}
|
||||
function onCellInput(rowId, colId, value) {
|
||||
const key = `${rowId}_${colId}`;
|
||||
cellEdits.value[key] = value;
|
||||
}
|
||||
function openOptionsEditor(col) {
|
||||
editingCol.value = col;
|
||||
editingOptions.value = Array.isArray(col.options) ? [...col.options] : [];
|
||||
showOptionsEditor.value = true;
|
||||
}
|
||||
function saveOptions(newOptions) {
|
||||
if (!editingCol.value) return;
|
||||
tablesService.updateColumn(editingCol.value.id, { options: newOptions }).then(() => {
|
||||
showOptionsEditor.value = false;
|
||||
loadTable();
|
||||
});
|
||||
}
|
||||
function saveTableName() {
|
||||
tablesService.updateTable(props.tableId, { name: tableName.value });
|
||||
}
|
||||
function saveTableDesc() {
|
||||
tablesService.updateTable(props.tableId, { description: tableDesc.value });
|
||||
}
|
||||
watch([columns, rows], () => {
|
||||
cellEdits.value = {};
|
||||
});
|
||||
onMounted(loadTable);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-table-editor {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.13);
|
||||
padding: 28px 22px 18px 22px;
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
position: relative;
|
||||
}
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.table-title-input {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.table-desc-input {
|
||||
font-size: 0.9em;
|
||||
border: 1px solid #ccc;
|
||||
outline: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
resize: none;
|
||||
height: 60px;
|
||||
width: 200px;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #bbb;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
.table-controls {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.dynamic-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.dynamic-table th, .dynamic-table td {
|
||||
border: 1px solid #eee;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.dynamic-table th {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.btn {
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.loading {
|
||||
color: #888;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.empty-table {
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
100
frontend/src/components/tables/DynamicTablesModal.vue
Normal file
100
frontend/src/components/tables/DynamicTablesModal.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="dynamic-tables-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Пользовательские таблицы</h2>
|
||||
<button class="close-btn" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<UserTablesList @open-table="openTable" @table-deleted="onTableDeleted" />
|
||||
<DynamicTableEditor v-if="selectedTable" :table-id="selectedTable.id" @close="closeEditor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import UserTablesList from './UserTablesList.vue';
|
||||
import DynamicTableEditor from './DynamicTableEditor.vue';
|
||||
|
||||
const selectedTable = ref(null);
|
||||
function openTable(table) {
|
||||
selectedTable.value = table;
|
||||
}
|
||||
function closeEditor() {
|
||||
selectedTable.value = null;
|
||||
}
|
||||
function onTableDeleted(deletedTableId) {
|
||||
if (selectedTable.value && selectedTable.value.id === deletedTableId) {
|
||||
selectedTable.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-tables-modal {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
|
||||
padding: 32px 24px 24px 24px;
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: #bbb;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
.tables-list-block {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.tables-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.tables-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.btn {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.loading {
|
||||
color: #888;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.user-table-block {
|
||||
margin-bottom: 32px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
padding: 18px 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.user-table-full {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.user-table-full th, .user-table-full td {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.user-table-full th {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
68
frontend/src/components/tables/SelectOptionsEditor.vue
Normal file
68
frontend/src/components/tables/SelectOptionsEditor.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="select-options-editor">
|
||||
<h4>Опции для select</h4>
|
||||
<ul>
|
||||
<li v-for="(opt, idx) in localOptions" :key="idx">
|
||||
<input v-model="localOptions[idx]" @blur="emitOptions" class="option-input" />
|
||||
<button class="btn btn-danger btn-xs" @click="removeOption(idx)">×</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn btn-success btn-xs" @click="addOption">Добавить опцию</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
const props = defineProps({
|
||||
options: { type: Array, default: () => [] }
|
||||
});
|
||||
const emit = defineEmits(['update:options']);
|
||||
const localOptions = ref([...props.options]);
|
||||
|
||||
watch(() => props.options, (val) => {
|
||||
localOptions.value = [...val];
|
||||
});
|
||||
function addOption() {
|
||||
localOptions.value.push('');
|
||||
emitOptions();
|
||||
}
|
||||
function removeOption(idx) {
|
||||
localOptions.value.splice(idx, 1);
|
||||
emitOptions();
|
||||
}
|
||||
function emitOptions() {
|
||||
emit('update:options', localOptions.value.filter(opt => opt.trim() !== ''));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.select-options-editor {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
max-width: 320px;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.option-input {
|
||||
flex: 1 1 80px;
|
||||
border: 1px solid #b0b0b0;
|
||||
border-radius: 5px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.btn-xs {
|
||||
font-size: 0.8em;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
</style>
|
||||
104
frontend/src/components/tables/TableColumnsDraggable.vue
Normal file
104
frontend/src/components/tables/TableColumnsDraggable.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="columns-container">
|
||||
<div v-for="(col, index) in localColumns" :key="col.id" class="column-header">
|
||||
<input v-model="col.name" @blur="updateColumn(col)" class="col-name-input" :placeholder="'Название'" />
|
||||
<select v-model="col.type" @change="updateColumn(col)" class="col-type-select">
|
||||
<option value="text">Текст</option>
|
||||
<option value="select">Список</option>
|
||||
</select>
|
||||
<button class="btn btn-danger btn-sm" @click="$emit('delete-column', col.id)">×</button>
|
||||
<button v-if="col.type==='select'" class="btn btn-secondary btn-xs" @click="$emit('edit-options', col)">Опции</button>
|
||||
<div class="reorder-buttons">
|
||||
<button v-if="index > 0" class="btn btn-light btn-xs" @click="moveColumn(index, -1)">←</button>
|
||||
<button v-if="index < localColumns.length - 1" class="btn btn-light btn-xs" @click="moveColumn(index, 1)">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true }
|
||||
});
|
||||
const emit = defineEmits(['update:columns', 'update-column', 'delete-column', 'edit-options', 'columns-reordered']);
|
||||
const localColumns = ref([...props.columns]);
|
||||
|
||||
watch(() => props.columns, (val) => {
|
||||
localColumns.value = [...val];
|
||||
});
|
||||
function updateColumn(col) {
|
||||
emit('update-column', col);
|
||||
}
|
||||
function moveColumn(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex >= 0 && newIndex < localColumns.value.length) {
|
||||
const columns = [...localColumns.value];
|
||||
[columns[index], columns[newIndex]] = [columns[newIndex], columns[index]];
|
||||
localColumns.value = columns;
|
||||
emit('columns-reordered', localColumns.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.columns-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.column-header {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 180px;
|
||||
position: relative;
|
||||
}
|
||||
.col-name-input {
|
||||
flex: 1 1 80px;
|
||||
border: 1px solid #b0b0b0;
|
||||
border-radius: 5px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.col-type-select {
|
||||
border-radius: 5px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.btn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
}
|
||||
.btn-light {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.btn-xs {
|
||||
font-size: 0.7em;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 0.75em;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.reorder-buttons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
167
frontend/src/components/tables/UserTablesList.vue
Normal file
167
frontend/src/components/tables/UserTablesList.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="user-tables-list">
|
||||
<div class="header-block">
|
||||
<h2>Пользовательские таблицы</h2>
|
||||
<button class="btn btn-success" @click="showCreateTable = true">Создать таблицу</button>
|
||||
</div>
|
||||
<div v-if="isLoading" class="loading">Загрузка...</div>
|
||||
<div v-else>
|
||||
<div v-if="tables.length === 0" class="empty-block">Нет таблиц. Создайте первую!</div>
|
||||
<div v-else class="tables-cards">
|
||||
<div v-for="table in tables" :key="table.id" class="table-card">
|
||||
<div class="table-card-header">
|
||||
<input v-if="editingTableId === table.id" v-model="editName" @blur="saveName(table)" @keyup.enter="saveName(table)" class="table-name-input" />
|
||||
<h3 v-else @dblclick="startEditName(table)">{{ table.name }}</h3>
|
||||
<div class="table-card-actions">
|
||||
<button class="btn btn-info btn-sm" @click="$emit('open-table', table)">Открыть</button>
|
||||
<button class="btn btn-warning btn-sm" @click="startEditName(table)">Переименовать</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteTable(table)">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card-desc">
|
||||
<textarea v-if="editingDescId === table.id" v-model="editDesc" @blur="saveDesc(table)" @keyup.enter="saveDesc(table)" class="table-desc-input" />
|
||||
<p v-else @dblclick="startEditDesc(table)">{{ table.description || 'Без описания' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateTableModal v-if="showCreateTable" @close="showCreateTable = false" @table-created="onTableCreated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import tablesService from '../../services/tablesService';
|
||||
import CreateTableModal from './CreateTableModal.vue';
|
||||
|
||||
const emit = defineEmits(['open-table', 'table-deleted']);
|
||||
const tables = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const showCreateTable = ref(false);
|
||||
const editingTableId = ref(null);
|
||||
const editName = ref('');
|
||||
const editingDescId = ref(null);
|
||||
const editDesc = ref('');
|
||||
|
||||
function loadTables() {
|
||||
isLoading.value = true;
|
||||
tablesService.getTables()
|
||||
.then(res => {
|
||||
tables.value = [...res]; // Создаем новый массив для принудительного обновления
|
||||
})
|
||||
.finally(() => { isLoading.value = false; });
|
||||
}
|
||||
function onTableCreated() {
|
||||
showCreateTable.value = false;
|
||||
loadTables();
|
||||
}
|
||||
function startEditName(table) {
|
||||
editingTableId.value = table.id;
|
||||
editName.value = table.name;
|
||||
}
|
||||
function saveName(table) {
|
||||
if (editName.value && editName.value !== table.name) {
|
||||
tablesService.updateTable(table.id, { name: editName.value })
|
||||
.then(loadTables);
|
||||
}
|
||||
editingTableId.value = null;
|
||||
}
|
||||
function startEditDesc(table) {
|
||||
editingDescId.value = table.id;
|
||||
editDesc.value = table.description || '';
|
||||
}
|
||||
function saveDesc(table) {
|
||||
tablesService.updateTable(table.id, { description: editDesc.value })
|
||||
.then(loadTables);
|
||||
editingDescId.value = null;
|
||||
}
|
||||
function deleteTable(table) {
|
||||
console.log('deleteTable called with:', table);
|
||||
|
||||
if (!confirm(`Удалить таблицу "${table.name}"?`)) {
|
||||
console.log('User cancelled deletion');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('User confirmed deletion, proceeding...');
|
||||
|
||||
// Немедленно удаляем из локального списка для быстрой реакции UI
|
||||
tables.value = tables.value.filter(t => t.id !== table.id);
|
||||
console.log('Removed from local list, making API call...');
|
||||
|
||||
tablesService.deleteTable(table.id)
|
||||
.then((result) => {
|
||||
console.log('Таблица удалена:', result);
|
||||
// Уведомляем родительский компонент об удалении
|
||||
emit('table-deleted', table.id);
|
||||
// Принудительно обновляем список с сервера для синхронизации
|
||||
loadTables();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Ошибка удаления таблицы:', error);
|
||||
alert('Ошибка при удалении таблицы');
|
||||
// При ошибке восстанавливаем список с сервера
|
||||
loadTables();
|
||||
});
|
||||
}
|
||||
onMounted(loadTables);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-tables-list {
|
||||
padding: 18px 8px;
|
||||
}
|
||||
.header-block {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.tables-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
}
|
||||
.table-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
min-width: 260px;
|
||||
max-width: 340px;
|
||||
flex: 1 1 260px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.table-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.table-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.table-card-desc {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.table-name-input, .table-desc-input {
|
||||
width: 100%;
|
||||
font-size: 1.1em;
|
||||
border: 1px solid #b0b0b0;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.empty-block {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
margin: 32px 0;
|
||||
}
|
||||
.loading {
|
||||
color: #888;
|
||||
margin: 16px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user