From ad93ef342b778e67b22a69fa0f8c54dd9fa785a2 Mon Sep 17 00:00:00 2001 From: stary <834207172@qq.com> Date: Mon, 25 May 2026 21:57:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=96=B0=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/public/call/store.js | 45 +++++ client/public/recordings/index.html | 34 +++- client/public/recordings/recordings-admin.js | 134 +++++++++++++- client/public/styles/style.css | 184 +++++++++++++++++-- src/server.ts | 132 ++++++++++++- 5 files changed, 499 insertions(+), 30 deletions(-) diff --git a/client/public/call/store.js b/client/public/call/store.js index 87bd5fa..ea83db1 100644 --- a/client/public/call/store.js +++ b/client/public/call/store.js @@ -164,9 +164,14 @@ class CallStateManager { } async uploadRecording({ blob, filename }) { const formData = new FormData(); + const people = this.buildRecordingPeopleMetadata(); formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown'); formData.append('userId', this.state.session.localUser.id || ''); formData.append('filename', filename); + if (people.host) { + formData.append('host', JSON.stringify(people.host)); + } + formData.append('participants', JSON.stringify(people.participants)); formData.append('recording', blob, filename); const response = await fetch('/api/recordings', { @@ -181,6 +186,46 @@ class CallStateManager { return responseBody; } + buildRecordingPeopleMetadata() { + const localUser = this.state.session.localUser || {}; + const remoteUser = this.state.session.remoteUser || {}; + const members = Object.entries(this.state.participants || {}).map(([participantId, participant]) => ( + this._buildRecordingPerson(participant, participant.role || 'participant', participantId) + )); + const remoteHost = members.find(member => member.role === 'host'); + const localPerson = this._buildRecordingPerson( + localUser, + this.role === 'host' || localUser.isHost ? 'host' : 'participant', + this.selfParticipantId || (this.role === 'host' ? 'host' : 'local') + ); + + if (localPerson.role === 'host') { + return { + host: localPerson, + participants: members.filter(member => member.role !== 'host') + }; + } + + return { + host: remoteHost || this._buildRecordingPerson(remoteUser, 'host', 'host'), + participants: [ + localPerson, + ...members.filter(member => member.role !== 'host' && member.participantId !== localPerson.participantId) + ] + }; + } + _buildRecordingPerson(user = {}, role = 'participant', participantId = '') { + return { + participantId, + userId: user.id || user.userId || '', + id: user.id || user.userId || '', + name: user.name || '', + avatar: user.avatar || '', + role, + status: user.status || '', + mediaState: user.mediaState ? { ...user.mediaState } : undefined + }; + } async _updateLocalMediaRefactored(mediaType, value) { if (mediaType === 'video' && value) { await this._enableLocalVideo(); diff --git a/client/public/recordings/index.html b/client/public/recordings/index.html index 0b43715..33520df 100644 --- a/client/public/recordings/index.html +++ b/client/public/recordings/index.html @@ -56,11 +56,31 @@ - +
+ + + +
@@ -127,6 +147,8 @@ 文件 会议 + 房主 + 参与者 大小 上传时间 操作 @@ -179,7 +201,7 @@
- +
diff --git a/client/public/recordings/recordings-admin.js b/client/public/recordings/recordings-admin.js index 4e636ab..b0e507c 100644 --- a/client/public/recordings/recordings-admin.js +++ b/client/public/recordings/recordings-admin.js @@ -9,6 +9,10 @@ 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'), @@ -41,6 +45,12 @@ const elements = { notificationText: document.getElementById('notificationText') }; +const typeFilterLabels = { + all: '全部格式', + mp4: 'MP4', + webm: 'WebM' +}; + function recordingKey(recording) { return `${recording.meetingId}/${recording.filename}`; } @@ -89,6 +99,87 @@ function formatDate(value) { }); } +function getPersonId(person) { + return person?.userId || person?.id || ''; +} + +function getPersonName(person) { + return person?.name || 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); @@ -143,7 +234,8 @@ function applyFilters() { recording.meetingId, recording.filename, recording.originalFilename, - recording.userId + recording.userId, + getPeopleSearchText(recording) ].join(' ').toLowerCase(); return (type === 'all' || extension === type) && (!query || haystack.includes(query)); }); @@ -169,6 +261,8 @@ function renderTable() { 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 ` @@ -182,6 +276,8 @@ function renderTable() { ${escapeHtml(recording.meetingId)} + ${escapeHtml(renderPersonSummary(host))} + ${participants.length} ${formatBytes(recording.size)} ${formatDate(recording.uploadedAt)} @@ -222,6 +318,8 @@ function selectRecording(recording) { } 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; @@ -232,6 +330,14 @@ function selectRecording(recording) {
大小${formatBytes(recording.size)}
用户 ID${escapeHtml(recording.userId || '-')}
上传时间${formatDate(recording.uploadedAt)}
+
+
房主
+ ${renderPeopleList(host ? [host] : [])} +
+
+
参与者 (${participants.length})
+ ${renderPeopleList(participants)} +
@@ -349,9 +455,32 @@ 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 = ''; - elements.typeFilter.value = 'all'; + setTypeFilter('all'); applyFilters(); }); elements.recordingFile.addEventListener('change', () => { @@ -397,4 +526,5 @@ function bindEvents() { } bindEvents(); +setTypeFilter(elements.typeFilter.value); loadRecordings(); diff --git a/client/public/styles/style.css b/client/public/styles/style.css index 3654a8c..0aa58d6 100644 --- a/client/public/styles/style.css +++ b/client/public/styles/style.css @@ -266,11 +266,13 @@ body { } .recordings-toolbar { + position: relative; + z-index: 50; min-height: 76px; border-radius: 16px; padding: 14px; display: grid; - grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px; + grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 152px; align-items: center; gap: 12px; } @@ -296,7 +298,6 @@ body { } .recordings-search, -.recordings-select, .recordings-input { height: 42px; border-radius: 12px; @@ -327,13 +328,118 @@ body { color: #64748b; } -.recordings-select { - padding: 0 12px; - outline: 0; +.recordings-format-filter { + position: relative; + z-index: 20; + min-width: 152px; } -.recordings-select option { - color: #0f172a; +.recordings-select-native { + position: absolute; + inset: 0; + width: 100%; + height: 42px; + opacity: 0; + pointer-events: none; +} + +.recordings-filter-trigger { + width: 100%; + height: 42px; + padding: 0 12px 0 14px; + border: 1px solid rgba(129, 140, 248, 0.45); + border-radius: 12px; + background: linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(15, 23, 42, 0.94)); + color: #e5e7eb; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 14px; + font-weight: 700; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 10px 24px rgba(2, 6, 23, 0.24); +} + +.recordings-filter-trigger i { + color: #c7d2fe; + font-size: 12px; + transition: transform 0.2s ease; +} + +.recordings-filter-trigger:hover, +.recordings-format-filter.is-open .recordings-filter-trigger { + border-color: rgba(165, 180, 252, 0.9); + background: linear-gradient(180deg, rgba(49, 46, 129, 0.72), rgba(30, 41, 59, 0.96)); +} + +.recordings-filter-trigger:focus-visible { + border-color: rgba(129, 140, 248, 0.95); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.recordings-format-filter.is-open .recordings-filter-trigger i { + transform: rotate(180deg); +} + +.recordings-filter-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + left: 0; + z-index: 40; + padding: 6px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 14px; + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 18px 40px rgba(2, 6, 23, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.recordings-filter-option { + position: relative; + width: 100%; + min-height: 50px; + padding: 8px 34px 8px 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: #e2e8f0; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; + text-align: left; + cursor: pointer; + transition: background 0.18s, color 0.18s; +} + +.recordings-filter-option span { + font-size: 14px; + font-weight: 700; +} + +.recordings-filter-option small { + color: #64748b; + font-size: 11px; + line-height: 1; +} + +.recordings-filter-option:hover, +.recordings-filter-option.is-active { + background: rgba(79, 70, 229, 0.22); + color: #ffffff; +} + +.recordings-filter-option.is-active::after { + content: "\f00c"; + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + color: #a5b4fc; + font-family: "Font Awesome 6 Free"; + font-size: 12px; + font-weight: 900; } .recordings-content { @@ -415,7 +521,6 @@ body { } .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); @@ -534,11 +639,13 @@ body { 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 th:nth-child(1) { width: 28%; } +.recordings-table th:nth-child(2) { width: 12%; } +.recordings-table th:nth-child(3) { width: 15%; } +.recordings-table th:nth-child(4) { width: 8%; } +.recordings-table th:nth-child(5) { width: 9%; } +.recordings-table th:nth-child(6) { width: 14%; } +.recordings-table th:nth-child(7) { width: 14%; } .recordings-table tbody tr { transition: background 0.2s; @@ -670,6 +777,53 @@ body { word-break: break-all; } +.recordings-people-section { + padding: 12px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.recordings-people-title { + margin-bottom: 10px; + color: #94a3b8; + font-size: 13px; + font-weight: 600; +} + +.recordings-person { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; +} + +.recordings-person img { + width: 34px; + height: 34px; + border-radius: 999px; + object-fit: cover; + background: rgba(255, 255, 255, 0.08); +} + +.recordings-person strong, +.recordings-person span { + display: block; + max-width: 230px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recordings-person strong { + color: #ffffff; + font-size: 13px; +} + +.recordings-person span, +.recordings-person-empty { + color: #64748b; + font-size: 12px; +} + .recordings-preview-actions { display: grid; grid-template-columns: 1fr 1fr; @@ -738,7 +892,7 @@ body { } .recordings-search, - .recordings-select { + .recordings-format-filter { grid-column: 1 / -1; } @@ -755,6 +909,6 @@ body { } .recordings-table { - min-width: 760px; + min-width: 920px; } } diff --git a/src/server.ts b/src/server.ts index 91a3fc8..c890bf2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -27,10 +27,23 @@ type RecordingMetadata = { mimetype?: string; size?: number; userId?: string; + host?: RecordingPerson; + participants?: RecordingPerson[]; uploadedAt?: string; updatedAt?: string; }; +type RecordingPerson = { + participantId?: string; + userId?: string; + id?: string; + name?: string; + avatar?: string; + role?: string; + status?: string; + mediaState?: any; +}; + function safeAvatarExtension(file: any): string { const originalExt = path.extname(file.originalname || '').toLowerCase(); if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) { @@ -101,6 +114,88 @@ function isAllowedRecording(file: any): boolean { return ext.length > 0 && isCompatibleMime; } +function getRecordingMimeTypeFromExtension(ext: string): string { + return ext.toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm'; +} + +function parseJsonField(value: any): any { + if (value === undefined || value === null || value === '') { + return undefined; + } + + if (typeof value !== 'string') { + return value; + } + + try { + return JSON.parse(value); + } catch (_error) { + return undefined; + } +} + +function sanitizeMetadataString(value: any, maxLength = 200): string { + if (value === undefined || value === null) { + return ''; + } + + return String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength); +} + +function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined { + const parsed = parseJsonField(value); + if (!parsed || typeof parsed !== 'object') { + return undefined; + } + + const person: RecordingPerson = { + participantId: sanitizeMetadataString(parsed.participantId || parsed.connectionId, 120), + userId: sanitizeMetadataString(parsed.userId || parsed.id, 120), + id: sanitizeMetadataString(parsed.id || parsed.userId, 120), + name: sanitizeMetadataString(parsed.name, 120), + avatar: sanitizeMetadataString(parsed.avatar, 400), + role: sanitizeMetadataString(parsed.role || fallbackRole, 40), + status: sanitizeMetadataString(parsed.status, 40) + }; + + if (parsed.mediaState && typeof parsed.mediaState === 'object') { + person.mediaState = { + audio: Boolean(parsed.mediaState.audio), + video: Boolean(parsed.mediaState.video), + screenShare: Boolean(parsed.mediaState.screenShare), + recording: Boolean(parsed.mediaState.recording), + isSpeaking: Boolean(parsed.mediaState.isSpeaking) + }; + } + + return person; +} + +function sanitizeRecordingParticipants(value: any): RecordingPerson[] { + const parsed = parseJsonField(value); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .slice(0, 100) + .map((participant) => sanitizeRecordingPerson(participant, 'participant')) + .filter((participant) => Boolean(participant)); +} + +function buildFallbackRecordingHost(userId: string | undefined): RecordingPerson | undefined { + const safeUserId = sanitizeMetadataString(userId, 120); + if (!safeUserId) { + return undefined; + } + + return { + userId: safeUserId, + id: safeUserId, + role: 'host' + }; +} + function readRecordingMetadata(metadataPath: string): RecordingMetadata { try { if (!fs.existsSync(metadataPath)) { @@ -115,11 +210,13 @@ function readRecordingMetadata(metadataPath: string): RecordingMetadata { } function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string { - if (metadata.mimetype) { - return metadata.mimetype; + const ext = path.extname(filename); + const mimetype = normalizeMimeType(metadata.mimetype); + if (mimetype && mimetype !== 'text/plain' && mimetype !== 'application/octet-stream') { + return mimetype; } - return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm'; + return getRecordingMimeTypeFromExtension(ext); } function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) { @@ -129,6 +226,7 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: const metadata = readRecordingMetadata(metadataPath); const resolvedMeetingId = metadata.meetingId || meetingId; const resolvedFilename = metadata.filename || filename; + const participants = Array.isArray(metadata.participants) ? metadata.participants : []; return { id: metadata.id || path.basename(filename, path.extname(filename)), @@ -138,6 +236,9 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: mimetype: getRecordingMimeType(filename, metadata), size: stat.size, userId: metadata.userId || '', + host: metadata.host || buildFallbackRecordingHost(metadata.userId), + participants, + participantCount: participants.length, uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(), updatedAt: metadata.updatedAt || stat.mtime.toISOString(), modifiedAt: stat.mtime.toISOString(), @@ -394,6 +495,9 @@ export const createServer = (config: Options): express.Express => { const recordingId = uuid(); const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown'); const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${ext}`); + const userId = sanitizeMetadataString(request.body.userId, 120); + const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId); + const participants = sanitizeRecordingParticipants(request.body.participants); const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}${ext}`; const meetingDir = path.join(recordingRoot, meetingId); const finalPath = path.join(meetingDir, finalFilename); @@ -425,9 +529,11 @@ export const createServer = (config: Options): express.Express => { meetingId, filename: finalFilename, originalFilename, - mimetype: normalizeMimeType(request.file.mimetype), + mimetype: getRecordingMimeTypeFromExtension(ext), size: request.file.size, - userId: request.body.userId || '', + userId, + host, + participants, uploadedAt: new Date().toISOString() }; fs.writeFile(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2), () => undefined); @@ -516,6 +622,16 @@ export const createServer = (config: Options): express.Express => { } const metadata = readRecordingMetadata(targetMetadataPath); + const nextUserId = typeof req.body.userId === 'string' ? sanitizeMetadataString(req.body.userId, 120) : metadata.userId || ''; + const shouldSyncHostFromUserId = !metadata.host + || metadata.host.userId === metadata.userId + || metadata.host.id === metadata.userId; + const nextHost = req.body.host !== undefined + ? sanitizeRecordingPerson(req.body.host, 'host') || buildFallbackRecordingHost(nextUserId) + : shouldSyncHostFromUserId ? buildFallbackRecordingHost(nextUserId) : metadata.host; + const nextParticipants = req.body.participants !== undefined + ? sanitizeRecordingParticipants(req.body.participants) + : Array.isArray(metadata.participants) ? metadata.participants : []; const nextMetadata = { ...metadata, meetingId: nextMeetingId, @@ -523,8 +639,10 @@ export const createServer = (config: Options): express.Express => { 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'), + userId: nextUserId, + host: nextHost, + participants: nextParticipants, + mimetype: metadata.mimetype || getRecordingMimeTypeFromExtension(ext), size: fs.statSync(targetPath).size, uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(), updatedAt: new Date().toISOString()