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

948 lines
34 KiB
TypeScript

import {
AppstoreOutlined,
CalendarOutlined,
CheckOutlined,
CloudUploadOutlined,
DeleteOutlined,
FilterOutlined,
InfoCircleOutlined,
PauseCircleOutlined,
PlusOutlined,
SyncOutlined,
UnorderedListOutlined,
UserOutlined,
} from "@ant-design/icons";
import {
App,
Button,
Card,
Empty,
Input,
List,
Popconfirm,
Radio,
Select,
Skeleton,
Space,
Table,
Tag,
Typography,
} from "antd";
import dayjs from "dayjs";
import React, { useEffect, useRef, useState } from "react";
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,
} from "../../api/business/meeting";
import { MeetingCreateDrawer, type MeetingCreateType } from "../../components/business/MeetingCreateDrawer";
import AppPagination from "../../components/shared/AppPagination";
import DataListPanel from "../../components/shared/DataListPanel";
import SectionCard from "../../components/shared/SectionCard";
import type { SysUser } from "../../types";
import PageContainer from "../../components/shared/PageContainer";
import "./Meetings.css";
const { Title, Text } = Typography;
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 CARD_PAGE_SIZE = 12;
const TABLE_PAGE_SIZE = 10;
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) =>
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 getUnifiedStatusCode = (progress: MeetingProgress | null | undefined) =>
progress?.unifiedStatus?.statusCode;
const isWaitingUploadProgress = (progress: MeetingProgress | null | undefined) =>
getUnifiedStatusCode(progress) === "WAITING_UPLOAD";
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;
}
const status = item.displayStatus ?? item.status;
// 如果处于初始化中但已经有进度,则视为转译音频中
if (status === 0 && progress && progress.percent > 0) {
return 1;
}
if (status === 0 || status === 1 || status === 2) {
return status;
}
if (hasLatestGenerationFailure(item)) {
return 4;
}
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; className?: string; style?: React.CSSProperties }> = ({ meeting, progress, className, style }) => {
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: "#3c70f5", bgColor: "#eef4ff", 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;
const statusStyle = {
...style,
"--meeting-status-color": displayConfig.color,
"--meeting-status-bg": displayConfig.bgColor,
"--meeting-status-border": `${displayConfig.color}33`,
} as React.CSSProperties;
return (
<div className={["meeting-status-tag", isProcessing ? "is-processing" : "", className].filter(Boolean).join(" ")} style={statusStyle}>
{isProcessing && percent > 0 && (
<div
className="meeting-status-tag__progress"
style={{
width: `${percent}%`,
}}
/>
)}
<span className="meeting-status-tag__icon">
{isProcessing ? <SyncOutlined spin className="meeting-status-tag__spin-icon" /> : displayConfig.icon}
</span>
<span>{displayConfig.text}</span>
{isProcessing && <span className="meeting-status-tag__percent">{percent}%</span>}
</div>
);
};
const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
return <IntegratedStatusTag meeting={meeting} progress={progress} className="meeting-status-tag--table" />;
};
const getMeetingTagList = (tags: unknown) => {
const normalizeMeetingTag = (tag: unknown) => String(tag).replace(/^#+\s*/, '').trim();
if (Array.isArray(tags)) {
return tags.map(normalizeMeetingTag).filter(Boolean);
}
if (typeof tags === "string") {
return tags.split(",").map(normalizeMeetingTag).filter(Boolean);
}
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 isWaitingUpload = isWaitingUploadProgress(progress) || effectiveStatus === 8;
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 ownerName = item.creatorName || "未知";
const tags = getMeetingTagList(item.tags).slice(0, 2);
const processingMessage = isWaitingUpload
? (progress?.message || progress?.unifiedStatus?.message || config.text)
: (progress?.unifiedStatus?.message || progress?.message || "深度分析中...");
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
return (
<List.Item className="meeting-card-list-item">
<Card
hoverable
onClick={() => onOpenMeeting(item)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenMeeting(item);
}
}}
role="button"
tabIndex={0}
className="meeting-card-v2"
>
<div className="meeting-card-v2__top">
<IntegratedStatusTag meeting={item} progress={progress} className="meeting-status-tag--card" />
<div className="meeting-card-v2__top-actions">
<div
className="meeting-card-v2__source"
style={{ "--meeting-source-color": sourceColor } as React.CSSProperties}
>
<span className="meeting-card-v2__source-dot" />
{getMeetingSourceLabel(item.meetingSource)}
</div>
{canManageMeeting(item) && (
<div
className="meeting-card-v2__actions"
onClick={e => e.stopPropagation()}
>
<Popconfirm
title="确定删除会议吗?"
description="删除后将无法找回该会议记录。"
onConfirm={() => onDelete(item.id)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} title="删除会议" aria-label="删除会议" />
</Popconfirm>
</div>
)}
</div>
</div>
<div className="meeting-card-v2__title-row">
<Title level={4} className="meeting-card-v2__title" ellipsis={{ tooltip: item.title }}>
{item.title}
</Title>
</div>
<div className="meeting-card-v2__body">
{(isProcessing || isPaused || isRealtimeActive || isRealtimeIdle) ? (
<div
className="meeting-card-v2__status-note"
style={{ "--meeting-progress-color": config.color } as React.CSSProperties}
>
<div className="meeting-card-v2__status-inner">
<div className="meeting-card-v2__status-text">
{isProcessing ? <SyncOutlined spin /> : <InfoCircleOutlined />}
<span>
{isProcessing ? processingMessage : (isCrossPlatformRealtime ? crossPlatformHint : config.text)}
</span>
</div>
{canRetry && (
<Button
type="link"
size="small"
loading={retrying}
onClick={(event) => {
event.stopPropagation();
onRetrySchedule(item);
}}
className="meeting-card-v2__retry"
>
</Button>
)}
</div>
</div>
) : (
<div className="meeting-card-v2__meta">
<div className="meeting-card-v2__meta-item">
<CalendarOutlined />
<span>{dayjs(item.meetingTime).format("MM-DD HH:mm")}</span>
</div>
</div>
)}
</div>
<div className="meeting-card-v2__footer">
<div className="meeting-card-v2__tags">
{tags.length > 0 ? (
tags.map(tag => (
<span key={tag} className="meeting-card-v2__tag">
{tag}
</span>
))
) : (
<span className="meeting-card-v2__tag-empty"></span>
)}
</div>
<div
className="meeting-card-v2__owner"
title={`所属用户:${ownerName}`}
>
<UserOutlined />
<span>{ownerName}</span>
</div>
</div>
</Card>
</List.Item>
);
};
const Meetings: React.FC = () => {
const { message } = App.useApp();
const navigate = useNavigate();
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(CARD_PAGE_SIZE);
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 [, 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 handlePaginationChange = (page: number, pageSize: number) => {
setCurrent(pageSize !== size ? 1 : page);
setSize(pageSize);
};
const handleDisplayModeChange = (mode: "card" | "list") => {
setDisplayMode(mode);
setSize(mode === "card" ? CARD_PAGE_SIZE : TABLE_PAGE_SIZE);
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 maxPage = Math.max(1, Math.ceil(total / size));
if (current > maxPage) {
setCurrent(maxPage);
}
}, [current, size, total]);
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 className="meetings-table__title-link" 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 className="meetings-table__source-tag">{getMeetingSourceLabel(value)}</Tag>,
},
{
title: "参会人",
dataIndex: "participants",
key: "participants",
render: (text: string) => <Text type="secondary" ellipsis className="meetings-table__participants">{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={null}
className="meetings-page"
>
<SectionCard
title="会议中心"
description="管理会议记录、处理进度与实时会议入口。"
>
<DataListPanel
className={displayMode === "card" ? "meetings-list-panel meetings-list-panel--cards" : "meetings-list-panel"}
toolbarClassName="meetings-list-toolbar"
leftActions={
<div className="meetings-toolbar-stack">
<div className="meetings-scope-row">
<Radio.Group
value={viewType}
onChange={(e) => { setViewType(e.target.value); setCurrent(1); }}
buttonStyle="solid"
className="meetings-scope-switch"
>
<Radio.Button value="all"></Radio.Button>
<Radio.Button value="created"></Radio.Button>
<Radio.Button value="involved"></Radio.Button>
</Radio.Group>
</div>
<div className="meetings-toolbar-primary">
<Space wrap size={10} align="center" className="meetings-primary-actions">
{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>
<Space wrap size={10} align="center" className="meetings-filter-actions">
<Radio.Group
value={displayMode}
onChange={(e) => handleDisplayModeChange(e.target.value)}
buttonStyle="solid"
className="meetings-display-switch"
>
<Radio.Button value="card"><AppstoreOutlined /></Radio.Button>
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
</Radio.Group>
<Select
value={statusFilter}
onChange={(value) => { setStatusFilter(value); setCurrent(1); }}
className="meetings-status-filter"
popupMatchSelectWidth={false}
suffixIcon={<FilterOutlined className="meetings-status-filter__suffix" />}
options={MEETING_STATUS_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: (
<Space size={8}>
<span
className="meetings-status-filter-dot"
style={{
background: option.color,
}}
/>
<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}
className="meetings-search-input"
/>
{activeFilterCount > 0 && (
<Button onClick={handleResetFilters}>
</Button>
)}
</Space>
</div>
</div>
}
footer={
<AppPagination
variant={displayMode === "card" ? "card" : "table"}
current={current}
pageSize={size}
pageSizeOptions={displayMode === "card" ? ["12", "24", "36", "48"] : undefined}
total={total}
onChange={handlePaginationChange}
/>
}
>
{displayMode === "card" ? (
<div className="meetings-card-scroll">
<Skeleton loading={loading} active paragraph={{ rows: 8 }}>
<List
grid={{ gutter: [12, 12], 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>
</div>
) : (
<Table
className="meetings-list-table"
columns={tableColumns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: "max-content", y: "calc(100vh - 430px)" }}
onRow={(record) => ({ onClick: () => handleOpenMeeting(record) })}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/>
)}
</DataListPanel>
</SectionCard>
<MeetingCreateDrawer
open={createDrawerVisible}
initialType={createDrawerType}
onCancel={() => setCreateDrawerVisible(false)}
onSuccess={() => {
setCreateDrawerVisible(false);
void fetchData();
}}
/>
</PageContainer>
);
};
export default Meetings;