1111 lines
37 KiB
Markdown
1111 lines
37 KiB
Markdown
# Руководство по работе с Vditor
|
||
## 6. Отображение контента без редактора
|
||
### 6.1 Рендеринг HTML с поддержкой KaTeX
|
||
```javascript
|
||
// Функция для отображения контента без редактора
|
||
function renderContentWithoutEditor(content, containerId, options = {}) {
|
||
const container = document.getElementById(containerId);
|
||
if (!container) {
|
||
console.error(`Контейнер ${containerId} не найден`);
|
||
return;
|
||
}
|
||
|
||
const config = {
|
||
enableMath: true,
|
||
enableSyntaxHighlight: true,
|
||
theme: 'light',
|
||
...options
|
||
};
|
||
|
||
// Конвертация Markdown в HTML если нужно
|
||
let htmlContent;
|
||
if (content.markdown) {
|
||
htmlContent = convertMarkdownToHTML(content.markdown);
|
||
} else if (content.html) {
|
||
htmlContent = content.html;
|
||
} else {
|
||
htmlContent = convertMarkdownToHTML(content);
|
||
}
|
||
|
||
// Создание структуры для отображения
|
||
container.innerHTML = `
|
||
<div class=\\\"rendered-content ${config.theme}-theme\\\">
|
||
<div class=\\\"content-body\\\">
|
||
${htmlContent}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Инициализация KaTeX для рендеринга формул
|
||
if (config.enableMath && typeof katex !== 'undefined') {
|
||
renderLatexFormulas(container);
|
||
}
|
||
|
||
// Подсветка синтаксиса
|
||
if (config.enableSyntaxHighlight && typeof hljs !== 'undefined') {
|
||
hljs.highlightAll();
|
||
}
|
||
|
||
// Добавление стилей если их нет
|
||
addContentStyles();
|
||
}
|
||
// Рендеринг LaTeX формул в контейнере
|
||
function renderLatexFormulas(container) {
|
||
if (!container || typeof katex === 'undefined') return;
|
||
|
||
// Рендеринг display формул ($$...$$)
|
||
container.querySelectorAll('.language-math.display, .katex-display').forEach(element => {
|
||
try {
|
||
const formula = element.textContent.trim();
|
||
katex.render(formula, element, {
|
||
displayMode: true,
|
||
throwOnError: false,
|
||
errorColor: '#cc0000'
|
||
});
|
||
} catch (error) {
|
||
console.error('Ошибка рендеринга display формулы:', error);
|
||
element.innerHTML = `<div class=\\\"latex-error\\\">Ошибка формулы: ${element.textContent}</div>`;
|
||
}
|
||
});
|
||
|
||
// Рендеринг inline формул ($...$)
|
||
container.querySelectorAll('.language-math:not(.display), .katex').forEach(element => {
|
||
try {
|
||
const formula = element.textContent.trim();
|
||
katex.render(formula, element, {
|
||
displayMode: false,
|
||
throwOnError: false,
|
||
errorColor: '#cc0000'
|
||
});
|
||
} catch (error) {
|
||
console.error('Ошибка рендеринга inline формулы:', error);
|
||
element.innerHTML = `<span class=\\\"latex-error\\\">${element.textContent}</span>`;
|
||
}
|
||
});
|
||
|
||
// Обработка формул в тексте (если они не обернуты в теги)
|
||
const textNodes = Array.from(container.childNodes).filter(node =>
|
||
node.nodeType === Node.TEXT_NODE &&
|
||
(node.textContent.includes('$') || node.textContent.includes('\\\\\\\\['))
|
||
);
|
||
|
||
textNodes.forEach(node => {
|
||
const wrapper = document.createElement('span');
|
||
wrapper.innerHTML = node.textContent
|
||
.replace(/\\\\$\\\\$(.*?)\\\\$\\\\$/g, (match, formula) => {
|
||
try {
|
||
return katex.renderToString(formula, { displayMode: true });
|
||
} catch (e) {
|
||
return `<div class=\\\"latex-error\\\">Ошибка: ${formula}</div>`;
|
||
}
|
||
})
|
||
.replace(/\\\\$(.*?)\\\\$/g, (match, formula) => {
|
||
try {
|
||
return katex.renderToString(formula, { displayMode: false });
|
||
} catch (e) {
|
||
return `<span class=\\\"latex-error\\\">${formula}</span>`;
|
||
}
|
||
});
|
||
|
||
node.parentNode.replaceChild(wrapper, node);
|
||
});
|
||
}
|
||
// Добавление необходимых стилей
|
||
function addContentStyles() {
|
||
if (document.getElementById('content-render-styles')) return;
|
||
|
||
const styles = `
|
||
<style id=\\\"content-render-styles\\\">
|
||
.rendered-content {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
}
|
||
|
||
.rendered-content.light-theme {
|
||
background: #fff;
|
||
color: #333;
|
||
}
|
||
|
||
.rendered-content.dark-theme {
|
||
background: #1a1a1a;
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
.content-body {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.content-body h1,
|
||
.content-body h2,
|
||
.content-body h3,
|
||
.content-body h4 {
|
||
margin-top: 1.5em;
|
||
margin-bottom: 0.5em;
|
||
font-weight: 600;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.content-body h1 { font-size: 2em; border-bottom: 2px solid #eee; padding-bottom: 0.3em; }
|
||
.content-body h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||
.content-body h3 { font-size: 1.25em; }
|
||
.content-body h4 { font-size: 1em; }
|
||
|
||
.content-body p {
|
||
margin: 1em 0;
|
||
}
|
||
|
||
.content-body ul,
|
||
.content-body ol {
|
||
padding-left: 2em;
|
||
margin: 1em 0;
|
||
}
|
||
|
||
.content-body li {
|
||
margin: 0.5em 0;
|
||
}
|
||
|
||
.content-body blockquote {
|
||
margin: 1em 0;
|
||
padding: 0.5em 1em;
|
||
border-left: 4px solid #ddd;
|
||
background: #f9f9f9;
|
||
color: #666;
|
||
}
|
||
|
||
.content-body pre {
|
||
background: #f6f8fa;
|
||
border-radius: 6px;
|
||
padding: 16px;
|
||
overflow: auto;
|
||
margin: 1em 0;
|
||
}
|
||
|
||
.content-body code {
|
||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||
padding: 0.2em 0.4em;
|
||
background: rgba(175, 184, 193, 0.2);
|
||
border-radius: 3px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.content-body pre code {
|
||
background: transparent;
|
||
padding: 0;
|
||
}
|
||
|
||
.content-body table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 1em 0;
|
||
}
|
||
|
||
.content-body th,
|
||
.content-body td {
|
||
border: 1px solid #ddd;
|
||
padding: 8px;
|
||
text-align: left;
|
||
}
|
||
|
||
.content-body th {
|
||
background: #f6f8fa;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.content-body tr:nth-child(even) {
|
||
background: #f9f9f9;
|
||
}
|
||
|
||
.latex-error {
|
||
color: #d73a49;
|
||
background: #ffebee;
|
||
padding: 4px 8px;
|
||
border-radius: 3px;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.katex-display {
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.katex {
|
||
font-size: 1.1em;
|
||
}
|
||
</style>
|
||
`;
|
||
|
||
document.head.insertAdjacentHTML('beforeend', styles);
|
||
}
|
||
```
|
||
|
||
### 6.2 Пример использования
|
||
```javascript
|
||
// Пример 1: Отображение сохраненного контента
|
||
async function displaySavedContent(contentId) {
|
||
try {
|
||
// Загрузка контента из базы данных
|
||
const response = await fetch(`/api/content/${contentId}`);
|
||
const content = await response.json();
|
||
|
||
// Отображение в контейнере
|
||
renderContentWithoutEditor(content, 'content-container', {
|
||
theme: 'light',
|
||
enableMath: true,
|
||
enableSyntaxHighlight: true
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки контента:', error);
|
||
document.getElementById('content-container').innerHTML = `
|
||
<div class=\\\"error-message\\\">
|
||
<h3>Ошибка загрузки контента</h3>
|
||
<p>${error.message}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
// Пример 2: Предпросмотр перед сохранением
|
||
function previewContent() {
|
||
if (!vditorInstance) return;
|
||
|
||
const content = {
|
||
markdown: vditorInstance.getValue(),
|
||
html: vditorInstance.getHTML()
|
||
};
|
||
|
||
// Открытие предпросмотра в модальном окне
|
||
const modal = document.createElement('div');
|
||
modal.className = 'preview-modal';
|
||
modal.innerHTML = `
|
||
<div class=\\\"modal-content\\\">
|
||
<div class=\\\"modal-header\\\">
|
||
<h3>Предпросмотр</h3>
|
||
<button class=\\\"close-btn\\\">×</button>
|
||
</div>
|
||
<div class=\\\"modal-body\\\" id=\\\"preview-container\\\"></div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// Отображение контента
|
||
renderContentWithoutEditor(content, 'preview-container', {
|
||
theme: 'light',
|
||
enableMath: true
|
||
});
|
||
|
||
// Закрытие модального окна
|
||
modal.querySelector('.close-btn').addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
modal.remove();
|
||
}
|
||
});
|
||
}
|
||
// Пример 3: Экспорт в PDF/печать
|
||
function exportToPDF(content, filename = 'document') {
|
||
const printWindow = window.open('', '_blank');
|
||
|
||
printWindow.document.write(`
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>${filename}</title>
|
||
<meta charset=\\\"UTF-8\\\">
|
||
<link rel=\\\"stylesheet\\\" href=\\\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css\\\">
|
||
<link rel=\\\"stylesheet\\\" href=\\\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\\\">
|
||
<style>
|
||
@media print {
|
||
body {
|
||
font-family: 'Times New Roman', Times, serif;
|
||
line-height: 1.5;
|
||
font-size: 12pt;
|
||
color: #000;
|
||
}
|
||
|
||
h1, h2, h3, h4 {
|
||
page-break-after: avoid;
|
||
}
|
||
|
||
pre, blockquote, table {
|
||
page-break-inside: avoid;
|
||
}
|
||
|
||
.katex-display {
|
||
page-break-inside: avoid;
|
||
}
|
||
}
|
||
|
||
body {
|
||
max-width: 210mm;
|
||
margin: 20mm auto;
|
||
padding: 0 20mm;
|
||
}
|
||
|
||
.katex { font-size: 1em; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
${content.html || convertMarkdownToHTML(content.markdown || content)}
|
||
</body>
|
||
</html>
|
||
`);
|
||
|
||
printWindow.document.close();
|
||
|
||
// Автоматическая печать после загрузки
|
||
printWindow.onload = function() {
|
||
printWindow.print();
|
||
};
|
||
}
|
||
```
|
||
### 6.3 Оптимизация производительности
|
||
```javascript
|
||
// Ленивая загрузка KaTeX и Highlight.js
|
||
let katexLoaded = false;
|
||
let hljsLoaded = false;
|
||
async function loadMathRenderer() {
|
||
if (katexLoaded) return;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js';
|
||
script.onload = () => {
|
||
katexLoaded = true;
|
||
resolve();
|
||
};
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
|
||
const css = document.createElement('link');
|
||
css.rel = 'stylesheet';
|
||
css.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css';
|
||
document.head.appendChild(css);
|
||
});
|
||
}
|
||
async function loadSyntaxHighlighter() {
|
||
if (hljsLoaded) return;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js';
|
||
script.onload = () => {
|
||
hljsLoaded = true;
|
||
resolve();
|
||
};
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
|
||
const css = document.createElement('link');
|
||
css.rel = 'stylesheet';
|
||
css.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
|
||
document.head.appendChild(css);
|
||
});
|
||
}
|
||
// Оптимизированный рендеринг с ленивой загрузкой
|
||
async function renderContentOptimized(content, containerId) {
|
||
const container = document.getElementById(containerId);
|
||
|
||
// Показываем спиннер загрузки
|
||
container.innerHTML = '<div class=\\\"loading-spinner\\\">Загрузка...</div>';
|
||
|
||
try {
|
||
// Параллельная загрузка зависимостей
|
||
await Promise.all([
|
||
loadMathRenderer(),
|
||
loadSyntaxHighlighter()
|
||
]);
|
||
|
||
// Рендеринг контента
|
||
renderContentWithoutEditor(content, containerId, {
|
||
enableMath: true,
|
||
enableSyntaxHighlight: true
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки зависимостей:', error);
|
||
container.innerHTML = `
|
||
<div class=\\\"error\\\">
|
||
<h3>Ошибка отображения контента</h3>
|
||
<p>${error.message}</p>
|
||
<button onclick=\\\"renderContentOptimized(content, '${containerId}')\\\">
|
||
Повторить попытку
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
```
|
||
## 7. Хранение в базе данных
|
||
### 7.1 Структура таблиц для хранения контента
|
||
```sql
|
||
-- Таблица для хранения курсов/статей
|
||
CREATE TABLE content (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
title VARCHAR(255) NOT NULL,
|
||
description TEXT,
|
||
markdown_content TEXT NOT NULL,
|
||
html_content TEXT,
|
||
metadata JSONB DEFAULT '{}',
|
||
author_id UUID REFERENCES users(id),
|
||
status VARCHAR(50) DEFAULT 'draft',
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
published_at TIMESTAMP WITH TIME ZONE,
|
||
version INTEGER DEFAULT 1
|
||
);
|
||
-- Индексы для оптимизации поиска
|
||
CREATE INDEX idx_content_author ON content(author_id);
|
||
CREATE INDEX idx_content_status ON content(status);
|
||
CREATE INDEX idx_content_created ON content(created_at);
|
||
CREATE INDEX idx_content_title ON content(title);
|
||
CREATE INDEX idx_content_metadata ON content USING gin(metadata);
|
||
-- Таблица для версий контента (история изменений)
|
||
CREATE TABLE content_versions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
content_id UUID REFERENCES content(id) ON DELETE CASCADE,
|
||
markdown_content TEXT NOT NULL,
|
||
html_content TEXT,
|
||
version INTEGER NOT NULL,
|
||
change_description TEXT,
|
||
author_id UUID REFERENCES users(id),
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
-- Индекс для быстрого поиска версий
|
||
CREATE INDEX idx_content_versions_content ON content_versions(content_id);
|
||
CREATE INDEX idx_content_versions_version ON content_versions(version);
|
||
-- Таблица для извлеченных формул (для поиска и индексации)
|
||
CREATE TABLE content_formulas (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
content_id UUID REFERENCES content(id) ON DELETE CASCADE,
|
||
formula_text TEXT NOT NULL,
|
||
formula_type VARCHAR(20), -- 'inline' или 'display'
|
||
position INTEGER,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
-- Индекс для поиска формул
|
||
CREATE INDEX idx_formulas_content ON content_formulas(content_id);
|
||
CREATE INDEX idx_formulas_text ON content_formulas USING gin(formula_text gin_trgm_ops);
|
||
```
|
||
### 7.2 Модели данных для Node.js/Express
|
||
```javascript
|
||
// models/Content.js
|
||
const { DataTypes } = require('sequelize');
|
||
module.exports = (sequelize) => {
|
||
const Content = sequelize.define('Content', {
|
||
id: {
|
||
type: DataTypes.UUID,
|
||
defaultValue: DataTypes.UUIDV4,
|
||
primaryKey: true
|
||
},
|
||
title: {
|
||
type: DataTypes.STRING(255),
|
||
allowNull: false,
|
||
validate: {
|
||
notEmpty: true
|
||
}
|
||
},
|
||
description: {
|
||
type: DataTypes.TEXT,
|
||
allowNull: true
|
||
},
|
||
markdownContent: {
|
||
type: DataTypes.TEXT,
|
||
allowNull: false,
|
||
field: 'markdown_content'
|
||
},
|
||
htmlContent: {
|
||
type: DataTypes.TEXT,
|
||
allowNull: true,
|
||
field: 'html_content'
|
||
},
|
||
metadata: {
|
||
type: DataTypes.JSONB,
|
||
defaultValue: {},
|
||
get() {
|
||
const rawValue = this.getDataValue('metadata');
|
||
return rawValue ? rawValue : {};
|
||
}
|
||
},
|
||
status: {
|
||
type: DataTypes.ENUM('draft', 'published', 'archived'),
|
||
defaultValue: 'draft'
|
||
},
|
||
version: {
|
||
type: DataTypes.INTEGER,
|
||
defaultValue: 1
|
||
},
|
||
publishedAt: {
|
||
type: DataTypes.DATE,
|
||
allowNull: true,
|
||
field: 'published_at'
|
||
}
|
||
}, {
|
||
tableName: 'content',
|
||
timestamps: true,
|
||
underscored: true,
|
||
hooks: {
|
||
beforeSave: async (content) => {
|
||
// Автоматическая генерация HTML из Markdown
|
||
if (content.changed('markdownContent') && content.markdownContent) {
|
||
content.htmlContent = await convertMarkdownToHTML(content.markdownContent);
|
||
|
||
// Извлечение и сохранение формул
|
||
await extractAndSaveFormulas(content);
|
||
}
|
||
|
||
// Обновление версии при изменении контента
|
||
if (content.changed('markdownContent')) {
|
||
content.version += 1;
|
||
}
|
||
|
||
// Обновление времени публикации
|
||
if (content.changed('status') && content.status === 'published') {
|
||
content.publishedAt = new Date();
|
||
}
|
||
},
|
||
afterSave: async (content) => {
|
||
// Сохранение версии в историю
|
||
if (content.changed('markdownContent')) {
|
||
await sequelize.models.ContentVersion.create({
|
||
contentId: content.id,
|
||
markdownContent: content.markdownContent,
|
||
htmlContent: content.htmlContent,
|
||
version: content.version,
|
||
authorId: content.authorId
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
Content.associate = (models) => {
|
||
Content.belongsTo(models.User, {
|
||
foreignKey: 'author_id',
|
||
as: 'author'
|
||
});
|
||
|
||
Content.hasMany(models.ContentVersion, {
|
||
foreignKey: 'content_id',
|
||
as: 'versions'
|
||
});
|
||
|
||
Content.hasMany(models.ContentFormula, {
|
||
foreignKey: 'content_id',
|
||
as: 'formulas'
|
||
});
|
||
};
|
||
return Content;
|
||
};
|
||
// models/ContentVersion.js
|
||
module.exports = (sequelize) => {
|
||
const ContentVersion = sequelize.define('ContentVersion', {
|
||
id: {
|
||
type: DataTypes.UUID,
|
||
defaultValue: DataTypes.UUIDV4,
|
||
primaryKey: true
|
||
},
|
||
markdownContent: {
|
||
type: DataTypes.TEXT,
|
||
allowNull: false,
|
||
field: 'markdown_content'
|
||
},
|
||
htmlContent: {
|
||
type: DataTypes.TEXT,
|
||
allowNull: true,
|
||
field: 'html_content'
|
||
},
|
||
version: {
|
||
type: DataTypes.INTEGER,
|
||
allowNull: false
|
||
},
|
||
changeDescription: {
|
||
type: DataTypes.TEXT,
|
||
allowNull: true,
|
||
field: 'change_description'
|
||
}
|
||
}, {
|
||
tableName: 'content_versions',
|
||
timestamps: true,
|
||
underscored: true
|
||
});
|
||
ContentVersion.associate = (models) => {
|
||
ContentVersion.belongsTo(models.Content, {
|
||
foreignKey: 'content_id',
|
||
as: 'content'
|
||
});
|
||
|
||
ContentVersion.belongsTo(models.User, {
|
||
foreignKey: 'author_id',
|
||
as: 'author'
|
||
});
|
||
};
|
||
return ContentVersion;
|
||
};
|
||
// Вспомогательные функции
|
||
async function convertMarkdownToHTML(markdown) {
|
||
// Используем Lute или marked.js для конвертации
|
||
// с поддержкой KaTeX
|
||
// ...
|
||
}
|
||
async function extractAndSaveFormulas(content) {
|
||
const formulas = extractLatexFormulas(content.markdownContent);
|
||
|
||
// Удаляем старые формулы
|
||
await sequelize.models.ContentFormula.destroy({
|
||
where: { contentId: content.id }
|
||
});
|
||
|
||
// Сохраняем новые формулы
|
||
const formulaPromises = [];
|
||
|
||
formulas.inline.forEach((formula, index) => {
|
||
formulaPromises.push(
|
||
sequelize.models.ContentFormula.create({
|
||
contentId: content.id,
|
||
formulaText: formula.formula,
|
||
formulaType: 'inline',
|
||
position: formula.position
|
||
})
|
||
);
|
||
});
|
||
|
||
formulas.display.forEach((formula, index) => {
|
||
formulaPromises.push(
|
||
sequelize.models.ContentFormula.create({
|
||
contentId: content.id,
|
||
formulaText: formula.formula,
|
||
formulaType: 'display',
|
||
position: formula.position
|
||
})
|
||
);
|
||
});
|
||
|
||
await Promise.all(formulaPromises);
|
||
}
|
||
```
|
||
### 7.3 API endpoints для работы с контентом
|
||
```javascript
|
||
// routes/content.js
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const { Content, ContentVersion } = require('../models');
|
||
const auth = require('../middleware/auth');
|
||
// Получение списка контента
|
||
router.get('/', auth, async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, status, search } = req.query;
|
||
const offset = (page - 1) * limit;
|
||
|
||
const where = {};
|
||
|
||
if (status) {
|
||
where.status = status;
|
||
}
|
||
|
||
if (search) {
|
||
where[Op.or] = [
|
||
{ title: { [Op.iLike]: `%${search}%` } },
|
||
{ description: { [Op.iLike]: `%${search}%` } }
|
||
];
|
||
}
|
||
|
||
const { count, rows } = await Content.findAndCountAll({
|
||
where,
|
||
limit: parseInt(limit),
|
||
offset: parseInt(offset),
|
||
order: [['created_at', 'DESC']],
|
||
include: [{
|
||
model: User,
|
||
as: 'author',
|
||
attributes: ['id', 'name', 'email']
|
||
}]
|
||
});
|
||
|
||
res.json({
|
||
data: rows,
|
||
pagination: {
|
||
total: count,
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
pages: Math.ceil(count / limit)
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка получения контента:', error);
|
||
res.status(500).json({ error: 'Ошибка сервера' });
|
||
}
|
||
});
|
||
// Создание нового контента
|
||
router.post('/', auth, async (req, res) => {
|
||
try {
|
||
const { title, description, markdownContent, metadata } = req.body;
|
||
|
||
const content = await Content.create({
|
||
title,
|
||
description,
|
||
markdownContent,
|
||
metadata: metadata || {},
|
||
authorId: req.user.id
|
||
});
|
||
|
||
res.status(201).json({
|
||
message: 'Контент создан',
|
||
data: content
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка создания контента:', error);
|
||
res.status(400).json({ error: error.message });
|
||
}
|
||
});
|
||
// Получение контента по ID
|
||
router.get('/:id', async (req, res) => {
|
||
try {
|
||
const content = await Content.findByPk(req.params.id, {
|
||
include: [{
|
||
model: User,
|
||
as: 'author',
|
||
attributes: ['id', 'name', 'email']
|
||
}]
|
||
});
|
||
|
||
if (!content) {
|
||
return res.status(404).json({ error: 'Контент не найден' });
|
||
}
|
||
|
||
res.json({
|
||
data: content
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка получения контента:', error);
|
||
res.status(500).json({ error: 'Ошибка сервера' });
|
||
}
|
||
});
|
||
// Обновление контента
|
||
router.put('/:id', auth, async (req, res) => {
|
||
try {
|
||
const { title, description, markdownContent, metadata } = req.body;
|
||
|
||
const content = await Content.findByPk(req.params.id);
|
||
|
||
if (!content) {
|
||
return res.status(404).json({ error: 'Контент не найден' });
|
||
}
|
||
|
||
// Проверка прав доступа
|
||
if (content.authorId !== req.user.id && !req.user.isAdmin) {
|
||
return res.status(403).json({ error: 'Доступ запрещен' });
|
||
}
|
||
|
||
// Обновление полей
|
||
await content.update({
|
||
title: title || content.title,
|
||
description: description !== undefined ? description : content.description,
|
||
markdownContent: markdownContent || content.markdownContent,
|
||
metadata: metadata || content.metadata
|
||
});
|
||
|
||
res.json({
|
||
message: 'Контент обновлен',
|
||
data: content
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка обновления контента:', error);
|
||
res.status(400).json({ error: error.message });
|
||
}
|
||
});
|
||
// Публикация контента
|
||
router.post('/:id/publish', auth, async (req, res) => {
|
||
try {
|
||
const content = await Content.findByPk(req.params.id);
|
||
|
||
if (!content) {
|
||
return res.status(404).json({ error: 'Контент не найден' });
|
||
}
|
||
|
||
if (content.authorId !== req.user.id && !req.user.isAdmin) {
|
||
return res.status(403).json({ error: 'Доступ запрещен' });
|
||
}
|
||
|
||
await content.update({
|
||
status: 'published',
|
||
publishedAt: new Date()
|
||
});
|
||
|
||
res.json({
|
||
message: 'Контент опубликован',
|
||
data: content
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка публикации:', error);
|
||
res.status(500).json({ error: 'Ошибка сервера' });
|
||
}
|
||
});
|
||
// Получение версий контента
|
||
router.get('/:id/versions', auth, async (req, res) => {
|
||
try {
|
||
const versions = await ContentVersion.findAll({
|
||
where: { contentId: req.params.id },
|
||
order: [['version', 'DESC']],
|
||
include: [{
|
||
model: User,
|
||
as: 'author',
|
||
attributes: ['id', 'name', 'email']
|
||
}]
|
||
});
|
||
|
||
res.json({
|
||
data: versions
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка получения версий:', error);
|
||
res.status(500).json({ error: 'Ошибка сервера' });
|
||
}
|
||
});
|
||
// Восстановление версии
|
||
router.post('/:id/restore/:versionId', auth, async (req, res) => {
|
||
try {
|
||
const version = await ContentVersion.findByPk(req.params.versionId, {
|
||
include: [{
|
||
model: Content,
|
||
as: 'content',
|
||
where: { id: req.params.id }
|
||
}]
|
||
});
|
||
|
||
if (!version) {
|
||
return res.status(404).json({ error: 'Версия не найдена' });
|
||
}
|
||
|
||
const content = version.content;
|
||
|
||
if (content.authorId !== req.user.id && !req.user.isAdmin) {
|
||
return res.status(403).json({ error: 'Доступ запрещен' });
|
||
}
|
||
|
||
// Восстановление контента из версии
|
||
await content.update({
|
||
markdownContent: version.markdownContent,
|
||
htmlContent: version.htmlContent,
|
||
version: content.version + 1
|
||
});
|
||
|
||
res.json({
|
||
message: 'Версия восстановлена',
|
||
data: content
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка восстановления:', error);
|
||
res.status(500).json({ error: 'Ошибка сервера' });
|
||
}
|
||
});
|
||
module.exports = router;
|
||
```
|
||
### 7.4 Оптимизация хранения больших документов
|
||
```javascript
|
||
// Стратегии хранения больших документов
|
||
// 1. Компрессия контента
|
||
async function compressContent(content) {
|
||
// Используем gzip для сжатия больших текстов
|
||
const compressed = await new Promise((resolve, reject) => {
|
||
zlib.gzip(content, (err, result) => {
|
||
if (err) reject(err);
|
||
else resolve(result);
|
||
});
|
||
});
|
||
|
||
return compressed.toString('base64');
|
||
}
|
||
async function decompressContent(compressed) {
|
||
const buffer = Buffer.from(compressed, 'base64');
|
||
|
||
return new Promise((resolve, reject) => {
|
||
zlib.gunzip(buffer, (err, result) => {
|
||
if (err) reject(err);
|
||
else resolve(result.toString());
|
||
});
|
||
});
|
||
}
|
||
// 2. Разделение контента на части
|
||
async function splitLargeContent(content, maxChunkSize = 100000) {
|
||
if (content.length <= maxChunkSize) {
|
||
return [content];
|
||
}
|
||
|
||
const chunks = [];
|
||
let currentChunk = '';
|
||
const lines = content.split('\\
|
||
');
|
||
|
||
for (const line of lines) {
|
||
if ((currentChunk + line + '\\
|
||
').length > maxChunkSize) {
|
||
chunks.push(currentChunk);
|
||
currentChunk = line + '\\
|
||
';
|
||
} else {
|
||
currentChunk += line + '\\
|
||
';
|
||
}
|
||
}
|
||
|
||
if (currentChunk) {
|
||
chunks.push(currentChunk);
|
||
}
|
||
|
||
return chunks;
|
||
}
|
||
// 3. Ленивая загрузка частей контента
|
||
class LazyContentLoader {
|
||
constructor(contentId) {
|
||
this.contentId = contentId;
|
||
this.chunks = [];
|
||
this.loadedChunks = new Set();
|
||
}
|
||
|
||
async getChunk(index) {
|
||
if (this.loadedChunks.has(index)) {
|
||
return this.chunks[index];
|
||
}
|
||
|
||
// Загрузка чанка из базы данных
|
||
const response = await fetch(`/api/content/${this.contentId}/chunks/${index}`);
|
||
const chunk = await response.json();
|
||
|
||
this.chunks[index] = chunk;
|
||
this.loadedChunks.add(index);
|
||
|
||
return chunk;
|
||
}
|
||
|
||
async *getContent() {
|
||
// Получаем метаданные о количестве чанков
|
||
const metadata = await fetch(`/api/content/${this.contentId}/metadata`);
|
||
const { totalChunks } = await metadata.json();
|
||
|
||
for (let i = 0; i < totalChunks; i++) {
|
||
yield await this.getChunk(i);
|
||
}
|
||
}
|
||
}
|
||
// 4. Кэширование в Redis
|
||
const redis = require('redis');
|
||
const client = redis.createClient();
|
||
async function getContentWithCache(contentId) {
|
||
const cacheKey = `content:${contentId}`;
|
||
|
||
// Попытка получить из кэша
|
||
const cached = await client.get(cacheKey);
|
||
if (cached) {
|
||
return JSON.parse(cached);
|
||
}
|
||
|
||
// Загрузка из базы данных
|
||
const content = await Content.findByPk(contentId);
|
||
if (!content) {
|
||
return null;
|
||
}
|
||
|
||
// Сохранение в кэш на 1 час
|
||
await client.setex(cacheKey, 3600, JSON.stringify(content));
|
||
|
||
return content;
|
||
}
|
||
// 5. Индексация для полнотекстового поиска
|
||
async function indexContentForSearch(content) {
|
||
// Извлечение текста без Markdown разметки
|
||
const plainText = content.markdownContent
|
||
.replace(/#+\\\\s*/g, '') // Удаляем заголовки
|
||
.replace(/\\\\*\\\\*?(.*?)\\\\*\\\\*?/g, '$1') // Удаляем жирный/курсив
|
||
.replace(/`(.*?)`/g, '$1') // Удаляем inline код
|
||
.replace(/\\\\[(.*?)\\\\]\\\\(.*?\\\\)/g, '$1') // Удаляем ссылки
|
||
.replace(/\\\\$\\\\$?(.*?)\\\\$?\\\\$/g, '') // Удаляем формулы
|
||
.replace(/```[\\\\s\\\\S]*?```/g, '') // Удаляем блоки кода
|
||
.replace(/[^\\\\w\\\\sа-яА-ЯёЁ]/g, ' ') // Оставляем только буквы и пробелы
|
||
.replace(/\\\\s+/g, ' ') // Убираем лишние пробелы
|
||
.trim();
|
||
|
||
// Сохранение в поисковый индекс (Elasticsearch или PostgreSQL tsvector)
|
||
await saveToSearchIndex(content.id, plainText, {
|
||
title: content.title,
|
||
description: content.description,
|
||
author: content.authorId,
|
||
status: content.status,
|
||
publishedAt: content.publishedAt
|
||
});
|
||
}
|
||
```
|
||
## 8. Примеры использования
|
||
### 8.1 Полный пример: Система управления курсами
|
||
```javascript
|
||
// course-system.js
|
||
class CourseSystem {
|
||
constructor() {
|
||
this.vditorInstance = null;
|
||
this.currentCourse = null;
|
||
this.draftManager = null;
|
||
}
|
||
|
||
// Инициализация системы
|
||
async initialize() {
|
||
// Загрузка Vditor
|
||
await this.loadVditor();
|
||
|
||
// Инициализация менеджера черновиков
|
||
this.draftManager = new DraftManager(this.vditorInstance, 'course_draft');
|
||
this.draftManager.enableAutoSave(30000); // Автосохранение каждые 30 секунд
|
||
|
||
// Настройка обработчиков событий
|
||
this.setupEventHandlers();
|
||
|
||
// Загрузка сохраненного черновика
|
||
const draft = this.draftManager.loadDraft();
|
||
if (draft) {
|
||
this.showDraftNotification(draft);
|
||
}
|
||
|
||
console.log('Система управления курсами инициализирована');
|
||
}
|
||
|
||
// Загрузка Vditor
|
||
async loadVditor() {
|
||
return new Promise((resolve, reject) => {
|
||
if (typeof Vditor !== 'undefined') {
|
||
this.initVditorEditor();
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
const script = document.createElement('script');
|
||
script.src = 'https://unpkg.com/vditor@3.10.4/dist/index.min.js';
|
||
script.onload = () => {
|
||
this.initVditorEditor();
|
||
resolve();
|
||
};
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
// Инициализация редактора
|
||
initVditorEditor() {
|
||
this.vditorInstance = new Vditor('vditor-container', {
|
||
height: 600,
|
||
placeholder: 'Начните создавать курс...',
|
||
theme: 'classic',
|
||
lang: 'ru_RU',
|
||
toolbar: [
|
||
'emoji', 'headings', 'bold', 'italic',. |