1017 lines
37 KiB
TypeScript
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;
|