feat: harden recording lifecycle and refresh fnOS UI
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.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,
|
||||
@@ -47,10 +49,11 @@ const loadError = ref("");
|
||||
const roomDetailVisible = ref(false);
|
||||
const activeRoom = ref<LiveRoom | null>(null);
|
||||
const { isMobile } = useViewport();
|
||||
const roomsTableShellRef = ref<HTMLElement | null>(null);
|
||||
const roomsTableProxyRef = ref<HTMLElement | null>(null);
|
||||
const showRoomsTableProxyScroll = ref(false);
|
||||
const roomsTableProxyInnerWidth = ref(0);
|
||||
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: "",
|
||||
@@ -113,12 +116,36 @@ 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 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 "";
|
||||
@@ -126,182 +153,16 @@ const activeRoomSubtitle = computed(() => {
|
||||
|
||||
return `${activeRoom.value.platformName || "--"} · ${activeRoom.value.roomId || "--"}`;
|
||||
});
|
||||
const tableHeight = computed(() => (isMobile.value ? undefined : 700));
|
||||
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;
|
||||
let roomsTableScrollWrap: HTMLElement | null = null;
|
||||
let roomsTableResizeObserver: ResizeObserver | null = null;
|
||||
let observedRoomsTableShell: HTMLElement | null = null;
|
||||
let observedRoomsTableWrap: HTMLElement | null = null;
|
||||
let syncingRoomsTableProxy = false;
|
||||
let syncingRoomsTableBody = false;
|
||||
|
||||
function cleanupRoomsTableScrollSync() {
|
||||
if (roomsTableScrollWrap) {
|
||||
roomsTableScrollWrap.removeEventListener("scroll", handleRoomsTableBodyScroll);
|
||||
roomsTableScrollWrap = null;
|
||||
}
|
||||
|
||||
roomsTableResizeObserver?.disconnect();
|
||||
roomsTableResizeObserver = null;
|
||||
observedRoomsTableShell = null;
|
||||
observedRoomsTableWrap = null;
|
||||
}
|
||||
|
||||
function getRoomsTableScrollWrap() {
|
||||
const shell = roomsTableShellRef.value;
|
||||
|
||||
if (!shell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return shell.querySelector<HTMLElement>(".el-table__body-wrapper .el-scrollbar__wrap") ??
|
||||
shell.querySelector<HTMLElement>(".el-scrollbar__wrap") ??
|
||||
shell.querySelector<HTMLElement>(".el-table__body-wrapper");
|
||||
}
|
||||
|
||||
function getRoomsTableContentWidth(wrap: HTMLElement) {
|
||||
const shell = roomsTableShellRef.value;
|
||||
|
||||
if (!shell) {
|
||||
return wrap.scrollWidth;
|
||||
}
|
||||
|
||||
const widths = [
|
||||
wrap.scrollWidth,
|
||||
wrap.firstElementChild?.scrollWidth ?? 0,
|
||||
shell.querySelector<HTMLElement>(".el-table__body table")?.scrollWidth ?? 0,
|
||||
shell.querySelector<HTMLElement>(".el-table__header table")?.scrollWidth ?? 0
|
||||
];
|
||||
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
let maxRight = 0;
|
||||
|
||||
shell.querySelectorAll<HTMLElement>(
|
||||
".el-table__header th, .el-table__body td, .room-actions-cell, .config-summary, .auto-start-cell"
|
||||
).forEach((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
maxRight = Math.max(maxRight, rect.right - wrapRect.left + wrap.scrollLeft);
|
||||
});
|
||||
|
||||
widths.push(Math.ceil(maxRight) + 24);
|
||||
|
||||
return Math.max(...widths);
|
||||
}
|
||||
|
||||
function handleRoomsTableBodyScroll() {
|
||||
if (syncingRoomsTableProxy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxy = roomsTableProxyRef.value;
|
||||
|
||||
if (!proxy || !roomsTableScrollWrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncingRoomsTableBody = true;
|
||||
proxy.scrollLeft = roomsTableScrollWrap.scrollLeft;
|
||||
window.requestAnimationFrame(() => {
|
||||
syncingRoomsTableBody = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleRoomsTableProxyScroll() {
|
||||
if (syncingRoomsTableBody || !roomsTableScrollWrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxy = roomsTableProxyRef.value;
|
||||
|
||||
if (!proxy) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncingRoomsTableProxy = true;
|
||||
roomsTableScrollWrap.scrollLeft = proxy.scrollLeft;
|
||||
window.requestAnimationFrame(() => {
|
||||
syncingRoomsTableProxy = false;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureRoomsTableResizeObserver() {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const shell = roomsTableShellRef.value;
|
||||
const wrap = roomsTableScrollWrap;
|
||||
|
||||
if (roomsTableResizeObserver && observedRoomsTableShell === shell && observedRoomsTableWrap === wrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
roomsTableResizeObserver?.disconnect();
|
||||
roomsTableResizeObserver = new ResizeObserver(() => {
|
||||
void syncRoomsTableProxyScroll();
|
||||
});
|
||||
|
||||
if (shell) {
|
||||
roomsTableResizeObserver.observe(shell);
|
||||
}
|
||||
|
||||
if (wrap) {
|
||||
roomsTableResizeObserver.observe(wrap);
|
||||
}
|
||||
|
||||
observedRoomsTableShell = shell;
|
||||
observedRoomsTableWrap = wrap;
|
||||
}
|
||||
|
||||
async function syncRoomsTableProxyScroll() {
|
||||
await nextTick();
|
||||
|
||||
if (isMobile.value) {
|
||||
showRoomsTableProxyScroll.value = false;
|
||||
roomsTableProxyInnerWidth.value = 0;
|
||||
cleanupRoomsTableScrollSync();
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = getRoomsTableScrollWrap();
|
||||
|
||||
if (roomsTableScrollWrap !== wrap) {
|
||||
roomsTableScrollWrap?.removeEventListener("scroll", handleRoomsTableBodyScroll);
|
||||
roomsTableScrollWrap = wrap;
|
||||
roomsTableScrollWrap?.addEventListener("scroll", handleRoomsTableBodyScroll, { passive: true });
|
||||
}
|
||||
|
||||
const proxy = roomsTableProxyRef.value;
|
||||
|
||||
if (!wrap) {
|
||||
showRoomsTableProxyScroll.value = false;
|
||||
roomsTableProxyInnerWidth.value = 0;
|
||||
ensureRoomsTableResizeObserver();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollWidth = getRoomsTableContentWidth(wrap);
|
||||
const clientWidth = wrap.clientWidth;
|
||||
const shellClientWidth = roomsTableShellRef.value?.clientWidth ?? clientWidth;
|
||||
const proxyViewportWidth = proxy?.clientWidth ?? shellClientWidth;
|
||||
const canScrollHorizontally = scrollWidth > clientWidth + 1;
|
||||
const proxyContentWidth = scrollWidth + Math.max(0, proxyViewportWidth - clientWidth);
|
||||
|
||||
roomsTableProxyInnerWidth.value = canScrollHorizontally ? Math.ceil(proxyContentWidth) : 0;
|
||||
showRoomsTableProxyScroll.value = canScrollHorizontally;
|
||||
|
||||
if (canScrollHorizontally && proxy && Math.abs(proxy.scrollLeft - wrap.scrollLeft) > 1) {
|
||||
proxy.scrollLeft = wrap.scrollLeft;
|
||||
}
|
||||
|
||||
ensureRoomsTableResizeObserver();
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
@@ -309,7 +170,6 @@ async function loadRooms() {
|
||||
try {
|
||||
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
|
||||
rooms.value = data;
|
||||
void syncRoomsTableProxyScroll();
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "直播间列表加载失败,请稍后重试。");
|
||||
} finally {
|
||||
@@ -344,7 +204,6 @@ async function autoRefreshRooms() {
|
||||
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
|
||||
rooms.value = data;
|
||||
loadError.value = "";
|
||||
void syncRoomsTableProxyScroll();
|
||||
} catch {
|
||||
// Keep the current view stable; the background poller will try again.
|
||||
}
|
||||
@@ -711,12 +570,26 @@ function getRoomAvatarText(room: LiveRoom) {
|
||||
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 room.lastAutoStartDecisionSummary || "暂无事件";
|
||||
return formatAutoStartDecisionSummary(room.lastAutoStartDecisionCode, room.lastAutoStartDecisionSummary || "暂无事件");
|
||||
}
|
||||
|
||||
function getQualityLabel(quality?: string | null) {
|
||||
@@ -814,17 +687,13 @@ function nullableStringFromSelect(value: string | number) {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRooms();
|
||||
void syncRoomsTableProxyScroll();
|
||||
startAutoRefresh();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("resize", syncRoomsTableProxyScroll);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAutoRefresh();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("resize", syncRoomsTableProxyScroll);
|
||||
cleanupRoomsTableScrollSync();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -857,36 +726,13 @@ onBeforeUnmount(() => {
|
||||
<MetricCard label="重点监控" :value="priorityRooms" description="标记为重点巡检与优先关注的房间" :icon="RefreshRight" />
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card focus-card" shadow="never">
|
||||
<div class="focus-card__header">
|
||||
<div>
|
||||
<div class="focus-card__eyebrow">监控概览</div>
|
||||
<h3 class="section-title">直播采集态势</h3>
|
||||
</div>
|
||||
<StatusBadge
|
||||
:label="recordingRooms > 0 ? '录制通道活跃' : '等待开播'"
|
||||
:status="recordingRooms > 0"
|
||||
context="boolean"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="focus-card__description">
|
||||
当前共接入 {{ totalRooms }} 个直播间,其中 {{ enabledRooms }} 个处于自动巡检范围,{{ disabledRooms }} 个处于保留但停用状态。
|
||||
</p>
|
||||
|
||||
<div class="focus-card__chips">
|
||||
<span class="info-pill">自动开录复用现有后端策略</span>
|
||||
<span class="info-pill">单房间配置优先于全局设置</span>
|
||||
<span class="info-pill">无实时吞吐字段时统一显示占位</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</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>
|
||||
<p class="section-subtitle">搜索主播或房间,并按运行状态快速筛选。</p>
|
||||
</div>
|
||||
<el-space v-if="!isMobile" wrap class="batch-actions">
|
||||
<span class="batch-actions__count">已选 {{ selectedRoomCount }} 个</span>
|
||||
@@ -898,20 +744,32 @@ onBeforeUnmount(() => {
|
||||
</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 && rooms.length === 0"
|
||||
title="暂无数据"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
action-text="刷新列表"
|
||||
@action="loadRooms"
|
||||
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 rooms" :key="row.id" class="data-card room-card">
|
||||
<article v-for="row in paginatedMobileRooms" :key="row.id" class="data-card room-card">
|
||||
<div class="data-card__header">
|
||||
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
|
||||
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
|
||||
{{ getRoomAvatarText(row) }}
|
||||
</el-avatar>
|
||||
</SafeAvatar>
|
||||
|
||||
<div>
|
||||
<div class="data-card__title">{{ row.title || row.anchorName || row.roomId }}</div>
|
||||
@@ -987,32 +845,36 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="data-card__actions">
|
||||
<el-button size="small" @click="openRoomDetails(row)">查看详情</el-button>
|
||||
<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>
|
||||
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
|
||||
<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 ref="roomsTableShellRef" class="table-scroll-shell rooms-table-shell">
|
||||
<div
|
||||
v-if="showRoomsTableProxyScroll"
|
||||
ref="roomsTableProxyRef"
|
||||
class="rooms-table-proxy-scroll"
|
||||
@scroll.passive="handleRoomsTableProxyScroll"
|
||||
>
|
||||
<div class="rooms-table-proxy-scroll__inner" :style="{ width: `${roomsTableProxyInnerWidth}px` }"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-scroll-shell rooms-table-shell" data-testid="rooms-table-scroll">
|
||||
<el-table
|
||||
:data="rooms"
|
||||
:height="tableHeight"
|
||||
:data="filteredRooms"
|
||||
class="premium-table rooms-table"
|
||||
table-layout="fixed"
|
||||
row-key="id"
|
||||
@@ -1023,9 +885,9 @@ onBeforeUnmount(() => {
|
||||
<el-table-column label="直播间" min-width="340">
|
||||
<template #default="{ row }">
|
||||
<div class="room-summary-cell">
|
||||
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
|
||||
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
|
||||
{{ getRoomAvatarText(row) }}
|
||||
</el-avatar>
|
||||
</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>
|
||||
@@ -1112,34 +974,27 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="监控字段" width="168">
|
||||
<template #default="{ row }">
|
||||
<div class="table-placeholder-grid">
|
||||
<span>在线人数 --</span>
|
||||
<span>码率 --</span>
|
||||
<span>采集账号 --</span>
|
||||
</div>
|
||||
</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="380">
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="room-actions-cell">
|
||||
<el-button size="small" @click="openRoomDetails(row)">查看</el-button>
|
||||
<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>
|
||||
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
|
||||
<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>
|
||||
@@ -1154,9 +1009,9 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div v-if="activeRoom" class="detail-panel">
|
||||
<div class="detail-panel__hero">
|
||||
<el-avatar :src="activeRoom.avatarUrl || activeRoom.coverUrl" :size="64" class="room-avatar">
|
||||
<SafeAvatar :src="getRoomAvatarUrl(activeRoom)" :alt="activeRoom.anchorName || activeRoom.title" :size="64" class="room-avatar" @error="markRoomAvatarFailed(activeRoom)">
|
||||
{{ getRoomAvatarText(activeRoom) }}
|
||||
</el-avatar>
|
||||
</SafeAvatar>
|
||||
<div>
|
||||
<div class="detail-panel__title">{{ activeRoom.anchorName || "未知主播" }}</div>
|
||||
<div class="detail-panel__meta">{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}</div>
|
||||
@@ -1674,28 +1529,18 @@ onBeforeUnmount(() => {
|
||||
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: hidden;
|
||||
}
|
||||
|
||||
.rooms-table-proxy-scroll {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 6px;
|
||||
background: var(--surface);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.rooms-table-proxy-scroll__inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.rooms-table {
|
||||
min-width: 1920px;
|
||||
}
|
||||
|
||||
.rooms-table :deep(.el-table__cell) {
|
||||
@@ -1724,6 +1569,11 @@ onBeforeUnmount(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mobile-room-pagination {
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.room-card__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2040,6 +1890,9 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-filterbar { grid-template-columns: 1fr 160px; }
|
||||
.list-filterbar__count { grid-column: 1 / -1; }
|
||||
|
||||
.batch-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -2063,6 +1916,11 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.list-filterbar { grid-template-columns: 1fr; }
|
||||
.list-filterbar__count { grid-column: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-actions :deep(.el-space__item) {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user