ваше сообщение коммита
This commit is contained in:
@@ -63,6 +63,7 @@ const isicRoutes = require('./routes/isic'); // Добавленный импо
|
|||||||
const geocodingRoutes = require('./routes/geocoding'); // Добавленный импорт
|
const geocodingRoutes = require('./routes/geocoding'); // Добавленный импорт
|
||||||
const dleRoutes = require('./routes/dle'); // Добавляем импорт DLE маршрутов
|
const dleRoutes = require('./routes/dle'); // Добавляем импорт DLE маршрутов
|
||||||
const settingsRoutes = require('./routes/settings'); // Добавляем импорт маршрутов настроек
|
const settingsRoutes = require('./routes/settings'); // Добавляем импорт маршрутов настроек
|
||||||
|
const tablesRoutes = require('./routes/tables'); // Добавляем импорт таблиц
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -154,9 +155,10 @@ app.use((req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Маршруты API
|
// Маршруты API
|
||||||
|
app.use('/api/tables', tablesRoutes); // ДОЛЖНО БЫТЬ ВЫШЕ!
|
||||||
|
app.use('/api', identitiesRoutes);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/users', usersRoutes);
|
app.use('/api/users', usersRoutes);
|
||||||
app.use('/api', identitiesRoutes);
|
|
||||||
app.use('/api/chat', chatRoutes);
|
app.use('/api/chat', chatRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/tokens', tokensRouter);
|
app.use('/api/tokens', tokensRouter);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS user_tables (
|
|||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_columns (
|
CREATE TABLE IF NOT EXISTS user_columns (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
table_id INTEGER NOT NULL REFERENCES user_tables(id) ON DELETE CASCADE,
|
table_id INTEGER NOT NULL REFERENCES user_tables(id) ON DELETE CASCADE,
|
||||||
@@ -15,7 +16,8 @@ CREATE TABLE IF NOT EXISTS user_columns (
|
|||||||
type VARCHAR(50) NOT NULL, -- text, number, select, multiselect, date, etc.
|
type VARCHAR(50) NOT NULL, -- text, number, select, multiselect, date, etc.
|
||||||
options JSONB DEFAULT NULL, -- для select/multiselect
|
options JSONB DEFAULT NULL, -- для select/multiselect
|
||||||
"order" INTEGER DEFAULT 0,
|
"order" INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_rows (
|
CREATE TABLE IF NOT EXISTS user_rows (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
console.log('[DIAG][auth.js] Файл загружен:', __filename);
|
||||||
|
|
||||||
const { createError } = require('../utils/error');
|
const { createError } = require('../utils/error');
|
||||||
const authService = require('../services/auth-service');
|
const authService = require('../services/auth-service');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -9,82 +11,12 @@ const { checkAdminTokens } = require('../services/auth-service');
|
|||||||
* Middleware для проверки аутентификации
|
* Middleware для проверки аутентификации
|
||||||
*/
|
*/
|
||||||
const requireAuth = async (req, res, next) => {
|
const requireAuth = async (req, res, next) => {
|
||||||
try {
|
console.log('[DIAG][requireAuth] session:', req.session);
|
||||||
console.log('Session in requireAuth:', {
|
if (!req.session || !req.session.authenticated) {
|
||||||
id: req.sessionID,
|
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||||
userId: req.session?.userId,
|
|
||||||
authenticated: req.session?.authenticated,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Проверяем сессию
|
|
||||||
if (req.session?.authenticated && req.session?.userId) {
|
|
||||||
// Обновляем время жизни сессии
|
|
||||||
req.session.touch();
|
|
||||||
|
|
||||||
req.user = {
|
|
||||||
userId: req.session.userId,
|
|
||||||
address: req.session.address,
|
|
||||||
isAdmin: req.session.isAdmin,
|
|
||||||
authType: req.session.authType,
|
|
||||||
};
|
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
// Можно добавить проверку isAdmin здесь, если нужно
|
||||||
// Проверяем Bearer токен
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
|
||||||
const address = authHeader.split(' ')[1];
|
|
||||||
|
|
||||||
if (address.startsWith('0x')) {
|
|
||||||
const result = await db.getQuery()(
|
|
||||||
`
|
|
||||||
SELECT u.id, u.is_admin
|
|
||||||
FROM users u
|
|
||||||
JOIN user_identities ui ON u.id = ui.user_id
|
|
||||||
WHERE ui.identity_type = 'wallet'
|
|
||||||
AND LOWER(ui.identity_value) = LOWER($1)
|
|
||||||
`,
|
|
||||||
[address]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
|
||||||
const user = result.rows[0];
|
|
||||||
|
|
||||||
// Создаем новую сессию
|
|
||||||
req.session.regenerate(async (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error regenerating session:', err);
|
|
||||||
return res.status(500).json({ error: 'Session error' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем данные сессии
|
|
||||||
req.session.authenticated = true;
|
|
||||||
req.session.userId = user.id;
|
|
||||||
req.session.address = address;
|
|
||||||
req.session.isAdmin = user.is_admin;
|
|
||||||
req.session.authType = 'wallet';
|
|
||||||
|
|
||||||
// Сохраняем сессию
|
|
||||||
await new Promise((resolve) => req.session.save(resolve));
|
|
||||||
|
|
||||||
req.user = {
|
|
||||||
userId: user.id,
|
|
||||||
address: address,
|
|
||||||
isAdmin: user.is_admin,
|
|
||||||
authType: 'wallet',
|
|
||||||
};
|
|
||||||
next();
|
next();
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(401).json({ error: 'Unauthorized' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auth middleware error:', error);
|
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
console.log('[DIAG][tables.js] Файл загружен:', __filename);
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
@@ -196,23 +198,37 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
// DELETE: удалить таблицу и каскадно все связанные строки/столбцы/ячейки (доступно всем)
|
// DELETE: удалить таблицу и каскадно все связанные строки/столбцы/ячейки (доступно всем)
|
||||||
router.delete('/:id', requireAuth, async (req, res, next) => {
|
router.delete('/:id', requireAuth, async (req, res, next) => {
|
||||||
|
const dbModule = require('../db');
|
||||||
try {
|
try {
|
||||||
const tableId = Number(req.params.id);
|
// Логируем строку подключения и pool.options
|
||||||
console.log('Backend: typeof tableId:', typeof tableId, 'value:', tableId);
|
console.log('[DIAG][DELETE] pool.options:', dbModule.pool.options);
|
||||||
// Проверяем, существует ли таблица
|
console.log('[DIAG][DELETE] process.env.DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
const checkResult = await db.getQuery()('SELECT id, name FROM user_tables WHERE id = $1', [tableId]);
|
console.log('[DIAG][DELETE] process.env.DB_HOST:', process.env.DB_HOST);
|
||||||
console.log('Backend: Table check result:', checkResult.rows);
|
console.log('[DIAG][DELETE] process.env.DB_NAME:', process.env.DB_NAME);
|
||||||
if (checkResult.rows.length === 0) {
|
console.log('=== [DIAG] Попытка удаления таблицы ===');
|
||||||
console.log('Backend: Table not found');
|
console.log('Сессия пользователя:', req.session);
|
||||||
return res.status(404).json({ error: 'Table not found' });
|
if (!req.session.isAdmin) {
|
||||||
|
console.log('[DIAG] Нет прав администратора');
|
||||||
|
return res.status(403).json({ error: 'Удаление доступно только администраторам' });
|
||||||
}
|
}
|
||||||
// Удаляем только основную таблицу - каскадное удаление сработает автоматически
|
const tableId = Number(req.params.id);
|
||||||
console.log('Backend: Executing DELETE query for table_id:', tableId);
|
console.log('[DIAG] id из запроса:', req.params.id, 'Преобразованный id:', tableId, 'typeof:', typeof tableId);
|
||||||
const result = await db.getQuery()('DELETE FROM user_tables WHERE id = $1', [tableId]);
|
|
||||||
console.log('Backend: Delete result - rowCount:', result.rowCount);
|
// Проверяем наличие таблицы перед удалением
|
||||||
|
const checkBefore = await db.getQuery()('SELECT * FROM user_tables WHERE id = $1', [tableId]);
|
||||||
|
console.log('[DIAG] Таблица перед удалением:', checkBefore.rows);
|
||||||
|
|
||||||
|
// Пытаемся удалить
|
||||||
|
const result = await db.getQuery()('DELETE FROM user_tables WHERE id = $1 RETURNING *', [tableId]);
|
||||||
|
console.log('[DIAG] Результат удаления (rowCount):', result.rowCount, 'rows:', result.rows);
|
||||||
|
|
||||||
|
// Проверяем наличие таблицы после удаления
|
||||||
|
const checkAfter = await db.getQuery()('SELECT * FROM user_tables WHERE id = $1', [tableId]);
|
||||||
|
console.log('[DIAG] Таблица после удаления:', checkAfter.rows);
|
||||||
|
|
||||||
res.json({ success: true, deleted: result.rowCount });
|
res.json({ success: true, deleted: result.rowCount });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Backend: Error deleting table:', err);
|
console.error('[DIAG] Ошибка при удалении таблицы:', err);
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span>{{ value }}</span>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
defineProps({ value: [String, Number] });
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
span { font-variant-numeric: tabular-nums; }
|
|
||||||
</style>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span>{{ value }}</span>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
defineProps({ value: String });
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
span { white-space: pre-line; }
|
|
||||||
</style>
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -2,30 +2,31 @@
|
|||||||
<div class="dynamic-tables-modal">
|
<div class="dynamic-tables-modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>Пользовательские таблицы</h2>
|
<h2>Пользовательские таблицы</h2>
|
||||||
<button class="close-btn" @click="$emit('close')">×</button>
|
<button class="close-btn" @click="closeModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<UserTablesList @open-table="openTable" @table-deleted="onTableDeleted" />
|
<UserTablesList
|
||||||
<DynamicTableEditor v-if="selectedTable" :table-id="selectedTable.id" @close="closeEditor" />
|
:selected-table-id="selectedTableId"
|
||||||
|
@update:selected-table-id="val => selectedTableId = val"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import UserTablesList from './UserTablesList.vue';
|
import UserTablesList from './UserTablesList.vue';
|
||||||
import DynamicTableEditor from './DynamicTableEditor.vue';
|
|
||||||
|
|
||||||
const selectedTable = ref(null);
|
const selectedTableId = ref(null);
|
||||||
function openTable(table) {
|
|
||||||
selectedTable.value = table;
|
function closeModal() {
|
||||||
}
|
selectedTableId.value = null;
|
||||||
function closeEditor() {
|
// эмитим наружу, чтобы закрыть модалку
|
||||||
selectedTable.value = null;
|
// (если используется <DynamicTablesModal @close=... />)
|
||||||
}
|
// иначе просто убираем модалку
|
||||||
function onTableDeleted(deletedTableId) {
|
// eslint-disable-next-line vue/custom-event-name-casing
|
||||||
if (selectedTable.value && selectedTable.value.id === deletedTableId) {
|
// $emit('close') не работает в <script setup>, используем defineEmits
|
||||||
selectedTable.value = null;
|
emit('close');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<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>
|
|
||||||
49
frontend/src/components/tables/TableCell.vue
Normal file
49
frontend/src/components/tables/TableCell.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="localValue"
|
||||||
|
@blur="save"
|
||||||
|
@keyup.enter="save"
|
||||||
|
:placeholder="column.name"
|
||||||
|
class="cell-input"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
const props = defineProps(['rowId', 'column', 'cellValues']);
|
||||||
|
const emit = defineEmits(['update']);
|
||||||
|
|
||||||
|
const localValue = ref('');
|
||||||
|
watch(
|
||||||
|
() => [props.rowId, props.column.id, props.cellValues],
|
||||||
|
() => {
|
||||||
|
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 save() {
|
||||||
|
emit('update', localValue.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cell-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
font-size: 1em;
|
||||||
|
background: #fff;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.cell-input:focus {
|
||||||
|
border: 1.5px solid #2ecc40;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<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>
|
|
||||||
36
frontend/src/components/tables/TableRow.vue
Normal file
36
frontend/src/components/tables/TableRow.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<tr>
|
||||||
|
<TableCell
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.id"
|
||||||
|
:row-id="row.id"
|
||||||
|
:column="col"
|
||||||
|
:cell-values="cellValues"
|
||||||
|
@update="val => $emit('update', { rowId: row.id, columnId: col.id, value: val })"
|
||||||
|
/>
|
||||||
|
<td>
|
||||||
|
<button class="danger" @click="$emit('delete')">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import TableCell from './TableCell.vue';
|
||||||
|
const props = defineProps(['row', 'columns', 'cellValues']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.danger {
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.danger:hover {
|
||||||
|
background: #d9363e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
155
frontend/src/components/tables/UserTableView.vue
Normal file
155
frontend/src/components/tables/UserTableView.vue
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<div class="notion-table-wrapper">
|
||||||
|
<table class="notion-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="col in columns" :key="col.id" @dblclick="editColumn(col)">
|
||||||
|
<span v-if="!editingCol || editingCol.id !== col.id">{{ col.name }}</span>
|
||||||
|
<input v-else v-model="colEditValue" @blur="saveColEdit(col)" @keyup.enter="saveColEdit(col)" @keyup.esc="cancelColEdit" class="notion-input" />
|
||||||
|
<button class="col-menu" @click.stop="openColMenu(col)">⋮</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button class="add-col" @click="addColumn">+</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td v-for="col in columns" :key="col.id" @click="startEdit(row, col)">
|
||||||
|
<span v-if="!isEditing(row, col)">{{ getCellValue(row, col) || '—' }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="editValue"
|
||||||
|
@blur="saveEdit(row, col)"
|
||||||
|
@keyup.enter="saveEdit(row, col)"
|
||||||
|
@keyup.esc="cancelEdit"
|
||||||
|
class="notion-input"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="row-menu" @click.stop="openRowMenu(row)">⋮</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td :colspan="columns.length + 1">
|
||||||
|
<button class="add-row" @click="addRow">+ Добавить строку</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<!-- Модалки и меню можно реализовать через отдельные компоненты или простые div -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import tablesService from '../../services/tablesService';
|
||||||
|
|
||||||
|
const props = defineProps({ tableId: Number });
|
||||||
|
const columns = ref([]);
|
||||||
|
const rows = ref([]);
|
||||||
|
const cellValues = ref([]);
|
||||||
|
|
||||||
|
// Для редактирования ячеек
|
||||||
|
const editing = ref({ rowId: null, colId: null });
|
||||||
|
const editValue = ref('');
|
||||||
|
function isEditing(row, col) {
|
||||||
|
return editing.value.rowId === row.id && editing.value.colId === col.id;
|
||||||
|
}
|
||||||
|
function startEdit(row, col) {
|
||||||
|
editing.value = { rowId: row.id, colId: col.id };
|
||||||
|
editValue.value = getCellValue(row, col) || '';
|
||||||
|
}
|
||||||
|
function saveEdit(row, col) {
|
||||||
|
tablesService.saveCell({ rowId: row.id, columnId: col.id, value: editValue.value }).then(fetchTable);
|
||||||
|
editing.value = { rowId: null, colId: null };
|
||||||
|
}
|
||||||
|
function cancelEdit() {
|
||||||
|
editing.value = { rowId: null, colId: null };
|
||||||
|
}
|
||||||
|
function getCellValue(row, col) {
|
||||||
|
const cell = cellValues.value.find(c => c.row_id === row.id && c.column_id === col.id);
|
||||||
|
return cell ? cell.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для редактирования названия столбца
|
||||||
|
const editingCol = ref(null);
|
||||||
|
const colEditValue = ref('');
|
||||||
|
function editColumn(col) {
|
||||||
|
editingCol.value = col;
|
||||||
|
colEditValue.value = col.name;
|
||||||
|
}
|
||||||
|
function saveColEdit(col) {
|
||||||
|
tablesService.updateColumn(col.id, { name: colEditValue.value }).then(fetchTable);
|
||||||
|
editingCol.value = null;
|
||||||
|
}
|
||||||
|
function cancelColEdit() {
|
||||||
|
editingCol.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавление/удаление
|
||||||
|
function addColumn() {
|
||||||
|
tablesService.addColumn(props.tableId, { name: 'Новый столбец', type: 'text' }).then(fetchTable);
|
||||||
|
}
|
||||||
|
function addRow() {
|
||||||
|
tablesService.addRow(props.tableId).then(fetchTable);
|
||||||
|
}
|
||||||
|
function openColMenu(col) { /* TODO: контекстное меню */ }
|
||||||
|
function openRowMenu(row) { /* TODO: контекстное меню */ }
|
||||||
|
|
||||||
|
// Загрузка данных
|
||||||
|
async function fetchTable() {
|
||||||
|
const data = await tablesService.getTable(props.tableId);
|
||||||
|
columns.value = data.columns;
|
||||||
|
rows.value = data.rows;
|
||||||
|
cellValues.value = data.cellValues;
|
||||||
|
}
|
||||||
|
fetchTable();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notion-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
.notion-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.notion-table th, .notion-table td {
|
||||||
|
border: 1px solid #ececec;
|
||||||
|
padding: 0.5em 0.7em;
|
||||||
|
min-width: 80px;
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.notion-table th {
|
||||||
|
background: #f7f7f7;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.notion-table tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.col-menu, .row-menu, .add-col, .add-row {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
}
|
||||||
|
.col-menu:hover, .row-menu:hover, .add-col:hover, .add-row:hover {
|
||||||
|
color: #2ecc40;
|
||||||
|
}
|
||||||
|
.notion-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #2ecc40;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,167 +1,236 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-tables-list">
|
<div class="tables-container">
|
||||||
<div class="header-block">
|
<header class="tables-header">
|
||||||
<h2>Пользовательские таблицы</h2>
|
<!-- <h2>Пользовательские таблицы</h2> -->
|
||||||
<button class="btn btn-success" @click="showCreateTable = true">Создать таблицу</button>
|
<button class="create-btn" @click="createTable">Создать таблицу</button>
|
||||||
|
</header>
|
||||||
|
<div class="tables-list">
|
||||||
|
<div
|
||||||
|
v-for="table in tables"
|
||||||
|
:key="table.id"
|
||||||
|
class="table-card"
|
||||||
|
:class="{ selected: table.id === props.selectedTableId }"
|
||||||
|
@click="selectTable(table)"
|
||||||
|
>
|
||||||
|
<div class="table-info">
|
||||||
|
<div class="table-title">{{ table.name }}</div>
|
||||||
|
<div class="table-desc">{{ table.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="loading">Загрузка...</div>
|
<div class="table-actions">
|
||||||
<div v-else>
|
<button @click.stop="renameTable(table)">Переименовать</button>
|
||||||
<div v-if="tables.length === 0" class="empty-block">Нет таблиц. Создайте первую!</div>
|
<button class="danger" @click.stop="confirmDelete(table)">Удалить</button>
|
||||||
<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>
|
</div>
|
||||||
<div class="table-card-desc">
|
<div v-if="!tables.length" class="empty-state">
|
||||||
<textarea v-if="editingDescId === table.id" v-model="editDesc" @blur="saveDesc(table)" @keyup.enter="saveDesc(table)" class="table-desc-input" />
|
<span>Нет таблиц. Создайте первую!</span>
|
||||||
<p v-else @dblclick="startEditDesc(table)">{{ table.description || 'Без описания' }}</p>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showDeleteModal" class="modal-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<p>Удалить таблицу <b>{{ selectedTable?.name }}</b>?</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="danger" @click="deleteTable(selectedTable)">Удалить</button>
|
||||||
|
<button @click="showDeleteModal = false">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<UserTableView
|
||||||
<CreateTableModal v-if="showCreateTable" @close="showCreateTable = false" @table-created="onTableCreated" />
|
v-if="props.selectedTableId"
|
||||||
|
:table-id="props.selectedTableId"
|
||||||
|
@close="emit('update:selected-table-id', null)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, watch, nextTick } from 'vue';
|
||||||
|
import UserTableView from './UserTableView.vue';
|
||||||
import tablesService from '../../services/tablesService';
|
import tablesService from '../../services/tablesService';
|
||||||
import CreateTableModal from './CreateTableModal.vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['open-table', 'table-deleted']);
|
const props = defineProps({
|
||||||
|
selectedTableId: Number
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:selected-table-id']);
|
||||||
|
|
||||||
const tables = ref([]);
|
const tables = ref([]);
|
||||||
const isLoading = ref(false);
|
const showDeleteModal = ref(false);
|
||||||
const showCreateTable = ref(false);
|
const selectedTable = ref(null);
|
||||||
const editingTableId = ref(null);
|
|
||||||
const editName = ref('');
|
|
||||||
const editingDescId = ref(null);
|
|
||||||
const editDesc = ref('');
|
|
||||||
|
|
||||||
function loadTables() {
|
async function fetchTables() {
|
||||||
isLoading.value = true;
|
tables.value = await tablesService.getTables();
|
||||||
tablesService.getTables()
|
|
||||||
.then(res => {
|
|
||||||
tables.value = [...res]; // Создаем новый массив для принудительного обновления
|
|
||||||
})
|
|
||||||
.finally(() => { isLoading.value = false; });
|
|
||||||
}
|
}
|
||||||
function onTableCreated() {
|
onMounted(fetchTables);
|
||||||
showCreateTable.value = false;
|
|
||||||
loadTables();
|
function selectTable(table) {
|
||||||
}
|
if (props.selectedTableId === table.id) return;
|
||||||
function startEditName(table) {
|
if (props.selectedTableId) {
|
||||||
editingTableId.value = table.id;
|
emit('update:selected-table-id', null);
|
||||||
editName.value = table.name;
|
nextTick(() => {
|
||||||
}
|
emit('update:selected-table-id', table.id);
|
||||||
function saveName(table) {
|
});
|
||||||
if (editName.value && editName.value !== table.name) {
|
} else {
|
||||||
tablesService.updateTable(table.id, { name: editName.value })
|
emit('update:selected-table-id', table.id);
|
||||||
.then(loadTables);
|
|
||||||
}
|
}
|
||||||
editingTableId.value = null;
|
|
||||||
}
|
}
|
||||||
function startEditDesc(table) {
|
function createTable() {
|
||||||
editingDescId.value = table.id;
|
const name = prompt('Название таблицы');
|
||||||
editDesc.value = table.description || '';
|
if (name) {
|
||||||
|
tablesService.createTable({ name }).then(fetchTables);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function saveDesc(table) {
|
function renameTable(table) {
|
||||||
tablesService.updateTable(table.id, { description: editDesc.value })
|
const name = prompt('Новое имя', table.name);
|
||||||
.then(loadTables);
|
if (name && name !== table.name) {
|
||||||
editingDescId.value = null;
|
tablesService.updateTable(table.id, { name }).then(fetchTables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function confirmDelete(table) {
|
||||||
|
selectedTable.value = table;
|
||||||
|
showDeleteModal.value = true;
|
||||||
}
|
}
|
||||||
function deleteTable(table) {
|
function deleteTable(table) {
|
||||||
console.log('deleteTable called with:', table);
|
tablesService.deleteTable(table.id).then(() => {
|
||||||
|
showDeleteModal.value = false;
|
||||||
if (!confirm(`Удалить таблицу "${table.name}"?`)) {
|
fetchTables();
|
||||||
console.log('User cancelled deletion');
|
if (props.selectedTableId === table.id) emit('update:selected-table-id', null);
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-tables-list {
|
.tables-container {
|
||||||
padding: 18px 8px;
|
max-width: 600px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.07);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
}
|
}
|
||||||
.header-block {
|
.tables-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
.tables-cards {
|
.create-btn {
|
||||||
display: flex;
|
background: #2ecc40;
|
||||||
flex-wrap: wrap;
|
color: #fff;
|
||||||
gap: 18px;
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5em 1.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
.table-card {
|
.create-btn:hover {
|
||||||
background: #f8fafc;
|
background: #27ae38;
|
||||||
border: 1px solid #e0e0e0;
|
}
|
||||||
border-radius: 10px;
|
.tables-list {
|
||||||
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.table-card-header {
|
.table-card {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.03);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.table-card.selected {
|
||||||
|
border: 2px solid #2ecc40;
|
||||||
|
}
|
||||||
|
.table-info {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
.table-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
.table-desc {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.95em;
|
||||||
|
margin-top: 0.2em;
|
||||||
|
}
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
.table-actions button {
|
||||||
|
background: #eaeaea;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4em 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.table-actions button:hover {
|
||||||
|
background: #d5d5d5;
|
||||||
|
}
|
||||||
|
.table-actions .danger {
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.table-actions .danger:hover {
|
||||||
|
background: #d9363e;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: #aaa;
|
||||||
|
margin: 2em 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Модалка */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 8px;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
.table-card-actions {
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2em 1.5em;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.13);
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 1em;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.table-card-desc {
|
|
||||||
margin-top: 10px;
|
/* Адаптивность */
|
||||||
}
|
@media (max-width: 600px) {
|
||||||
.table-name-input, .table-desc-input {
|
.tables-container {
|
||||||
width: 100%;
|
padding: 1em 0.3em;
|
||||||
font-size: 1.1em;
|
}
|
||||||
border: 1px solid #b0b0b0;
|
.table-card {
|
||||||
border-radius: 6px;
|
flex-direction: column;
|
||||||
padding: 4px 8px;
|
gap: 0.7em;
|
||||||
}
|
padding: 0.7em;
|
||||||
.empty-block {
|
}
|
||||||
color: #888;
|
.tables-header {
|
||||||
text-align: center;
|
flex-direction: column;
|
||||||
margin: 32px 0;
|
gap: 0.7em;
|
||||||
}
|
align-items: flex-start;
|
||||||
.loading {
|
}
|
||||||
color: #888;
|
.table-actions {
|
||||||
margin: 16px 0;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -416,15 +416,6 @@ const updateConnectionDisplay = (isConnected, authType, authData = {}) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await checkAuth();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Очищаем интервал при размонтировании компонента
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopIdentitiesPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Связывает новый идентификатор с текущим аккаунтом пользователя
|
* Связывает новый идентификатор с текущим аккаунтом пользователя
|
||||||
* @param {string} type - Тип идентификатора (wallet, email, telegram)
|
* @param {string} type - Тип идентификатора (wallet, email, telegram)
|
||||||
@@ -488,5 +479,11 @@ export function useAuthContext() {
|
|||||||
|
|
||||||
// === useAuth теперь просто возвращает singleton ===
|
// === useAuth теперь просто возвращает singleton ===
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
onMounted(async () => {
|
||||||
|
await checkAuth();
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopIdentitiesPolling();
|
||||||
|
});
|
||||||
return authApi;
|
return authApi;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
import axios from 'axios';
|
import api from '../api/axios';
|
||||||
|
|
||||||
const api = '/api/tables';
|
const tablesApi = '/api/tables';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async getTables() {
|
async getTables() {
|
||||||
const res = await axios.get(`${api}?_t=${Date.now()}`);
|
const res = await api.get(`${tablesApi}?_t=${Date.now()}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async createTable(data) {
|
async createTable(data) {
|
||||||
const res = await axios.post(api, data);
|
const res = await api.post(tablesApi, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async getTable(id) {
|
async getTable(id) {
|
||||||
const res = await axios.get(`${api}/${id}`);
|
const res = await api.get(`${tablesApi}/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async addColumn(tableId, data) {
|
async addColumn(tableId, data) {
|
||||||
const res = await axios.post(`${api}/${tableId}/columns`, data);
|
const res = await api.post(`${tablesApi}/${tableId}/columns`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async addRow(tableId) {
|
async addRow(tableId) {
|
||||||
const res = await axios.post(`${api}/${tableId}/rows`);
|
const res = await api.post(`${tablesApi}/${tableId}/rows`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async saveCell(data) {
|
async saveCell(data) {
|
||||||
const res = await axios.post(`${api}/cell`, data);
|
const res = await api.post(`${tablesApi}/cell`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async deleteColumn(columnId) {
|
async deleteColumn(columnId) {
|
||||||
const res = await axios.delete(`${api}/column/${columnId}`);
|
const res = await api.delete(`${tablesApi}/column/${columnId}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async deleteRow(rowId) {
|
async deleteRow(rowId) {
|
||||||
const res = await axios.delete(`${api}/row/${rowId}`);
|
const res = await api.delete(`${tablesApi}/row/${rowId}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async updateColumn(columnId, data) {
|
async updateColumn(columnId, data) {
|
||||||
const res = await axios.patch(`${api}/column/${columnId}`, data);
|
const res = await api.patch(`${tablesApi}/column/${columnId}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async updateTable(id, data) {
|
async updateTable(id, data) {
|
||||||
const res = await axios.patch(`${api}/${id}`, data);
|
const res = await api.patch(`${tablesApi}/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
async deleteTable(id) {
|
async deleteTable(id) {
|
||||||
console.log('tablesService.deleteTable called with id:', id);
|
console.log('tablesService.deleteTable called with id:', id);
|
||||||
try {
|
try {
|
||||||
const res = await axios.delete(`${api}/${id}`);
|
const res = await api.delete(`${tablesApi}/${id}`);
|
||||||
console.log('Delete response:', res.data);
|
console.log('Delete response:', res.data);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user