const state = { recordings: [], filtered: [], selectedKey: '', editing: null }; const elements = { refreshBtn: document.getElementById('refreshBtn'), searchInput: document.getElementById('searchInput'), typeFilter: document.getElementById('typeFilter'), clearSearchBtn: document.getElementById('clearSearchBtn'), uploadForm: document.getElementById('uploadForm'), uploadBtn: document.getElementById('uploadBtn'), recordingFile: document.getElementById('recordingFile'), fileNameText: document.getElementById('fileNameText'), uploadMeetingId: document.getElementById('uploadMeetingId'), uploadUserId: document.getElementById('uploadUserId'), totalCount: document.getElementById('totalCount'), meetingCount: document.getElementById('meetingCount'), storageSize: document.getElementById('storageSize'), recordingRootText: document.getElementById('recordingRootText'), listSummary: document.getElementById('listSummary'), loadingState: document.getElementById('loadingState'), emptyState: document.getElementById('emptyState'), recordingsTableWrap: document.getElementById('recordingsTableWrap'), recordingsTableBody: document.getElementById('recordingsTableBody'), previewVideo: document.getElementById('previewVideo'), previewPlaceholder: document.getElementById('previewPlaceholder'), previewTitle: document.getElementById('previewTitle'), previewDetails: document.getElementById('previewDetails'), editDialog: document.getElementById('editDialog'), editForm: document.getElementById('editForm'), editFilenameText: document.getElementById('editFilenameText'), editMeetingId: document.getElementById('editMeetingId'), editOriginalFilename: document.getElementById('editOriginalFilename'), editUserId: document.getElementById('editUserId'), closeEditBtn: document.getElementById('closeEditBtn'), cancelEditBtn: document.getElementById('cancelEditBtn'), notification: document.getElementById('notification'), notificationText: document.getElementById('notificationText') }; function recordingKey(recording) { return `${recording.meetingId}/${recording.filename}`; } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatBytes(bytes) { const value = Number(bytes) || 0; if (value < 1024) { return `${value} B`; } const units = ['KB', 'MB', 'GB', 'TB']; let size = value / 1024; let index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; } return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; } function formatDate(value) { if (!value) { return '-'; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return '-'; } return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } function showNotification(message, isError = false) { elements.notificationText.textContent = message; elements.notification.classList.toggle('recordings-notification-error', isError); elements.notification.classList.add('recordings-notification-visible'); window.clearTimeout(showNotification.timer); showNotification.timer = window.setTimeout(() => { elements.notification.classList.remove('recordings-notification-visible'); }, 2600); } async function requestJson(url, options = {}) { const response = await fetch(url, options); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.success === false) { throw new Error(payload.message || `请求失败: ${response.status}`); } return payload; } function setLoading(isLoading) { elements.loadingState.classList.toggle('hidden', !isLoading); elements.recordingsTableWrap.classList.toggle('hidden', isLoading || state.filtered.length === 0); elements.emptyState.classList.toggle('hidden', isLoading || state.filtered.length > 0); } async function loadRecordings() { setLoading(true); try { const payload = await requestJson('/api/recordings'); state.recordings = payload.recordings || []; elements.recordingRootText.textContent = payload.root || 'recordings'; applyFilters(); const selected = state.filtered.find(item => recordingKey(item) === state.selectedKey) || state.filtered[0]; selectRecording(selected || null); } catch (error) { state.recordings = []; applyFilters(); selectRecording(null); showNotification(error.message, true); } finally { setLoading(false); } } function applyFilters() { const query = elements.searchInput.value.trim().toLowerCase(); const type = elements.typeFilter.value; state.filtered = state.recordings.filter((recording) => { const extension = (recording.filename || '').split('.').pop().toLowerCase(); const haystack = [ recording.meetingId, recording.filename, recording.originalFilename, recording.userId ].join(' ').toLowerCase(); return (type === 'all' || extension === type) && (!query || haystack.includes(query)); }); elements.clearSearchBtn.classList.toggle('hidden', !query && type === 'all'); renderSummary(); renderTable(); setLoading(false); } function renderSummary() { const meetings = new Set(state.recordings.map(recording => recording.meetingId)); const totalSize = state.recordings.reduce((sum, recording) => sum + (Number(recording.size) || 0), 0); elements.totalCount.textContent = state.recordings.length; elements.meetingCount.textContent = meetings.size; elements.storageSize.textContent = formatBytes(totalSize); elements.listSummary.textContent = `当前显示 ${state.filtered.length} 条,共 ${state.recordings.length} 条`; } function renderTable() { elements.recordingsTableBody.innerHTML = state.filtered.map((recording) => { const key = recordingKey(recording); const active = key === state.selectedKey ? 'recordings-row-active' : ''; const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase()); return ` ${escapeHtml(recording.meetingId)} ${formatBytes(recording.size)} ${formatDate(recording.uploadedAt)}
`; }).join(''); } function findRecording(key) { return state.recordings.find(recording => recordingKey(recording) === key); } function selectRecording(recording) { if (!recording) { state.selectedKey = ''; elements.previewVideo.removeAttribute('src'); elements.previewVideo.load(); elements.previewPlaceholder.classList.remove('hidden'); elements.previewTitle.textContent = '未选择录制'; elements.previewDetails.innerHTML = '

从左侧列表选择文件后,可播放、下载、编辑或删除。

'; renderTable(); return; } state.selectedKey = recordingKey(recording); elements.previewVideo.src = recording.streamUrl; elements.previewPlaceholder.classList.add('hidden'); elements.previewTitle.textContent = recording.originalFilename || recording.filename; elements.previewDetails.innerHTML = `
会议 ID${escapeHtml(recording.meetingId)}
文件名${escapeHtml(recording.filename)}
格式${escapeHtml(recording.mimetype)}
大小${formatBytes(recording.size)}
用户 ID${escapeHtml(recording.userId || '-')}
上传时间${formatDate(recording.uploadedAt)}
下载
`; renderTable(); } function openEdit(recording) { if (!recording) { return; } state.editing = recording; elements.editFilenameText.textContent = recording.filename; elements.editMeetingId.value = recording.meetingId || ''; elements.editOriginalFilename.value = recording.originalFilename || recording.filename || ''; elements.editUserId.value = recording.userId || ''; elements.editDialog.classList.remove('hidden'); elements.editDialog.classList.add('flex'); elements.editMeetingId.focus(); } function closeEdit() { state.editing = null; elements.editDialog.classList.add('hidden'); elements.editDialog.classList.remove('flex'); } async function handleUpload(event) { event.preventDefault(); const file = elements.recordingFile.files[0]; if (!file) { showNotification('请选择录制文件', true); return; } const formData = new FormData(); formData.append('recording', file, file.name); formData.append('filename', file.name); formData.append('meetingId', elements.uploadMeetingId.value.trim()); formData.append('userId', elements.uploadUserId.value.trim()); elements.uploadBtn.disabled = true; try { await requestJson('/api/recordings', { method: 'POST', body: formData }); elements.uploadForm.reset(); elements.fileNameText.textContent = '选择 MP4 或 WebM 文件'; showNotification('录制已上传'); await loadRecordings(); } catch (error) { showNotification(error.message, true); } finally { elements.uploadBtn.disabled = false; } } async function handleEdit(event) { event.preventDefault(); if (!state.editing) { return; } const recording = state.editing; try { const payload = await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ meetingId: elements.editMeetingId.value.trim(), originalFilename: elements.editOriginalFilename.value.trim(), userId: elements.editUserId.value.trim() }) }); state.selectedKey = recordingKey(payload.recording); closeEdit(); showNotification('录制信息已更新'); await loadRecordings(); } catch (error) { showNotification(error.message, true); } } async function deleteRecording(recording) { if (!recording) { return; } const confirmed = window.confirm(`确定删除录制文件 "${recording.originalFilename || recording.filename}" 吗?`); if (!confirmed) { return; } try { await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, { method: 'DELETE' }); if (state.selectedKey === recordingKey(recording)) { state.selectedKey = ''; } showNotification('录制已删除'); await loadRecordings(); } catch (error) { showNotification(error.message, true); } } function bindEvents() { elements.refreshBtn.addEventListener('click', loadRecordings); elements.searchInput.addEventListener('input', applyFilters); elements.typeFilter.addEventListener('change', applyFilters); elements.clearSearchBtn.addEventListener('click', () => { elements.searchInput.value = ''; elements.typeFilter.value = 'all'; applyFilters(); }); elements.recordingFile.addEventListener('change', () => { const file = elements.recordingFile.files[0]; elements.fileNameText.textContent = file ? file.name : '选择 MP4 或 WebM 文件'; }); elements.uploadForm.addEventListener('submit', handleUpload); elements.editForm.addEventListener('submit', handleEdit); elements.closeEditBtn.addEventListener('click', closeEdit); elements.cancelEditBtn.addEventListener('click', closeEdit); elements.editDialog.addEventListener('click', (event) => { if (event.target === elements.editDialog) { closeEdit(); } }); elements.recordingsTableBody.addEventListener('click', (event) => { const control = event.target.closest('[data-action]'); if (!control) { return; } const recording = findRecording(control.dataset.key); const action = control.dataset.action; if (action === 'select' || action === 'preview') { selectRecording(recording); if (action === 'preview') { elements.previewVideo.play().catch(() => undefined); } } else if (action === 'edit') { openEdit(recording); } else if (action === 'delete') { deleteRecording(recording); } }); elements.previewDetails.addEventListener('click', (event) => { const control = event.target.closest('[data-preview-action]'); if (control && control.dataset.previewAction === 'edit') { openEdit(findRecording(state.selectedKey)); } }); } bindEvents(); loadRecordings();