视频会议保存
This commit is contained in:
56
admin/src/api/meeting.js
Normal file
56
admin/src/api/meeting.js
Normal 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'
|
||||
})
|
||||
}
|
||||
@@ -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: '会议记录' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
654
admin/src/views/meeting/list.vue
Normal file
654
admin/src/views/meeting/list.vue
Normal 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>
|
||||
Reference in New Issue
Block a user