diff --git a/.gitignore b/.gitignore
index bfa5d2f..49f709d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,6 +44,8 @@ node_modules/
# Coverage
coverage/
recordings/
+!client/public/recordings/
+!client/public/recordings/**
*.lcov
.nyc_output
diff --git a/client/public/css/style.css b/client/public/css/style.css
index 7277e68..3654a8c 100644
--- a/client/public/css/style.css
+++ b/client/public/css/style.css
@@ -253,3 +253,508 @@ body {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
+
+.recordings-page {
+ overflow: hidden;
+}
+
+.recordings-shell {
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.recordings-toolbar {
+ min-height: 76px;
+ border-radius: 16px;
+ padding: 14px;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px;
+ align-items: center;
+ gap: 12px;
+}
+
+.recordings-stat {
+ min-width: 112px;
+ padding: 6px 10px;
+}
+
+.recordings-stat-value {
+ display: block;
+ font-size: 20px;
+ line-height: 1.1;
+ font-weight: 700;
+ color: #ffffff;
+}
+
+.recordings-stat-label {
+ display: block;
+ margin-top: 4px;
+ font-size: 12px;
+ color: #94a3b8;
+}
+
+.recordings-search,
+.recordings-select,
+.recordings-input {
+ height: 42px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(15, 23, 42, 0.58);
+ color: #ffffff;
+}
+
+.recordings-search {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 14px;
+}
+
+.recordings-search input {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: #ffffff;
+ font-size: 14px;
+}
+
+.recordings-search input::placeholder,
+.recordings-input::placeholder {
+ color: #64748b;
+}
+
+.recordings-select {
+ padding: 0 12px;
+ outline: 0;
+}
+
+.recordings-select option {
+ color: #0f172a;
+}
+
+.recordings-content {
+ min-height: 0;
+ flex: 1;
+ display: grid;
+ grid-template-columns: 280px minmax(420px, 1fr) 360px;
+ gap: 16px;
+}
+
+.recordings-upload,
+.recordings-list,
+.recordings-preview {
+ min-height: 0;
+ border-radius: 16px;
+}
+
+.recordings-upload {
+ padding: 18px;
+}
+
+.recordings-list {
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.recordings-list-head {
+ min-height: 72px;
+ padding: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.recordings-preview {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow: hidden;
+}
+
+.recordings-dropzone {
+ min-height: 132px;
+ border-radius: 14px;
+ border: 1px dashed rgba(129, 140, 248, 0.55);
+ background: rgba(79, 70, 229, 0.08);
+ color: #c7d2fe;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ text-align: center;
+ cursor: pointer;
+ transition: background 0.2s, border-color 0.2s;
+}
+
+.recordings-dropzone:hover {
+ background: rgba(79, 70, 229, 0.16);
+ border-color: rgba(165, 180, 252, 0.8);
+}
+
+.recordings-label {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 12px;
+ color: #94a3b8;
+}
+
+.recordings-input {
+ width: 100%;
+ padding: 0 12px;
+ outline: 0;
+}
+
+.recordings-input:focus,
+.recordings-select:focus,
+.recordings-search:focus-within {
+ border-color: rgba(129, 140, 248, 0.9);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22);
+}
+
+.recordings-primary-btn,
+.recordings-secondary-btn,
+.recordings-text-btn,
+.recordings-icon-btn {
+ border: 0;
+ outline: 0;
+ cursor: pointer;
+ transition: transform 0.2s, background 0.2s, color 0.2s, border-color 0.2s;
+}
+
+.recordings-primary-btn,
+.recordings-secondary-btn {
+ min-height: 42px;
+ border-radius: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.recordings-primary-btn {
+ width: 100%;
+ background: #4f46e5;
+ color: #ffffff;
+}
+
+.recordings-primary-btn:hover {
+ background: #4338ca;
+}
+
+.recordings-primary-btn:disabled {
+ opacity: 0.55;
+ cursor: wait;
+}
+
+.recordings-secondary-btn {
+ width: 100%;
+ background: rgba(255, 255, 255, 0.08);
+ color: #e2e8f0;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.recordings-secondary-btn:hover {
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.recordings-text-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #c7d2fe;
+ background: transparent;
+ font-size: 13px;
+}
+
+.recordings-icon-btn {
+ width: 38px;
+ height: 38px;
+ border-radius: 11px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #cbd5e1;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.recordings-icon-btn:hover {
+ transform: translateY(-1px);
+ color: #ffffff;
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.recordings-danger {
+ color: #fca5a5;
+}
+
+.recordings-danger:hover {
+ color: #ffffff;
+ background: rgba(239, 68, 68, 0.75);
+}
+
+.recordings-table-wrap {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+
+.recordings-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+}
+
+.recordings-table th,
+.recordings-table td {
+ padding: 13px 14px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ text-align: left;
+ vertical-align: middle;
+}
+
+.recordings-table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: rgba(15, 23, 42, 0.95);
+ color: #94a3b8;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.recordings-table th:nth-child(1) { width: 38%; }
+.recordings-table th:nth-child(2) { width: 18%; }
+.recordings-table th:nth-child(3) { width: 12%; }
+.recordings-table th:nth-child(4) { width: 18%; }
+.recordings-table th:nth-child(5) { width: 14%; }
+
+.recordings-table tbody tr {
+ transition: background 0.2s;
+}
+
+.recordings-table tbody tr:hover,
+.recordings-row-active {
+ background: rgba(99, 102, 241, 0.14);
+}
+
+.recordings-file-cell {
+ max-width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.recordings-file-icon {
+ flex: 0 0 auto;
+ width: 46px;
+ height: 30px;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(16, 185, 129, 0.16);
+ color: #6ee7b7;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.recordings-file-name,
+.recordings-file-sub {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.recordings-file-name {
+ color: #ffffff;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.recordings-file-sub {
+ margin-top: 3px;
+ color: #64748b;
+ font-size: 12px;
+}
+
+.recordings-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.recordings-empty {
+ flex: 1;
+ min-height: 260px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ color: #94a3b8;
+}
+
+.recordings-preview-video {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ border-radius: 14px;
+ overflow: hidden;
+ background: #020617;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.recordings-preview-video video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.recordings-preview-placeholder {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ color: #94a3b8;
+ background: linear-gradient(135deg, rgba(49, 46, 129, 0.55), rgba(8, 47, 73, 0.45));
+}
+
+.recordings-preview-meta {
+ min-height: 0;
+ overflow: auto;
+}
+
+.recordings-preview-meta h2 {
+ margin-bottom: 14px;
+ font-size: 17px;
+ font-weight: 700;
+ word-break: break-word;
+}
+
+.recordings-detail-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.recordings-detail-row span {
+ color: #94a3b8;
+}
+
+.recordings-detail-row strong {
+ color: #e2e8f0;
+ font-weight: 600;
+ text-align: right;
+ word-break: break-all;
+}
+
+.recordings-preview-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin-top: 16px;
+}
+
+.recordings-dialog {
+ width: min(440px, calc(100vw - 32px));
+ border-radius: 18px;
+ padding: 22px;
+}
+
+.recordings-notification {
+ position: fixed;
+ top: 82px;
+ left: 50%;
+ transform: translate(-50%, -16px);
+ z-index: 60;
+ min-height: 44px;
+ padding: 0 18px;
+ border-radius: 999px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: #ffffff;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s, transform 0.2s;
+}
+
+.recordings-notification-visible {
+ opacity: 1;
+ transform: translate(-50%, 0);
+}
+
+.recordings-notification-error i {
+ color: #f87171;
+}
+
+@media (max-width: 1180px) {
+ .recordings-content {
+ grid-template-columns: 260px minmax(420px, 1fr);
+ }
+
+ .recordings-preview {
+ grid-column: 1 / -1;
+ min-height: 360px;
+ display: grid;
+ grid-template-columns: minmax(360px, 0.8fr) 1fr;
+ }
+}
+
+@media (max-width: 860px) {
+ .recordings-page {
+ overflow: auto;
+ }
+
+ .recordings-shell {
+ padding: 14px;
+ overflow: visible;
+ }
+
+ .recordings-toolbar {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .recordings-search,
+ .recordings-select {
+ grid-column: 1 / -1;
+ }
+
+ .recordings-content,
+ .recordings-preview {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .recordings-list,
+ .recordings-upload,
+ .recordings-preview {
+ flex: none;
+ }
+
+ .recordings-table {
+ min-width: 760px;
+ }
+}
diff --git a/client/public/recordings/index.html b/client/public/recordings/index.html
new file mode 100644
index 0000000..d66d5ec
--- /dev/null
+++ b/client/public/recordings/index.html
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+ VideoCall - 录制管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
录制管理后台
+
+
+ recordings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
正在读取 recordings 目录...
+
+
+
+
+ 还没有录制文件
+
+
+
+
+
+
+ | 文件 |
+ 会议 |
+ 大小 |
+ 上传时间 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/public/recordings/recordings-admin.js b/client/public/recordings/recordings-admin.js
new file mode 100644
index 0000000..4e636ab
--- /dev/null
+++ b/client/public/recordings/recordings-admin.js
@@ -0,0 +1,400 @@
+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();
diff --git a/client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png b/client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png
new file mode 100644
index 0000000..d7e88ac
Binary files /dev/null and b/client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png differ
diff --git a/client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png b/client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png
new file mode 100644
index 0000000..613eea9
Binary files /dev/null and b/client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png differ
diff --git a/src/server.ts b/src/server.ts
index e28eb34..af2c466 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -19,6 +19,18 @@ const DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
const ALLOWED_RECORDING_MIME_TYPES = new Set(['video/webm', 'video/mp4', 'application/octet-stream']);
const ALLOWED_RECORDING_EXTENSIONS = new Set(['.webm', '.mp4']);
+type RecordingMetadata = {
+ id?: string;
+ meetingId?: string;
+ filename?: string;
+ originalFilename?: string;
+ mimetype?: string;
+ size?: number;
+ userId?: string;
+ uploadedAt?: string;
+ updatedAt?: string;
+};
+
function safeAvatarExtension(file: any): string {
const originalExt = path.extname(file.originalname || '').toLowerCase();
if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) {
@@ -89,6 +101,89 @@ function isAllowedRecording(file: any): boolean {
return ext.length > 0 && isCompatibleMime;
}
+function readRecordingMetadata(metadataPath: string): RecordingMetadata {
+ try {
+ if (!fs.existsSync(metadataPath)) {
+ return {};
+ }
+
+ return JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
+ } catch (error) {
+ log(LogLevel.warn, 'Failed to read recording metadata:', error);
+ return {};
+ }
+}
+
+function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string {
+ if (metadata.mimetype) {
+ return metadata.mimetype;
+ }
+
+ return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm';
+}
+
+function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) {
+ const filePath = path.join(recordingRoot, meetingId, filename);
+ const metadataPath = path.join(recordingRoot, meetingId, `${filename}.json`);
+ const stat = fs.statSync(filePath);
+ const metadata = readRecordingMetadata(metadataPath);
+ const resolvedMeetingId = metadata.meetingId || meetingId;
+ const resolvedFilename = metadata.filename || filename;
+
+ return {
+ id: metadata.id || path.basename(filename, path.extname(filename)),
+ meetingId: resolvedMeetingId,
+ filename: resolvedFilename,
+ originalFilename: metadata.originalFilename || filename,
+ mimetype: getRecordingMimeType(filename, metadata),
+ size: stat.size,
+ userId: metadata.userId || '',
+ uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(),
+ updatedAt: metadata.updatedAt || stat.mtime.toISOString(),
+ modifiedAt: stat.mtime.toISOString(),
+ downloadUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/download`,
+ streamUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/stream`
+ };
+}
+
+function listRecordings(recordingRoot: string) {
+ if (!fs.existsSync(recordingRoot)) {
+ return [];
+ }
+
+ const recordings = [];
+ const meetingIds = fs.readdirSync(recordingRoot).filter((name) => {
+ const fullPath = path.join(recordingRoot, name);
+ return name !== '.tmp' && fs.statSync(fullPath).isDirectory();
+ });
+
+ meetingIds.forEach((meetingId) => {
+ const meetingDir = path.join(recordingRoot, meetingId);
+ fs.readdirSync(meetingDir).forEach((filename) => {
+ const ext = path.extname(filename).toLowerCase();
+ const filePath = path.join(meetingDir, filename);
+ if (!ALLOWED_RECORDING_EXTENSIONS.has(ext) || !fs.statSync(filePath).isFile()) {
+ return;
+ }
+
+ recordings.push(buildRecordingInfo(recordingRoot, meetingId, filename));
+ });
+ });
+
+ recordings.sort((a, b) => Date.parse(b.uploadedAt) - Date.parse(a.uploadedAt));
+ return recordings;
+}
+
+function removeEmptyDirectory(directory: string): void {
+ try {
+ if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) {
+ fs.rmdirSync(directory);
+ }
+ } catch (error) {
+ log(LogLevel.warn, 'Failed to remove empty recording directory:', error);
+ }
+}
+
export const createServer = (config: Options): express.Express => {
const app: express.Express = express();
resetHandler(config.mode);
@@ -110,6 +205,20 @@ export const createServer = (config: Options): express.Express => {
}));
app.use('/signaling', signaling);
+
+ app.get(['/recordings', '/recordings/'], (_req, res) => {
+ const recordingsPagePath = path.join(__dirname, '../client/public/recordings/index.html');
+ fs.access(recordingsPagePath, (err) => {
+ if (err) {
+ log(LogLevel.warn, `Can't find file '${recordingsPagePath}'`);
+ res.status(404).send(`Can't find file ${recordingsPagePath}`);
+ return;
+ }
+
+ res.sendFile(recordingsPagePath);
+ });
+ });
+
app.use(express.static(path.join(__dirname, '../client/public')));
app.use('/module', express.static(path.join(__dirname, '../client/src')));
@@ -232,6 +341,21 @@ export const createServer = (config: Options): express.Express => {
}
});
+ app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
+ try {
+ const recordings = listRecordings(recordingRoot);
+ res.json({
+ success: true,
+ recordings,
+ totalCount: recordings.length,
+ root: recordingRoot
+ });
+ } catch (error) {
+ log(LogLevel.error, 'Error listing recordings:', error);
+ res.status(500).json({ success: false, message: 'Failed to list recordings' });
+ }
+ });
+
app.post('/api/recordings', (req: express.Request, res: express.Response) => {
recordingUpload.single('recording')(req, res, (error: Error) => {
if (error) {
@@ -312,6 +436,186 @@ export const createServer = (config: Options): express.Express => {
});
});
+ app.get('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => {
+ const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
+ const filename = sanitizePathSegment(req.params.filename, '');
+ const ext = path.extname(filename).toLowerCase();
+
+ if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
+ res.status(400).json({ success: false, message: 'Invalid recording filename' });
+ return;
+ }
+
+ const filePath = path.join(recordingRoot, meetingId, filename);
+ if (!isPathInside(recordingRoot, filePath) || !fs.existsSync(filePath)) {
+ res.status(404).json({ success: false, message: 'Recording not found' });
+ return;
+ }
+
+ try {
+ res.json({ success: true, recording: buildRecordingInfo(recordingRoot, meetingId, filename) });
+ } catch (error) {
+ log(LogLevel.error, 'Error reading recording:', error);
+ res.status(500).json({ success: false, message: 'Failed to read recording' });
+ }
+ });
+
+ app.patch('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => {
+ const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
+ const filename = sanitizePathSegment(req.params.filename, '');
+ const nextMeetingId = sanitizePathSegment(req.body.meetingId, meetingId);
+ const ext = path.extname(filename).toLowerCase();
+
+ if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
+ res.status(400).json({ success: false, message: 'Invalid recording filename' });
+ return;
+ }
+
+ const sourceDir = path.join(recordingRoot, meetingId);
+ const sourcePath = path.join(sourceDir, filename);
+ const sourceMetadataPath = path.join(sourceDir, `${filename}.json`);
+ const targetDir = path.join(recordingRoot, nextMeetingId);
+ const targetPath = path.join(targetDir, filename);
+ const targetMetadataPath = path.join(targetDir, `${filename}.json`);
+
+ if (!isPathInside(recordingRoot, sourcePath) || !isPathInside(recordingRoot, targetPath)) {
+ res.status(400).json({ success: false, message: 'Invalid recording path' });
+ return;
+ }
+
+ if (!fs.existsSync(sourcePath)) {
+ res.status(404).json({ success: false, message: 'Recording not found' });
+ return;
+ }
+
+ if (sourcePath !== targetPath && fs.existsSync(targetPath)) {
+ res.status(409).json({ success: false, message: 'Recording already exists in target meeting' });
+ return;
+ }
+
+ try {
+ if (!fs.existsSync(targetDir)) {
+ fs.mkdirSync(targetDir, { recursive: true });
+ }
+
+ if (sourcePath !== targetPath) {
+ fs.renameSync(sourcePath, targetPath);
+ if (fs.existsSync(sourceMetadataPath)) {
+ fs.renameSync(sourceMetadataPath, targetMetadataPath);
+ }
+ }
+
+ const metadata = readRecordingMetadata(targetMetadataPath);
+ const nextMetadata = {
+ ...metadata,
+ meetingId: nextMeetingId,
+ filename,
+ originalFilename: typeof req.body.originalFilename === 'string' && req.body.originalFilename.trim()
+ ? path.basename(req.body.originalFilename.trim())
+ : metadata.originalFilename || filename,
+ userId: typeof req.body.userId === 'string' ? req.body.userId.trim() : metadata.userId || '',
+ mimetype: metadata.mimetype || (ext === '.mp4' ? 'video/mp4' : 'video/webm'),
+ size: fs.statSync(targetPath).size,
+ uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(),
+ updatedAt: new Date().toISOString()
+ };
+
+ fs.writeFileSync(targetMetadataPath, JSON.stringify(nextMetadata, null, 2));
+ if (sourcePath !== targetPath) {
+ removeEmptyDirectory(sourceDir);
+ }
+
+ res.json({ success: true, recording: buildRecordingInfo(recordingRoot, nextMeetingId, filename) });
+ } catch (error) {
+ log(LogLevel.error, 'Error updating recording:', error);
+ res.status(500).json({ success: false, message: 'Failed to update recording' });
+ }
+ });
+
+ app.delete('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => {
+ const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
+ const filename = sanitizePathSegment(req.params.filename, '');
+ const ext = path.extname(filename).toLowerCase();
+
+ if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
+ res.status(400).json({ success: false, message: 'Invalid recording filename' });
+ return;
+ }
+
+ const meetingDir = path.join(recordingRoot, meetingId);
+ const filePath = path.join(meetingDir, filename);
+ const metadataPath = path.join(meetingDir, `${filename}.json`);
+ if (!isPathInside(recordingRoot, filePath)) {
+ res.status(400).json({ success: false, message: 'Invalid recording path' });
+ return;
+ }
+
+ if (!fs.existsSync(filePath)) {
+ res.status(404).json({ success: false, message: 'Recording not found' });
+ return;
+ }
+
+ try {
+ fs.unlinkSync(filePath);
+ if (fs.existsSync(metadataPath)) {
+ fs.unlinkSync(metadataPath);
+ }
+ removeEmptyDirectory(meetingDir);
+ res.json({ success: true });
+ } catch (error) {
+ log(LogLevel.error, 'Error deleting recording:', error);
+ res.status(500).json({ success: false, message: 'Failed to delete recording' });
+ }
+ });
+
+ app.get('/api/recordings/:meetingId/:filename/stream', (req: express.Request, res: express.Response) => {
+ const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
+ const filename = sanitizePathSegment(req.params.filename, '');
+ const ext = path.extname(filename).toLowerCase();
+
+ if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
+ res.status(400).json({ success: false, message: 'Invalid recording filename' });
+ return;
+ }
+
+ const filePath = path.join(recordingRoot, meetingId, filename);
+ if (!isPathInside(recordingRoot, filePath) || !fs.existsSync(filePath)) {
+ res.status(404).json({ success: false, message: 'Recording not found' });
+ return;
+ }
+
+ const stat = fs.statSync(filePath);
+ const range = req.headers.range;
+ const contentType = ext === '.mp4' ? 'video/mp4' : 'video/webm';
+
+ if (!range) {
+ res.writeHead(200, {
+ 'Content-Length': stat.size,
+ 'Content-Type': contentType,
+ 'Accept-Ranges': 'bytes'
+ });
+ fs.createReadStream(filePath).pipe(res);
+ return;
+ }
+
+ const parts = range.replace(/bytes=/, '').split('-');
+ const start = parseInt(parts[0], 10);
+ const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
+
+ if (Number.isNaN(start) || Number.isNaN(end) || start >= stat.size || end >= stat.size || start > end) {
+ res.status(416).set('Content-Range', `bytes */${stat.size}`).end();
+ return;
+ }
+
+ res.writeHead(206, {
+ 'Content-Range': `bytes ${start}-${end}/${stat.size}`,
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': end - start + 1,
+ 'Content-Type': contentType
+ });
+ fs.createReadStream(filePath, { start, end }).pipe(res);
+ });
+
app.get('/api/recordings/:meetingId/:filename/download', (req: express.Request, res: express.Response) => {
const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
const filename = sanitizePathSegment(req.params.filename, '');