This commit is contained in:
2026-04-16 15:19:48 +08:00
commit 1c892259a9
127 changed files with 17390 additions and 0 deletions
+570
View File
@@ -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>