1111
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient from "@/api/client";
|
||||
import type { LiveRoom, RecordTask } from "@/types";
|
||||
import { availabilityLabelMap, outputFormatLabelMap } from "@/types";
|
||||
|
||||
const loading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const startLoading = ref(false);
|
||||
const togglingRoomId = ref("");
|
||||
const createDialogVisible = ref(false);
|
||||
const recordDialogVisible = ref(false);
|
||||
const deleteDialogVisible = ref(false);
|
||||
const deletingRoom = ref(false);
|
||||
const pendingDeleteRoom = ref<LiveRoom | null>(null);
|
||||
const rooms = ref<LiveRoom[]>([]);
|
||||
|
||||
const createForm = reactive({
|
||||
url: "",
|
||||
platformOverride: null as number | null
|
||||
});
|
||||
|
||||
const recordForm = reactive({
|
||||
liveRoomId: "",
|
||||
preferredQuality: "origin",
|
||||
outputFormat: 0
|
||||
});
|
||||
|
||||
const platformOptions = [
|
||||
{ label: "自动识别", value: null },
|
||||
{ label: "抖音", value: 1 },
|
||||
{ label: "Bilibili", value: 2 },
|
||||
{ label: "虎牙", value: 3 }
|
||||
];
|
||||
|
||||
const canSubmit = computed(() => createForm.url.trim().length > 0);
|
||||
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);
|
||||
|
||||
async function loadRooms() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
|
||||
rooms.value = data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
createDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
createForm.url = "";
|
||||
createForm.platformOverride = 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 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 = "origin";
|
||||
recordForm.outputFormat = 0;
|
||||
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 openDeleteDialog(room: LiveRoom) {
|
||||
pendingDeleteRoom.value = room;
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (deletingRoom.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteDialogVisible.value = false;
|
||||
pendingDeleteRoom.value = null;
|
||||
}
|
||||
|
||||
async function removeRoom() {
|
||||
if (!pendingDeleteRoom.value) {
|
||||
closeDeleteDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
deletingRoom.value = true;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/live-rooms/${pendingDeleteRoom.value.id}`);
|
||||
ElMessage.success("直播间已删除");
|
||||
deleteDialogVisible.value = false;
|
||||
pendingDeleteRoom.value = null;
|
||||
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 formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
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 type="primary" @click="openCreateDialog">新增直播间</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<el-table :data="rooms" v-loading="loading" height="700" class="premium-table">
|
||||
<el-table-column label="封面" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.coverUrl"
|
||||
:src="row.coverUrl"
|
||||
fit="cover"
|
||||
class="cover-thumb"
|
||||
/>
|
||||
<el-tag v-else effect="plain">N/A</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="直播间" min-width="300">
|
||||
<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="100">
|
||||
<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="录制开关" 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 }">
|
||||
{{ formatDate(row.lastCheckedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="270" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-space wrap>
|
||||
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
|
||||
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
|
||||
开始录制
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
|
||||
</el-space>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="createDialogVisible"
|
||||
class="form-dialog"
|
||||
title="新增直播间"
|
||||
width="540px"
|
||||
@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="粘贴抖音分享链接、直播间地址或 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="recordDialogVisible"
|
||||
class="form-dialog"
|
||||
title="启动录制任务"
|
||||
width="420px"
|
||||
>
|
||||
<div class="dialog-form">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="清晰度">
|
||||
<el-select v-model="recordForm.preferredQuality">
|
||||
<el-option label="最高质量 origin" value="origin" />
|
||||
<el-option label="FULL_HD" value="FULL_HD" />
|
||||
<el-option label="HD" value="HD" />
|
||||
<el-option label="SD" value="SD" />
|
||||
</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="deleteDialogVisible"
|
||||
class="form-dialog delete-room-dialog"
|
||||
width="clamp(480px, 46vw, 560px)"
|
||||
:show-close="!deletingRoom"
|
||||
:close-on-click-modal="!deletingRoom"
|
||||
:close-on-press-escape="!deletingRoom"
|
||||
destroy-on-close
|
||||
@closed="pendingDeleteRoom = null"
|
||||
>
|
||||
<template #header>
|
||||
<div class="delete-dialog__header">
|
||||
<span class="delete-dialog__eyebrow">删除确认</span>
|
||||
<h3 class="delete-dialog__title">删除直播间</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="delete-dialog__body">
|
||||
<div class="delete-dialog__lead">
|
||||
确认删除直播间
|
||||
<span class="monospace">{{ pendingDeleteRoom?.roomId || "-" }}</span>
|
||||
吗?删除后,该直播间的关联记录会按当前后端规则自动清理。
|
||||
</div>
|
||||
|
||||
<div class="delete-dialog__note">
|
||||
<div class="delete-dialog__note-title">影响范围</div>
|
||||
<p>只删除当前直播间本身。满足 stopped orphan 规则且没有有效视频文件的空会话会被后端一并清理。</p>
|
||||
</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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.cover-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 22px rgba(31, 24, 18, 0.08);
|
||||
}
|
||||
|
||||
.cell-title {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #171614;
|
||||
}
|
||||
|
||||
.cell-subtitle {
|
||||
margin-top: 6px;
|
||||
color: #6f675f;
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
margin-top: 8px;
|
||||
color: #90887d;
|
||||
}
|
||||
|
||||
.dialog-form {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.delete-dialog__note {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(183, 80, 70, 0.14);
|
||||
background: rgba(183, 80, 70, 0.06);
|
||||
}
|
||||
|
||||
.delete-dialog__note-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #8f443a;
|
||||
}
|
||||
|
||||
.delete-dialog__note p {
|
||||
margin: 0;
|
||||
color: #6b5d57;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-footer--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.form-dialog .el-dialog__header) {
|
||||
padding: 24px 24px 8px;
|
||||
}
|
||||
|
||||
:deep(.form-dialog .el-dialog__body) {
|
||||
padding: 12px 24px 10px;
|
||||
}
|
||||
|
||||
:deep(.form-dialog .el-dialog__footer) {
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
:deep(.delete-room-dialog .el-dialog__header) {
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
:deep(.delete-room-dialog .el-dialog__body) {
|
||||
padding: 18px 24px 10px;
|
||||
}
|
||||
|
||||
:deep(.delete-room-dialog .el-dialog__footer) {
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbar-row {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialog-footer :deep(.el-button) {
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user