const state = { recordings: [], filtered: [], selectedKey: '', editing: null }; const elements = { refreshBtn: document.getElementById('refreshBtn'), searchInput: document.getElementById('searchInput'), typeFilter: document.getElementById('typeFilter'), typeFilterControl: document.getElementById('typeFilterControl'), typeFilterButton: document.getElementById('typeFilterButton'), typeFilterText: document.getElementById('typeFilterText'), typeFilterMenu: document.getElementById('typeFilterMenu'), 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') }; const typeFilterLabels = { all: '全部格式', mp4: 'MP4', webm: 'WebM' }; 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 getPersonId(person) { return person?.userId || person?.id || person?.participantId || ''; } function getPersonName(person) { return person?.name || person?.displayName || getPersonId(person) || '-'; } function getRecordingHost(recording) { return recording.host || (recording.userId ? { userId: recording.userId, id: recording.userId, role: 'host' } : null); } function getRecordingParticipants(recording) { return Array.isArray(recording.participants) ? recording.participants : []; } function getPeopleSearchText(recording) { const host = getRecordingHost(recording); const participants = getRecordingParticipants(recording); return [ host?.participantId, host?.userId, host?.id, host?.name, ...participants.flatMap(participant => [ participant.participantId, participant.userId, participant.id, participant.name ]) ].filter(Boolean).join(' '); } function renderPersonSummary(person) { if (!person) { return '-'; } const name = getPersonName(person); const id = getPersonId(person); return id && id !== name ? `${name} (${id})` : name; } function renderPeopleList(people) { if (!people.length) { return '
暂无参与者
'; } return people.map((person) => `
${escapeHtml(getPersonName(person))} ${escapeHtml(getPersonId(person) || person.participantId || '-')}
`).join(''); } function setTypeFilter(value) { const nextValue = typeFilterLabels[value] ? value : 'all'; elements.typeFilter.value = nextValue; elements.typeFilterText.textContent = typeFilterLabels[nextValue]; elements.typeFilterMenu.querySelectorAll('[data-type-value]').forEach((option) => { const isActive = option.dataset.typeValue === nextValue; option.classList.toggle('is-active', isActive); option.setAttribute('aria-selected', String(isActive)); }); } function setTypeFilterMenuOpen(isOpen) { elements.typeFilterControl.classList.toggle('is-open', isOpen); elements.typeFilterMenu.classList.toggle('hidden', !isOpen); elements.typeFilterButton.setAttribute('aria-expanded', String(isOpen)); } 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, getPeopleSearchText(recording) ].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()); const host = getRecordingHost(recording); const participants = getRecordingParticipants(recording); return ` ${escapeHtml(recording.meetingId)} ${escapeHtml(renderPersonSummary(host))} ${participants.length} ${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); const host = getRecordingHost(recording); const participants = getRecordingParticipants(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)}
房主
${renderPeopleList(host ? [host] : [])}
参与者 (${participants.length})
${renderPeopleList(participants)}
下载
`; 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.typeFilterButton.addEventListener('click', () => { setTypeFilterMenuOpen(!elements.typeFilterControl.classList.contains('is-open')); }); elements.typeFilterMenu.addEventListener('click', (event) => { const option = event.target.closest('[data-type-value]'); if (!option) { return; } setTypeFilter(option.dataset.typeValue); setTypeFilterMenuOpen(false); applyFilters(); }); document.addEventListener('click', (event) => { if (!elements.typeFilterControl.contains(event.target)) { setTypeFilterMenuOpen(false); } }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { setTypeFilterMenuOpen(false); } }); elements.clearSearchBtn.addEventListener('click', () => { elements.searchInput.value = ''; setTypeFilter('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(); setTypeFilter(elements.typeFilter.value); loadRecordings();