1965 lines
62 KiB
Vue
1965 lines
62 KiB
Vue
<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,主播: 熊宇一 https://www.twitch.tv/example_channel 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>
|