362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
|
|
/**
|
|||
|
|
* Frontend application for Transcription Service
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
class TranscriptionApp {
|
|||
|
|
constructor() {
|
|||
|
|
this.ws = null;
|
|||
|
|
this.tasks = new Map();
|
|||
|
|
this.currentFile = null;
|
|||
|
|
this.init();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
init() {
|
|||
|
|
this.connectWebSocket();
|
|||
|
|
this.setupUpload();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== WebSocket =====
|
|||
|
|
connectWebSocket() {
|
|||
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|||
|
|
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|||
|
|
|
|||
|
|
this.ws.onopen = () => {
|
|||
|
|
console.log('WebSocket connected');
|
|||
|
|
this.showToast('Подключено к серверу', 'success');
|
|||
|
|
this.requestTasks();
|
|||
|
|
this.requestTree();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
this.ws.onmessage = (event) => {
|
|||
|
|
const data = JSON.parse(event.data);
|
|||
|
|
this.handleWebSocketMessage(data);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
this.ws.onclose = () => {
|
|||
|
|
console.log('WebSocket disconnected, reconnecting in 3s...');
|
|||
|
|
setTimeout(() => this.connectWebSocket(), 3000);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
this.ws.onerror = (error) => {
|
|||
|
|
console.error('WebSocket error:', error);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sendWS(data) {
|
|||
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|||
|
|
this.ws.send(JSON.stringify(data));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
requestTasks() {
|
|||
|
|
this.sendWS({ action: 'get_tasks' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
requestTree() {
|
|||
|
|
this.sendWS({ action: 'get_tree' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handleWebSocketMessage(data) {
|
|||
|
|
if (data.type === 'tasks_list') {
|
|||
|
|
this.updateTasks(data.tasks);
|
|||
|
|
} else if (data.type === 'file_tree') {
|
|||
|
|
this.renderFileTree(data.tree);
|
|||
|
|
} else if (data.task_id) {
|
|||
|
|
// Прогресс обработки
|
|||
|
|
this.updateTaskProgress(data);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== Upload =====
|
|||
|
|
setupUpload() {
|
|||
|
|
const dropZone = document.getElementById('dropZone');
|
|||
|
|
const fileInput = document.getElementById('fileInput');
|
|||
|
|
const browseLink = document.querySelector('.browse-link');
|
|||
|
|
|
|||
|
|
// Click to browse
|
|||
|
|
browseLink.addEventListener('click', () => fileInput.click());
|
|||
|
|
dropZone.addEventListener('click', (e) => {
|
|||
|
|
if (e.target === dropZone || e.target.closest('.drop-zone-content')) {
|
|||
|
|
fileInput.click();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// File input change
|
|||
|
|
fileInput.addEventListener('change', (e) => {
|
|||
|
|
this.handleFiles(e.target.files);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Drag & drop
|
|||
|
|
dropZone.addEventListener('dragover', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
dropZone.classList.add('drag-over');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
dropZone.addEventListener('dragleave', () => {
|
|||
|
|
dropZone.classList.remove('drag-over');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
dropZone.addEventListener('drop', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
dropZone.classList.remove('drag-over');
|
|||
|
|
this.handleFiles(e.dataTransfer.files);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async handleFiles(files) {
|
|||
|
|
if (!files.length) return;
|
|||
|
|
|
|||
|
|
const formData = new FormData();
|
|||
|
|
for (const file of files) {
|
|||
|
|
formData.append('files', file);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
this.showToast(`Загрузка ${files.length} файл(а)...`, 'info');
|
|||
|
|
|
|||
|
|
const response = await fetch('/upload-batch', {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: formData,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const result = await response.json();
|
|||
|
|
|
|||
|
|
if (result.error) {
|
|||
|
|
this.showToast(`Ошибка: ${result.error}`, 'error');
|
|||
|
|
} else {
|
|||
|
|
this.showToast(`Загружено ${result.uploaded} файл(а). Начинается обработка...`, 'success');
|
|||
|
|
result.tasks.forEach(task => {
|
|||
|
|
this.tasks.set(task.task_id, task);
|
|||
|
|
});
|
|||
|
|
this.renderTasks();
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
this.showToast(`Ошибка загрузки: ${error.message}`, 'error');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== Tasks / Progress =====
|
|||
|
|
updateTasks(tasks) {
|
|||
|
|
tasks.forEach(task => {
|
|||
|
|
this.tasks.set(task.file + '_' + task.started, task);
|
|||
|
|
});
|
|||
|
|
this.renderTasks();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateTaskProgress(data) {
|
|||
|
|
const existing = Array.from(this.tasks.values()).find(t => t.task_id === data.task_id);
|
|||
|
|
if (existing) {
|
|||
|
|
Object.assign(existing, data);
|
|||
|
|
} else {
|
|||
|
|
this.tasks.set(data.task_id, data);
|
|||
|
|
}
|
|||
|
|
this.renderTasks();
|
|||
|
|
|
|||
|
|
if (data.status === 'completed') {
|
|||
|
|
this.showToast(`Готово: ${data.message}`, 'success');
|
|||
|
|
this.requestTree();
|
|||
|
|
} else if (data.status === 'error') {
|
|||
|
|
this.showToast(`Ошибка: ${data.message}`, 'error');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderTasks() {
|
|||
|
|
const container = document.getElementById('tasksList');
|
|||
|
|
const tasks = Array.from(this.tasks.values());
|
|||
|
|
|
|||
|
|
if (tasks.length === 0) {
|
|||
|
|
container.innerHTML = '<p class="empty-state">Нет активных задач</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = tasks.map(task => this.renderTaskItem(task)).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderTaskItem(task) {
|
|||
|
|
const progress = task.progress || 0;
|
|||
|
|
const statusClass = task.status === 'completed' ? 'success' :
|
|||
|
|
task.status === 'error' ? 'error' :
|
|||
|
|
task.status === 'processing' ? 'processing' : 'queued';
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<div class="task-item ${statusClass}">
|
|||
|
|
<div class="task-header">
|
|||
|
|
<span class="task-filename">${this.escapeHtml(task.file || '')}</span>
|
|||
|
|
<span class="task-status-badge ${statusClass}">${this.getStatusLabel(task.status)}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="task-progress-bar">
|
|||
|
|
<div class="task-progress-fill" style="width: ${progress}%"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="task-message">${this.escapeHtml(task.message || '')}</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
getStatusLabel(status) {
|
|||
|
|
const labels = {
|
|||
|
|
'queued': 'В очереди',
|
|||
|
|
'processing': 'Обработка',
|
|||
|
|
'completed': 'Готово',
|
|||
|
|
'error': 'Ошибка',
|
|||
|
|
};
|
|||
|
|
return labels[status] || status;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== File Tree =====
|
|||
|
|
renderFileTree(tree) {
|
|||
|
|
const container = document.getElementById('fileTree');
|
|||
|
|
|
|||
|
|
if (!tree || tree.length === 0) {
|
|||
|
|
container.innerHTML = '<p class="empty-state">Нет обработанных файлов</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = tree.map(folder => this.renderFolder(folder)).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderFolder(folder) {
|
|||
|
|
const files = folder.files.map(file => {
|
|||
|
|
const isMd = file.ext === '.md';
|
|||
|
|
const isDocx = file.ext === '.docx';
|
|||
|
|
const icon = isMd ? '📝' : isDocx ? '📄' : '📎';
|
|||
|
|
const clickable = isMd ? 'clickable' : '';
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<div class="file-item ${clickable}" data-path="${this.escapeHtml(file.path)}" data-ext="${file.ext}">
|
|||
|
|
<span class="file-icon">${icon}</span>
|
|||
|
|
<span class="file-name">${this.escapeHtml(file.name)}</span>
|
|||
|
|
<span class="file-size">${this.formatBytes(file.size)}</span>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<div class="folder-item">
|
|||
|
|
<div class="folder-header">
|
|||
|
|
<span class="folder-toggle">▼</span>
|
|||
|
|
<span class="folder-icon">📁</span>
|
|||
|
|
<span class="folder-name">${this.escapeHtml(folder.name)}</span>
|
|||
|
|
<span class="folder-date">${this.formatDate(folder.created)}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="folder-files">
|
|||
|
|
${files}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setupFileTreeEvents() {
|
|||
|
|
const container = document.getElementById('fileTree');
|
|||
|
|
|
|||
|
|
container.addEventListener('click', (e) => {
|
|||
|
|
const folderHeader = e.target.closest('.folder-header');
|
|||
|
|
if (folderHeader) {
|
|||
|
|
const folder = folderHeader.closest('.folder-item');
|
|||
|
|
const files = folder.querySelector('.folder-files');
|
|||
|
|
const toggle = folderHeader.querySelector('.folder-toggle');
|
|||
|
|
|
|||
|
|
if (files.style.display === 'none') {
|
|||
|
|
files.style.display = 'block';
|
|||
|
|
toggle.textContent = '▼';
|
|||
|
|
} else {
|
|||
|
|
files.style.display = 'none';
|
|||
|
|
toggle.textContent = '▶';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fileItem = e.target.closest('.file-item.clickable');
|
|||
|
|
if (fileItem) {
|
|||
|
|
const path = fileItem.dataset.path;
|
|||
|
|
const ext = fileItem.dataset.ext;
|
|||
|
|
this.loadFileContent(path, ext);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== Viewer =====
|
|||
|
|
async loadFileContent(path, ext) {
|
|||
|
|
const viewer = document.getElementById('viewer');
|
|||
|
|
|
|||
|
|
if (ext === '.md') {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
|||
|
|
const result = await response.json();
|
|||
|
|
|
|||
|
|
if (result.error) {
|
|||
|
|
viewer.innerHTML = `<div class="error">${this.escapeHtml(result.error)}</div>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Render markdown
|
|||
|
|
const html = marked.parse(result.content);
|
|||
|
|
viewer.innerHTML = `
|
|||
|
|
<div class="md-content">
|
|||
|
|
<div class="md-header">
|
|||
|
|
<span class="md-title">${this.escapeHtml(path)}</span>
|
|||
|
|
<a href="/api/files/download?path=${encodeURIComponent(path)}"
|
|||
|
|
class="btn-download" download>⬇️ Скачать</a>
|
|||
|
|
</div>
|
|||
|
|
<div class="md-body">${html}</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
this.currentFile = path;
|
|||
|
|
} catch (error) {
|
|||
|
|
viewer.innerHTML = `<div class="error">Ошибка загрузки: ${this.escapeHtml(error.message)}</div>`;
|
|||
|
|
}
|
|||
|
|
} else if (ext === '.docx') {
|
|||
|
|
viewer.innerHTML = `
|
|||
|
|
<div class="file-preview">
|
|||
|
|
<p>Для просмотра DOCX скачайте файл:</p>
|
|||
|
|
<a href="/api/files/download?path=${encodeURIComponent(path)}"
|
|||
|
|
class="btn-download" download>⬇️ Скачать ${this.escapeHtml(path)}</a>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== Utilities =====
|
|||
|
|
showToast(message, type = 'info') {
|
|||
|
|
const container = document.getElementById('toastContainer');
|
|||
|
|
const toast = document.createElement('div');
|
|||
|
|
toast.className = `toast toast-${type}`;
|
|||
|
|
toast.textContent = message;
|
|||
|
|
container.appendChild(toast);
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
toast.classList.add('toast-hide');
|
|||
|
|
setTimeout(() => toast.remove(), 300);
|
|||
|
|
}, 4000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
escapeHtml(text) {
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.textContent = text;
|
|||
|
|
return div.innerHTML;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
formatBytes(bytes) {
|
|||
|
|
if (bytes === 0) return '0 B';
|
|||
|
|
const k = 1024;
|
|||
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|||
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
formatDate(isoDate) {
|
|||
|
|
const date = new Date(isoDate);
|
|||
|
|
return date.toLocaleString('ru-RU', {
|
|||
|
|
day: '2-digit',
|
|||
|
|
month: '2-digit',
|
|||
|
|
year: 'numeric',
|
|||
|
|
hour: '2-digit',
|
|||
|
|
minute: '2-digit',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Initialize
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
const app = new TranscriptionApp();
|
|||
|
|
app.setupFileTreeEvents();
|
|||
|
|
});
|