Files
live_recorder/frontend/src/views/LiveRoomsView.vue
T

1171 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { BatchLiveRoomsResult, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
import {
availabilityLabelMap,
outputFormatLabelMap,
recordingTemplateLabelMap,
saveModeLabelMap
} from "@/types";
const inheritValue = "__inherit__";
const loading = ref(false);
const submitLoading = ref(false);
const importLoading = ref(false);
const exportLoading = ref(false);
const batchActionLoading = ref(false);
const startLoading = ref(false);
const settingsLoading = ref(false);
const togglingRoomId = ref("");
const createDialogVisible = ref(false);
const importDialogVisible = ref(false);
const recordDialogVisible = ref(false);
const settingsDialogVisible = ref(false);
const deleteDialogVisible = ref(false);
const deletingRoom = ref(false);
const pendingDeleteRoom = ref<LiveRoom | null>(null);
const pendingDeleteRoomIds = ref<string[]>([]);
const settingsRoom = ref<LiveRoom | null>(null);
const rooms = ref<LiveRoom[]>([]);
const selectedRooms = ref<LiveRoom[]>([]);
const loadError = ref("");
const { isMobile } = useViewport();
const createForm = reactive({
url: "",
platformOverride: null as number | null
});
const importForm = reactive({
content: "",
platformOverride: null as number | null
});
const importResult = ref<ImportLiveRoomsResult | null>(null);
const recordForm = reactive({
liveRoomId: "",
preferredQuality: "origin",
outputFormat: 0
});
const settingsForm = reactive({
preferredQualityOverride: inheritValue as string | number,
outputFormatOverride: inheritValue as string | number,
saveModeOverride: inheritValue as string | number,
recordingTemplateOverride: inheritValue as string | number,
segmentDurationMinutesOverride: "",
enableAutoReconnectOverride: inheritValue as string,
reconnectDelayMaxSecondsOverride: "",
readWriteTimeoutMillisecondsOverride: "",
enableDanmakuRecordingOverride: inheritValue as string,
danmakuIncludeNonChatEventsOverride: inheritValue as string,
danmakuMinPollIntervalMillisecondsOverride: "",
danmakuRetryDelayMaxSecondsOverride: ""
});
const platformOptions = [
{ label: "自动识别", value: null },
{ label: "抖音", value: 1 },
{ label: "Bilibili", value: 2 },
{ label: "虎牙", value: 3 }
];
const qualityOptions = ["origin", "FULL_HD", "HD", "SD"];
const booleanOverrideOptions = [
{ label: "跟随全局", value: inheritValue },
{ label: "开启", value: "true" },
{ label: "关闭", value: "false" }
];
const canSubmit = computed(() => createForm.url.trim().length > 0);
const canImport = computed(() => importForm.content.trim().length > 0);
const selectedRoomIds = computed(() => selectedRooms.value.map((item) => item.id));
const selectedRoomCount = computed(() => selectedRooms.value.length);
const hasSelectedRooms = computed(() => selectedRoomCount.value > 0);
const pendingDeleteCount = computed(() => pendingDeleteRoom.value ? 1 : pendingDeleteRoomIds.value.length);
const totalRooms = computed(() => rooms.value.length);
const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
const disabledRooms = computed(() => rooms.value.filter((item) => !item.isEnabled).length);
const liveRooms = computed(() => rooms.value.filter((item) => item.availabilityStatus === 2).length);
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 deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 46vw, 560px)"));
async function loadRooms() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
rooms.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "直播间列表加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function openCreateDialog() {
createDialogVisible.value = true;
}
function openImportDialog() {
importDialogVisible.value = true;
}
function resetCreateForm() {
createForm.url = "";
createForm.platformOverride = null;
}
function resetImportForm() {
importForm.content = "";
importForm.platformOverride = null;
importResult.value = null;
}
async function createRoom() {
if (!canSubmit.value) {
ElMessage.warning("请输入直播间链接或 roomId。");
return;
}
submitLoading.value = true;
try {
await apiClient.post<LiveRoom>("/live-rooms", {
url: createForm.url,
platformOverride: createForm.platformOverride
});
ElMessage.success("直播间已创建。");
createDialogVisible.value = false;
resetCreateForm();
await loadRooms();
} finally {
submitLoading.value = false;
}
}
async function importRooms() {
if (!canImport.value) {
ElMessage.warning("请输入要导入的直播间列表。");
return;
}
importLoading.value = true;
importResult.value = null;
try {
const { data } = await apiClient.post<ImportLiveRoomsResult>("/live-rooms/import", {
content: importForm.content,
platformOverride: importForm.platformOverride
});
importResult.value = data;
ElMessage.success(`导入完成:成功 ${data.successCount} 条,失败 ${data.failedCount} 条。`);
if (data.successCount > 0) {
await loadRooms();
}
} finally {
importLoading.value = false;
}
}
async function exportRooms() {
exportLoading.value = true;
try {
const { data } = await apiClient.get<Blob>("/live-rooms/export", {
responseType: "blob"
});
const blob = data instanceof Blob ? data : new Blob([data], { type: "text/plain;charset=utf-8" });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `live-rooms-${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}.txt`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
ElMessage.success("直播间列表已导出。");
} finally {
exportLoading.value = false;
}
}
function handleRoomSelectionChange(selection: LiveRoom[]) {
selectedRooms.value = selection;
}
async function batchSetRoomsEnabled(isEnabled: boolean) {
if (!hasSelectedRooms.value) {
ElMessage.warning("请先选择直播间。");
return;
}
batchActionLoading.value = true;
try {
const { data } = await apiClient.post<BatchLiveRoomsResult>("/live-rooms/batch/enabled", {
liveRoomIds: selectedRoomIds.value,
isEnabled
});
ElMessage.success(`${isEnabled ? "启用" : "停用"}完成:成功 ${data.successCount} 个,失败 ${data.failedCount} 个。`);
await loadRooms();
selectedRooms.value = [];
} finally {
batchActionLoading.value = false;
}
}
function openBatchDeleteDialog() {
if (!hasSelectedRooms.value) {
ElMessage.warning("请先选择直播间。");
return;
}
pendingDeleteRoom.value = null;
pendingDeleteRoomIds.value = selectedRoomIds.value;
deleteDialogVisible.value = true;
}
async function refreshRoom(room: LiveRoom) {
await apiClient.post(`/live-rooms/${room.id}/refresh`);
ElMessage.success("状态已刷新。");
await loadRooms();
}
function openRecordDialog(room: LiveRoom) {
if (!room.isEnabled) {
ElMessage.warning("该直播间当前已停用,请先启用。");
return;
}
recordForm.liveRoomId = room.id;
recordForm.preferredQuality = room.effectiveSettings.preferredQuality;
recordForm.outputFormat = room.effectiveSettings.outputFormat;
recordDialogVisible.value = true;
}
async function startRecording() {
startLoading.value = true;
try {
await apiClient.post<RecordTask>("/record-tasks/start", {
liveRoomId: recordForm.liveRoomId,
preferredQuality: recordForm.preferredQuality,
outputFormat: recordForm.outputFormat
});
ElMessage.success("录制任务已启动。");
recordDialogVisible.value = false;
} finally {
startLoading.value = false;
}
}
function openSettingsDialog(room: LiveRoom) {
settingsRoom.value = room;
settingsForm.preferredQualityOverride = room.overrides.preferredQuality ?? inheritValue;
settingsForm.outputFormatOverride = room.overrides.outputFormat ?? inheritValue;
settingsForm.saveModeOverride = room.overrides.saveMode ?? inheritValue;
settingsForm.recordingTemplateOverride = room.overrides.recordingTemplate ?? inheritValue;
settingsForm.segmentDurationMinutesOverride = nullableNumberToInput(room.overrides.segmentDurationMinutes);
settingsForm.enableAutoReconnectOverride = nullableBooleanToInput(room.overrides.enableAutoReconnect);
settingsForm.reconnectDelayMaxSecondsOverride = nullableNumberToInput(room.overrides.reconnectDelayMaxSeconds);
settingsForm.readWriteTimeoutMillisecondsOverride = nullableNumberToInput(room.overrides.readWriteTimeoutMilliseconds);
settingsForm.enableDanmakuRecordingOverride = nullableBooleanToInput(room.overrides.enableDanmakuRecording);
settingsForm.danmakuIncludeNonChatEventsOverride = nullableBooleanToInput(room.overrides.danmakuIncludeNonChatEvents);
settingsForm.danmakuMinPollIntervalMillisecondsOverride = nullableNumberToInput(room.overrides.danmakuMinPollIntervalMilliseconds);
settingsForm.danmakuRetryDelayMaxSecondsOverride = nullableNumberToInput(room.overrides.danmakuRetryDelayMaxSeconds);
settingsDialogVisible.value = true;
}
async function saveRoomSettings() {
if (!settingsRoom.value) {
return;
}
settingsLoading.value = true;
try {
const { data } = await apiClient.put<LiveRoom>(`/live-rooms/${settingsRoom.value.id}/settings`, {
preferredQualityOverride: nullableStringFromSelect(settingsForm.preferredQualityOverride),
outputFormatOverride: nullableNumberFromSelect(settingsForm.outputFormatOverride),
saveModeOverride: nullableNumberFromSelect(settingsForm.saveModeOverride),
recordingTemplateOverride: nullableNumberFromSelect(settingsForm.recordingTemplateOverride),
segmentDurationMinutesOverride: parseNullableInt(settingsForm.segmentDurationMinutesOverride),
enableAutoReconnectOverride: parseNullableBoolean(settingsForm.enableAutoReconnectOverride),
reconnectDelayMaxSecondsOverride: parseNullableInt(settingsForm.reconnectDelayMaxSecondsOverride),
readWriteTimeoutMillisecondsOverride: parseNullableInt(settingsForm.readWriteTimeoutMillisecondsOverride),
enableDanmakuRecordingOverride: parseNullableBoolean(settingsForm.enableDanmakuRecordingOverride),
danmakuIncludeNonChatEventsOverride: parseNullableBoolean(settingsForm.danmakuIncludeNonChatEventsOverride),
danmakuMinPollIntervalMillisecondsOverride: parseNullableInt(settingsForm.danmakuMinPollIntervalMillisecondsOverride),
danmakuRetryDelayMaxSecondsOverride: parseNullableInt(settingsForm.danmakuRetryDelayMaxSecondsOverride)
});
rooms.value = rooms.value.map((item) => (item.id === data.id ? data : item));
settingsRoom.value = data;
settingsDialogVisible.value = false;
ElMessage.success("直播间配置已保存。");
} finally {
settingsLoading.value = false;
}
}
function openDeleteDialog(room: LiveRoom) {
pendingDeleteRoom.value = room;
pendingDeleteRoomIds.value = [];
deleteDialogVisible.value = true;
}
function closeDeleteDialog() {
if (deletingRoom.value) {
return;
}
deleteDialogVisible.value = false;
pendingDeleteRoom.value = null;
pendingDeleteRoomIds.value = [];
}
async function removeRoom() {
if (!pendingDeleteRoom.value && pendingDeleteRoomIds.value.length === 0) {
closeDeleteDialog();
return;
}
deletingRoom.value = true;
try {
if (pendingDeleteRoom.value) {
await apiClient.delete(`/live-rooms/${pendingDeleteRoom.value.id}`);
ElMessage.success("直播间已删除。");
} else {
const { data } = await apiClient.post<BatchLiveRoomsResult>("/live-rooms/batch/delete", {
liveRoomIds: pendingDeleteRoomIds.value
});
ElMessage.success(`删除完成:成功 ${data.successCount} 个,失败 ${data.failedCount} 个。`);
}
deleteDialogVisible.value = false;
pendingDeleteRoom.value = null;
pendingDeleteRoomIds.value = [];
selectedRooms.value = [];
await loadRooms();
} finally {
deletingRoom.value = false;
}
}
async function setRoomEnabled(room: LiveRoom, isEnabled: boolean) {
togglingRoomId.value = room.id;
try {
await apiClient.put<LiveRoom>(`/live-rooms/${room.id}/enabled`, { isEnabled });
ElMessage.success(isEnabled ? "直播间已启用。" : "直播间已停用。");
await loadRooms();
} finally {
togglingRoomId.value = "";
}
}
function availabilityTagType(status: number) {
if (status === 2) {
return "success";
}
if (status === 1) {
return "info";
}
return "warning";
}
function getRoomAvatarText(room: LiveRoom) {
const source = room.anchorName?.trim() || room.title?.trim() || room.roomId;
return source.slice(0, 1).toUpperCase();
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function nullableBooleanToInput(value?: boolean | null) {
if (value === true) {
return "true";
}
if (value === false) {
return "false";
}
return inheritValue;
}
function parseNullableBoolean(value: string) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return null;
}
function nullableNumberToInput(value?: number | null) {
return typeof value === "number" && Number.isFinite(value) ? String(value) : "";
}
function parseNullableInt(value: string) {
if (!value.trim()) {
return null;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function nullableNumberFromSelect(value: string | number) {
return value === inheritValue ? null : Number(value);
}
function nullableStringFromSelect(value: string | number) {
return value === inheritValue ? null : String(value);
}
onMounted(loadRooms);
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<h1 class="page-title">直播间管理</h1>
<p class="page-subtitle">
直播间会长期保留在系统里单房间配置优先于全局配置未设置的项会自动回退到系统设置
</p>
</div>
<el-space wrap class="header-actions">
<el-button @click="loadRooms">刷新列表</el-button>
<el-button @click="openImportDialog">批量导入</el-button>
<el-button :loading="exportLoading" @click="exportRooms">导出列表</el-button>
<el-button type="primary" @click="openCreateDialog">新增直播间</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">直播间总数</div>
<div class="stat-card__value">{{ totalRooms }}</div>
<div class="stat-card__hint">当前已接入系统的直播间数量</div>
</div>
<div class="stat-card">
<div class="stat-card__label">启用中</div>
<div class="stat-card__value">{{ enabledRooms }}</div>
<div class="stat-card__hint">参与后台巡检与自动录制</div>
</div>
<div class="stat-card">
<div class="stat-card__label">已停用</div>
<div class="stat-card__value">{{ disabledRooms }}</div>
<div class="stat-card__hint">保留解析结果但不再自动录制</div>
</div>
<div class="stat-card">
<div class="stat-card__label">当前开播</div>
<div class="stat-card__value">{{ liveRooms }}</div>
<div class="stat-card__hint">基于最近一次状态检测</div>
</div>
</div>
<el-card class="surface-card table-card" shadow="never">
<div class="toolbar-row">
<div>
<h3 class="section-title">直播间列表</h3>
<p class="section-subtitle">支持刷新状态手动启动录制单房间配置覆盖以及按需删除直播间</p>
</div>
<el-space wrap class="batch-actions">
<span class="batch-actions__count">已选 {{ selectedRoomCount }} </span>
<el-button :disabled="!hasSelectedRooms || batchActionLoading" @click="batchSetRoomsEnabled(true)">批量启用</el-button>
<el-button :disabled="!hasSelectedRooms || batchActionLoading" @click="batchSetRoomsEnabled(false)">批量停用</el-button>
<el-button type="danger" plain :disabled="!hasSelectedRooms || batchActionLoading" @click="openBatchDeleteDialog">
批量删除
</el-button>
</el-space>
</div>
<div class="table-scroll-shell rooms-table-shell">
<el-table
:data="rooms"
v-loading="loading"
:height="tableHeight"
class="premium-table rooms-table"
table-layout="auto"
row-key="id"
@selection-change="handleRoomSelectionChange"
>
<el-table-column type="selection" width="48" />
<el-table-column label="头像" width="88">
<template #default="{ row }">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="56" class="room-avatar">
{{ getRoomAvatarText(row) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column label="直播间" min-width="320">
<template #default="{ row }">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div class="monospace cell-mono">{{ row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="110">
<template #default="{ row }">
<el-tag effect="plain">{{ row.platformName }}</el-tag>
</template>
</el-table-column>
<el-table-column label="直播状态" width="120">
<template #default="{ row }">
<el-tag :type="availabilityTagType(row.availabilityStatus)">
{{ availabilityLabelMap[row.availabilityStatus] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="房间配置" min-width="240">
<template #default="{ row }">
<div class="config-summary">
<span>画质 {{ row.effectiveSettings.preferredQuality }}</span>
<span>{{ outputFormatLabelMap[row.effectiveSettings.outputFormat] }}</span>
<span>{{ saveModeLabelMap[row.effectiveSettings.saveMode] }}</span>
<span>{{ row.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="录制开关" width="128">
<template #default="{ row }">
<el-switch
:model-value="row.isEnabled"
:loading="togglingRoomId === row.id"
inline-prompt
active-text=""
inactive-text=""
@change="setRoomEnabled(row, Boolean($event))"
/>
</template>
</el-table-column>
<el-table-column label="最近检测" width="180">
<template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="320">
<template #default="{ row }">
<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" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
开始录制
</el-button>
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-dialog
v-model="createDialogVisible"
class="form-dialog"
title="新增直播间"
:width="createDialogWidth"
@closed="resetCreateForm"
>
<div class="dialog-form">
<el-form label-position="top">
<el-form-item label="直播间链接 / roomId">
<el-input
v-model="createForm.url"
type="textarea"
:rows="5"
placeholder="粘贴抖音 / Bilibili / 虎牙直播间链接,或直接输入 roomId"
/>
</el-form-item>
<el-form-item label="平台覆盖">
<el-select v-model="createForm.platformOverride" clearable placeholder="自动识别">
<el-option
v-for="option in platformOptions"
:key="String(option.value)"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer dialog-footer--end">
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="createRoom">创建直播间</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="importDialogVisible"
class="form-dialog import-rooms-dialog"
title="批量导入直播间"
:width="importDialogWidth"
@closed="resetImportForm"
>
<div class="dialog-form import-dialog-body">
<el-alert
type="info"
:closable="false"
show-icon
title="支持每行一个链接,也支持“链接,主播: 名称”。导入时会逐条处理,单条失败不会影响其它直播间。"
/>
<el-form label-position="top">
<el-form-item label="直播间列表">
<el-input
v-model="importForm.content"
type="textarea"
:rows="12"
placeholder="https://live.douyin.com/845878323112,主播: 熊宇一&#10;https://live.douyin.com/262011082654"
/>
</el-form-item>
<el-form-item label="平台覆盖">
<el-select v-model="importForm.platformOverride" clearable placeholder="自动识别">
<el-option
v-for="option in platformOptions"
:key="String(option.value)"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-form>
<div v-if="importResult" class="import-result">
<div class="import-result__summary">
{{ importResult.totalCount }} 成功 {{ importResult.successCount }} 新增
{{ importResult.createdCount }} 更新 {{ importResult.updatedCount }} 失败
{{ importResult.failedCount }}
</div>
<div v-if="importResult.failedCount > 0" class="import-result__failures">
<div
v-for="item in importResult.items.filter((entry) => !entry.success)"
:key="item.lineNumber"
class="import-result__failure"
>
<span> {{ item.lineNumber }} </span>
<strong>{{ item.errorMessage || "导入失败" }}</strong>
<code>{{ item.rawLine }}</code>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer dialog-footer--end">
<el-button :disabled="importLoading" @click="importDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="importLoading" :disabled="!canImport" @click="importRooms">
开始导入
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="recordDialogVisible"
class="form-dialog"
title="启动录制任务"
:width="recordDialogWidth"
>
<div class="dialog-form">
<el-form label-position="top">
<el-form-item label="清晰度">
<el-select v-model="recordForm.preferredQuality">
<el-option v-for="option in qualityOptions" :key="option" :label="option" :value="option" />
</el-select>
</el-form-item>
<el-form-item label="输出格式">
<el-select v-model="recordForm.outputFormat">
<el-option
v-for="(label, value) in outputFormatLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer dialog-footer--end">
<el-button @click="recordDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="startLoading" @click="startRecording">启动</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="settingsDialogVisible"
class="form-dialog room-settings-dialog"
title="直播间录制配置"
:width="settingsDialogWidth"
>
<div class="dialog-form settings-form">
<div class="settings-note">
当前直播间<strong>{{ settingsRoom?.title || settingsRoom?.anchorName || settingsRoom?.roomId || "-" }}</strong>
<span>未设置的项会自动跟随全局配置</span>
</div>
<el-form label-position="top" class="settings-grid">
<el-form-item label="默认画质">
<el-select v-model="settingsForm.preferredQualityOverride">
<el-option label="跟随全局" :value="inheritValue" />
<el-option v-for="option in qualityOptions" :key="option" :label="option" :value="option" />
</el-select>
</el-form-item>
<el-form-item label="默认输出格式">
<el-select v-model="settingsForm.outputFormatOverride">
<el-option label="跟随全局" :value="inheritValue" />
<el-option
v-for="(label, value) in outputFormatLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
<el-form-item label="保存模式">
<el-select v-model="settingsForm.saveModeOverride">
<el-option label="跟随全局" :value="inheritValue" />
<el-option
v-for="(label, value) in saveModeLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
<el-form-item label="录制模板">
<el-select v-model="settingsForm.recordingTemplateOverride">
<el-option label="跟随全局" :value="inheritValue" />
<el-option
v-for="(label, value) in recordingTemplateLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
<el-form-item label="分段时长(分钟)">
<el-input
v-model="settingsForm.segmentDurationMinutesOverride"
placeholder="留空则跟随全局"
/>
</el-form-item>
<el-form-item label="自动重连">
<el-select v-model="settingsForm.enableAutoReconnectOverride">
<el-option
v-for="option in booleanOverrideOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="最大重连延迟(秒)">
<el-input
v-model="settingsForm.reconnectDelayMaxSecondsOverride"
placeholder="留空则跟随全局"
/>
</el-form-item>
<el-form-item label="读写超时(毫秒)">
<el-input
v-model="settingsForm.readWriteTimeoutMillisecondsOverride"
placeholder="留空则跟随全局"
/>
</el-form-item>
<el-form-item label="启用弹幕录制">
<el-select v-model="settingsForm.enableDanmakuRecordingOverride">
<el-option
v-for="option in booleanOverrideOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="记录非聊天事件">
<el-select v-model="settingsForm.danmakuIncludeNonChatEventsOverride">
<el-option
v-for="option in booleanOverrideOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="弹幕最小轮询间隔(毫秒)">
<el-input
v-model="settingsForm.danmakuMinPollIntervalMillisecondsOverride"
placeholder="留空则跟随全局"
/>
</el-form-item>
<el-form-item label="弹幕最大重试退避(秒)">
<el-input
v-model="settingsForm.danmakuRetryDelayMaxSecondsOverride"
placeholder="留空则跟随全局"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer dialog-footer--end">
<el-button @click="settingsDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="settingsLoading" @click="saveRoomSettings">保存配置</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="deleteDialogVisible"
class="form-dialog delete-room-dialog"
:width="deleteDialogWidth"
:show-close="!deletingRoom"
:close-on-click-modal="!deletingRoom"
:close-on-press-escape="!deletingRoom"
destroy-on-close
@closed="pendingDeleteRoom = null; pendingDeleteRoomIds = []"
>
<template #header>
<div class="delete-dialog__header">
<span class="delete-dialog__eyebrow">删除确认</span>
<h3 class="delete-dialog__title">{{ pendingDeleteRoom ? "删除直播间" : "批量删除直播间" }}</h3>
</div>
</template>
<div class="delete-dialog__body">
<div class="delete-dialog__lead">
{{
pendingDeleteRoom
? `确认删除直播间 ${pendingDeleteRoom.roomId} 吗?删除后该直播间的关联记录会按现有后端规则清理。`
: `确认删除选中的 ${pendingDeleteCount} 个直播间吗?删除后相关记录会按现有后端规则清理。`
}}
</div>
</div>
<template #footer>
<div class="dialog-footer dialog-footer--end">
<el-button :disabled="deletingRoom" @click="closeDeleteDialog">取消</el-button>
<el-button type="danger" :loading="deletingRoom" @click="removeRoom">确认删除</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.table-card :deep(.el-card__body) {
padding-top: 18px;
}
.toolbar-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(82, 74, 63, 0.08);
}
.batch-actions {
justify-content: flex-end;
}
.batch-actions__count {
color: var(--text-muted);
font-size: 13px;
}
.rooms-table-shell {
margin-inline: -4px;
padding-inline: 4px;
}
.rooms-table {
min-width: 1380px;
}
.rooms-table :deep(.el-table__cell) {
vertical-align: top;
}
.rooms-table :deep(.cell) {
overflow: visible;
}
.room-avatar {
box-shadow: 0 14px 28px rgba(52, 84, 112, 0.14);
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: #17202b;
line-height: 1.5;
}
.cell-subtitle {
margin-top: 6px;
color: #5f6d79;
font-size: 13px;
}
.cell-mono {
margin-top: 8px;
color: #7c8690;
font-size: 12px;
}
.config-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: #5f6d79;
font-size: 12px;
}
.config-summary span {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: rgba(243, 246, 248, 0.9);
border: 1px solid rgba(95, 109, 124, 0.08);
}
.table-date-text {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.room-actions-cell {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
.room-actions-cell :deep(.el-button) {
margin: 0;
}
.settings-note {
display: grid;
gap: 6px;
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(243, 246, 248, 0.9);
color: #51606c;
line-height: 1.6;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.dialog-form {
display: grid;
}
.import-dialog-body {
gap: 16px;
}
.import-result {
display: grid;
gap: 12px;
padding: 14px 16px;
border: 1px solid rgba(62, 95, 124, 0.14);
border-radius: 14px;
background: rgba(246, 248, 250, 0.82);
}
.import-result__summary {
color: #26323d;
font-size: 14px;
font-weight: 700;
line-height: 1.7;
}
.import-result__failures {
display: grid;
gap: 8px;
max-height: 180px;
overflow: auto;
}
.import-result__failure {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(255, 241, 242, 0.86);
color: #7f1d1d;
font-size: 12px;
}
.import-result__failure code {
color: #475569;
word-break: break-all;
}
.delete-dialog__header {
display: grid;
gap: 8px;
}
.delete-dialog__eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6e7c88;
}
.delete-dialog__title {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
color: #15202b;
}
.delete-dialog__body {
display: grid;
gap: 16px;
}
.delete-dialog__lead {
color: #4f5d69;
font-size: 14px;
line-height: 1.75;
}
.dialog-footer {
display: flex;
align-items: center;
gap: 12px;
}
.dialog-footer--end {
justify-content: flex-end;
}
@media (max-width: 960px) {
.toolbar-row {
flex-direction: column;
}
.batch-actions {
justify-content: flex-start;
}
.header-actions {
align-self: stretch;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.dialog-footer {
flex-wrap: wrap;
justify-content: stretch;
}
.dialog-footer :deep(.el-button) {
flex: 1 1 calc(50% - 6px);
margin-left: 0;
}
}
</style>