新增视频管理

This commit is contained in:
2026-05-25 17:39:57 +08:00
parent 254d9337bf
commit bbe7e71274
7 changed files with 1416 additions and 0 deletions

2
.gitignore vendored
View File

@@ -44,6 +44,8 @@ node_modules/
# Coverage # Coverage
coverage/ coverage/
recordings/ recordings/
!client/public/recordings/
!client/public/recordings/**
*.lcov *.lcov
.nyc_output .nyc_output

View File

@@ -253,3 +253,508 @@ body {
from { opacity: 0; transform: translateX(-50%) translateY(8px); } from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); } 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;
}
}

View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoCall - 录制管理</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="min-h-screen w-screen text-white bg-grid recordings-page">
<div class="min-h-screen bg-black/70 flex flex-col">
<header class="glass-strong h-16 flex items-center justify-between px-6 border-b border-white/10">
<div class="flex items-center gap-3 min-w-0">
<div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<i class="fas fa-video text-white text-lg"></i>
</div>
<div class="min-w-0">
<h1 class="font-bold text-lg tracking-tight">录制管理后台</h1>
<div class="flex items-center gap-3 text-xs text-gray-400">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
<span id="recordingRootText" class="truncate max-w-[60vw]">recordings</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<a href="/" class="recordings-icon-btn glass" title="返回视频通话">
<i class="fas fa-phone"></i>
</a>
<button id="refreshBtn" class="recordings-icon-btn glass" title="刷新列表">
<i class="fas fa-rotate-right"></i>
</button>
</div>
</header>
<main class="recordings-shell flex-1 overflow-hidden">
<section class="recordings-toolbar glass">
<div class="recordings-stat">
<span class="recordings-stat-value" id="totalCount">0</span>
<span class="recordings-stat-label">总录制</span>
</div>
<div class="recordings-stat">
<span class="recordings-stat-value" id="meetingCount">0</span>
<span class="recordings-stat-label">会议数</span>
</div>
<div class="recordings-stat">
<span class="recordings-stat-value" id="storageSize">0 MB</span>
<span class="recordings-stat-label">占用空间</span>
</div>
<div class="recordings-search">
<i class="fas fa-magnifying-glass text-gray-500"></i>
<input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off">
</div>
<select id="typeFilter" class="recordings-select">
<option value="all">全部格式</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</section>
<section class="recordings-content">
<aside class="recordings-upload glass">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-base font-semibold">新增录制</h2>
<p class="text-xs text-gray-500 mt-1">上传到 recordings 目录</p>
</div>
<div class="w-10 h-10 rounded-xl bg-indigo-500/20 flex items-center justify-center text-indigo-300">
<i class="fas fa-cloud-arrow-up"></i>
</div>
</div>
<form id="uploadForm" class="space-y-4">
<label class="recordings-dropzone" for="recordingFile">
<input id="recordingFile" type="file" accept="video/mp4,video/webm" class="hidden" required>
<i class="fas fa-file-video text-2xl text-indigo-300"></i>
<span id="fileNameText">选择 MP4 或 WebM 文件</span>
</label>
<div>
<label class="recordings-label" for="uploadMeetingId">会议 ID</label>
<input id="uploadMeetingId" class="recordings-input" type="text" placeholder="例如 665-261-326" required>
</div>
<div>
<label class="recordings-label" for="uploadUserId">用户 ID</label>
<input id="uploadUserId" class="recordings-input" type="text" placeholder="可选">
</div>
<button id="uploadBtn" class="recordings-primary-btn" type="submit">
<i class="fas fa-plus"></i>
<span>上传录制</span>
</button>
</form>
</aside>
<section class="recordings-list glass">
<div class="recordings-list-head">
<div>
<h2 class="text-base font-semibold">录制文件</h2>
<p class="text-xs text-gray-500 mt-1" id="listSummary">等待加载</p>
</div>
<button id="clearSearchBtn" class="recordings-text-btn hidden">
<i class="fas fa-xmark"></i>
<span>清空筛选</span>
</button>
</div>
<div id="loadingState" class="recordings-empty">
<div class="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<span>正在读取 recordings 目录...</span>
</div>
<div id="emptyState" class="recordings-empty hidden">
<i class="fas fa-folder-open text-3xl text-gray-500"></i>
<span>还没有录制文件</span>
</div>
<div id="recordingsTableWrap" class="recordings-table-wrap hidden">
<table class="recordings-table">
<thead>
<tr>
<th>文件</th>
<th>会议</th>
<th>大小</th>
<th>上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="recordingsTableBody"></tbody>
</table>
</div>
</section>
<aside class="recordings-preview glass">
<div class="recordings-preview-video">
<video id="previewVideo" controls playsinline></video>
<div id="previewPlaceholder" class="recordings-preview-placeholder">
<i class="fas fa-play text-2xl"></i>
<span>选择一条录制预览</span>
</div>
</div>
<div class="recordings-preview-meta">
<h2 id="previewTitle">未选择录制</h2>
<div id="previewDetails" class="space-y-3 text-sm text-gray-400">
<p>从左侧列表选择文件后,可播放、下载、编辑或删除。</p>
</div>
</div>
</aside>
</section>
</main>
</div>
<div id="editDialog" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50">
<form id="editForm" class="recordings-dialog glass">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold">编辑录制信息</h2>
<p class="text-xs text-gray-500 mt-1" id="editFilenameText"></p>
</div>
<button type="button" id="closeEditBtn" class="recordings-icon-btn" title="关闭">
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="recordings-label" for="editMeetingId">会议 ID</label>
<input id="editMeetingId" class="recordings-input" type="text" required>
</div>
<div>
<label class="recordings-label" for="editOriginalFilename">显示名称</label>
<input id="editOriginalFilename" class="recordings-input" type="text" required>
</div>
<div>
<label class="recordings-label" for="editUserId">用户 ID</label>
<input id="editUserId" class="recordings-input" type="text">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="button" id="cancelEditBtn" class="recordings-secondary-btn">取消</button>
<button type="submit" class="recordings-primary-btn">
<i class="fas fa-floppy-disk"></i>
<span>保存</span>
</button>
</div>
</form>
</div>
<div id="notification" class="recordings-notification glass">
<i class="fas fa-info-circle text-indigo-400"></i>
<span id="notificationText"></span>
</div>
<script type="module" src="/recordings/recordings-admin.js"></script>
</body>
</html>

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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 `
<tr class="${active}" data-key="${escapeHtml(key)}">
<td>
<button class="recordings-file-cell" data-action="select" data-key="${escapeHtml(key)}">
<span class="recordings-file-icon">${ext}</span>
<span class="min-w-0">
<span class="recordings-file-name">${escapeHtml(recording.originalFilename || recording.filename)}</span>
<span class="recordings-file-sub">${escapeHtml(recording.filename)}</span>
</span>
</button>
</td>
<td>${escapeHtml(recording.meetingId)}</td>
<td>${formatBytes(recording.size)}</td>
<td>${formatDate(recording.uploadedAt)}</td>
<td>
<div class="recordings-actions">
<button class="recordings-icon-btn" data-action="preview" data-key="${escapeHtml(key)}" title="预览">
<i class="fas fa-play"></i>
</button>
<a class="recordings-icon-btn" href="${escapeHtml(recording.downloadUrl)}" title="下载">
<i class="fas fa-download"></i>
</a>
<button class="recordings-icon-btn" data-action="edit" data-key="${escapeHtml(key)}" title="编辑">
<i class="fas fa-pen"></i>
</button>
<button class="recordings-icon-btn recordings-danger" data-action="delete" data-key="${escapeHtml(key)}" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).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 = '<p>从左侧列表选择文件后,可播放、下载、编辑或删除。</p>';
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 = `
<div class="recordings-detail-row"><span>会议 ID</span><strong>${escapeHtml(recording.meetingId)}</strong></div>
<div class="recordings-detail-row"><span>文件名</span><strong>${escapeHtml(recording.filename)}</strong></div>
<div class="recordings-detail-row"><span>格式</span><strong>${escapeHtml(recording.mimetype)}</strong></div>
<div class="recordings-detail-row"><span>大小</span><strong>${formatBytes(recording.size)}</strong></div>
<div class="recordings-detail-row"><span>用户 ID</span><strong>${escapeHtml(recording.userId || '-')}</strong></div>
<div class="recordings-detail-row"><span>上传时间</span><strong>${formatDate(recording.uploadedAt)}</strong></div>
<div class="recordings-preview-actions">
<a class="recordings-primary-btn" href="${escapeHtml(recording.downloadUrl)}">
<i class="fas fa-download"></i>
<span>下载</span>
</a>
<button class="recordings-secondary-btn" type="button" data-preview-action="edit">编辑</button>
</div>
`;
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();

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -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_MIME_TYPES = new Set(['video/webm', 'video/mp4', 'application/octet-stream']);
const ALLOWED_RECORDING_EXTENSIONS = new Set(['.webm', '.mp4']); 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 { function safeAvatarExtension(file: any): string {
const originalExt = path.extname(file.originalname || '').toLowerCase(); const originalExt = path.extname(file.originalname || '').toLowerCase();
if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) { if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) {
@@ -89,6 +101,89 @@ function isAllowedRecording(file: any): boolean {
return ext.length > 0 && isCompatibleMime; 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 => { export const createServer = (config: Options): express.Express => {
const app: express.Express = express(); const app: express.Express = express();
resetHandler(config.mode); resetHandler(config.mode);
@@ -110,6 +205,20 @@ export const createServer = (config: Options): express.Express => {
})); }));
app.use('/signaling', signaling); 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(express.static(path.join(__dirname, '../client/public')));
app.use('/module', express.static(path.join(__dirname, '../client/src'))); 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) => { app.post('/api/recordings', (req: express.Request, res: express.Response) => {
recordingUpload.single('recording')(req, res, (error: Error) => { recordingUpload.single('recording')(req, res, (error: Error) => {
if (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) => { app.get('/api/recordings/:meetingId/:filename/download', (req: express.Request, res: express.Response) => {
const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
const filename = sanitizePathSegment(req.params.filename, ''); const filename = sanitizePathSegment(req.params.filename, '');