Files
minicrm/public/doc.html
2026-02-03 00:42:22 +05:00

1058 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- Добавьте в ваш index.html или создайте отдельную страницу -->
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Документация БД</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2c3e50;
color: white;
padding: 20px 0;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
text-align: center;
font-size: 28px;
font-weight: 300;
}
.header p {
text-align: center;
margin-top: 10px;
opacity: 0.8;
}
.controls {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.table-selector {
display: flex;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.table-btn {
padding: 10px 20px;
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
font-size: 16px;
}
.table-btn:hover {
background-color: #2980b9;
}
.table-btn.active {
background-color: #2c3e50;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-top: 20px;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
.filter-group input,
.filter-group select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: #3498db;
}
.stats {
display: flex;
gap: 20px;
margin-top: 15px;
font-size: 14px;
color: #666;
}
.database-info {
background: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #3498db;
}
.data-section {
display: none;
animation: fadeIn 0.5s;
}
.data-section.active {
display: block;
}
.table-container {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.table-header {
background-color: #2c3e50;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-header h3 {
font-weight: 400;
}
.row-count {
background-color: rgba(255,255,255,0.2);
padding: 5px 10px;
border-radius: 20px;
font-size: 14px;
}
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
}
thead {
background-color: #f8f9fa;
}
th {
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6;
position: sticky;
top: 0;
background-color: #f8f9fa;
}
td {
padding: 12px 15px;
border-bottom: 1px solid #dee2e6;
vertical-align: top;
}
tr:hover {
background-color: #f8f9fa;
}
.boolean {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.boolean.true {
background-color: #d4edda;
color: #155724;
}
.boolean.false {
background-color: #f8d7da;
color: #721c24;
}
.null-value {
color: #6c757d;
font-style: italic;
}
.json-value {
background-color: #f8f9fa;
padding: 5px 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-width: 300px;
overflow: auto;
white-space: pre-wrap;
}
.datetime {
font-family: 'Courier New', monospace;
font-size: 13px;
color: #495057;
}
.export-btn {
background-color: #27ae60;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.export-btn:hover {
background-color: #219653;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 20px;
padding: 10px;
background: white;
border-radius: 8px;
}
.page-btn {
padding: 5px 10px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn.active {
background-color: #3498db;
color: white;
border-color: #3498db;
}
.page-btn:hover:not(.active) {
background-color: #f8f9fa;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
color: #666;
}
.error {
text-align: center;
padding: 40px;
color: #e74c3c;
background-color: #fdf2f2;
border-radius: 8px;
margin: 20px 0;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.table-selector {
flex-direction: column;
}
.table-btn {
width: 100%;
}
.filters {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="header">
<div class="container">
<h1>📊 Документация Базы Данных</h1>
<p>Просмотр и фильтрация данных таблиц SQLite</p>
</div>
</div>
<div class="container">
<div class="controls">
<div class="table-selector">
<button class="table-btn active" onclick="showTable('users')">👥 Пользователи</button>
<button class="table-btn" onclick="showTable('tasks')">📋 Задачи</button>
<button class="table-btn" onclick="showTable('files')">📎 Файлы</button>
<button class="table-btn" onclick="showTable('activity_logs')">📊 Логи активности</button>
<button class="table-btn" onclick="showTable('assignments')">👨‍💻 Назначения</button>
</div>
<div class="filters" id="filters-container">
<!-- Фильтры будут добавляться динамически -->
</div>
<div class="stats">
<span id="total-records">Записей: 0</span>
<span id="filtered-records">Показано: 0</span>
<span id="last-updated">Обновлено: --</span>
</div>
</div>
<div class="database-info">
<p><strong>База данных:</strong> SQLite</p>
<p><strong>Таблицы:</strong> users, tasks, files, activity_logs, assignments</p>
</div>
<div id="users-section" class="data-section active">
<div class="table-container">
<div class="table-header">
<h3>Таблица: users (Пользователи)</h3>
<span class="row-count" id="users-count">0 записей</span>
</div>
<div class="table-wrapper">
<table id="users-table">
<thead>
<tr id="users-headers">
<!-- Заголовки будут заполнены динамически -->
</tr>
</thead>
<tbody id="users-body">
<!-- Данные будут заполнены динамически -->
</tbody>
</table>
</div>
</div>
</div>
<div id="tasks-section" class="data-section">
<div class="table-container">
<div class="table-header">
<h3>Таблица: tasks (Задачи)</h3>
<span class="row-count" id="tasks-count">0 записей</span>
</div>
<div class="table-wrapper">
<table id="tasks-table">
<thead>
<tr id="tasks-headers">
<!-- Заголовки будут заполнены динамически -->
</tr>
</thead>
<tbody id="tasks-body">
<!-- Данные будут заполнены динамически -->
</tbody>
</table>
</div>
</div>
</div>
<div id="files-section" class="data-section">
<div class="table-container">
<div class="table-header">
<h3>Таблица: files (Файлы)</h3>
<span class="row-count" id="files-count">0 записей</span>
</div>
<div class="table-wrapper">
<table id="files-table">
<thead>
<tr id="files-headers">
<!-- Заголовки будут заполнены динамически -->
</tr>
</thead>
<tbody id="files-body">
<!-- Данные будут заполнены динамически -->
</tbody>
</table>
</div>
</div>
</div>
<div id="activity_logs-section" class="data-section">
<div class="table-container">
<div class="table-header">
<h3>Таблица: activity_logs (Логи активности)</h3>
<span class="row-count" id="activity_logs-count">0 записей</span>
</div>
<div class="table-wrapper">
<table id="activity_logs-table">
<thead>
<tr id="activity_logs-headers">
<!-- Заголовки будут заполнены динамически -->
</tr>
</thead>
<tbody id="activity_logs-body">
<!-- Данные будут заполнены динамически -->
</tbody>
</table>
</div>
</div>
</div>
<div id="assignments-section" class="data-section">
<div class="table-container">
<div class="table-header">
<h3>Таблица: assignments (Назначения)</h3>
<span class="row-count" id="assignments-count">0 записей</span>
</div>
<div class="table-wrapper">
<table id="assignments-table">
<thead>
<tr id="assignments-headers">
<!-- Заголовки будут заполнены динамически -->
</tr>
</thead>
<tbody id="assignments-body">
<!-- Данные будут заполнены динамически -->
</tbody>
</table>
</div>
</div>
</div>
<div class="pagination" id="pagination">
<!-- Пагинация будет добавляться динамически -->
</div>
</div>
<script>
let currentTable = 'users';
let currentData = {
users: [],
tasks: [],
files: [],
activity_logs: [],
assignments: []
};
let filteredData = {
users: [],
tasks: [],
files: [],
activity_logs: [],
assignments: []
};
let filters = {
users: {},
tasks: {},
files: {},
activity_logs: {},
assignments: {}
};
let pageSize = 50;
let currentPage = 1;
// Загрузка данных при открытии страницы
document.addEventListener('DOMContentLoaded', function() {
loadTableData('users');
updateLastUpdated();
});
// Показать выбранную таблицу
function showTable(tableName) {
// Обновить активную кнопку
document.querySelectorAll('.table-btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
// Скрыть все секции
document.querySelectorAll('.data-section').forEach(section => {
section.classList.remove('active');
});
// Показать выбранную секцию
document.getElementById(`${tableName}-section`).classList.add('active');
currentTable = tableName;
currentPage = 1;
// Загрузить данные, если они еще не загружены
if (currentData[tableName].length === 0) {
loadTableData(tableName);
} else {
applyFilters();
}
// Обновить фильтры для выбранной таблицы
updateFiltersForTable(tableName);
}
// Загрузка данных таблицы
async function loadTableData(tableName) {
try {
showLoading(tableName);
const response = await fetch(`/api/${tableName}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
currentData[tableName] = data;
filteredData[tableName] = [...data];
renderTable(tableName);
applyFilters();
updateFiltersForTable(tableName);
} catch (error) {
console.error(`Ошибка загрузки данных для таблицы ${tableName}:`, error);
showError(tableName, error.message);
}
}
// Отображение таблицы
function renderTable(tableName) {
const data = filteredData[tableName];
const containerId = `${tableName}-body`;
const headersId = `${tableName}-headers`;
const countId = `${tableName}-count`;
// Обновить счетчик
document.getElementById(countId).textContent = `${data.length} записей`;
// Очистить текущие данные
document.getElementById(containerId).innerHTML = '';
document.getElementById(headersId).innerHTML = '';
if (data.length === 0) {
document.getElementById(containerId).innerHTML = `
<tr>
<td colspan="100" style="text-align: center; padding: 40px; color: #666;">
Нет данных для отображения
</td>
</tr>
`;
return;
}
// Получить все уникальные ключи из данных
const allKeys = new Set();
data.forEach(item => {
Object.keys(item).forEach(key => allKeys.add(key));
});
const sortedKeys = Array.from(allKeys).sort();
// Создать заголовки таблицы
const headersRow = document.getElementById(headersId);
sortedKeys.forEach(key => {
const th = document.createElement('th');
th.textContent = formatHeader(key);
th.style.cursor = 'pointer';
th.title = 'Нажмите для сортировки';
th.onclick = () => sortTable(tableName, key);
headersRow.appendChild(th);
});
// Создать строки данных с пагинацией
const startIndex = (currentPage - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, data.length);
const pageData = data.slice(startIndex, endIndex);
pageData.forEach(item => {
const row = document.createElement('tr');
sortedKeys.forEach(key => {
const td = document.createElement('td');
const value = item[key];
td.innerHTML = formatValue(value);
row.appendChild(td);
});
document.getElementById(containerId).appendChild(row);
});
// Обновить пагинацию
updatePagination(tableName, data.length);
// Обновить статистику
updateStats();
}
// Форматирование заголовка
function formatHeader(key) {
const headerMap = {
'id': 'ID',
'name': 'Имя',
'login': 'Логин',
'email': 'Email',
'role': 'Роль',
'created_at': 'Создан',
'updated_at': 'Обновлен',
'deleted_at': 'Удален',
'title': 'Название',
'description': 'Описание',
'task_type': 'Тип задачи',
'due_date': 'Срок выполнения',
'closed_at': 'Закрыта',
'rework_comment': 'Комментарий доработки',
'original_task_id': 'ID оригинальной задачи',
'original_task_title': 'Название оригинала',
'original_creator_name': 'Автор оригинала',
'filename': 'Имя файла',
'original_filename': 'Оригинальное имя',
'filepath': 'Путь к файлу',
'file_size': 'Размер файла',
'mime_type': 'Тип файла',
'user_id': 'ID пользователя',
'task_id': 'ID задачи',
'action': 'Действие',
'details': 'Детали',
'status': 'Статус',
'start_date': 'Дата начала',
'user_name': 'Имя пользователя',
'user_login': 'Логин пользователя',
'creator_name': 'Имя создателя',
'created_by': 'Создатель'
};
return headerMap[key] || key.replace(/_/g, ' ');
}
// Форматирование значения
function formatValue(value) {
if (value === null || value === undefined) {
return '<span class="null-value">NULL</span>';
}
if (typeof value === 'boolean') {
return `<span class="boolean ${value}">${value ? 'Да' : 'Нет'}</span>`;
}
if (typeof value === 'object') {
try {
return `<div class="json-value">${JSON.stringify(value, null, 2)}</div>`;
} catch {
return String(value);
}
}
if (typeof value === 'string') {
// Проверить, является ли это датой
if (value.match(/^\d{4}-\d{2}-\d{2}/)) {
try {
const date = new Date(value);
return `<span class="datetime" title="${value}">${date.toLocaleString('ru-RU')}</span>`;
} catch {
return value;
}
}
// Проверить, является ли это JSON строкой
if (value.startsWith('{') || value.startsWith('[')) {
try {
const parsed = JSON.parse(value);
return `<div class="json-value">${JSON.stringify(parsed, null, 2)}</div>`;
} catch {
return value;
}
}
return value;
}
return String(value);
}
// Обновление фильтров для таблицы
function updateFiltersForTable(tableName) {
const filtersContainer = document.getElementById('filters-container');
const data = currentData[tableName];
if (data.length === 0) {
filtersContainer.innerHTML = '<p>Нет данных для фильтрации</p>';
return;
}
// Получить все уникальные ключи
const allKeys = new Set();
data.forEach(item => {
Object.keys(item).forEach(key => allKeys.add(key));
});
const sortedKeys = Array.from(allKeys).sort();
// Создать фильтры для каждого поля
let filtersHtml = '';
sortedKeys.forEach(key => {
// Получить уникальные значения для этого поля
const uniqueValues = [...new Set(data.map(item => item[key]))]
.filter(val => val !== null && val !== undefined)
.sort();
// Определить тип фильтра
const sampleValue = data[0][key];
let filterType = 'text';
if (typeof sampleValue === 'boolean') {
filterType = 'boolean';
} else if (typeof sampleValue === 'number') {
filterType = 'number';
} else if (sampleValue && typeof sampleValue === 'string') {
if (sampleValue.match(/^\d{4}-\d{2}-\d{2}/)) {
filterType = 'date';
}
}
filtersHtml += `
<div class="filter-group">
<label for="filter-${key}">${formatHeader(key)}</label>
`;
if (filterType === 'boolean') {
filtersHtml += `
<select id="filter-${key}" onchange="updateFilter('${tableName}', '${key}', this.value)">
<option value="">Все</option>
<option value="true">Да</option>
<option value="false">Нет</option>
</select>
`;
} else if (filterType === 'date') {
filtersHtml += `
<input type="date" id="filter-${key}"
onchange="updateFilter('${tableName}', '${key}', this.value)"
placeholder="Фильтр по дате">
`;
} else if (uniqueValues.length <= 20) {
// Для полей с небольшим количеством уникальных значений используем select
filtersHtml += `
<select id="filter-${key}" onchange="updateFilter('${tableName}', '${key}', this.value)">
<option value="">Все</option>
${uniqueValues.map(val =>
`<option value="${val}">${formatValue(val)}</option>`
).join('')}
</select>
`;
} else {
// Для остальных полей используем текстовый ввод
filtersHtml += `
<input type="text" id="filter-${key}"
oninput="updateFilter('${tableName}', '${key}', this.value)"
placeholder="Введите значение...">
`;
}
filtersHtml += `</div>`;
});
filtersContainer.innerHTML = filtersHtml;
// Восстановить сохраненные значения фильтров
Object.keys(filters[tableName]).forEach(key => {
const filterElement = document.getElementById(`filter-${key}`);
if (filterElement) {
filterElement.value = filters[tableName][key];
}
});
}
// Обновление фильтра
function updateFilter(tableName, field, value) {
filters[tableName][field] = value;
applyFilters();
}
// Применение фильтров
function applyFilters() {
const tableName = currentTable;
const data = currentData[tableName];
if (data.length === 0) {
filteredData[tableName] = [];
renderTable(tableName);
return;
}
let filtered = [...data];
// Применить все активные фильтры
Object.keys(filters[tableName]).forEach(field => {
const filterValue = filters[tableName][field];
if (filterValue && filterValue !== '') {
filtered = filtered.filter(item => {
const itemValue = item[field];
if (itemValue === null || itemValue === undefined) {
return false;
}
const itemStr = String(itemValue).toLowerCase();
const filterStr = filterValue.toLowerCase();
if (typeof itemValue === 'boolean') {
return itemStr === filterStr;
} else if (filterValue.match(/^\d{4}-\d{2}-\d{2}/)) {
// Фильтр по дате
return itemStr.includes(filterStr);
}
return itemStr.includes(filterStr);
});
}
});
filteredData[tableName] = filtered;
currentPage = 1;
renderTable(tableName);
}
// Сортировка таблицы
function sortTable(tableName, field) {
const data = filteredData[tableName];
data.sort((a, b) => {
const valA = a[field];
const valB = b[field];
if (valA === null && valB === null) return 0;
if (valA === null) return 1;
if (valB === null) return -1;
if (typeof valA === 'string' && typeof valB === 'string') {
return valA.localeCompare(valB);
}
return (valA < valB) ? -1 : (valA > valB) ? 1 : 0;
});
filteredData[tableName] = data;
renderTable(tableName);
}
// Обновление пагинации
function updatePagination(tableName, totalItems) {
const pagination = document.getElementById('pagination');
const totalPages = Math.ceil(totalItems / pageSize);
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let paginationHtml = '';
// Кнопка "Назад"
paginationHtml += `
<button class="page-btn" onclick="changePage(${currentPage - 1})"
${currentPage === 1 ? 'disabled style="opacity: 0.5;"' : ''}>
</button>
`;
// Номера страниц
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
paginationHtml += `
<button class="page-btn ${i === currentPage ? 'active' : ''}"
onclick="changePage(${i})">
${i}
</button>
`;
}
// Кнопка "Вперед"
paginationHtml += `
<button class="page-btn" onclick="changePage(${currentPage + 1})"
${currentPage === totalPages ? 'disabled style="opacity: 0.5;"' : ''}>
</button>
`;
// Информация о странице
paginationHtml += `
<span style="margin-left: 20px; color: #666; font-size: 14px;">
Страница ${currentPage} из ${totalPages} (${totalItems} записей)
</span>
`;
pagination.innerHTML = paginationHtml;
}
// Изменение страницы
function changePage(page) {
const tableName = currentTable;
const totalItems = filteredData[tableName].length;
const totalPages = Math.ceil(totalItems / pageSize);
if (page < 1 || page > totalPages) return;
currentPage = page;
renderTable(tableName);
}
// Обновление статистики
function updateStats() {
const tableName = currentTable;
const total = currentData[tableName].length;
const filtered = filteredData[tableName].length;
document.getElementById('total-records').textContent = `Всего записей: ${total}`;
document.getElementById('filtered-records').textContent = `Показано: ${filtered}`;
}
// Обновление времени последнего обновления
function updateLastUpdated() {
const now = new Date();
const timeString = now.toLocaleString('ru-RU');
document.getElementById('last-updated').textContent = `Обновлено: ${timeString}`;
}
// Показать индикатор загрузки
function showLoading(tableName) {
const containerId = `${tableName}-body`;
document.getElementById(containerId).innerHTML = `
<tr>
<td colspan="100" style="text-align: center; padding: 40px;">
<div class="loading">Загрузка данных...</div>
</td>
</tr>
`;
}
// Показать ошибку
function showError(tableName, errorMessage) {
const containerId = `${tableName}-body`;
document.getElementById(containerId).innerHTML = `
<tr>
<td colspan="100">
<div class="error">
<strong>Ошибка загрузки данных:</strong><br>
${errorMessage}
<br><br>
<button onclick="loadTableData('${tableName}')"
style="padding: 8px 16px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer;">
Попробовать снова
</button>
</div>
</td>
</tr>
`;
}
// Экспорт данных в CSV
function exportToCSV(tableName) {
const data = filteredData[tableName];
if (data.length === 0) {
alert('Нет данных для экспорта');
return;
}
// Получить все ключи
const allKeys = new Set();
data.forEach(item => {
Object.keys(item).forEach(key => allKeys.add(key));
});
const sortedKeys = Array.from(allKeys).sort();
// Создать CSV заголовок
let csv = sortedKeys.map(key => `"${formatHeader(key)}"`).join(',') + '\n';
// Добавить данные
data.forEach(item => {
const row = sortedKeys.map(key => {
let value = item[key];
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
value = JSON.stringify(value);
}
// Экранировать кавычки и специальные символы
value = String(value).replace(/"/g, '""');
return `"${value}"`;
}).join(',');
csv += row + '\n';
});
// Создать и скачать файл
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${tableName}_export_${new Date().toISOString().slice(0, 10)}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Автоматическое обновление данных каждые 5 минут
setInterval(() => {
if (currentTable) {
loadTableData(currentTable);
updateLastUpdated();
}
}, 5 * 60 * 1000);
</script>
</body>
</html>