Files
kra-new/web/src/modules/live/view/rooms.vue
T
2026-09-22 20:20:23 +08:00

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>