视频会议保存

This commit is contained in:
duoaohui
2026-05-18 19:42:55 +08:00
parent 315884685f
commit ac8ec4c903
41 changed files with 5069 additions and 39 deletions

56
admin/src/api/meeting.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* 会议管理 API 模块Phase B
*
* 对应后端路由:/api/v1/admin/meetings
* 所有接口需要 JWT + admin 角色权限
*
* 接口列表:
* - GET /api/v1/admin/meetings 会议列表(分页 + 多条件筛选)
* - GET /api/v1/admin/meetings/:id 会议详情(含参与者 + 录制列表)
* - GET /api/v1/admin/meetings/stats 会议统计(卡片)
*/
import request from '@/utils/request'
/**
* 获取会议列表
* @param {Object} params
* @param {number} [params.page]
* @param {number} [params.page_size]
* @param {string} [params.keyword] - 模糊匹配 title / room_code
* @param {number} [params.status] - 0=未开始 1=进行中 2=已结束
* @param {number} [params.host_id] - 主持人 ID
* @param {boolean} [params.has_recording] - true=仅含录制的会议
* @param {string} [params.start_time] - YYYY-MM-DD
* @param {string} [params.end_time] - YYYY-MM-DD
* @returns {Promise<{data:{total:number,list:Array,page:number,page_size:number}}>}
*/
export function getMeetingList(params) {
return request({
url: '/api/v1/admin/meetings',
method: 'get',
params
})
}
/**
* 获取会议详情(含参与者 + 录制列表)
* @param {number} id - 会议主键 ID
* @returns {Promise<{data:Object}>}
*/
export function getMeetingDetail(id) {
return request({
url: `/api/v1/admin/meetings/${id}`,
method: 'get'
})
}
/**
* 获取会议统计(顶栏卡片)
* @returns {Promise<{data:{total_count,active_count,today_count,recording_count}}>}
*/
export function getMeetingStats() {
return request({
url: '/api/v1/admin/meetings/stats',
method: 'get'
})
}

View File

@@ -74,6 +74,12 @@ const routes = [
name: 'MessageStats',
component: () => import('@/views/message/stats.vue'),
meta: { title: '消息统计' }
},
{
path: 'meeting/list',
name: 'MeetingList',
component: () => import('@/views/meeting/list.vue'),
meta: { title: '会议记录' }
}
]
}

View File

@@ -65,6 +65,14 @@
<el-menu-item index="/message/stats">消息统计</el-menu-item>
</el-sub-menu>
<el-sub-menu index="meeting-manage">
<template #title>
<el-icon><VideoCamera /></el-icon>
<span>会议管理</span>
</template>
<el-menu-item index="/meeting/list">会议记录</el-menu-item>
</el-sub-menu>
<el-menu-item index="/monitor/online">
<el-icon><Monitor /></el-icon>
<template #title>在线监控</template>

View File

@@ -0,0 +1,654 @@
<!--
管理端 - 会议管理列表Phase B
设计系统 message/list.vue 一致的 Data-Dense Dashboard 风格
功能
- 顶部统计卡片历史会议总数 / 进行中 / 今日新建 / 录制总数
- 多条件筛选关键词 / 状态 / 仅含录制 / 时间范围
- 数据表格会议号 / 标题 / 主持人 / 状态 / 参与人数 / 录制数 / 时长 / 创建时间
- 详情弹窗基础信息 + 参与者列表 + 录制列表含播放链接 / 失败原因
对应后端 API管理端
- GET /api/v1/admin/meetings
- GET /api/v1/admin/meetings/:id
- GET /api/v1/admin/meetings/stats
-->
<template>
<div class="meeting-list-page">
<div class="page-header">
<h2 class="page-title">会议管理</h2>
</div>
<!-- 顶部统计卡片 -->
<div class="stat-cards">
<el-card class="stat-card" shadow="never">
<div class="stat-label">会议总数</div>
<div class="stat-value">{{ stats.total_count }}</div>
</el-card>
<el-card class="stat-card stat-card--accent" shadow="never">
<div class="stat-label">进行中</div>
<div class="stat-value">{{ stats.active_count }}</div>
</el-card>
<el-card class="stat-card" shadow="never">
<div class="stat-label">今日新建</div>
<div class="stat-value">{{ stats.today_count }}</div>
</el-card>
<el-card class="stat-card stat-card--rec" shadow="never">
<div class="stat-label">录制总数</div>
<div class="stat-value">{{ stats.recording_count }}</div>
</el-card>
</div>
<!-- 筛选区 -->
<el-card class="filter-card" shadow="never">
<el-form :inline="true" class="filter-form" @submit.prevent="handleSearch">
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="会议号 / 标题"
clearable
style="width: 220px"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 120px">
<el-option label="未开始" :value="0" />
<el-option label="进行中" :value="1" />
<el-option label="已结束" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="主持人ID">
<el-input
v-model="filters.host_id"
placeholder="用户ID"
clearable
style="width: 120px"
type="number"
/>
</el-form-item>
<el-form-item label="仅含录制">
<el-switch v-model="filters.has_recording" />
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
@change="onDateRangeChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="meetingList"
stripe
border
style="width: 100%"
row-class-name="table-row"
>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="会议号" width="140">
<template #default="{ row }">
<span class="room-code">{{ row.room_code }}</span>
</template>
</el-table-column>
<el-table-column label="标题 / 类型" min-width="200">
<template #default="{ row }">
<div class="title-cell">
<span class="meeting-title">{{ row.title || '(未命名会议)' }}</span>
<el-tag size="small" type="info" effect="plain">{{ row.type_label }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="主持人" min-width="160">
<template #default="{ row }">
<div class="host-cell">
<div
class="host-avatar"
:style="{ backgroundColor: getAvatarColor(row.host_nickname) }"
>
<img v-if="row.host_avatar" :src="row.host_avatar" class="host-avatar-img" />
<span v-else>{{ (row.host_nickname || '?')[0] }}</span>
</div>
<div class="host-info">
<span class="host-name">{{ row.host_nickname || '-' }}</span>
<span class="host-id">ID: {{ row.host_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagMap[row.status]?.type || 'info'" size="small">
{{ row.status_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="参与人数" width="100" align="center">
<template #default="{ row }">
<span class="num-cell">{{ row.participant_count }}</span>
<span class="num-suffix">/ {{ row.max_members }}</span>
</template>
</el-table-column>
<el-table-column label="录制" width="100" align="center">
<template #default="{ row }">
<el-tag
v-if="row.recording_count > 0"
type="warning"
size="small"
effect="light"
>
{{ row.recording_count }}
</el-tag>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="时长" width="110" align="center">
<template #default="{ row }">
<span>{{ formatDuration(row.duration_sec) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" align="center" />
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchMeetingList"
@current-change="fetchMeetingList"
/>
</div>
</el-card>
<!-- 会议详情弹窗 -->
<el-dialog
v-model="detailVisible"
title="会议详情"
width="780px"
destroy-on-close
class="detail-dialog"
>
<div v-loading="detailLoading" class="detail-content">
<template v-if="currentMeeting">
<!-- 会议头部 -->
<div class="detail-header">
<div class="detail-header-main">
<div class="detail-room-code">{{ currentMeeting.room_code }}</div>
<div class="detail-title">{{ currentMeeting.title }}</div>
<div class="detail-meta">
<el-tag size="small" :type="statusTagMap[currentMeeting.status]?.type || 'info'">
{{ currentMeeting.status_label }}
</el-tag>
<el-tag size="small" type="info" effect="plain">{{ currentMeeting.type_label }}</el-tag>
<span class="meta-divider">·</span>
<span class="meta-item">主持人 {{ currentMeeting.host_nickname || currentMeeting.host_id }}</span>
<span class="meta-divider">·</span>
<span class="meta-item">时长 {{ formatDuration(currentMeeting.duration_sec) }}</span>
</div>
</div>
</div>
<!-- 元数据栅格 -->
<div class="detail-grid">
<div class="detail-grid-item">
<span class="grid-label">参与人数</span>
<span class="grid-value">
{{ currentMeeting.participant_count }} / {{ currentMeeting.max_members }}
</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">录制段数</span>
<span class="grid-value">{{ currentMeeting.recording_count }}</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">开始时间</span>
<span class="grid-value">{{ currentMeeting.started_at || '—' }}</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">结束时间</span>
<span class="grid-value">
{{ currentMeeting.ended_at || '—' }}
<el-tag
v-if="currentMeeting.ended_reason"
size="small"
effect="plain"
type="info"
style="margin-left: 6px"
>
{{ endedReasonLabel(currentMeeting.ended_reason) }}
</el-tag>
</span>
</div>
</div>
<!-- 参与者 -->
<div class="detail-section">
<div class="section-header">
<span>参与者</span>
<span class="section-count">{{ currentMeeting.participants?.length || 0 }}</span>
</div>
<el-table
:data="currentMeeting.participants || []"
size="small"
border
style="width: 100%"
>
<el-table-column label="用户" min-width="180">
<template #default="{ row }">
<div class="participant-cell">
<div
class="participant-avatar"
:style="{ backgroundColor: getAvatarColor(row.nickname) }"
>
<img v-if="row.avatar" :src="row.avatar" class="participant-avatar-img" />
<span v-else>{{ (row.nickname || '?')[0] }}</span>
</div>
<div class="participant-info">
<span class="participant-name">{{ row.nickname || '-' }}</span>
<span class="participant-id">ID: {{ row.user_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="角色" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.role === 1 ? 'danger' : row.role === 2 ? 'warning' : 'info'"
size="small"
effect="light"
>
{{ row.role_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="joined_at" label="加入时间" width="170" align="center" />
<el-table-column label="离开时间" width="170" align="center">
<template #default="{ row }">
<span v-if="row.left_at">{{ row.left_at }}</span>
<el-tag v-else type="success" size="small" effect="plain">在线</el-tag>
</template>
</el-table-column>
<el-table-column label="离会原因" width="110" align="center">
<template #default="{ row }">
{{ row.left_reason ? leftReasonLabel(row.left_reason) : '—' }}
</template>
</el-table-column>
<el-table-column label="参会时长" width="100" align="center">
<template #default="{ row }">{{ formatDuration(row.duration) }}</template>
</el-table-column>
</el-table>
</div>
<!-- 录制列表 -->
<div class="detail-section">
<div class="section-header">
<span>录制列表</span>
<span class="section-count">{{ currentMeeting.recordings?.length || 0 }}</span>
</div>
<el-empty
v-if="!currentMeeting.recordings || currentMeeting.recordings.length === 0"
description="无录制记录"
:image-size="60"
/>
<el-table
v-else
:data="currentMeeting.recordings"
size="small"
border
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="发起人" width="100" align="center">
<template #default="{ row }">ID: {{ row.started_by }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="recordingStatusTagMap[row.status]?.type || 'info'" size="small">
{{ recordingStatusTagMap[row.status]?.label || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时长" width="100" align="center">
<template #default="{ row }">{{ formatDuration(row.duration_sec) }}</template>
</el-table-column>
<el-table-column label="文件大小" width="110" align="center">
<template #default="{ row }">{{ formatBytes(row.size_bytes) }}</template>
</el-table-column>
<el-table-column prop="started_at" label="开始时间" width="170" align="center" />
<el-table-column label="文件" min-width="100" align="center">
<template #default="{ row }">
<a
v-if="row.status === 'ready' && row.file_url"
:href="row.file_url"
target="_blank"
class="file-link"
>播放 / 下载</a>
<span v-else-if="row.status === 'failed'" class="failure-text">
{{ row.failure_reason || '失败' }}
</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getMeetingList, getMeetingDetail, getMeetingStats } from '@/api/meeting'
const AVATAR_COLORS = ['#2563EB', '#7C3AED', '#059669', '#D97706', '#DC2626', '#0891B2', '#4F46E5', '#BE185D']
const statusTagMap = {
0: { label: '未开始', type: 'info' },
1: { label: '进行中', type: 'success' },
2: { label: '已结束', type: 'warning' }
}
const recordingStatusTagMap = {
recording: { label: '录制中', type: 'success' },
uploading: { label: '上传中', type: 'warning' },
ready: { label: '已就绪', type: 'success' },
failed: { label: '失败', type: 'danger' }
}
const endedReasonLabels = {
host_ended: '主持人结束',
empty_ttl: '空房超时',
admin_force: '管理员强制',
system_error: '系统异常'
}
const leftReasonLabels = {
self: '主动离开',
kicked: '被移除',
host_end: '会议结束',
empty_ttl: '空房超时',
disconnect: '掉线'
}
const endedReasonLabel = (r) => endedReasonLabels[r] || r
const leftReasonLabel = (r) => leftReasonLabels[r] || r
const loading = ref(false)
const meetingList = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const dateRange = ref(null)
const stats = reactive({
total_count: 0,
active_count: 0,
today_count: 0,
recording_count: 0
})
const filters = reactive({
keyword: '',
status: null,
host_id: null,
has_recording: false
})
const detailVisible = ref(false)
const detailLoading = ref(false)
const currentMeeting = ref(null)
const getAvatarColor = (name) => {
if (!name) return '#94A3B8'
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
}
const formatDuration = (sec) => {
if (!sec || sec <= 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const formatBytes = (b) => {
if (!b || b <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let n = b
let i = 0
while (n >= 1024 && i < units.length - 1) {
n /= 1024
i++
}
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`
}
const onDateRangeChange = (val) => {
if (!val) {
filters.start_time = undefined
filters.end_time = undefined
}
}
const fetchMeetingList = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status != null) params.status = filters.status
if (filters.host_id) params.host_id = Number(filters.host_id)
if (filters.has_recording) params.has_recording = true
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = dateRange.value[0]
params.end_time = dateRange.value[1]
}
const res = await getMeetingList(params)
meetingList.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (e) {
ElMessage.error(e?.message || '获取会议列表失败')
} finally {
loading.value = false
}
}
const fetchStats = async () => {
try {
const res = await getMeetingStats()
Object.assign(stats, res.data || {})
} catch (e) {
// 统计失败不阻塞列表
console.warn('[meeting] stats load failed', e)
}
}
const handleSearch = () => {
currentPage.value = 1
fetchMeetingList()
}
const handleReset = () => {
filters.keyword = ''
filters.status = null
filters.host_id = null
filters.has_recording = false
dateRange.value = null
currentPage.value = 1
fetchMeetingList()
}
const viewDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
currentMeeting.value = null
try {
const res = await getMeetingDetail(row.id)
currentMeeting.value = res.data
} catch (e) {
ElMessage.error(e?.message || '获取会议详情失败')
} finally {
detailLoading.value = false
}
}
onMounted(() => {
fetchMeetingList()
fetchStats()
})
</script>
<style scoped>
.meeting-list-page { padding: 0; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-title { font-size: 20px; font-weight: 600; color: #1E293B; margin: 0; }
/* 统计卡片 */
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.stat-card { border: 1px solid #E2E8F0; }
.stat-card.stat-card--accent { border-color: #BFDBFE; background: linear-gradient(135deg, #EFF6FF 0%, #FFFFFF 100%); }
.stat-card.stat-card--rec { border-color: #FCD34D; background: linear-gradient(135deg, #FFFBEB 0%, #FFFFFF 100%); }
.stat-label { font-size: 12px; color: #64748B; text-transform: uppercase; letter-spacing: 0.04em; }
.stat-value { font-size: 26px; font-weight: 700; color: #0F172A; margin-top: 4px; }
.filter-card { margin-bottom: 16px; }
.filter-form { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.table-card { margin-bottom: 16px; }
.room-code {
font-family: 'SF Mono', 'Menlo', monospace;
font-weight: 600;
color: #2563EB;
}
.title-cell { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.meeting-title { font-weight: 500; color: #1E293B; }
.num-cell { font-weight: 600; color: #0F172A; }
.num-suffix { font-size: 12px; color: #94A3B8; margin-left: 2px; }
.text-muted { color: #94A3B8; }
/* 主持人 */
.host-cell { display: flex; align-items: center; gap: 10px; }
.host-avatar {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600; color: #FFFFFF; flex-shrink: 0; overflow: hidden;
}
.host-avatar-img { width: 100%; height: 100%; object-fit: cover; }
.host-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.host-name { font-weight: 500; color: #1E293B; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host-id { font-size: 11px; color: #94A3B8; }
.pagination-wrapper { display: flex; justify-content: flex-end; padding-top: 16px; }
/* ===== 详情弹窗 ===== */
.detail-content { min-height: 200px; }
.detail-header {
padding: 16px 20px;
background: linear-gradient(135deg, #F8FAFC 0%, #EFF6FF 100%);
border-radius: 12px;
margin-bottom: 20px;
}
.detail-room-code {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 13px;
color: #2563EB;
font-weight: 600;
letter-spacing: 0.05em;
}
.detail-title { font-size: 18px; font-weight: 700; color: #0F172A; margin: 4px 0 8px; }
.detail-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 13px; color: #475569; }
.meta-divider { color: #CBD5E1; }
.meta-item { display: inline-flex; align-items: center; }
.detail-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 20px;
}
.detail-grid-item {
display: flex; flex-direction: column; gap: 4px;
padding: 12px 16px; background: #F8FAFC; border-radius: 8px; border: 1px solid #E2E8F0;
}
.grid-label { font-size: 12px; color: #94A3B8; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
.grid-value { font-size: 14px; color: #1E293B; font-weight: 500; display: inline-flex; align-items: center; }
.detail-section { margin-bottom: 20px; }
.section-header {
display: flex; align-items: center; gap: 8px;
font-size: 14px; font-weight: 600; color: #334155;
margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #E2E8F0;
}
.section-count { font-size: 12px; color: #94A3B8; font-weight: 500; }
/* 参与者头像 */
.participant-cell { display: flex; align-items: center; gap: 8px; }
.participant-avatar {
width: 28px; height: 28px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 600; color: #FFFFFF; flex-shrink: 0; overflow: hidden;
}
.participant-avatar-img { width: 100%; height: 100%; object-fit: cover; }
.participant-info { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.participant-name { font-size: 12px; font-weight: 500; color: #1E293B; }
.participant-id { font-size: 10px; color: #94A3B8; }
.file-link { color: #2563EB; font-size: 13px; text-decoration: none; }
.file-link:hover { text-decoration: underline; }
.failure-text { color: #DC2626; font-size: 12px; }
</style>