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