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

1965 lines
62 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, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useRoute } from "vue-router";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
import RightDrawer from "@/components/ui/RightDrawer.vue";
import SafeAvatar from "@/components/ui/SafeAvatar.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport";
import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
import {
autoStartDecisionLabelMap,
formatAutoStartDecisionSummary,
formatQualityLabel,
availabilityLabelMap,
currentRecordingStateLabelMap,
outputFormatLabelMap,
platformOptionList,
qualityOptionList,
recordingTemplateLabelMap,
saveModeLabelMap
} from "@/types";
import { House, RefreshRight, SwitchButton, VideoCamera } from "@element-plus/icons-vue";
const inheritValue = "__inherit__";
const AUTO_REFRESH_INTERVAL_MS = 15000;
const route = useRoute();
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 roomDetailVisible = ref(false);
const activeRoom = ref<LiveRoom | null>(null);
const { isMobile } = useViewport();
const roomSearch = ref("");
const roomState = ref("all");
const mobileRoomPage = ref(1);
const mobileRoomPageSize = 12;
const failedAvatarUrls = ref<Set<string>>(new Set());
const createForm = reactive({
url: "",
platformOverride: null as number | null
});
const importForm = reactive({
content: "",
platformOverride: null as number | null,
detectRoomMetadata: true,
detectLiveStatus: true
});
const importResult = ref<ImportLiveRoomsResult | null>(null);
const recordForm = reactive({
liveRoomId: "",
preferredQuality: "origin",
outputFormat: 0
});
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,
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 as number | null },
...platformOptionList.map((option) => ({
label: option.label,
value: option.value
}))
];
const qualityOptions = qualityOptionList;
const qualitySupportHint = "不同平台的可选画质不同;如果目标档位不可用,系统会自动回退到当前平台最接近的可用流。";
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 enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
const liveRooms = computed(() => rooms.value.filter((item) => item.availabilityStatus === 2).length);
const recordingRooms = computed(() => rooms.value.filter((item) => item.currentRecordingState === 2).length);
const priorityRooms = computed(() => rooms.value.filter((item) => item.isPriority).length);
const filteredRooms = computed(() => {
const keyword = roomSearch.value.trim().toLowerCase();
return rooms.value.filter((room) => {
const matchesKeyword = !keyword || [room.title, room.anchorName, room.alias, room.roomId, room.platformName]
.some((value) => String(value || "").toLowerCase().includes(keyword));
const matchesState = roomState.value === "all" ||
(roomState.value === "live" && room.availabilityStatus === 2) ||
(roomState.value === "recording" && room.currentRecordingState === 2) ||
(roomState.value === "enabled" && room.isEnabled) ||
(roomState.value === "disabled" && !room.isEnabled);
return matchesKeyword && matchesState;
});
});
const mobileRoomPageCount = computed(() =>
Math.max(1, Math.ceil(filteredRooms.value.length / mobileRoomPageSize))
);
const paginatedMobileRooms = computed(() => {
const start = (mobileRoomPage.value - 1) * mobileRoomPageSize;
return filteredRooms.value.slice(start, start + mobileRoomPageSize);
});
watch([roomSearch, roomState], () => {
mobileRoomPage.value = 1;
});
watch(mobileRoomPageCount, (pageCount) => {
mobileRoomPage.value = Math.min(mobileRoomPage.value, pageCount);
});
const activeRoomSubtitle = computed(() => {
if (!activeRoom.value) {
return "";
}
return `${activeRoom.value.platformName || "--"} · ${activeRoom.value.roomId || "--"}`;
});
function clearRoomFilters() {
roomSearch.value = "";
roomState.value = "all";
}
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, 760px)" : "840px"));
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 46vw, 560px)"));
let autoRefreshTimer: number | null = null;
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 shouldAutoRefreshRooms() {
return !document.hidden &&
!loading.value &&
!createDialogVisible.value &&
!importDialogVisible.value &&
!recordDialogVisible.value &&
!settingsDialogVisible.value &&
!deleteDialogVisible.value &&
!submitLoading.value &&
!importLoading.value &&
!exportLoading.value &&
!batchActionLoading.value &&
!startLoading.value &&
!settingsLoading.value &&
!deletingRoom.value &&
!togglingRoomId.value;
}
async function autoRefreshRooms() {
if (!shouldAutoRefreshRooms()) {
return;
}
try {
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
rooms.value = data;
loadError.value = "";
} catch {
// Keep the current view stable; the background poller will try again.
}
}
function startAutoRefresh() {
stopAutoRefresh();
autoRefreshTimer = window.setInterval(() => {
void autoRefreshRooms();
}, AUTO_REFRESH_INTERVAL_MS);
}
function stopAutoRefresh() {
if (autoRefreshTimer !== null) {
window.clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
function handleVisibilityChange() {
if (!document.hidden) {
void autoRefreshRooms();
}
}
function openCreateDialog() {
createDialogVisible.value = true;
}
function openImportDialog() {
importDialogVisible.value = true;
}
function resetCreateForm() {
createForm.url = "";
createForm.platformOverride = null;
}
function resetImportForm() {
importForm.content = "";
importForm.platformOverride = null;
importForm.detectRoomMetadata = true;
importForm.detectLiveStatus = true;
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 payload: ImportLiveRoomsRequest = {
content: importForm.content,
platformOverride: importForm.platformOverride,
detectRoomMetadata: importForm.detectRoomMetadata,
detectLiveStatus: importForm.detectLiveStatus
};
const { data } = await apiClient.post<ImportLiveRoomsResult>("/live-rooms/import", payload);
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;
}
function openRoomDetails(room: LiveRoom) {
activeRoom.value = room;
roomDetailVisible.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;
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.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;
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 {
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),
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 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();
}
function getRoomAvatarUrl(room: LiveRoom) {
const url = room.avatarUrl || room.coverUrl || "";
return url && !failedAvatarUrls.value.has(url) ? url : "";
}
function markRoomAvatarFailed(room: LiveRoom) {
const url = room.avatarUrl || room.coverUrl || "";
if (!url || failedAvatarUrls.value.has(url)) {
return;
}
failedAvatarUrls.value = new Set([...failedAvatarUrls.value, url]);
}
function roomAvailabilityLabel(room: LiveRoom) {
return availabilityLabelMap[room.availabilityStatus] ?? "--";
}
function latestEventLabel(room: LiveRoom) {
return formatAutoStartDecisionSummary(room.lastAutoStartDecisionCode, room.lastAutoStartDecisionSummary || "暂无事件");
}
function getQualityLabel(quality?: string | null) {
return formatQualityLabel(quality);
}
function autoStartDecisionLabel(code?: string) {
if (!code) {
return "未记录";
}
return autoStartDecisionLabelMap[code] ?? code;
}
function autoStartDecisionTagType(code?: string) {
if (code === "started") {
return "success";
}
if (code === "failed_startup" || code === "poll_failed") {
return "danger";
}
if (
code === "skipped_storage" ||
code === "skipped_active_session" ||
code === "skipped_debounce" ||
code === "poll_failed_transient"
) {
return "warning";
}
return "info";
}
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";
}
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(async () => {
await loadRooms();
if (route.query.action === "create") openCreateDialog();
startAutoRefresh();
document.addEventListener("visibilitychange", handleVisibilityChange);
});
onBeforeUnmount(() => {
stopAutoRefresh();
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">直播监控</div>
<h1 class="page-title">直播间监控录制控制台</h1>
<p class="page-subtitle">
聚合直播状态自动开录决策和单房间录制配置面向开播检测异常巡检与手动介入场景
</p>
</div>
<div class="page-toolbar">
<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>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="live-console-grid">
<div class="stats-grid">
<MetricCard label="在线直播间" :value="liveRooms" description="基于最近一次巡检识别为开播中的房间" :icon="House" />
<MetricCard label="录制中任务" :value="recordingRooms" description="当前处于录制状态的直播间数量" :icon="VideoCamera" />
<MetricCard label="启用房间" :value="enabledRooms" description="参与自动检测与自动录制的直播间" :icon="SwitchButton" />
<MetricCard label="重点监控" :value="priorityRooms" description="标记为重点巡检与优先关注的房间" :icon="RefreshRight" />
</div>
</div>
<el-card class="surface-card table-card" shadow="never" v-loading="loading">
<div class="toolbar-row">
<div>
<h3 class="section-title">直播间列表</h3>
<p class="section-subtitle">搜索主播或房间并按运行状态快速筛选</p>
</div>
<el-space v-if="!isMobile" 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="list-filterbar">
<el-input v-model="roomSearch" clearable placeholder="搜索主播、标题、平台或 Room ID" />
<el-select v-model="roomState" aria-label="直播间状态筛选">
<el-option label="全部状态" value="all" />
<el-option label="正在直播" value="live" />
<el-option label="正在录制" value="recording" />
<el-option label="已启用" value="enabled" />
<el-option label="已停用" value="disabled" />
</el-select>
<span class="list-filterbar__count">{{ filteredRooms.length }} / {{ rooms.length }}</span>
</div>
<EmptyState
v-if="!loading && filteredRooms.length === 0"
:title="rooms.length === 0 ? '尚未添加直播间' : '没有匹配的直播间'"
:description="rooms.length === 0 ? '添加直播间后即可开始自动巡检和录制' : '请调整搜索词或状态筛选'"
:action-text="rooms.length === 0 ? '刷新列表' : '清除筛选'"
@action="rooms.length === 0 ? loadRooms() : clearRoomFilters()"
/>
<div v-else-if="isMobile" class="data-card-list room-card-list">
<article v-for="row in paginatedMobileRooms" :key="row.id" class="data-card room-card">
<div class="data-card__header">
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
{{ getRoomAvatarText(row) }}
</SafeAvatar>
<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">
<StatusBadge :label="roomAvailabilityLabel(row)" :status="row.availabilityStatus" context="availability" />
<StatusBadge
:label="currentRecordingStateLabelMap[row.currentRecordingState]"
:status="row.currentRecordingState"
context="recording"
/>
<PlatformMark :name="row.platformName" />
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
</div>
<div class="data-card__grid">
<div>
<dt>最近事件</dt>
<dd>{{ latestEventLabel(row) }}</dd>
</div>
<div>
<dt>最近巡检</dt>
<dd>{{ formatDate(row.lastCheckedAt) }}</dd>
</div>
<div>
<dt>在线人数</dt>
<dd>--</dd>
</div>
<div>
<dt>码率</dt>
<dd>--</dd>
</div>
<div>
<dt>检测间隔</dt>
<dd>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</dd>
</div>
<div>
<dt>采集账号</dt>
<dd>--</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>房间配置</dt>
<dd>
{{ getQualityLabel(row.effectiveSettings.preferredQuality) }} ·
{{ outputFormatLabelMap[row.effectiveSettings.outputFormat] }} ·
{{ saveModeLabelMap[row.effectiveSettings.saveMode] }} ·
{{ row.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}
</dd>
</div>
<div v-if="row.lastAutoStartDecisionDetail" style="grid-column: 1 / -1;">
<dt>决策详情</dt>
<dd>{{ row.lastAutoStartDecisionDetail }}</dd>
</div>
</div>
<div class="room-card__toggle">
<span class="room-card__toggle-label">录制开关</span>
<el-switch
:model-value="row.isEnabled"
:loading="togglingRoomId === row.id"
inline-prompt
active-text=""
inactive-text=""
@change="setRoomEnabled(row, Boolean($event))"
/>
</div>
<div class="data-card__actions">
<el-button size="small" type="primary" @click="openRoomDetails(row)">查看详情</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="!row.isEnabled" @click="openRecordDialog(row)">开始录制</el-dropdown-item>
<el-dropdown-item @click="refreshRoom(row)">刷新状态</el-dropdown-item>
<el-dropdown-item @click="openSettingsDialog(row)">房间配置</el-dropdown-item>
<el-dropdown-item @click="copyRoomLink(row)">复制链接</el-dropdown-item>
<el-dropdown-item @click="openOriginalRoom(row)">打开原房间</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" @click="openDeleteDialog(row)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</article>
<el-pagination
v-model:current-page="mobileRoomPage"
class="mobile-room-pagination"
background
layout="prev, pager, next"
:page-size="mobileRoomPageSize"
:pager-count="5"
:total="filteredRooms.length"
hide-on-single-page
/>
</div>
<div v-else class="table-scroll-shell rooms-table-shell" data-testid="rooms-table-scroll">
<el-table
:data="filteredRooms"
class="premium-table rooms-table"
table-layout="fixed"
row-key="id"
@selection-change="handleRoomSelectionChange"
>
<el-table-column type="selection" width="48" />
<el-table-column label="直播间" min-width="340">
<template #default="{ row }">
<div class="room-summary-cell">
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
{{ getRoomAvatarText(row) }}
</SafeAvatar>
<div class="room-summary-cell__copy">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div class="config-summary">
<PlatformMark :name="row.platformName" />
<span>{{ row.roomId || "--" }}</span>
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
</div>
</div>
</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="badge-row">
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
</div>
</template>
</el-table-column>
<el-table-column label="直播状态" width="128">
<template #default="{ row }">
<StatusBadge :label="roomAvailabilityLabel(row)" :status="row.availabilityStatus" context="availability" />
</template>
</el-table-column>
<el-table-column label="录制状态" width="128">
<template #default="{ row }">
<StatusBadge
:label="currentRecordingStateLabelMap[row.currentRecordingState]"
:status="row.currentRecordingState"
context="recording"
/>
</template>
</el-table-column>
<el-table-column label="最近事件" min-width="300">
<template #default="{ row }">
<div class="auto-start-cell">
<div class="auto-start-cell__head">
<StatusBadge
size="sm"
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ latestEventLabel(row) }}</div>
<el-popover
v-if="row.lastAutoStartDecisionDetail"
trigger="hover"
placement="top-start"
:width="320"
>
<template #reference>
<el-button text size="small" class="auto-start-cell__detail-trigger">详情</el-button>
</template>
<div class="detail-popover">{{ row.lastAutoStartDecisionDetail }}</div>
</el-popover>
</div>
</template>
</el-table-column>
<el-table-column label="房间配置" width="280">
<template #default="{ row }">
<div class="config-summary">
<span>画质 {{ getQualityLabel(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="120">
<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="170" fixed="right">
<template #default="{ row }">
<div class="room-actions-cell">
<el-button size="small" type="primary" @click="openRoomDetails(row)">查看</el-button>
<el-dropdown trigger="click">
<el-button size="small">更多</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item :disabled="!row.isEnabled" @click="openRecordDialog(row)">开始录制</el-dropdown-item>
<el-dropdown-item @click="refreshRoom(row)">刷新状态</el-dropdown-item>
<el-dropdown-item @click="openSettingsDialog(row)">房间配置</el-dropdown-item>
<el-dropdown-item @click="copyRoomLink(row)">复制链接</el-dropdown-item>
<el-dropdown-item @click="openOriginalRoom(row)">打开原房间</el-dropdown-item>
<el-dropdown-item divided class="danger-menu-item" @click="openDeleteDialog(row)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<RightDrawer
v-model="roomDetailVisible"
:title="activeRoom?.title || activeRoom?.anchorName || activeRoom?.roomId || '直播间详情'"
:subtitle="activeRoomSubtitle"
>
<div v-if="activeRoom" class="detail-panel">
<div class="detail-panel__hero">
<SafeAvatar :src="getRoomAvatarUrl(activeRoom)" :alt="activeRoom.anchorName || activeRoom.title" :size="64" class="room-avatar" @error="markRoomAvatarFailed(activeRoom)">
{{ getRoomAvatarText(activeRoom) }}
</SafeAvatar>
<div>
<div class="detail-panel__title">{{ activeRoom.anchorName || "未知主播" }}</div>
<div class="detail-panel__meta">{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}</div>
</div>
</div>
<div class="badge-row">
<StatusBadge :label="roomAvailabilityLabel(activeRoom)" :status="activeRoom.availabilityStatus" context="availability" />
<StatusBadge
:label="currentRecordingStateLabelMap[activeRoom.currentRecordingState]"
:status="activeRoom.currentRecordingState"
context="recording"
/>
<PlatformMark :name="activeRoom.platformName" />
</div>
<el-descriptions :column="1" border class="detail-panel__descriptions">
<el-descriptions-item label="直播间名称">
{{ activeRoom.title || activeRoom.anchorName || activeRoom.roomId || "--" }}
</el-descriptions-item>
<el-descriptions-item label="平台 + Room ID">
<span class="platform-room-id"><PlatformMark :name="activeRoom.platformName" /><span>· {{ activeRoom.roomId || "--" }}</span></span>
</el-descriptions-item>
<el-descriptions-item label="直播状态">
{{ roomAvailabilityLabel(activeRoom) }}
</el-descriptions-item>
<el-descriptions-item label="录制状态">
{{ currentRecordingStateLabelMap[activeRoom.currentRecordingState] || "--" }}
</el-descriptions-item>
<el-descriptions-item label="在线人数">--</el-descriptions-item>
<el-descriptions-item label="码率">--</el-descriptions-item>
<el-descriptions-item label="录制时长">--</el-descriptions-item>
<el-descriptions-item label="采集账号">--</el-descriptions-item>
<el-descriptions-item label="最近事件">
{{ latestEventLabel(activeRoom) }}
</el-descriptions-item>
<el-descriptions-item label="最近巡检">
{{ formatDate(activeRoom.lastCheckedAt) }}
</el-descriptions-item>
<el-descriptions-item label="自动开录详情">
{{ activeRoom.lastAutoStartDecisionDetail || "--" }}
</el-descriptions-item>
<el-descriptions-item label="房间配置">
{{ getQualityLabel(activeRoom.effectiveSettings.preferredQuality) }} ·
{{ outputFormatLabelMap[activeRoom.effectiveSettings.outputFormat] }} ·
{{ saveModeLabelMap[activeRoom.effectiveSettings.saveMode] }} ·
{{ activeRoom.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="roomDetailVisible = false">关闭</el-button>
<el-button @click="activeRoom && openSettingsDialog(activeRoom)">配置</el-button>
<el-button type="primary" :disabled="!activeRoom?.isEnabled" @click="activeRoom && openRecordDialog(activeRoom)">
开始录制
</el-button>
</template>
</RightDrawer>
<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="粘贴 Douyin / Bilibili / Huya / Douyu / Kuaishou / TikTok / YouTube / Twitch 等直播间链接"
/>
</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://www.twitch.tv/example_channel&#10;https://www.youtube.com/watch?v=example12345"
/>
</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 class="import-options">
<div class="settings-toggle-item import-options__item">
<div>
<div class="settings-toggle-item__label">检测直播间信息</div>
<div class="settings-toggle-item__hint">关闭后不在导入时拉取标题头像等信息</div>
</div>
<el-switch v-model="importForm.detectRoomMetadata" />
</div>
<div class="settings-toggle-item import-options__item">
<div>
<div class="settings-toggle-item__label">检测开播状态</div>
<div class="settings-toggle-item__hint">关闭后不在导入时请求开播状态也不会立即触发自动开录</div>
</div>
<el-switch v-model="importForm.detectLiveStatus" />
</div>
</div>
<el-alert
v-if="!importForm.detectRoomMetadata && !importForm.detectLiveStatus"
type="warning"
:closable="false"
show-icon
title="当前将只做本地解析并直接入库,不请求远端链接。适合一次导入很多直播间时使用。"
/>
<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.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<div class="field-help">{{ qualitySupportHint }}</div>
<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">
<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" />
<el-option
v-for="option in qualityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<div class="field-help field-help--full">{{ qualitySupportHint }}</div>
<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>
.platform-room-id { display: inline-flex; align-items: center; gap: 6px; }
.page-stack {
display: grid;
gap: 24px;
}
.live-console-grid {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr);
gap: 18px;
}
.focus-card :deep(.el-card__body) {
display: grid;
gap: 18px;
}
.focus-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.focus-card__eyebrow {
margin-bottom: 8px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.focus-card__description {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.75;
}
.focus-card__chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.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 var(--border-subtle);
}
.batch-actions {
justify-content: flex-end;
}
.batch-actions__count {
color: var(--text-muted);
font-size: 13px;
}
.list-filterbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) 180px auto;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.list-filterbar__count { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rooms-table-shell {
overflow-x: auto;
}
.rooms-table :deep(.el-table__cell) {
vertical-align: top;
}
.rooms-table :deep(.cell) {
overflow: hidden;
}
.room-summary-cell {
display: flex;
align-items: flex-start;
gap: 14px;
}
.room-summary-cell__copy {
min-width: 0;
}
.room-avatar {
box-shadow: 0 14px 28px rgba(52, 84, 112, 0.14);
}
.room-card {
gap: 16px;
}
.mobile-room-pagination {
justify-content: center;
padding-top: 4px;
}
.room-card__toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--surface);
}
.room-card__toggle-label {
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.5;
}
.cell-subtitle {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
}
.cell-mono {
margin-top: 8px;
color: var(--text-muted);
font-size: 12px;
}
.auto-start-cell {
display: grid;
gap: 6px;
}
.auto-start-cell__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.auto-start-cell__detail-trigger {
justify-content: flex-start;
padding: 0;
}
.detail-popover {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.config-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--text-secondary);
font-size: 12px;
}
.config-summary span {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
}
.table-date-text {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.room-actions-cell {
display: grid;
grid-template-columns: repeat(2, minmax(0, max-content));
gap: 8px;
justify-content: flex-start;
width: 100%;
}
.table-placeholder-grid {
display: grid;
gap: 6px;
color: var(--text-secondary);
font-size: 12px;
}
.room-actions-cell :deep(.el-button) {
margin: 0;
}
.detail-panel {
display: grid;
gap: 18px;
}
.detail-panel__hero {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.detail-panel__hero > div {
min-width: 0;
}
.detail-panel__title {
color: var(--text-primary);
font-size: 18px;
font-weight: 700;
}
.detail-panel__meta {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
word-break: break-all;
}
.field-help {
margin-top: -8px;
margin-bottom: 14px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.6;
}
.field-help--full {
grid-column: 1 / -1;
}
.settings-note {
display: grid;
gap: 6px;
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 14px;
background: var(--surface-muted);
color: var(--text-secondary);
line-height: 1.6;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
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;
}
.settings-toggle-item__hint {
margin-top: 4px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
}
.dialog-form {
display: grid;
}
.import-dialog-body {
gap: 16px;
}
.import-options {
display: grid;
gap: 12px;
}
.import-options__item {
align-items: flex-start;
}
.import-result {
display: grid;
gap: 12px;
padding: 14px 16px;
border: 1px solid rgba(47, 111, 180, 0.14);
border-radius: 14px;
background: var(--surface-muted);
}
.import-result__summary {
color: var(--text-primary);
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: var(--text-secondary);
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: var(--text-muted);
}
.delete-dialog__title {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text-primary);
}
.delete-dialog__body {
display: grid;
gap: 16px;
}
.delete-dialog__lead {
color: var(--text-secondary);
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) {
.live-console-grid {
grid-template-columns: 1fr;
}
.toolbar-row {
flex-direction: column;
}
.list-filterbar { grid-template-columns: 1fr 160px; }
.list-filterbar__count { grid-column: 1 / -1; }
.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;
}
.settings-toggle-pair {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.list-filterbar { grid-template-columns: 1fr; }
.list-filterbar__count { grid-column: auto; }
}
@media (max-width: 640px) {
.detail-panel__hero {
align-items: flex-start;
gap: 12px;
padding: 12px;
}
.detail-panel__descriptions :deep(.el-descriptions__label) {
width: 104px;
min-width: 104px;
}
.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>