import { AppstoreOutlined, AudioOutlined, CalendarOutlined, CheckOutlined, CloudUploadOutlined, DeleteOutlined, EditOutlined, FilterOutlined, InfoCircleOutlined, PauseCircleOutlined, PlusOutlined, RightOutlined, SearchOutlined, SettingOutlined, SyncOutlined, TeamOutlined, UnorderedListOutlined, UserOutlined, } from "@ant-design/icons"; import { App, Avatar, Button, Card, Dropdown, Empty, Form, Input, List, Modal, Popconfirm, Radio, Select, Skeleton, Space, Table, Tag, Typography, } from "antd"; import dayjs from "dayjs"; import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { listUsers } from "../../api"; import { deleteMeeting, getMeetingCreateConfig, getMeetingPage, getMeetingProgressBatch, getRealtimeMeetingSessionStatus, getRealtimeMeetingSessionStatuses, type MeetingCreateConfig, type MeetingProgress, type MeetingVO, type RealtimeMeetingSessionStatus, retryScheduleMeeting, updateMeetingParticipants, } from "../../api/business/meeting"; import { MeetingCreateDrawer, type MeetingCreateType } from "../../components/business/MeetingCreateDrawer"; import AppPagination from "../../components/shared/AppPagination"; import { usePermission } from "../../hooks/usePermission"; import type { SysUser } from "../../types"; import PageContainer from "../../components/shared/PageContainer"; const { Title, Text } = Typography; const { Option } = Select; const { Search } = Input; const CURRENT_PLATFORM = "WEB" as const; const PAUSED_DISPLAY_STATUS = 5; const REALTIME_ACTIVE_DISPLAY_STATUS = 6; const REALTIME_IDLE_DISPLAY_STATUS = 7; const ALL_STATUS_FILTER = "all"; const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000; const MEETING_STATUS_FILTER_OPTIONS = [ { label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" }, { label: "数据初始化", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" }, { label: "转译音频", value: "1", color: "#1890ff", bgColor: "#e6f7ff" }, { label: "生成总结", value: "2", color: "#faad14", bgColor: "#fff7e6" }, { label: "处理完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" }, { label: "处理失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" }, ] as const; const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = { offlineEnabled: true, realtimeEnabled: true, offlineAudioMaxSizeMb: 1024, }; const isRealtimeMeetingCandidate = (item: MeetingVO) => item.meetingType === "REALTIME" || (!item.meetingType && item.status === 0 && !item.audioUrl); const canControlRealtimeFromCurrentPlatform = (item: MeetingVO) => !item.meetingSource || item.meetingSource === CURRENT_PLATFORM; const getMeetingSourceLabel = (source?: MeetingVO["meetingSource"]) => source === "ANDROID" ? "安卓端" : "Web端"; const getRealtimeSourceLabel = (item: MeetingVO) => getMeetingSourceLabel(item.meetingSource); const isPausedRealtimeSessionStatus = (status?: RealtimeMeetingSessionStatus["status"]) => status === "PAUSED_EMPTY" || status === "PAUSED_RESUMABLE"; const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"]) => status === "PAUSED_EMPTY" || status === "PAUSED_RESUMABLE" || status === "ACTIVE" || status === "IDLE"; const hasLatestGenerationFailure = (item: MeetingVO) => item.latestChapterAttemptStatus === 3 || item.latestSummaryAttemptStatus === 3; const shouldTrackGenerationProgress = (item: MeetingVO) => !hasLatestGenerationFailure(item) && (item.status === 0 || item.status === 1 || item.status === 2); const isTerminalMeetingProgress = (progress?: MeetingProgress | null) => !!progress && ( progress.percent === 100 || progress.percent < 0 || progress.unifiedStatus?.statusCode === "COMPLETED" || progress.unifiedStatus?.statusCode?.startsWith("FAILED_") ); const isUnifiedTerminalProgress = (progress?: MeetingProgress | null) => !!progress && ( progress.unifiedStatus?.statusCode === "COMPLETED" || progress.unifiedStatus?.statusCode?.startsWith("FAILED_") ); const shouldPollMeetingCard = (item: MeetingVO) => shouldTrackGenerationProgress(item) || item.realtimeSessionStatus === "ACTIVE" || isPausedRealtimeSessionStatus(item.realtimeSessionStatus); const getUnifiedStatusCode = (progress: MeetingProgress | null | undefined) => progress?.unifiedStatus?.statusCode; const getEffectiveStatus = (item: MeetingVO, progress: MeetingProgress | null) => { const unifiedStatusCode = getUnifiedStatusCode(progress); if (unifiedStatusCode === "WAITING_UPLOAD") { return 8; } if (unifiedStatusCode === "INITIALIZING") { return 0; } if (unifiedStatusCode === "TRANSCRIBING") { return 1; } if (unifiedStatusCode === "SUMMARIZING") { return 2; } if (unifiedStatusCode === "COMPLETED") { return 3; } if (unifiedStatusCode?.startsWith("FAILED_")) { return 4; } if (hasLatestGenerationFailure(item)) { return 4; } const status = item.displayStatus ?? item.status; // 如果处于初始化中但已经有进度,则视为转译音频中 if (status === 0 && progress && progress.percent > 0) { return 1; } return status; }; const canManageMeeting = (meeting: MeetingVO) => { try { const profileStr = sessionStorage.getItem("userProfile"); if (!profileStr) { return false; } const profile = JSON.parse(profileStr); return profile.isPlatformAdmin === true || profile.isTenantAdmin === true || profile.userId === meeting.creatorId; } catch { return false; } }; const canRetryQueuedMeeting = (meeting: MeetingVO, progress: MeetingProgress | null) => { if (!canManageMeeting(meeting) || getEffectiveStatus(meeting, progress) !== 0) { return false; } if (!progress?.queuedAt) { return false; } return dayjs().diff(dayjs(progress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS; }; const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => { if (!sessionStatus) { return item; } if (sessionStatus.status === "PAUSED_EMPTY" || sessionStatus.status === "PAUSED_RESUMABLE") { return { ...item, displayStatus: PAUSED_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } if (sessionStatus.status === "ACTIVE") { return { ...item, displayStatus: REALTIME_ACTIVE_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } if (sessionStatus.status === "IDLE" && isRealtimeMeetingCandidate(item)) { return { ...item, displayStatus: REALTIME_IDLE_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } return { ...item, realtimeSessionStatus: sessionStatus.status }; }; const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => { const effectiveStatus = getEffectiveStatus(meeting, progress); const statusConfig: Record = { 0: { text: "数据初始化", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: }, 1: { text: "转译音频", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: }, 2: { text: "生成总结", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: }, 3: { text: "处理完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: }, 4: { text: "处理失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: }, 5: { text: "暂停中", color: "#d48806", bgColor: "rgba(212, 136, 6, 0.1)", icon: }, 6: { text: "进行中", color: "#5f51ff", bgColor: "rgba(95, 81, 255, 0.1)", icon: }, 7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: }, 8: { text: "待上传录音文件", color: "#13a8a8", bgColor: "rgba(19, 168, 168, 0.1)", icon: }, }; const config = statusConfig[effectiveStatus] || statusConfig[0]; const displayConfig = progress?.unifiedStatus?.statusText ? { ...config, text: progress.unifiedStatus.statusText } : config; const isProcessing = shouldTrackGenerationProgress(meeting) && !isUnifiedTerminalProgress(progress); const percent = isProcessing ? progress?.percent || 0 : 0; return (
{isProcessing && percent > 0 && (
)} {isProcessing ? : displayConfig.icon} {displayConfig.text} {isProcessing && {percent}%}
); }; const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => { return ; }; const MeetingCardItem: React.FC<{ item: MeetingVO; config: { text: string; color: string; bgColor: string }; progress: MeetingProgress | null; onOpenMeeting: (meeting: MeetingVO) => void; onRetrySchedule: (meeting: MeetingVO) => void; onDelete: (id: number) => void; retrying: boolean; }> = ({ item, config, progress, onOpenMeeting, onRetrySchedule, onDelete, retrying }) => { const effectiveStatus = getEffectiveStatus(item, progress); const isProcessing = shouldTrackGenerationProgress(item); const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item); const crossPlatformHint = `在 ${getRealtimeSourceLabel(item)} 继续`; const canRetry = canRetryQueuedMeeting(item, progress); const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6"; return ( onOpenMeeting(item)} className="meeting-card-v2" style={{ borderRadius: "20px", border: "1px solid rgba(0, 0, 0, 0.04)", background: "#ffffff", overflow: "hidden", transition: "all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1)", boxShadow: "0 2px 8px rgba(0, 0, 0, 0.02), 0 1px 2px rgba(0, 0, 0, 0.02)", height: "100%", display: "flex", flexDirection: "column", minHeight: "200px" }} styles={{ body: { padding: "20px 24px", flex: 1, display: "flex", flexDirection: "column" } }} > {/* Top Section: Status & Source */}
{getMeetingSourceLabel(item.meetingSource)}
{/* Middle Section: Title & Actions */}
{item.title} {canManageMeeting(item) && (
e.stopPropagation()} > onDelete(item.id)} okText="删除" cancelText="取消" okButtonProps={{ danger: true }} >
)}
{/* Content Section: Progress or Metadata */}
{(isProcessing || isPaused || isRealtimeActive || isRealtimeIdle) ? (
{isProcessing ? : } {isProcessing ? (progress?.message || "深度分析中...") : (isCrossPlatformRealtime ? crossPlatformHint : config.text)}
{canRetry && ( )}
) : (
{dayjs(item.meetingTime).format("MM-DD HH:mm")}
{item.creatorName || "未知"}
)}
{/* Bottom Section: Tags & Navigate */}
{item.tags?.split(",").filter(Boolean).slice(0, 2).map(tag => ( #{tag} )) || 无标签}
查看详情
); }; const Meetings: React.FC = () => { const { message } = App.useApp(); const { t } = useTranslation(); const navigate = useNavigate(); const { can } = usePermission(); const [searchParams, setSearchParams] = useSearchParams(); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); const [progressMap, setProgressMap] = useState>({}); const [total, setTotal] = useState(0); const [current, setCurrent] = useState(1); const [displayMode, setDisplayMode] = useState<"card" | "list">("card"); const [size, setSize] = useState(8); const [searchTitle, setSearchTitle] = useState(""); const [searchKeyword, setSearchKeyword] = useState(""); const [viewType, setViewType] = useState<"all" | "created" | "involved">("all"); const [statusFilter, setStatusFilter] = useState(ALL_STATUS_FILTER); const [createDrawerVisible, setCreateDrawerVisible] = useState(false); const [createDrawerType, setCreateDrawerType] = useState("upload"); const [configLoaded, setConfigLoaded] = useState(false); const [createConfig, setCreateConfig] = useState({ offlineEnabled: false, realtimeEnabled: false, offlineAudioMaxSizeMb: 1024, }); const [userList, setUserList] = useState([]); const progressTerminalRefreshRef = useRef>(new Map()); const [retryingMeetingIds, setRetryingMeetingIds] = useState>({}); const activeFilterCount = (statusFilter !== ALL_STATUS_FILTER ? 1 : 0) + (searchTitle ? 1 : 0); const handleDisplayModeChange = (mode: "card" | "list") => { setDisplayMode(mode); setSize(mode === "card" ? 8 : 10); setCurrent(1); }; const handleSearch = (value?: string) => { const nextValue = (value ?? searchKeyword).trim(); setSearchKeyword(value ?? searchKeyword); setSearchTitle(nextValue); setCurrent(1); }; const handleResetFilters = () => { setSearchKeyword(""); setSearchTitle(""); setStatusFilter(ALL_STATUS_FILTER); setCurrent(1); }; const loadBatchProgress = async (meetings: MeetingVO[]) => { const trackedIds = meetings.filter(shouldTrackGenerationProgress).map((item) => item.id); if (trackedIds.length === 0) { progressTerminalRefreshRef.current.clear(); setProgressMap({}); return {} as Record; } try { const progressRes = await getMeetingProgressBatch(trackedIds, { suppressErrorToast: true }); const nextProgressMap = progressRes.data?.data || {}; const activeIds = new Set(trackedIds); progressTerminalRefreshRef.current.forEach((_, id) => { if (!activeIds.has(id)) { progressTerminalRefreshRef.current.delete(id); } }); setProgressMap(nextProgressMap); return nextProgressMap; } catch { setProgressMap({}); return {} as Record; } }; useEffect(() => { const action = searchParams.get("action"); const type = searchParams.get("type") as MeetingCreateType; if (action === "create" && (type === "realtime" || type === "upload")) { setCreateDrawerType(type); setCreateDrawerVisible(true); setSearchParams({}); } }, [searchParams, setSearchParams]); useEffect(() => { void fetchData(); }, [current, size, searchTitle, viewType, statusFilter]); useEffect(() => { const trackedMeetings = data.filter(shouldTrackGenerationProgress); if (trackedMeetings.length === 0) { progressTerminalRefreshRef.current.clear(); setProgressMap({}); return; } let cancelled = false; let timer: ReturnType | null = null; let requesting = false; const poll = async () => { if (cancelled || requesting) { return; } requesting = true; try { const nextProgressMap = await loadBatchProgress(trackedMeetings); if (cancelled) { return; } let shouldRefresh = false; for (const meeting of trackedMeetings) { const progress = nextProgressMap[meeting.id]; if (!isTerminalMeetingProgress(progress)) { progressTerminalRefreshRef.current.delete(meeting.id); continue; } const terminalKey = `${progress.updateAt}:${progress.percent}`; if (progressTerminalRefreshRef.current.get(meeting.id) !== terminalKey) { progressTerminalRefreshRef.current.set(meeting.id, terminalKey); shouldRefresh = true; } } if (shouldRefresh) { await fetchData(true); } } finally { requesting = false; if (!cancelled) { timer = setTimeout(() => { void poll(); }, 3000); } } }; timer = setTimeout(() => { void poll(); }, 3000); return () => { cancelled = true; if (timer) { clearTimeout(timer); } }; }, [data, current, size, searchTitle, viewType, statusFilter]); useEffect(() => { const hasRealtimeSessionsToPoll = data.some( (item) => item.realtimeSessionStatus === "ACTIVE" || isPausedRealtimeSessionStatus(item.realtimeSessionStatus), ); if (!hasRealtimeSessionsToPoll) { return; } let cancelled = false; let timer: ReturnType | null = null; let requesting = false; const poll = async () => { if (cancelled || requesting) { return; } requesting = true; try { await fetchData(true); } finally { requesting = false; if (!cancelled) { timer = setTimeout(() => { void poll(); }, 5000); } } }; timer = setTimeout(() => { void poll(); }, 5000); return () => { cancelled = true; if (timer) { clearTimeout(timer); } }; }, [data, current, size, searchTitle, viewType, statusFilter]); useEffect(() => { listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([])); getMeetingCreateConfig() .then((res) => { setCreateConfig(res.data.data || DEFAULT_CREATE_CONFIG); setConfigLoaded(true); }) .catch(() => { setCreateConfig(DEFAULT_CREATE_CONFIG); setConfigLoaded(true); }); }, []); const fetchData = async (silent = false) => { if (!silent) { setLoading(true); } try { const res = await getMeetingPage({ current, size, title: searchTitle, viewType, status: statusFilter === ALL_STATUS_FILTER ? undefined : Number(statusFilter), }); const records = res.data?.data?.records || []; let statusMap: Record = {}; const realtimeCandidates = records.filter(isRealtimeMeetingCandidate); if (realtimeCandidates.length > 0) { try { const sessionRes = await getRealtimeMeetingSessionStatuses(realtimeCandidates.map((item) => item.id)); statusMap = sessionRes.data?.data || {}; } catch {} } const nextData = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id])); setData(nextData); setTotal(res.data?.data?.total || 0); await loadBatchProgress(nextData); } finally { if (!silent) { setLoading(false); } } }; const handleRetrySchedule = async (meeting: MeetingVO) => { setRetryingMeetingIds((currentMap) => ({ ...currentMap, [meeting.id]: true })); try { const response = await retryScheduleMeeting(meeting.id); if (response.data?.data) { message.success("已触发重新调度"); } else { message.info("当前没有可重新调度的排队任务"); } await fetchData(true); } catch { message.error("重新调度失败"); } finally { setRetryingMeetingIds((currentMap) => { const nextMap = { ...currentMap }; delete nextMap[meeting.id]; return nextMap; }); } }; const handleOpenMeeting = async (meeting: MeetingVO) => { if (!isRealtimeMeetingCandidate(meeting)) { navigate("/meetings/" + meeting.id); return; } if (!canControlRealtimeFromCurrentPlatform(meeting)) { message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`); navigate("/meetings/" + meeting.id); return; } if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) { navigate("/meeting-live-session/" + meeting.id); return; } if (!canManageMeeting(meeting)) { navigate("/meetings/" + meeting.id); return; } try { const res = await getRealtimeMeetingSessionStatus(meeting.id); if (canOpenRealtimeSession(res.data?.data?.status)) { navigate("/meeting-live-session/" + meeting.id); return; } } catch {} navigate("/meetings/" + meeting.id); }; const tableColumns = [ { title: "会议标题", dataIndex: "title", key: "title", render: (text: string, record: MeetingVO) => handleOpenMeeting(record)}>{text}, }, { title: "状态", key: "status", width: 150, render: (_: unknown, record: MeetingVO) => , }, { title: "会议时间", dataIndex: "meetingTime", key: "meetingTime", width: 160, render: (text: string) => dayjs(text).format("YYYY-MM-DD HH:mm"), }, { title: "创建人", dataIndex: "creatorName", key: "creatorName", width: 100, render: (text: string) => {text || "未知"}, }, { title: "鏉ユ簮", dataIndex: "meetingSource", key: "meetingSource", width: 80, render: (value: MeetingVO["meetingSource"]) => {getMeetingSourceLabel(value)}, }, { title: "参会人", dataIndex: "participants", key: "participants", render: (text: string) => {text || "无参会人员"}, }, { title: "鎿嶄綔", key: "action", width: 220, render: (_: unknown, record: MeetingVO) => ( {canRetryQueuedMeeting(record, progressMap[record.id] || null) && ( )} {canManageMeeting(record) && ( deleteMeeting(record.id).then(() => fetchData())}> )} ), }, ]; const statusConfig: Record = { 0: { text: "数据初始化", color: "#8c8c8c", bgColor: "#f5f5f5" }, 1: { text: "转译音频", color: "#1890ff", bgColor: "#e6f7ff" }, 2: { text: "生成总结", color: "#faad14", bgColor: "#fff7e6" }, 3: { text: "处理完成", color: "#52c41a", bgColor: "#f6ffed" }, 4: { text: "处理失败", color: "#ff4d4f", bgColor: "#fff1f0" }, 5: { text: "会议暂停", color: "#d48806", bgColor: "#fff7e6" }, 6: { text: "实时进行中", color: "#1677ff", bgColor: "#e6f4ff" }, 7: { text: "待开始", color: "#595959", bgColor: "#f5f5f5" }, }; return ( handleDisplayModeChange(e.target.value)} buttonStyle="solid"> {configLoaded && ( )} } toolbar={ { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid"> 全部 我发起 我参与 0 ? "processing" : "default"} style={{ margin: 0, borderRadius: 999, paddingInline: 10, lineHeight: "24px" }} > {activeFilterCount > 0 ? `已筛选 ${activeFilterCount} 项` : "未筛选"}