feat: add live room metadata, uploads, proxies and backups

This commit is contained in:
2026-04-26 17:35:51 +08:00
parent 85f24f8a84
commit d2b2f714e0
38 changed files with 2371 additions and 116 deletions
+166 -9
View File
@@ -7,6 +7,7 @@ import type { BatchLiveRoomsResult, ImportLiveRoomsResult, LiveRoom, RecordTask
import {
autoStartDecisionLabelMap,
availabilityLabelMap,
currentRecordingStateLabelMap,
outputFormatLabelMap,
recordingTemplateLabelMap,
saveModeLabelMap
@@ -56,6 +57,11 @@ const recordForm = reactive({
});
const settingsForm = reactive({
remark: "",
isPinned: false,
alias: "",
isPriority: false,
pollingIntervalSecondsOverride: "",
preferredQualityOverride: inheritValue as string | number,
outputFormatOverride: inheritValue as string | number,
saveModeOverride: inheritValue as string | number,
@@ -97,7 +103,7 @@ const tableHeight = computed(() => (isMobile.value ? undefined : 700));
const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px"));
const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
const recordDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 440px)" : "440px"));
const settingsDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
const settingsDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 760px)" : "840px"));
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 46vw, 560px)"));
let autoRefreshTimer: number | null = null;
@@ -311,6 +317,28 @@ function openRecordDialog(room: LiveRoom) {
recordDialogVisible.value = true;
}
async function copyRoomLink(room: LiveRoom) {
const url = room.originalLiveRoomUrl || room.sourceUrl;
try {
await navigator.clipboard.writeText(url);
} catch {
const input = document.createElement("textarea");
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
ElMessage.success("直播间链接已复制。");
}
function openOriginalRoom(room: LiveRoom) {
const url = room.originalLiveRoomUrl || room.sourceUrl;
window.open(url, "_blank", "noopener,noreferrer");
}
async function startRecording() {
startLoading.value = true;
@@ -330,6 +358,11 @@ async function startRecording() {
function openSettingsDialog(room: LiveRoom) {
settingsRoom.value = room;
settingsForm.remark = room.remark ?? "";
settingsForm.isPinned = room.isPinned;
settingsForm.alias = room.alias ?? "";
settingsForm.isPriority = room.isPriority;
settingsForm.pollingIntervalSecondsOverride = nullableNumberToInput(room.pollingIntervalSecondsOverride);
settingsForm.preferredQualityOverride = room.overrides.preferredQuality ?? inheritValue;
settingsForm.outputFormatOverride = room.overrides.outputFormat ?? inheritValue;
settingsForm.saveModeOverride = room.overrides.saveMode ?? inheritValue;
@@ -353,6 +386,14 @@ async function saveRoomSettings() {
settingsLoading.value = true;
try {
await apiClient.put<LiveRoom>(`/live-rooms/${settingsRoom.value.id}/metadata`, {
remark: nullableString(settingsForm.remark),
isPinned: settingsForm.isPinned,
alias: nullableString(settingsForm.alias),
isPriority: settingsForm.isPriority,
pollingIntervalSecondsOverride: parseNullableInt(settingsForm.pollingIntervalSecondsOverride)
});
const { data } = await apiClient.put<LiveRoom>(`/live-rooms/${settingsRoom.value.id}/settings`, {
preferredQualityOverride: nullableStringFromSelect(settingsForm.preferredQualityOverride),
outputFormatOverride: nullableNumberFromSelect(settingsForm.outputFormatOverride),
@@ -446,6 +487,18 @@ function availabilityTagType(status: number) {
return "warning";
}
function currentRecordingStateTagType(state: number) {
if (state === 2) {
return "danger";
}
if (state === 1) {
return "success";
}
return "info";
}
function getRoomAvatarText(room: LiveRoom) {
const source = room.anchorName?.trim() || room.title?.trim() || room.roomId;
return source.slice(0, 1).toUpperCase();
@@ -468,7 +521,7 @@ function autoStartDecisionTagType(code?: string) {
return "danger";
}
if (code === "skipped_storage" || code === "skipped_active_session") {
if (code === "skipped_storage" || code === "skipped_active_session" || code === "skipped_debounce") {
return "warning";
}
@@ -479,6 +532,17 @@ function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatPollingInterval(override?: number) {
return typeof override === "number" && Number.isFinite(override)
? `${override}`
: "跟随全局";
}
function nullableString(value: string) {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function nullableBooleanToInput(value?: boolean | null) {
if (value === true) {
return "true";
@@ -540,7 +604,7 @@ onBeforeUnmount(() => {
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Live Monitoring</div>
<div class="page-kicker">直播监控</div>
<h1 class="page-title">直播间管理</h1>
<p class="page-subtitle">
直播间会长期保留在系统里单房间配置优先于全局配置未设置的项会自动回退到系统设置
@@ -606,15 +670,19 @@ onBeforeUnmount(() => {
<div>
<div class="data-card__title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
<div v-if="row.alias && row.alias !== row.anchorName" class="cell-subtitle">别名{{ row.alias }}</div>
<div v-if="row.remark" class="cell-subtitle">{{ row.remark }}</div>
<div class="cell-mono">{{ row.roomId }}</div>
</div>
</div>
<div class="badge-row">
<el-tag :type="availabilityTagType(row.availabilityStatus)">
{{ availabilityLabelMap[row.availabilityStatus] }}
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)">
{{ currentRecordingStateLabelMap[row.currentRecordingState] }}
</el-tag>
<el-tag effect="plain">{{ row.platformName }}</el-tag>
<el-tag v-if="row.isPinned" size="small" effect="plain">置顶</el-tag>
<el-tag v-if="row.isPriority" size="small" effect="plain" type="danger">重点</el-tag>
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
@@ -629,6 +697,10 @@ onBeforeUnmount(() => {
<dt>最近巡检</dt>
<dd>{{ formatDate(row.lastCheckedAt) }}</dd>
</div>
<div>
<dt>检测间隔</dt>
<dd>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>房间配置</dt>
<dd>
@@ -659,6 +731,8 @@ onBeforeUnmount(() => {
<div class="data-card__actions">
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
录制
</el-button>
@@ -691,6 +765,13 @@ onBeforeUnmount(() => {
<template #default="{ row }">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div v-if="row.alias && row.alias !== row.anchorName" class="cell-subtitle">别名{{ row.alias }}</div>
<div v-if="row.remark" class="cell-subtitle">{{ row.remark }}</div>
<div class="config-summary">
<span v-if="row.isPinned">置顶</span>
<span v-if="row.isPriority">重点</span>
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
</div>
<div class="monospace cell-mono">{{ row.roomId }}</div>
</template>
</el-table-column>
@@ -703,8 +784,8 @@ onBeforeUnmount(() => {
<el-table-column label="直播状态" width="120">
<template #default="{ row }">
<el-tag :type="availabilityTagType(row.availabilityStatus)">
{{ availabilityLabelMap[row.availabilityStatus] }}
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)">
{{ currentRecordingStateLabelMap[row.currentRecordingState] }}
</el-tag>
</template>
</el-table-column>
@@ -769,6 +850,8 @@ onBeforeUnmount(() => {
<div class="room-actions-cell">
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
开始录制
</el-button>
@@ -935,6 +1018,34 @@ onBeforeUnmount(() => {
</div>
<el-form label-position="top" class="settings-grid">
<div class="settings-section-title">房间信息</div>
<el-form-item label="备注">
<el-input v-model="settingsForm.remark" placeholder="给这个直播间补充说明,便于识别" />
</el-form-item>
<el-form-item label="主播别名">
<el-input v-model="settingsForm.alias" placeholder="默认跟随首次解析到的主播昵称" />
</el-form-item>
<el-form-item label="单房间检测间隔(秒)">
<el-input
v-model="settingsForm.pollingIntervalSecondsOverride"
placeholder="留空则跟随全局巡检间隔"
/>
</el-form-item>
<div class="settings-toggle-pair">
<div class="settings-toggle-item">
<span class="settings-toggle-item__label">置顶常用直播间</span>
<el-switch v-model="settingsForm.isPinned" />
</div>
<div class="settings-toggle-item">
<span class="settings-toggle-item__label">重点主播</span>
<el-switch v-model="settingsForm.isPriority" />
</div>
</div>
<div class="settings-section-title">录制覆盖</div>
<el-form-item label="默认画质">
<el-select v-model="settingsForm.preferredQualityOverride">
<el-option label="跟随全局" :value="inheritValue" />
@@ -1242,8 +1353,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
flex-wrap: wrap;
}
.room-actions-cell :deep(.el-button) {
@@ -1267,6 +1377,49 @@ onBeforeUnmount(() => {
gap: 0 16px;
}
.settings-section-title {
grid-column: 1 / -1;
margin: 4px 0 8px;
padding-top: 8px;
border-top: 1px solid var(--border-subtle);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.settings-section-title:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.settings-toggle-pair {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-toggle-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 52px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.settings-toggle-item__label {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
}
.dialog-form {
display: grid;
}
@@ -1377,6 +1530,10 @@ onBeforeUnmount(() => {
.settings-grid {
grid-template-columns: 1fr;
}
.settings-toggle-pair {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {