imeeting/frontend/src/pages/business/Meetings.tsx

1017 lines
37 KiB
TypeScript

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<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = {
0: { text: "数据初始化", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> },
1: { text: "转译音频", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> },
2: { text: "生成总结", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: <SyncOutlined spin /> },
3: { text: "处理完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: <CheckOutlined /> },
4: { text: "处理失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: <InfoCircleOutlined /> },
5: { text: "暂停中", color: "#d48806", bgColor: "rgba(212, 136, 6, 0.1)", icon: <PauseCircleOutlined /> },
6: { text: "进行中", color: "#5f51ff", bgColor: "rgba(95, 81, 255, 0.1)", icon: <SyncOutlined spin /> },
7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> },
8: { text: "待上传录音文件", color: "#13a8a8", bgColor: "rgba(19, 168, 168, 0.1)", icon: <CloudUploadOutlined /> },
};
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 (
<div
style={{
display: "inline-flex",
alignItems: "center",
padding: "4px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 700,
color: displayConfig.color,
background: displayConfig.bgColor,
border: `1px solid ${displayConfig.color}20`,
gap: "4px",
position: "relative",
overflow: "hidden"
}}
>
{isProcessing && percent > 0 && (
<div
style={{
position: "absolute",
left: 0,
bottom: 0,
height: "2px",
width: `${percent}%`,
background: displayConfig.color,
transition: "width 0.4s ease-out",
boxShadow: `0 0 8px ${displayConfig.color}`
}}
/>
)}
<span style={{ display: "flex", alignItems: "center" }}>
{isProcessing ? <SyncOutlined spin style={{ fontSize: 11 }} /> : displayConfig.icon}
</span>
<span>{displayConfig.text}</span>
{isProcessing && <span style={{ opacity: 0.8, fontSize: "11px", fontWeight: 500 }}>{percent}%</span>}
</div>
);
};
const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
return <IntegratedStatusTag meeting={meeting} progress={progress} />;
};
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 (
<List.Item style={{ padding: 0 }}>
<Card
hoverable
onClick={() => 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 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<IntegratedStatusTag meeting={item} progress={progress} />
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
background: `${sourceColor}08`,
borderRadius: "6px",
color: sourceColor,
fontSize: "11px",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.01em"
}}
>
<div style={{ width: "5px", height: "5px", borderRadius: "50%", background: sourceColor }} />
{getMeetingSourceLabel(item.meetingSource)}
</div>
</div>
{/* Middle Section: Title & Actions */}
<div style={{ position: "relative", marginBottom: 12 }}>
<Title level={4} style={{ margin: 0, paddingRight: "32px", fontSize: "16px", lineHeight: 1.4, fontWeight: 700, color: "#1a1a1a" }} ellipsis={{ tooltip: item.title }}>
{item.title}
</Title>
{canManageMeeting(item) && (
<div
className="card-actions-v2"
style={{ position: "absolute", right: -8, top: -4 }}
onClick={e => e.stopPropagation()}
>
<Popconfirm
title="确定删除会议吗?"
description="删除后将无法找回该会议记录。"
onConfirm={() => onDelete(item.id)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="text" size="small" shape="circle" icon={<DeleteOutlined style={{ color: "#ff4d4f", fontSize: 13 }} />} />
</Popconfirm>
</div>
)}
</div>
{/* Content Section: Progress or Metadata */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px" }}>
{(isProcessing || isPaused || isRealtimeActive || isRealtimeIdle) ? (
<div
style={{
padding: "8px 10px",
borderRadius: "10px",
background: "var(--app-bg-surface-strong)",
border: "1px solid rgba(0,0,0,0.02)",
fontSize: "12px"
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px", color: config.color, fontWeight: 600 }}>
{isProcessing ? <SyncOutlined spin /> : <InfoCircleOutlined />}
<span style={{ fontSize: "12px" }}>
{isProcessing ? (progress?.message || "深度分析中...") : (isCrossPlatformRealtime ? crossPlatformHint : config.text)}
</span>
</div>
{canRetry && (
<Button
type="link"
size="small"
loading={retrying}
onClick={(event) => {
event.stopPropagation();
onRetrySchedule(item);
}}
style={{ paddingInline: 0, height: "auto" }}
>
</Button>
)}
</div>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", color: "#8c8c8c", fontSize: "12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<CalendarOutlined style={{ fontSize: "13px", opacity: 0.7 }} />
<span>{dayjs(item.meetingTime).format("MM-DD HH:mm")}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<UserOutlined style={{ fontSize: "13px", opacity: 0.7 }} />
<span style={{ maxWidth: "80px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.creatorName || "未知"}</span>
</div>
</div>
)}
</div>
{/* Bottom Section: Tags & Navigate */}
<div style={{ marginTop: "auto", paddingTop: 12, borderTop: "1px solid rgba(0,0,0,0.04)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", gap: "4px", flex: 1, overflow: "hidden" }}>
{item.tags?.split(",").filter(Boolean).slice(0, 2).map(tag => (
<span
key={tag}
style={{
padding: "1px 8px",
borderRadius: "4px",
background: "#f3f4f6",
color: "#6b7280",
fontSize: "11px",
fontWeight: 600,
whiteSpace: "nowrap",
maxWidth: "90px",
overflow: "hidden",
textOverflow: "ellipsis"
}}
>
#{tag}
</span>
)) || <span style={{ fontSize: "11px", color: "#bfbfbf" }}></span>}
</div>
<div
style={{
height: "28px",
borderRadius: "8px",
background: "rgba(95, 81, 255, 0.1)",
color: "var(--primary-blue)",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
padding: "0 10px",
overflow: "hidden",
whiteSpace: "nowrap",
minWidth: "28px"
}}
className="card-arrow"
>
<RightOutlined style={{ fontSize: "10px", flexShrink: 0 }} />
<span className="view-detail-text" style={{
fontSize: "11px",
fontWeight: 700,
maxWidth: 0,
opacity: 0,
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
marginLeft: 0,
overflow: 'hidden',
display: 'inline-block'
}}></span>
</div>
</div>
</Card>
</List.Item>
);
};
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<MeetingVO[]>([]);
const [progressMap, setProgressMap] = useState<Record<number, MeetingProgress>>({});
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<string>(ALL_STATUS_FILTER);
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>("upload");
const [configLoaded, setConfigLoaded] = useState(false);
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>({
offlineEnabled: false,
realtimeEnabled: false,
offlineAudioMaxSizeMb: 1024,
});
const [userList, setUserList] = useState<SysUser[]>([]);
const progressTerminalRefreshRef = useRef<Map<number, string>>(new Map());
const [retryingMeetingIds, setRetryingMeetingIds] = useState<Record<number, boolean>>({});
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<number, MeetingProgress>;
}
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<number, MeetingProgress>;
}
};
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<typeof setTimeout> | 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<typeof setTimeout> | 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<number, RealtimeMeetingSessionStatus> = {};
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) => <a style={{ fontWeight: 500 }} onClick={() => handleOpenMeeting(record)}>{text}</a>,
},
{
title: "状态",
key: "status",
width: 150,
render: (_: unknown, record: MeetingVO) => <TableStatusCell meeting={record} progress={progressMap[record.id] || null} />,
},
{
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 type="secondary">{text || "未知"}</Text>,
},
{
title: "鏉ユ簮",
dataIndex: "meetingSource",
key: "meetingSource",
width: 80,
render: (value: MeetingVO["meetingSource"]) => <Tag style={{ fontSize: '11px', margin: 0 }}>{getMeetingSourceLabel(value)}</Tag>,
},
{
title: "参会人",
dataIndex: "participants",
key: "participants",
render: (text: string) => <Text type="secondary" ellipsis style={{ maxWidth: 180 }}>{text || "无参会人员"}</Text>,
},
{
title: "鎿嶄綔",
key: "action",
width: 220,
render: (_: unknown, record: MeetingVO) => (
<Space size="middle">
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); handleOpenMeeting(record); }}></Button>
{canRetryQueuedMeeting(record, progressMap[record.id] || null) && (
<Button
type="link"
size="small"
loading={!!retryingMeetingIds[record.id]}
onClick={(e) => {
e.stopPropagation();
void handleRetrySchedule(record);
}}
>
</Button>
)}
{canManageMeeting(record) && (
<Popconfirm title="确定删除吗?" onConfirm={() => deleteMeeting(record.id).then(() => fetchData())}>
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}></Button>
</Popconfirm>
)}
</Space>
),
},
];
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
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 (
<PageContainer
title="会议中心"
subtitle="管理会议记录与分析"
style={{ padding: '20px 32px' }}
headerExtra={
<Space size={16} wrap>
<Radio.Group value={displayMode} onChange={(e) => handleDisplayModeChange(e.target.value)} buttonStyle="solid">
<Radio.Button value="card"><AppstoreOutlined /></Radio.Button>
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
</Radio.Group>
{configLoaded && (
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!createConfig.offlineEnabled && !createConfig.realtimeEnabled}
onClick={() => {
if (createConfig.offlineEnabled || createConfig.realtimeEnabled) {
setCreateDrawerType(createConfig.offlineEnabled ? "upload" : "realtime");
setCreateDrawerVisible(true);
}
}}
>
</Button>
)}
</Space>
}
toolbar={
<Space wrap size={12} style={{ width: "100%", justifyContent: "space-between" }}>
<Radio.Group value={viewType} onChange={(e) => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
<Radio.Button value="all"></Radio.Button>
<Radio.Button value="created"></Radio.Button>
<Radio.Button value="involved"></Radio.Button>
</Radio.Group>
<Space wrap size={10} align="center">
<Tag
color={activeFilterCount > 0 ? "processing" : "default"}
style={{ margin: 0, borderRadius: 999, paddingInline: 10, lineHeight: "24px" }}
>
<Space size={6}>
<FilterOutlined />
<span>{activeFilterCount > 0 ? `已筛选 ${activeFilterCount}` : "未筛选"}</span>
</Space>
</Tag>
<Select
value={statusFilter}
onChange={(value) => { setStatusFilter(value); setCurrent(1); }}
style={{ width: 170 }}
popupMatchSelectWidth={false}
suffixIcon={<FilterOutlined style={{ color: "#8c8c8c" }} />}
options={MEETING_STATUS_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: (
<Space size={8}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: option.color,
boxShadow: `0 0 0 4px ${option.bgColor}`,
flexShrink: 0,
}}
/>
<span>{option.label}</span>
</Space>
),
}))}
/>
<Search
placeholder="搜索会议标题"
value={searchKeyword}
allowClear
enterButton={false}
onChange={(e) => {
const nextValue = e.target.value;
setSearchKeyword(nextValue);
if (!nextValue) {
setSearchTitle("");
setCurrent(1);
}
}}
onSearch={handleSearch}
style={{ width: 240 }}
/>
{activeFilterCount > 0 && (
<Button onClick={handleResetFilters}>
</Button>
)}
</Space>
</Space>
}
>
<Card
className="app-page__content-card"
style={{ flex: 1, minHeight: 0, overflow: "hidden", display: "flex", flexDirection: "column", border: 'none', background: 'transparent' }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflowY: "auto", overflowX: "hidden", padding: "4px" }}>
{displayMode === "card" ? (
<Skeleton loading={loading} active paragraph={{ rows: 8 }}>
<List
grid={{ gutter: [20, 20], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
dataSource={data}
renderItem={(item) => {
const progress = progressMap[item.id] || null;
const visualStatus = getEffectiveStatus(item, progress);
const config = statusConfig[visualStatus] || statusConfig[0];
return (
<MeetingCardItem
item={item}
config={config}
progress={progress}
onOpenMeeting={handleOpenMeeting}
onRetrySchedule={(meeting) => { void handleRetrySchedule(meeting); }}
onDelete={(id) => { deleteMeeting(id).then(() => { message.success('删除成功'); fetchData(); }) }}
retrying={!!retryingMeetingIds[item.id]}
/>
);
}}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/>
</Skeleton>
) : (
<Table
columns={tableColumns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: "max-content" }}
onRow={(record) => ({ onClick: () => handleOpenMeeting(record), style: { cursor: "pointer" } })}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/>
)}
</div>
<div style={{ padding: "16px 4px 0", flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<AppPagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} />
</div>
</Card>
<MeetingCreateDrawer
open={createDrawerVisible}
initialType={createDrawerType}
onCancel={() => setCreateDrawerVisible(false)}
onSuccess={() => {
setCreateDrawerVisible(false);
void fetchData();
}}
/>
<style>{`
.meeting-card-v2:hover { transform: translateY(-3px); box-shadow: 0 12px 24px rgba(95, 81, 255, 0.08) !important; border-color: rgba(95, 81, 255, 0.15) !important; }
.meeting-card-v2:hover .card-arrow { background: var(--primary-blue) !important; color: white !important; padding: 0 12px; }
.meeting-card-v2:hover .view-detail-text { max-width: 60px; opacity: 1; margin-left: 6px; }
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
@keyframes statusBreathing { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
/* Premium Button Styles */
.ant-btn-primary:not(.ant-btn-dangerous) {
background: linear-gradient(135deg, #5f51ff, #6c8cff) !important;
border: none !important;
box-shadow: 0 4px 12px rgba(95, 81, 255, 0.2) !important;
transition: all 0.3s ease !important;
}
.ant-btn-primary:not(.ant-btn-dangerous):hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(95, 81, 255, 0.3) !important;
filter: brightness(1.05);
}
.ant-btn-default {
border-color: rgba(228, 232, 245, 0.8) !important;
background: #fff !important;
color: #6e7695 !important;
}
.ant-btn-default:hover {
border-color: #5f51ff !important;
color: #5f51ff !important;
}
`}</style>
</PageContainer>
);
};
export default Meetings;