149 lines
14 KiB
Vue
149 lines
14 KiB
Vue
<template>
|
|
<div class="live-page">
|
|
<div class="kra-search-box">
|
|
<el-form :inline="true" :model="query" class="live-search-form" @submit.prevent="search">
|
|
<el-form-item label="直播间"><el-input v-model="query.name" clearable placeholder="名称或房间号" /></el-form-item>
|
|
<el-form-item label="主播"><el-input v-model="query.anchorName" clearable placeholder="主播昵称" /></el-form-item>
|
|
<el-form-item label="分类"><el-select v-model="query.categoryId" clearable placeholder="全部分类"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
|
<el-form-item label="直播状态"><el-select v-model="query.status" clearable placeholder="全部状态"><el-option label="直播中" value="live" /><el-option label="未开播" value="offline" /><el-option label="已禁用" value="disabled" /></el-select></el-form-item>
|
|
<el-form-item label="排序"><el-select v-model="query.orderBy" placeholder="默认排序"><el-option label="展示排序" value="sort_asc" /><el-option label="最新创建" value="created_at_desc" /><el-option label="最早创建" value="created_at_asc" /><el-option label="房间号升序" value="id_asc" /><el-option label="房间号降序" value="id_desc" /></el-select></el-form-item>
|
|
<el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item>
|
|
</el-form>
|
|
</div>
|
|
<div class="kra-table-box live-table-box">
|
|
<div class="kra-btn-list">
|
|
<el-button type="primary" :icon="Plus" @click="openCreate">新建直播间</el-button>
|
|
<el-button :icon="Sort" @click="persistSort">保存排序</el-button>
|
|
</div>
|
|
<el-table v-loading="loading" :data="rows" row-key="id">
|
|
<el-table-column label="排序号" min-width="78" align="center"><template #default="{ row }"><el-input-number v-model="row.sort" class="live-sort-input" :min="0" :max="9999" controls-position="right" size="small" /></template></el-table-column>
|
|
<el-table-column label="房间编号" prop="id" min-width="82" align="right" />
|
|
<el-table-column label="直播间名称" min-width="170" show-overflow-tooltip><template #default="{ row }"><strong>{{ row.name }}</strong></template></el-table-column>
|
|
<el-table-column v-if="!compactTable" label="封面" min-width="88"><template #default="{ row }"><el-image class="live-cover" :src="getUrl(row.coverUrl)" fit="cover"><template #error><div class="image-slot">暂无</div></template></el-image></template></el-table-column>
|
|
<el-table-column label="所属分类" prop="categoryName" min-width="110" show-overflow-tooltip />
|
|
<el-table-column label="主播" min-width="130"><template #default="{ row }"><div class="anchor-cell"><el-avatar :size="30" :src="getUrl(row.anchorAvatar)">{{ row.anchorName?.slice(0, 1) }}</el-avatar><span>{{ row.anchorName || `用户 ${row.anchorUserId}` }}</span></div></template></el-table-column>
|
|
<el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusText(row.status) }}</el-tag></template></el-table-column>
|
|
<el-table-column v-if="!compactTable" label="观看人数" min-width="82" prop="viewerCount" align="right" />
|
|
<el-table-column v-if="!compactTable" label="创建时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column>
|
|
<el-table-column label="操作" width="260" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="VideoCamera" @click="openStudio(row)">开播</el-button><el-button link type="primary" :icon="Connection" @click="openDiagnostics(row)">测试</el-button><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-dropdown trigger="click" @command="handleRoomAction($event, row)"><el-button link type="primary" :icon="MoreFilled" title="更多操作" aria-label="更多操作" /><template #dropdown><el-dropdown-menu><el-dropdown-item command="studio" :icon="CopyDocument">复制主播链接</el-dropdown-item><el-dropdown-item command="viewer" :icon="Share">复制观众链接</el-dropdown-item><el-dropdown-item command="links" :icon="Connection">查看链接</el-dropdown-item><el-dropdown-item command="delete" :icon="Delete" divided class="room-delete-action">删除直播间</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
|
</el-table>
|
|
<div class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @change="load" /></div>
|
|
</div>
|
|
|
|
<el-drawer v-model="drawer" destroy-on-close size="640px" :show-close="false">
|
|
<template #header><div class="live-drawer-header"><strong>{{ editingId ? '编辑直播间' : '新建直播间' }}</strong><div><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">保存</el-button></div></div></template>
|
|
<el-form ref="formRef" :model="form" :rules="rules" class="live-drawer-form" label-width="110px">
|
|
<div class="live-form-section">基础信息</div>
|
|
<el-form-item label="直播间名称" prop="name"><el-input v-model="form.name" maxlength="40" show-word-limit /></el-form-item>
|
|
<el-form-item label="展示排序"><el-input-number v-model="form.sort" :min="0" :max="9999" /></el-form-item>
|
|
<el-form-item label="直播分类" prop="categoryId"><el-select v-model="form.categoryId" :disabled="editingStatus === 'live'" placeholder="选择分类"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select><div v-if="editingStatus === 'live'" class="live-muted">直播过程中不能更换分类</div></el-form-item>
|
|
<el-form-item label="主播" prop="anchorUserId"><el-select v-model="form.anchorUserId" filterable placeholder="选择 Kra 用户"><el-option v-for="item in users" :key="item.userId" :label="`${item.nickname || item.username} (#${item.userId})`" :value="item.userId" /></el-select></el-form-item>
|
|
<el-form-item label="直播封面" prop="coverUrl"><ImageUploader v-model="form.coverUrl" :max-mb="5" /></el-form-item>
|
|
<el-form-item label="直播简介"><el-input v-model="form.intro" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item>
|
|
<div class="live-form-section">播放与状态</div>
|
|
<el-form-item label="外部直播流地址"><el-input v-model="form.streamUrl" placeholder="可选,填写后观看端优先播放此地址" /></el-form-item>
|
|
<el-form-item label="回放视频"><VideoUploader v-model="form.replayUrl" /></el-form-item>
|
|
<el-form-item label="启用直播间"><el-switch v-model="form.enabled" /></el-form-item>
|
|
</el-form>
|
|
</el-drawer>
|
|
<el-dialog v-model="linksDialog" title="直播间链接" width="min(640px, calc(100vw - 32px))">
|
|
<p class="room-links-name">{{ linkedRoom?.name }} <span>#{{ linkedRoom?.id }}</span></p>
|
|
<div v-for="item in linkItems" :key="item.key" class="room-link-row">
|
|
<label :for="`room-link-${item.key}`">{{ item.label }}</label>
|
|
<el-input :id="`room-link-${item.key}`" :model-value="roomLinks[item.key]" readonly @focus="$event.target.select()" />
|
|
<el-tooltip :content="`复制${item.label}`"><el-button :icon="CopyDocument" :aria-label="`复制${item.label}`" @click="copyLink(linkedRoom, item.key)" /></el-tooltip>
|
|
<el-tooltip :content="`打开${item.label}`"><el-button :icon="TopRight" :aria-label="`打开${item.label}`" @click="openLink(roomLinks[item.key])" /></el-tooltip>
|
|
</div>
|
|
<template #footer><el-button @click="linksDialog = false">关闭</el-button></template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { Connection, CopyDocument, Delete, Edit, MoreFilled, Plus, Refresh, Search, Share, Sort, TopRight, VideoCamera } from '@element-plus/icons-vue'
|
|
import { useWindowSize } from '@vueuse/core'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { getUrl } from '@/utils/image'
|
|
import ImageUploader from '../components/ImageUploader.vue'
|
|
import VideoUploader from '../components/VideoUploader.vue'
|
|
import { createRoom, deleteRoom, getAssetUsers, getCategories, getRooms, saveRoomSort, updateRoom } from '../api/live'
|
|
import { getRoomLinks } from '../utils/roomLinks'
|
|
import { copyLiveText } from '../utils/clipboard'
|
|
|
|
defineOptions({ name: 'LiveRooms' })
|
|
const query = reactive({ name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' })
|
|
const rows = ref([]), categories = ref([]), users = ref([]), loading = ref(false), saving = ref(false), drawer = ref(false), formRef = ref()
|
|
const { width } = useWindowSize()
|
|
const compactTable = computed(() => width.value < 1180)
|
|
const page = ref(1), pageSize = ref(10), total = ref(0), editingId = ref(0), editingStatus = ref('')
|
|
const blank = () => ({ name: '', categoryId: undefined, coverUrl: '', intro: '', sort: 0, anchorUserId: undefined, streamUrl: '', replayUrl: '', enabled: true })
|
|
const form = reactive(blank())
|
|
const linksDialog = ref(false), linkedRoom = ref(null), roomLinks = ref({ viewer: '', studio: '' })
|
|
const linkItems = [{ key: 'studio', label: '主播链接' }, { key: 'viewer', label: '观众链接' }]
|
|
const rules = { name: [{ required: true, message: '请输入直播间名称', trigger: 'blur' }], categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }], anchorUserId: [{ required: true, message: '请选择主播', trigger: 'change' }], coverUrl: [{ required: true, message: '请上传直播封面', trigger: 'change' }] }
|
|
const statusText = (value) => ({ live: '直播中', offline: '未开播', disabled: '已禁用' }[value] || value)
|
|
const statusType = (value) => ({ live: 'success', offline: 'info', disabled: 'danger' }[value] || 'info')
|
|
const format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
|
|
const load = async () => { loading.value = true; try { const res = await getRooms({ page: page.value, pageSize: pageSize.value, ...query }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } }
|
|
const loadOptions = async () => { const [c, u] = await Promise.all([getCategories({ page: 1, pageSize: 100, orderBy: 'sort_asc' }), getAssetUsers({ page: 1, pageSize: 100, orderBy: 'created_desc' })]); if (c.code === 0) categories.value = c.data.list || []; if (u.code === 0) users.value = u.data.list || [] }
|
|
const search = () => { page.value = 1; load() }
|
|
const reset = () => { Object.assign(query, { name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' }); search() }
|
|
const openCreate = () => { editingId.value = 0; editingStatus.value = ''; Object.assign(form, blank()); drawer.value = true }
|
|
const openEdit = (row) => { editingId.value = row.id; editingStatus.value = row.status; Object.assign(form, { name: row.name, categoryId: row.categoryId, coverUrl: row.coverUrl, intro: row.intro, sort: row.sort, anchorUserId: row.anchorUserId, streamUrl: row.streamUrl, replayUrl: row.replayUrl, enabled: row.enabled }); drawer.value = true }
|
|
const submit = async () => {
|
|
await formRef.value?.validate()
|
|
saving.value = true
|
|
try {
|
|
const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form)
|
|
if (res.code === 0) {
|
|
ElMessage.success('直播间已保存')
|
|
drawer.value = false
|
|
showLinks(res.data || { ...form, id: editingId.value })
|
|
load()
|
|
}
|
|
} finally { saving.value = false }
|
|
}
|
|
const removeRow = async (row) => { await ElMessageBox.confirm(`确认删除直播间“${row.name}”吗?`, '删除直播间', { type: 'warning' }); const res = await deleteRoom(row.id); if (res.code === 0) { ElMessage.success('已删除'); load() } }
|
|
const persistSort = async () => { const values = Object.fromEntries(rows.value.map((item) => [item.id, item.sort])); const res = await saveRoomSort(values); if (res.code === 0) { ElMessage.success('排序已保存'); load() } }
|
|
const showLinks = (row) => {
|
|
try {
|
|
roomLinks.value = getRoomLinks(row.id)
|
|
linkedRoom.value = row
|
|
linksDialog.value = true
|
|
} catch { ElMessage.error('无法生成直播间链接,请检查 PC 站点地址配置') }
|
|
}
|
|
const copyLink = async (row, kind) => {
|
|
try {
|
|
await copyLiveText(getRoomLinks(row.id)[kind])
|
|
ElMessage.success(kind === 'studio' ? '主播链接已复制' : '观众链接已复制')
|
|
} catch {
|
|
showLinks(row)
|
|
ElMessage.warning('复制失败,请在链接窗口中选中地址复制')
|
|
}
|
|
}
|
|
const openLink = (url) => window.open(url, '_blank', 'noopener,noreferrer')
|
|
const openStudio = (row) => {
|
|
try { openLink(getRoomLinks(row.id).studio) }
|
|
catch { ElMessage.error('无法生成主播链接,请检查 PC 站点地址配置') }
|
|
}
|
|
const openDiagnostics = (row) => window.open(`${window.location.origin}${window.location.pathname}#/live/diagnostics/${row.id}`, '_blank', 'noopener')
|
|
const handleRoomAction = (command, row) => {
|
|
if (command === 'studio' || command === 'viewer') return copyLink(row, command)
|
|
if (command === 'links') return showLinks(row)
|
|
if (command === 'delete') return removeRow(row)
|
|
}
|
|
onMounted(() => { loadOptions(); load() })
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
@use '../styles/live.scss';
|
|
.room-name { display: grid; min-width: 0; gap: 5px; }.room-name strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.room-name span { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
|
.anchor-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }.anchor-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.image-slot { display: grid; width: 100%; height: 100%; place-items: center; color: var(--el-text-color-placeholder); font-size: 11px; }
|
|
.room-delete-action { color: var(--el-color-danger); }
|
|
.room-links-name { margin: 0 0 18px; overflow-wrap: anywhere; }.room-links-name span { margin-left: 6px; color: var(--el-text-color-secondary); }
|
|
.room-link-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) 32px 32px; gap: 8px; align-items: center; margin: 12px 0; }.room-link-row .el-button { width: 32px; margin: 0; }.room-link-row label { font-size: 12px; }
|
|
@media (max-width: 480px) { .room-link-row { grid-template-columns: minmax(0, 1fr) 32px 32px; }.room-link-row label { grid-column: 1 / -1; } }
|
|
</style>
|