feat: add live room metadata, uploads, proxies and backups
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user