2026-06-08 08:19:40 +00:00
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
2026-05-13 06:31:48 +00:00
|
|
|
|
import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
|
2026-03-05 09:52:08 +00:00
|
|
|
|
import {
|
|
|
|
|
|
AudioOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
CaretRightFilled,
|
|
|
|
|
|
ClockCircleOutlined,
|
2026-04-16 02:55:10 +00:00
|
|
|
|
CopyOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
DownloadOutlined,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
EditOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
FastForwardOutlined,
|
2026-05-06 01:41:07 +00:00
|
|
|
|
FileTextOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
LeftOutlined,
|
2026-04-16 02:55:10 +00:00
|
|
|
|
LinkOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
LoadingOutlined,
|
|
|
|
|
|
PauseOutlined,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
SyncOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
UserOutlined,
|
2026-04-16 05:05:50 +00:00
|
|
|
|
CheckCircleFilled,
|
2026-04-17 02:08:40 +00:00
|
|
|
|
FilePdfOutlined,
|
|
|
|
|
|
FileWordOutlined,
|
2026-05-13 06:31:48 +00:00
|
|
|
|
ShareAltOutlined, InfoCircleOutlined,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} from '@ant-design/icons';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import dayjs from 'dayjs';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
import ReactMarkdown from 'react-markdown';
|
2026-03-05 09:52:08 +00:00
|
|
|
|
import {
|
2026-04-28 02:34:15 +00:00
|
|
|
|
downloadMeetingTranscript,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
downloadMeetingSummary,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
getMeetingDetail,
|
2026-05-09 05:48:09 +00:00
|
|
|
|
getMeetingChapters,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
getMeetingProgress,
|
2026-06-05 07:07:45 +00:00
|
|
|
|
getMeetingShareConfig,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
getTranscripts,
|
2026-05-09 05:48:09 +00:00
|
|
|
|
MeetingChapterVO,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
MeetingProgress,
|
2026-05-13 06:31:48 +00:00
|
|
|
|
SummaryDetailLevel,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
MeetingTranscriptVO,
|
|
|
|
|
|
MeetingVO,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
reSummary,
|
2026-04-27 02:39:34 +00:00
|
|
|
|
resolveAudioMimeType,
|
2026-04-27 07:16:08 +00:00
|
|
|
|
resolveMeetingPlaybackAudioUrl,
|
2026-05-27 09:44:15 +00:00
|
|
|
|
retryMeetingChapter,
|
|
|
|
|
|
retryMeetingSummary,
|
2026-04-10 07:18:05 +00:00
|
|
|
|
retryMeetingTranscription,
|
2026-06-01 05:42:50 +00:00
|
|
|
|
retryScheduleMeeting,
|
2026-03-26 03:18:44 +00:00
|
|
|
|
updateMeetingBasic,
|
2026-03-27 05:50:01 +00:00
|
|
|
|
updateMeetingTranscript,
|
2026-03-26 03:18:44 +00:00
|
|
|
|
updateMeetingSummary,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
updateSpeakerInfo,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} from '../../api/business/meeting';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
|
2026-04-22 08:06:50 +00:00
|
|
|
|
import { getHotWordPage, getPinyinSuggestion, saveHotWord } from '../../api/business/hotword';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
|
|
|
|
|
import { listUsers } from '../../api';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
import { useDict } from '../../hooks/useDict';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import { SysUser } from '../../types';
|
2026-05-13 06:31:48 +00:00
|
|
|
|
import PageContainer from "../../components/shared/PageContainer";
|
2026-06-30 05:37:33 +00:00
|
|
|
|
import SectionCard from "../../components/shared/SectionCard";
|
|
|
|
|
|
import "./MeetingDetail.css";
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
|
|
const { Option } = Select;
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
type AnalysisChapter = {
|
|
|
|
|
|
time?: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
summary: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type AnalysisSpeakerSummary = {
|
|
|
|
|
|
speaker: string;
|
|
|
|
|
|
summary: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type AnalysisKeyPoint = {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
summary: string;
|
|
|
|
|
|
speaker?: string;
|
|
|
|
|
|
time?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type MeetingAnalysis = {
|
|
|
|
|
|
overview: string;
|
|
|
|
|
|
keywords: string[];
|
|
|
|
|
|
chapters: AnalysisChapter[];
|
|
|
|
|
|
speakerSummaries: AnalysisSpeakerSummary[];
|
|
|
|
|
|
keyPoints: AnalysisKeyPoint[];
|
|
|
|
|
|
todos: string[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-09 05:48:09 +00:00
|
|
|
|
type WorkspaceTab = 'catalog' | 'transcript';
|
|
|
|
|
|
|
|
|
|
|
|
type ChapterTranscriptLink = {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
timeLabel: string;
|
|
|
|
|
|
transcriptIds: number[];
|
|
|
|
|
|
firstTranscriptId: number | null;
|
|
|
|
|
|
firstTranscriptStartTime: number | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-14 01:20:11 +00:00
|
|
|
|
type MeetingStateNotice = {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
type: 'info' | 'warning';
|
|
|
|
|
|
hasFallbackContent: boolean;
|
|
|
|
|
|
scope: 'summary' | 'catalog' | 'global';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 05:42:50 +00:00
|
|
|
|
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const ANALYSIS_EMPTY: MeetingAnalysis = {
|
|
|
|
|
|
overview: '',
|
|
|
|
|
|
keywords: [],
|
|
|
|
|
|
chapters: [],
|
|
|
|
|
|
speakerSummaries: [],
|
|
|
|
|
|
keyPoints: [],
|
|
|
|
|
|
todos: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const ACCESS_PASSWORD_PATTERN = /^[A-Za-z0-9]{4}$/;
|
|
|
|
|
|
const ACCESS_PASSWORD_SOURCE = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
|
|
|
|
|
|
|
|
|
|
const copyMeetingLink = async (text: string) => {
|
|
|
|
|
|
if (navigator.clipboard?.writeText) {
|
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const textarea = document.createElement('textarea');
|
|
|
|
|
|
textarea.value = text;
|
|
|
|
|
|
textarea.setAttribute('readonly', 'true');
|
|
|
|
|
|
textarea.style.position = 'fixed';
|
|
|
|
|
|
textarea.style.opacity = '0';
|
|
|
|
|
|
document.body.appendChild(textarea);
|
|
|
|
|
|
textarea.select();
|
|
|
|
|
|
document.execCommand('copy');
|
|
|
|
|
|
document.body.removeChild(textarea);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const generateAccessPassword = () =>
|
|
|
|
|
|
Array.from({ length: 4 }, () => ACCESS_PASSWORD_SOURCE[Math.floor(Math.random() * ACCESS_PASSWORD_SOURCE.length)]).join('');
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeAccessPasswordInput = (value?: string | null) => (value || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 4);
|
|
|
|
|
|
|
2026-04-27 07:16:08 +00:00
|
|
|
|
const sanitizeDownloadFileName = (value?: string | null, fallback = 'meeting-recording') => {
|
|
|
|
|
|
const normalized = (value || '').replace(/[\\/:*?"<>|\r\n]/g, '_').trim();
|
|
|
|
|
|
return normalized || fallback;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resolveAudioExtension = (audioUrl?: string) => {
|
|
|
|
|
|
const normalizedUrl = audioUrl?.split('#')[0]?.split('?')[0] || '';
|
|
|
|
|
|
return normalizedUrl.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() || 'mp3';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-02 09:29:39 +00:00
|
|
|
|
const normalizeTagDisplay = (tag: string) => tag.replace(/^#+\s*/, '').trim();
|
|
|
|
|
|
|
2026-04-27 07:16:08 +00:00
|
|
|
|
const getMeetingAudioDownloadName = (meeting?: Pick<MeetingVO, 'title' | 'audioUrl' | 'playbackAudioUrl'> | null) => {
|
|
|
|
|
|
const audioUrl = resolveMeetingPlaybackAudioUrl(meeting);
|
|
|
|
|
|
const extension = resolveAudioExtension(audioUrl);
|
|
|
|
|
|
return `${sanitizeDownloadFileName(meeting?.title)}-录音.${extension}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-05 07:07:45 +00:00
|
|
|
|
const buildMeetingPreviewUrl = (baseUrl?: string, meetingId?: number, accessPassword?: string) => {
|
|
|
|
|
|
const normalizedBaseUrl = (baseUrl || '').trim();
|
|
|
|
|
|
if (!normalizedBaseUrl || !meetingId || Number.isNaN(meetingId)) {
|
2026-04-16 02:55:10 +00:00
|
|
|
|
return '';
|
|
|
|
|
|
}
|
2026-06-05 07:07:45 +00:00
|
|
|
|
const safeBaseUrl = normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl.slice(0, -1) : normalizedBaseUrl;
|
|
|
|
|
|
const url = new URL(`${safeBaseUrl}/meetings/${meetingId}/preview`);
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const normalizedPassword = (accessPassword || '').trim();
|
|
|
|
|
|
if (normalizedPassword) {
|
|
|
|
|
|
url.searchParams.set('accessPassword', normalizedPassword);
|
|
|
|
|
|
}
|
|
|
|
|
|
return url.toString();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 06:33:32 +00:00
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const splitLines = (value?: string | null) =>
|
|
|
|
|
|
(value || '')
|
|
|
|
|
|
.split(/\r?\n/)
|
|
|
|
|
|
.map((line) => line.trim())
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
const parseLooseJson = (raw?: string | null) => {
|
|
|
|
|
|
const input = (raw || '').trim();
|
|
|
|
|
|
if (!input) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const tryParse = (text: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(text);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const direct = tryParse(input);
|
|
|
|
|
|
if (direct && typeof direct === 'object') return direct;
|
|
|
|
|
|
|
|
|
|
|
|
const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim();
|
|
|
|
|
|
if (fenced) {
|
|
|
|
|
|
const fencedParsed = tryParse(fenced);
|
|
|
|
|
|
if (fencedParsed && typeof fencedParsed === 'object') return fencedParsed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const start = input.indexOf('{');
|
|
|
|
|
|
const end = input.lastIndexOf('}');
|
|
|
|
|
|
if (start >= 0 && end > start) {
|
|
|
|
|
|
const wrapped = tryParse(input.slice(start, end + 1));
|
|
|
|
|
|
if (wrapped && typeof wrapped === 'object') return wrapped;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const extractSection = (markdown: string, aliases: string[]) => {
|
|
|
|
|
|
const lines = markdown.split(/\r?\n/);
|
|
|
|
|
|
const lowerAliases = aliases.map((item) => item.toLowerCase());
|
|
|
|
|
|
const cleanHeading = (line: string) => line.replace(/^#{1,6}\s*/, '').trim().toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
let start = -1;
|
|
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
|
|
|
|
const line = lines[index].trim();
|
|
|
|
|
|
if (!line.startsWith('#')) continue;
|
|
|
|
|
|
const heading = cleanHeading(line);
|
|
|
|
|
|
if (lowerAliases.some((alias) => heading.includes(alias))) {
|
|
|
|
|
|
start = index + 1;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (start < 0) return '';
|
|
|
|
|
|
|
|
|
|
|
|
const buffer: string[] = [];
|
|
|
|
|
|
for (let index = start; index < lines.length; index += 1) {
|
|
|
|
|
|
const line = lines[index];
|
|
|
|
|
|
if (line.trim().startsWith('#')) break;
|
|
|
|
|
|
buffer.push(line);
|
|
|
|
|
|
}
|
|
|
|
|
|
return buffer.join('\n').trim();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const parseBulletList = (content?: string | null) =>
|
|
|
|
|
|
splitLines(content)
|
2026-06-08 08:19:40 +00:00
|
|
|
|
.map((line) => line.replace(/^[-*鈥s]+/, '').replace(/^\d+[.)]\s*/, '').trim())
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
const parseOverviewSection = (markdown: string) =>
|
|
|
|
|
|
extractSection(markdown, ['全文概要', '概要', '摘要', '概览']) || markdown.replace(/^---[\s\S]*?---/, '').trim();
|
|
|
|
|
|
|
|
|
|
|
|
const parseKeywordsSection = (markdown: string, tags: string) => {
|
|
|
|
|
|
const section = extractSection(markdown, ['关键词', '关键字', '标签']);
|
|
|
|
|
|
const fromSection = parseBulletList(section)
|
2026-06-08 08:19:40 +00:00
|
|
|
|
.flatMap((line) => line.split(/[,,、/]/))
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.map((item) => item.trim())
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
if (fromSection.length) {
|
|
|
|
|
|
return Array.from(new Set(fromSection)).slice(0, 12);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 12);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const buildMeetingAnalysis = (
|
|
|
|
|
|
sourceAnalysis: MeetingVO['analysis'] | undefined,
|
|
|
|
|
|
summaryContent: string | undefined,
|
|
|
|
|
|
tags: string,
|
|
|
|
|
|
): MeetingAnalysis => {
|
|
|
|
|
|
const parseStructured = (parsed: Record<string, any>): MeetingAnalysis => {
|
|
|
|
|
|
const chapters = Array.isArray(parsed.chapters) ? parsed.chapters : [];
|
|
|
|
|
|
const speakerSummaries = Array.isArray(parsed.speakerSummaries) ? parsed.speakerSummaries : [];
|
|
|
|
|
|
const keyPoints = Array.isArray(parsed.keyPoints) ? parsed.keyPoints : [];
|
|
|
|
|
|
const todos = Array.isArray(parsed.todos)
|
|
|
|
|
|
? parsed.todos
|
|
|
|
|
|
: Array.isArray(parsed.actionItems)
|
|
|
|
|
|
? parsed.actionItems
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
overview: String(parsed.overview || '').trim(),
|
|
|
|
|
|
keywords: Array.from(
|
|
|
|
|
|
new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)),
|
|
|
|
|
|
).slice(0, 12),
|
|
|
|
|
|
chapters: chapters
|
|
|
|
|
|
.map((item: any) => ({
|
|
|
|
|
|
time: item?.time ? String(item.time).trim() : undefined,
|
|
|
|
|
|
title: String(item?.title || '').trim(),
|
|
|
|
|
|
summary: String(item?.summary || '').trim(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
.filter((item: AnalysisChapter) => item.title || item.summary),
|
|
|
|
|
|
speakerSummaries: speakerSummaries
|
|
|
|
|
|
.map((item: any) => ({
|
|
|
|
|
|
speaker: String(item?.speaker || '').trim(),
|
|
|
|
|
|
summary: String(item?.summary || '').trim(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
.filter((item: AnalysisSpeakerSummary) => item.speaker || item.summary),
|
|
|
|
|
|
keyPoints: keyPoints
|
|
|
|
|
|
.map((item: any) => ({
|
|
|
|
|
|
title: String(item?.title || '').trim(),
|
|
|
|
|
|
summary: String(item?.summary || '').trim(),
|
|
|
|
|
|
speaker: item?.speaker ? String(item.speaker).trim() : undefined,
|
|
|
|
|
|
time: item?.time ? String(item.time).trim() : undefined,
|
|
|
|
|
|
}))
|
|
|
|
|
|
.filter((item: AnalysisKeyPoint) => item.title || item.summary),
|
|
|
|
|
|
todos: todos.map((item: any) => String(item).trim()).filter(Boolean).slice(0, 10),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (sourceAnalysis) {
|
|
|
|
|
|
return parseStructured(sourceAnalysis as Record<string, any>);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const raw = (summaryContent || '').trim();
|
|
|
|
|
|
if (!raw && !tags) return ANALYSIS_EMPTY;
|
|
|
|
|
|
|
|
|
|
|
|
const loose = parseLooseJson(raw);
|
|
|
|
|
|
if (loose) {
|
|
|
|
|
|
return parseStructured(loose);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
overview: parseOverviewSection(raw),
|
|
|
|
|
|
keywords: parseKeywordsSection(raw, tags),
|
|
|
|
|
|
chapters: [],
|
|
|
|
|
|
speakerSummaries: [],
|
|
|
|
|
|
keyPoints: [],
|
|
|
|
|
|
todos: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(ms: number) {
|
|
|
|
|
|
const seconds = Math.floor(ms / 1000);
|
|
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
|
|
const remainSeconds = seconds % 60;
|
|
|
|
|
|
return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPlayerTime(seconds: number) {
|
|
|
|
|
|
const safeSeconds = Math.max(0, Math.floor(seconds || 0));
|
|
|
|
|
|
const hours = Math.floor(safeSeconds / 3600);
|
|
|
|
|
|
const minutes = Math.floor((safeSeconds % 3600) / 60);
|
|
|
|
|
|
const remainSeconds = safeSeconds % 60;
|
|
|
|
|
|
|
|
|
|
|
|
if (hours > 0) {
|
|
|
|
|
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 05:48:09 +00:00
|
|
|
|
function parseChapterTimeToMs(value?: string) {
|
|
|
|
|
|
const raw = String(value || '').trim();
|
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const matched = raw.match(/(\d{1,2}:\d{2}(?::\d{2})?)/)?.[1];
|
|
|
|
|
|
if (!matched) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const parts = matched.split(':').map((item) => Number(item));
|
|
|
|
|
|
if (parts.some((item) => Number.isNaN(item))) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const totalSeconds = parts.length === 3
|
|
|
|
|
|
? (parts[0] * 3600) + (parts[1] * 60) + parts[2]
|
|
|
|
|
|
: (parts[0] * 60) + parts[1];
|
|
|
|
|
|
|
|
|
|
|
|
return totalSeconds * 1000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 09:28:59 +00:00
|
|
|
|
type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal';
|
|
|
|
|
|
|
|
|
|
|
|
const meetingProgressTerminalRefreshCache = new Map<number, string>();
|
|
|
|
|
|
const meetingProgressPhaseRefreshCache = new Map<number, MeetingProgressPhase>();
|
|
|
|
|
|
const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => {
|
2026-06-08 08:19:40 +00:00
|
|
|
|
const unifiedStatusCode = progress?.unifiedStatus?.statusCode;
|
|
|
|
|
|
if (unifiedStatusCode?.startsWith('FAILED_') || unifiedStatusCode === 'COMPLETED') {
|
|
|
|
|
|
return 'terminal';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unifiedStatusCode === 'SUMMARIZING') {
|
|
|
|
|
|
return 'summary';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unifiedStatusCode === 'TRANSCRIBING') {
|
|
|
|
|
|
return 'asr';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unifiedStatusCode === 'INITIALIZING' || unifiedStatusCode === 'WAITING_UPLOAD') {
|
|
|
|
|
|
return 'queued';
|
|
|
|
|
|
}
|
2026-05-22 09:28:59 +00:00
|
|
|
|
const percent = progress?.percent ?? 0;
|
|
|
|
|
|
if (percent < 0 || percent >= 100) {
|
|
|
|
|
|
return 'terminal';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (percent >= 90) {
|
|
|
|
|
|
return 'summary';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (percent >= 85) {
|
|
|
|
|
|
return 'chapter';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (percent >= 5) {
|
|
|
|
|
|
return 'asr';
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'queued';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 03:30:26 +00:00
|
|
|
|
const MeetingProgressDisplay: React.FC<{
|
|
|
|
|
|
meetingId: number;
|
|
|
|
|
|
onComplete: () => void;
|
2026-05-22 09:28:59 +00:00
|
|
|
|
onRefreshNeeded?: (phase: MeetingProgressPhase) => void;
|
2026-05-14 01:20:11 +00:00
|
|
|
|
onProgressChange?: (progress: MeetingProgress | null) => void;
|
2026-04-20 03:30:26 +00:00
|
|
|
|
compact?: boolean;
|
2026-05-14 01:20:11 +00:00
|
|
|
|
inline?: boolean;
|
2026-05-22 09:28:59 +00:00
|
|
|
|
}> = ({ meetingId, onComplete, onRefreshNeeded, onProgressChange, compact, inline }) => {
|
2026-03-04 09:19:41 +00:00
|
|
|
|
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
|
|
|
|
|
|
2026-05-19 09:49:41 +00:00
|
|
|
|
const onCompleteRef = useRef(onComplete);
|
2026-05-22 09:28:59 +00:00
|
|
|
|
const onRefreshNeededRef = useRef(onRefreshNeeded);
|
2026-05-19 09:49:41 +00:00
|
|
|
|
const onProgressChangeRef = useRef(onProgressChange);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
onCompleteRef.current = onComplete;
|
2026-05-22 09:28:59 +00:00
|
|
|
|
onRefreshNeededRef.current = onRefreshNeeded;
|
2026-05-19 09:49:41 +00:00
|
|
|
|
onProgressChangeRef.current = onProgressChange;
|
2026-05-22 09:28:59 +00:00
|
|
|
|
}, [onComplete, onRefreshNeeded, onProgressChange]);
|
2026-05-19 09:49:41 +00:00
|
|
|
|
|
2026-03-04 09:19:41 +00:00
|
|
|
|
useEffect(() => {
|
2026-05-11 02:54:33 +00:00
|
|
|
|
let completed = false;
|
2026-05-22 09:28:59 +00:00
|
|
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
let requesting = false;
|
2026-05-11 02:54:33 +00:00
|
|
|
|
|
2026-03-04 09:19:41 +00:00
|
|
|
|
const fetchProgress = async () => {
|
2026-05-22 09:28:59 +00:00
|
|
|
|
if (completed || requesting) {
|
2026-05-11 02:54:33 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-22 09:28:59 +00:00
|
|
|
|
requesting = true;
|
|
|
|
|
|
let shouldContinue = true;
|
2026-03-04 09:19:41 +00:00
|
|
|
|
try {
|
2026-05-22 09:28:59 +00:00
|
|
|
|
const progressRes = await getMeetingProgress(meetingId, { suppressErrorToast: true });
|
2026-04-20 03:30:26 +00:00
|
|
|
|
if (progressRes.data?.data) {
|
2026-05-14 01:20:11 +00:00
|
|
|
|
const nextProgress = progressRes.data.data;
|
|
|
|
|
|
setProgress(nextProgress);
|
2026-05-19 09:49:41 +00:00
|
|
|
|
onProgressChangeRef.current?.(nextProgress);
|
2026-05-22 09:28:59 +00:00
|
|
|
|
const phase = resolveProgressPhase(nextProgress);
|
|
|
|
|
|
const previousPhase = meetingProgressPhaseRefreshCache.get(meetingId);
|
|
|
|
|
|
meetingProgressPhaseRefreshCache.set(meetingId, phase);
|
|
|
|
|
|
if ((phase === 'chapter' || phase === 'summary') && previousPhase !== phase) {
|
|
|
|
|
|
onRefreshNeededRef.current?.(phase);
|
|
|
|
|
|
}
|
2026-05-14 01:20:11 +00:00
|
|
|
|
if (nextProgress.percent === 100 || nextProgress.percent < 0) {
|
2026-05-11 02:54:33 +00:00
|
|
|
|
completed = true;
|
2026-05-22 09:28:59 +00:00
|
|
|
|
shouldContinue = false;
|
|
|
|
|
|
const terminalKey = `${nextProgress.updateAt}:${nextProgress.percent}`;
|
|
|
|
|
|
if (meetingProgressTerminalRefreshCache.get(meetingId) !== terminalKey) {
|
|
|
|
|
|
meetingProgressTerminalRefreshCache.set(meetingId, terminalKey);
|
|
|
|
|
|
onCompleteRef.current?.();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
meetingProgressTerminalRefreshCache.delete(meetingId);
|
2026-03-04 09:19:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
2026-05-22 09:28:59 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
requesting = false;
|
|
|
|
|
|
if (!completed && shouldContinue) {
|
|
|
|
|
|
timer = setTimeout(() => {
|
|
|
|
|
|
void fetchProgress();
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
}
|
2026-03-05 09:52:08 +00:00
|
|
|
|
}
|
2026-03-04 09:19:41 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-22 09:28:59 +00:00
|
|
|
|
void fetchProgress();
|
2026-05-11 02:54:33 +00:00
|
|
|
|
return () => {
|
|
|
|
|
|
completed = true;
|
2026-05-22 09:28:59 +00:00
|
|
|
|
if (timer) {
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
}
|
2026-05-11 02:54:33 +00:00
|
|
|
|
};
|
2026-05-19 09:49:41 +00:00
|
|
|
|
}, [meetingId]);
|
2026-03-04 09:19:41 +00:00
|
|
|
|
|
|
|
|
|
|
const percent = progress?.percent || 0;
|
|
|
|
|
|
const isError = percent < 0;
|
2026-06-08 08:19:40 +00:00
|
|
|
|
const unifiedStatusText = progress?.unifiedStatus?.statusText;
|
|
|
|
|
|
const unifiedStatusMessage = progress?.unifiedStatus?.message;
|
2026-03-04 09:19:41 +00:00
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const formatEta = (seconds?: number) => {
|
2026-03-27 06:33:32 +00:00
|
|
|
|
if (!seconds || seconds <= 0) return '计算中';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
if (seconds < 60) return `${seconds} 秒`;
|
|
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
|
|
const remainSeconds = seconds % 60;
|
|
|
|
|
|
return remainSeconds > 0 ? `${minutes} 分 ${remainSeconds} 秒` : `${minutes} 分钟`;
|
2026-03-04 12:59:49 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-14 01:20:11 +00:00
|
|
|
|
if (inline) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: 12,
|
|
|
|
|
|
padding: '12px 14px',
|
|
|
|
|
|
borderRadius: 12,
|
|
|
|
|
|
border: `1px solid ${isError ? '#ffccc7' : '#d6dbff'}`,
|
|
|
|
|
|
background: isError ? '#fff2f0' : '#f7f8ff',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
gap: 12,
|
|
|
|
|
|
marginBottom: 8,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
|
2026-06-08 08:19:40 +00:00
|
|
|
|
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在生成最新结果...'}
|
2026-05-14 01:20:11 +00:00
|
|
|
|
</Text>
|
|
|
|
|
|
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
|
|
|
|
|
|
{isError ? '失败' : `${percent}%`}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
percent={isError ? 100 : percent}
|
|
|
|
|
|
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
|
|
|
|
|
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
|
|
|
|
|
|
showInfo={false}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
style={{ marginBottom: 6 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
|
|
|
|
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 03:30:26 +00:00
|
|
|
|
if (compact) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
padding: '40px 20px',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Title level={4} style={{ marginBottom: 24 }}>
|
|
|
|
|
|
AI 智能总结中
|
|
|
|
|
|
</Title>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
type="circle"
|
|
|
|
|
|
percent={isError ? 100 : percent}
|
|
|
|
|
|
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
|
|
|
|
|
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
|
|
|
|
|
|
size={140}
|
|
|
|
|
|
strokeWidth={10}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: 24 }}>
|
|
|
|
|
|
<Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}>
|
2026-06-08 08:19:40 +00:00
|
|
|
|
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在分析内容...'}
|
2026-04-20 03:30:26 +00:00
|
|
|
|
</Text>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
2026-06-08 08:19:40 +00:00
|
|
|
|
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
|
2026-04-20 03:30:26 +00:00
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 09:19:41 +00:00
|
|
|
|
return (
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
background: '#fff',
|
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
|
padding: 40,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
<div style={{ width: '100%', maxWidth: 600, textAlign: 'center' }}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Title level={3} style={{ marginBottom: 24 }}>
|
|
|
|
|
|
AI 智能分析中
|
|
|
|
|
|
</Title>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Progress
|
|
|
|
|
|
type="circle"
|
|
|
|
|
|
percent={isError ? 100 : percent}
|
|
|
|
|
|
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
|
2026-04-08 08:16:21 +00:00
|
|
|
|
size={180}
|
|
|
|
|
|
strokeWidth={8}
|
2026-03-04 09:19:41 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: 32 }}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
|
2026-06-08 08:19:40 +00:00
|
|
|
|
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在准备计算资源...'}
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Text>
|
2026-03-27 06:33:32 +00:00
|
|
|
|
<Text type="secondary">分析进行中,请稍候,你可以先处理其他工作。</Text>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<Divider style={{ margin: '32px 0' }} />
|
|
|
|
|
|
<Row gutter={24}>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">当前进度</Text>
|
2026-04-20 03:30:26 +00:00
|
|
|
|
<Title level={4} style={{ margin: 0 }}>
|
|
|
|
|
|
{isError ? 'ERROR' : `${percent}%`}
|
|
|
|
|
|
</Title>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">预计剩余</Text>
|
2026-04-20 03:30:26 +00:00
|
|
|
|
<Title level={4} style={{ margin: 0 }}>
|
|
|
|
|
|
{isError ? '--' : formatEta(progress?.eta)}
|
|
|
|
|
|
</Title>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">任务状态</Text>
|
2026-04-20 03:30:26 +00:00
|
|
|
|
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
|
2026-06-08 08:19:40 +00:00
|
|
|
|
{isError ? '失败' : '正常'}
|
|
|
|
|
|
</Title>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const DETAIL_STAGE_STEP_DISPLAY_ITEMS = [
|
|
|
|
|
|
{ code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' },
|
|
|
|
|
|
{ code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' },
|
|
|
|
|
|
{ code: 'SUMMARIZING', label: '生成总结', hint: '生成总结与 AI 目录' },
|
|
|
|
|
|
{ code: 'COMPLETED', label: '处理完成', hint: '全部任务处理完成' },
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
const UnifiedMeetingProgressDisplay: React.FC<{
|
|
|
|
|
|
progress: MeetingProgress | null;
|
|
|
|
|
|
compact?: boolean;
|
|
|
|
|
|
inline?: boolean;
|
|
|
|
|
|
}> = ({ progress, compact, inline }) => {
|
|
|
|
|
|
const percent = progress?.percent || 0;
|
|
|
|
|
|
const isError = percent < 0;
|
|
|
|
|
|
const unifiedStatusCode = progress?.unifiedStatus?.statusCode;
|
|
|
|
|
|
const unifiedStatusText = progress?.unifiedStatus?.statusText;
|
|
|
|
|
|
const unifiedStatusMessage = progress?.unifiedStatus?.message;
|
|
|
|
|
|
const failedStageCode = progress?.unifiedStatus?.failedStageCode;
|
|
|
|
|
|
const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中');
|
|
|
|
|
|
|
|
|
|
|
|
const formatEta = (seconds?: number) => {
|
|
|
|
|
|
if (!seconds || seconds <= 0) return '计算中';
|
|
|
|
|
|
if (seconds < 60) return `${seconds} 秒`;
|
|
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
|
|
const remainSeconds = seconds % 60;
|
|
|
|
|
|
return remainSeconds > 0 ? `${minutes} 分 ${remainSeconds} 秒` : `${minutes} 分钟`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resolveStageCode = () => {
|
|
|
|
|
|
if (failedStageCode) {
|
|
|
|
|
|
return failedStageCode;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unifiedStatusCode === 'WAITING_UPLOAD' || unifiedStatusCode === 'INITIALIZING') {
|
|
|
|
|
|
return 'INITIALIZING';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unifiedStatusCode === 'TRANSCRIBING') {
|
|
|
|
|
|
return 'TRANSCRIBING';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unifiedStatusCode === 'SUMMARIZING') {
|
|
|
|
|
|
return 'SUMMARIZING';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unifiedStatusCode === 'COMPLETED') {
|
|
|
|
|
|
return 'COMPLETED';
|
|
|
|
|
|
}
|
|
|
|
|
|
const phase = resolveProgressPhase(progress);
|
|
|
|
|
|
if (phase === 'queued') {
|
|
|
|
|
|
return 'INITIALIZING';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (phase === 'asr') {
|
|
|
|
|
|
return 'TRANSCRIBING';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (phase === 'chapter' || phase === 'summary') {
|
|
|
|
|
|
return 'SUMMARIZING';
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'COMPLETED';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const currentStageCode = resolveStageCode();
|
|
|
|
|
|
const currentStageIndex = Math.max(0, DETAIL_STAGE_STEP_DISPLAY_ITEMS.findIndex((item) => item.code === currentStageCode));
|
|
|
|
|
|
const isFailedStage = unifiedStatusCode?.startsWith('FAILED_') || isError;
|
|
|
|
|
|
const isCompletedStage = unifiedStatusCode === 'COMPLETED' || percent === 100;
|
|
|
|
|
|
const progressText = isError ? '--' : `${Math.max(percent, 0)}%`;
|
|
|
|
|
|
const helperText = unifiedStatusMessage || progress?.message || (isError ? '当前阶段执行失败,请稍后重试。' : '阶段状态已与安卓端统一。');
|
|
|
|
|
|
|
|
|
|
|
|
const renderStageTimeline = (options?: {
|
|
|
|
|
|
compact?: boolean;
|
|
|
|
|
|
inline?: boolean;
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const compactMode = options?.compact;
|
|
|
|
|
|
const inlineMode = options?.inline;
|
|
|
|
|
|
const containerStyle: React.CSSProperties = inlineMode
|
|
|
|
|
|
? {
|
|
|
|
|
|
marginTop: 10,
|
|
|
|
|
|
display: 'grid',
|
|
|
|
|
|
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
|
|
|
|
|
gap: 8,
|
|
|
|
|
|
}
|
|
|
|
|
|
: compactMode
|
|
|
|
|
|
? {
|
|
|
|
|
|
marginTop: 18,
|
|
|
|
|
|
display: 'grid',
|
|
|
|
|
|
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
|
|
|
|
|
gap: 8,
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
maxWidth: 560,
|
|
|
|
|
|
}
|
|
|
|
|
|
: {
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
gap: 12,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={containerStyle}>
|
|
|
|
|
|
{DETAIL_STAGE_STEP_DISPLAY_ITEMS.map((item, index) => {
|
|
|
|
|
|
const isCurrent = index === currentStageIndex;
|
|
|
|
|
|
const isDone = isCompletedStage || (!isFailedStage && index < currentStageIndex);
|
|
|
|
|
|
const isFailed = isFailedStage && isCurrent;
|
|
|
|
|
|
const dotBg = isFailed ? '#ff4d4f' : isDone ? '#52c41a' : isCurrent ? '#4f46e5' : '#d9d9d9';
|
|
|
|
|
|
const textColor = isFailed ? '#cf1322' : isDone || isCurrent ? '#1f1f1f' : '#8c8c8c';
|
|
|
|
|
|
const cardPadding = inlineMode ? '8px 6px' : compactMode ? '10px 8px' : '0';
|
|
|
|
|
|
const cardRadius = inlineMode || compactMode ? 12 : 0;
|
|
|
|
|
|
const cardBorder = inlineMode || compactMode
|
|
|
|
|
|
? `1px solid ${isFailed ? '#ffccc7' : isCurrent ? '#c7d2fe' : '#e5e7eb'}`
|
|
|
|
|
|
: 'none';
|
|
|
|
|
|
const cardBackground = inlineMode || compactMode
|
|
|
|
|
|
? isFailed ? '#fff2f0' : isCurrent ? '#eef2ff' : isDone ? '#f6ffed' : '#fafafa'
|
|
|
|
|
|
: 'transparent';
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<React.Fragment key={item.code}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
padding: cardPadding,
|
|
|
|
|
|
borderRadius: cardRadius,
|
|
|
|
|
|
border: cardBorder,
|
|
|
|
|
|
background: cardBackground,
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: compactMode || inlineMode ? 28 : 36,
|
|
|
|
|
|
height: compactMode || inlineMode ? 28 : 36,
|
|
|
|
|
|
margin: `0 auto ${compactMode || inlineMode ? 8 : 12}px`,
|
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
background: dotBg,
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
boxShadow: isCurrent ? `0 0 0 ${compactMode || inlineMode ? 4 : 6}px ${isFailed ? 'rgba(255,77,79,0.12)' : 'rgba(79,70,229,0.12)'}` : 'none',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isDone ? '√' : index + 1}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: compactMode || inlineMode ? 12 : 15,
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
color: textColor,
|
|
|
|
|
|
marginBottom: compactMode || inlineMode ? 0 : 6,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isFailed && item.code === currentStageCode ? `${item.label}失败` : item.label}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!compactMode && !inlineMode ? (
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.6 }}>
|
|
|
|
|
|
{item.hint}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!inlineMode && !compactMode && index < DETAIL_STAGE_STEP_DISPLAY_ITEMS.length - 1 ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
height: 2,
|
|
|
|
|
|
marginTop: 17,
|
|
|
|
|
|
background: (!isFailedStage && index < currentStageIndex) || isCompletedStage ? '#52c41a' : '#e5e7eb',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (inline) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: 12,
|
|
|
|
|
|
padding: '12px 14px',
|
|
|
|
|
|
borderRadius: 12,
|
|
|
|
|
|
border: `1px solid ${isError ? '#ffccc7' : '#d6dbff'}`,
|
|
|
|
|
|
background: isError ? '#fff2f0' : '#f7f8ff',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
gap: 12,
|
|
|
|
|
|
marginBottom: 8,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
|
|
|
|
|
|
{helperText}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
|
|
|
|
|
|
{isError ? '失败' : `${percent}%`}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
percent={isError ? 100 : percent}
|
|
|
|
|
|
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
|
|
|
|
|
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
|
|
|
|
|
|
showInfo={false}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
style={{ marginBottom: 6 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
|
|
|
|
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{renderStageTimeline({ inline: true })}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (compact) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
padding: '40px 20px',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Title level={4} style={{ marginBottom: 24 }}>
|
|
|
|
|
|
{primaryStatusText}
|
|
|
|
|
|
</Title>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
type="circle"
|
|
|
|
|
|
percent={isError ? 100 : percent}
|
|
|
|
|
|
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
|
|
|
|
|
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
|
|
|
|
|
|
size={140}
|
|
|
|
|
|
strokeWidth={10}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: 24 }}>
|
|
|
|
|
|
<Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}>
|
|
|
|
|
|
{helperText}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
|
|
|
|
|
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{renderStageTimeline({ compact: true })}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
background: '#fff',
|
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
|
padding: 40,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ width: '100%', maxWidth: 760, textAlign: 'center' }}>
|
|
|
|
|
|
<Title level={3} style={{ marginBottom: 8 }}>
|
|
|
|
|
|
{primaryStatusText}
|
|
|
|
|
|
</Title>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 15 }}>
|
|
|
|
|
|
{helperText}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: 28,
|
|
|
|
|
|
marginBottom: 32,
|
|
|
|
|
|
padding: '20px 24px',
|
|
|
|
|
|
borderRadius: 20,
|
|
|
|
|
|
background: isError ? 'linear-gradient(135deg, #fff2f0, #fff7f6)' : 'linear-gradient(135deg, #f5f7ff, #faf8ff)',
|
|
|
|
|
|
border: `1px solid ${isError ? '#ffccc7' : '#d9ddff'}`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
|
|
|
|
|
当前进度
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: 6,
|
|
|
|
|
|
fontSize: 40,
|
|
|
|
|
|
lineHeight: 1,
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
color: isError ? '#cf1322' : '#4f46e5',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{progressText}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
percent={isError ? 100 : percent}
|
|
|
|
|
|
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
|
|
|
|
|
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
|
|
|
|
|
|
showInfo={false}
|
|
|
|
|
|
style={{ marginTop: 18, marginBottom: 12 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
|
|
|
|
|
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{renderStageTimeline()}
|
|
|
|
|
|
|
|
|
|
|
|
<Divider style={{ margin: '32px 0 24px' }} />
|
|
|
|
|
|
<Row gutter={24}>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
|
|
|
|
|
<Text type="secondary">当前阶段</Text>
|
|
|
|
|
|
<Title level={4} style={{ margin: 0 }}>
|
|
|
|
|
|
{DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText}
|
|
|
|
|
|
</Title>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
|
|
|
|
|
<Text type="secondary">任务状态</Text>
|
|
|
|
|
|
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
|
|
|
|
|
|
{isError ? '失败' : isCompletedStage ? '已完成' : '处理中'}
|
|
|
|
|
|
</Title>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
|
|
|
|
|
<Text type="secondary">可查看内容</Text>
|
|
|
|
|
|
<Title level={4} style={{ margin: 0, color: '#4f46e5' }}>
|
|
|
|
|
|
{[
|
|
|
|
|
|
progress?.unifiedStatus?.canViewTranscript ? '转录' : null,
|
|
|
|
|
|
progress?.unifiedStatus?.canViewAiChapters ? '目录' : null,
|
|
|
|
|
|
progress?.unifiedStatus?.canViewSummary ? '总结' : null,
|
|
|
|
|
|
].filter(Boolean).join(' / ') || '暂无'}
|
2026-04-20 03:30:26 +00:00
|
|
|
|
</Title>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
2026-06-08 08:19:40 +00:00
|
|
|
|
<div style={{ marginTop: 20 }}>
|
|
|
|
|
|
<Text type="secondary">处理进度仅作辅助展示,阶段状态以统一状态机为准。</Text>
|
|
|
|
|
|
</div>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const SpeakerEditor: React.FC<{
|
|
|
|
|
|
meetingId: number;
|
|
|
|
|
|
speakerId: string;
|
|
|
|
|
|
initialName: string;
|
|
|
|
|
|
initialLabel: string;
|
|
|
|
|
|
onSuccess: () => void;
|
|
|
|
|
|
}> = ({ meetingId, speakerId, initialName, initialLabel, onSuccess }) => {
|
|
|
|
|
|
const [name, setName] = useState(initialName || speakerId);
|
|
|
|
|
|
const [label, setLabel] = useState(initialLabel);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
2026-04-08 06:34:59 +00:00
|
|
|
|
const { message } = App.useApp();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const { items: speakerLabels } = useDict('biz_speaker_label');
|
|
|
|
|
|
|
2026-04-16 02:55:10 +00:00
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const handleSave = async (event: React.MouseEvent) => {
|
|
|
|
|
|
event.stopPropagation();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateSpeakerInfo({ meetingId, speakerId, newName: name, label });
|
2026-03-27 02:30:48 +00:00
|
|
|
|
message.success('发言人信息已更新');
|
2026-03-02 11:59:47 +00:00
|
|
|
|
onSuccess();
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<div style={{ width: 250, padding: '8px 4px' }} onClick={(event) => event.stopPropagation()}>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<div style={{ marginBottom: 12 }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">发言人姓名</Text>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">角色标签</Text>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Select value={label} onChange={setLabel} placeholder="选择角色" style={{ width: '100%', marginTop: 4 }} size="small" allowClear>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
{speakerLabels.map((item) => (
|
|
|
|
|
|
<Select.Option key={item.itemValue} value={item.itemValue}>
|
|
|
|
|
|
{item.itemLabel}
|
|
|
|
|
|
</Select.Option>
|
|
|
|
|
|
))}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Button type="primary" size="small" block onClick={handleSave} loading={loading}>
|
|
|
|
|
|
同步到全局
|
|
|
|
|
|
</Button>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-06 02:09:07 +00:00
|
|
|
|
const renderContentWithHighlight = (content: string, keyword: string) => {
|
|
|
|
|
|
if (!keyword || !content.toLowerCase().includes(keyword.toLowerCase())) {
|
|
|
|
|
|
return content;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parts = content.split(new RegExp(`(${keyword})`, 'gi'));
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{parts.map((part, i) => (
|
|
|
|
|
|
part.toLowerCase() === keyword.toLowerCase() ? (
|
|
|
|
|
|
<span key={i} className="highlight-text">{part}</span>
|
|
|
|
|
|
) : part
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 06:33:32 +00:00
|
|
|
|
type ActiveTranscriptRowProps = {
|
|
|
|
|
|
item: MeetingTranscriptVO;
|
|
|
|
|
|
meetingId: number;
|
|
|
|
|
|
isOwner: boolean;
|
|
|
|
|
|
isEditing: boolean;
|
|
|
|
|
|
isSaving: boolean;
|
|
|
|
|
|
speakerLabelMap: Map<string, string>;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
onPlay: (timeMs: number) => void;
|
2026-03-27 06:33:32 +00:00
|
|
|
|
onStartEdit: (item: MeetingTranscriptVO, event: React.MouseEvent) => void;
|
|
|
|
|
|
onDraftBlur: (item: MeetingTranscriptVO, value: string) => void;
|
|
|
|
|
|
onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
|
|
|
|
onSpeakerUpdated: () => void;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
registerRow: (id: number, node: HTMLDivElement | null) => void;
|
2026-04-17 03:00:50 +00:00
|
|
|
|
isActive: boolean;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
isLinkedHighlight: boolean;
|
2026-04-17 03:00:50 +00:00
|
|
|
|
audioPlaying: boolean;
|
2026-05-06 02:09:07 +00:00
|
|
|
|
highlightKeyword?: string;
|
2026-03-27 06:33:32 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
|
|
|
|
|
item,
|
|
|
|
|
|
meetingId,
|
|
|
|
|
|
isOwner,
|
|
|
|
|
|
isEditing,
|
|
|
|
|
|
isSaving,
|
|
|
|
|
|
speakerLabelMap,
|
2026-05-09 05:48:09 +00:00
|
|
|
|
onPlay,
|
2026-03-27 06:33:32 +00:00
|
|
|
|
onStartEdit,
|
|
|
|
|
|
onDraftBlur,
|
|
|
|
|
|
onDraftKeyDown,
|
|
|
|
|
|
onSpeakerUpdated,
|
2026-05-09 05:48:09 +00:00
|
|
|
|
registerRow,
|
2026-04-17 03:00:50 +00:00
|
|
|
|
isActive,
|
2026-05-09 05:48:09 +00:00
|
|
|
|
isLinkedHighlight,
|
2026-04-17 03:00:50 +00:00
|
|
|
|
audioPlaying,
|
2026-05-06 02:09:07 +00:00
|
|
|
|
highlightKeyword = '',
|
2026-03-27 06:33:32 +00:00
|
|
|
|
}) => {
|
2026-04-17 03:00:50 +00:00
|
|
|
|
const [draftValue, setDraftValue] = useState(item.content);
|
2026-05-13 06:31:48 +00:00
|
|
|
|
const rowRef = useRef<HTMLDivElement | null>(null);
|
2026-03-27 06:33:32 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isEditing) {
|
2026-04-17 03:00:50 +00:00
|
|
|
|
setDraftValue(item.content);
|
2026-03-27 06:33:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
}, [isEditing, item.content]);
|
|
|
|
|
|
|
2026-04-17 03:00:50 +00:00
|
|
|
|
useEffect(() => {
|
2026-05-06 02:09:07 +00:00
|
|
|
|
if ((isActive && audioPlaying) || (highlightKeyword && item.content.toLowerCase().includes(highlightKeyword.toLowerCase()))) {
|
|
|
|
|
|
if (rowRef.current) {
|
|
|
|
|
|
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
|
}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
}
|
2026-05-06 02:09:07 +00:00
|
|
|
|
}, [isActive, audioPlaying, highlightKeyword, item.content]);
|
2026-04-17 03:00:50 +00:00
|
|
|
|
|
|
|
|
|
|
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
|
|
|
|
|
|
|
2026-03-27 06:33:32 +00:00
|
|
|
|
return (
|
2026-05-09 05:48:09 +00:00
|
|
|
|
<List.Item
|
|
|
|
|
|
className={`transcript-row ${isActive ? 'active' : ''} ${isLinkedHighlight ? 'linked' : ''}`}
|
|
|
|
|
|
onClick={() => onPlay(item.startTime)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="transcript-entry"
|
|
|
|
|
|
ref={(node) => {
|
|
|
|
|
|
rowRef.current = node;
|
|
|
|
|
|
registerRow(item.id, node);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
|
|
|
|
|
<div className="transcript-content-wrap">
|
|
|
|
|
|
<div className="transcript-meta">
|
|
|
|
|
|
{isOwner ? (
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
content={(
|
|
|
|
|
|
<SpeakerEditor
|
|
|
|
|
|
meetingId={meetingId}
|
|
|
|
|
|
speakerId={item.speakerId}
|
|
|
|
|
|
initialName={item.speakerName}
|
|
|
|
|
|
initialLabel={item.speakerLabel}
|
|
|
|
|
|
onSuccess={onSpeakerUpdated}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
title="编辑发言人"
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="transcript-speaker editable" onClick={(event) => event.stopPropagation()}>
|
|
|
|
|
|
{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: 12 }} />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="transcript-speaker">{item.speakerName || item.speakerId || '发言人'}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Text type="secondary">{formatTime(item.startTime)}</Text>
|
|
|
|
|
|
{speakerTagLabel && <Tag color="blue">{speakerTagLabel}</Tag>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{isEditing ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`transcript-bubble transcript-bubble-editing ${isSaving ? 'is-saving' : ''}`}
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
2026-03-27 06:33:32 +00:00
|
|
|
|
>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
<Input.TextArea
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
value={draftValue}
|
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
|
onChange={(event) => setDraftValue(event.target.value)}
|
|
|
|
|
|
onKeyDown={(event) => onDraftKeyDown(item, draftValue, event)}
|
|
|
|
|
|
onBlur={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
onDraftBlur(item, draftValue);
|
|
|
|
|
|
}}
|
|
|
|
|
|
autoSize={{ minRows: 1, maxRows: 8 }}
|
|
|
|
|
|
className="transcript-bubble-input"
|
|
|
|
|
|
bordered={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-03-27 06:33:32 +00:00
|
|
|
|
) : (
|
2026-04-16 05:05:50 +00:00
|
|
|
|
<div
|
|
|
|
|
|
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
|
|
|
|
|
|
onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined}
|
|
|
|
|
|
>
|
2026-05-06 02:09:07 +00:00
|
|
|
|
{renderContentWithHighlight(item.content, highlightKeyword)}
|
2026-04-16 05:05:50 +00:00
|
|
|
|
</div>
|
2026-03-27 06:33:32 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</List.Item>
|
|
|
|
|
|
);
|
|
|
|
|
|
}, (prevProps, nextProps) => (
|
|
|
|
|
|
prevProps.item === nextProps.item
|
|
|
|
|
|
&& prevProps.meetingId === nextProps.meetingId
|
|
|
|
|
|
&& prevProps.isOwner === nextProps.isOwner
|
|
|
|
|
|
&& prevProps.isEditing === nextProps.isEditing
|
|
|
|
|
|
&& prevProps.isSaving === nextProps.isSaving
|
|
|
|
|
|
&& prevProps.speakerLabelMap === nextProps.speakerLabelMap
|
2026-05-09 05:48:09 +00:00
|
|
|
|
&& prevProps.onPlay === nextProps.onPlay
|
2026-03-27 06:33:32 +00:00
|
|
|
|
&& prevProps.onStartEdit === nextProps.onStartEdit
|
|
|
|
|
|
&& prevProps.onDraftBlur === nextProps.onDraftBlur
|
|
|
|
|
|
&& prevProps.onDraftKeyDown === nextProps.onDraftKeyDown
|
|
|
|
|
|
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
|
2026-05-09 05:48:09 +00:00
|
|
|
|
&& prevProps.registerRow === nextProps.registerRow
|
2026-04-17 03:00:50 +00:00
|
|
|
|
&& prevProps.isActive === nextProps.isActive
|
2026-05-09 05:48:09 +00:00
|
|
|
|
&& prevProps.isLinkedHighlight === nextProps.isLinkedHighlight
|
2026-04-17 03:00:50 +00:00
|
|
|
|
&& prevProps.audioPlaying === nextProps.audioPlaying
|
2026-05-06 02:09:07 +00:00
|
|
|
|
&& prevProps.highlightKeyword === nextProps.highlightKeyword
|
2026-03-27 06:33:32 +00:00
|
|
|
|
));
|
|
|
|
|
|
|
2026-05-06 02:09:07 +00:00
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const MeetingDetail: React.FC = () => {
|
2026-04-08 06:34:59 +00:00
|
|
|
|
const { message } = App.useApp();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const { id } = useParams<{ id: string }>();
|
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const [form] = Form.useForm();
|
|
|
|
|
|
const [summaryForm] = Form.useForm();
|
2026-03-05 09:52:08 +00:00
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
|
|
|
|
|
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
2026-05-09 05:48:09 +00:00
|
|
|
|
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [editVisible, setEditVisible] = useState(false);
|
|
|
|
|
|
const [summaryVisible, setSummaryVisible] = useState(false);
|
|
|
|
|
|
const [actionLoading, setActionLoading] = useState(false);
|
2026-04-28 02:34:15 +00:00
|
|
|
|
const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | 'transcript' | null>(null);
|
2026-03-06 05:45:56 +00:00
|
|
|
|
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
|
|
|
|
|
const [summaryDraft, setSummaryDraft] = useState('');
|
2026-03-27 05:50:01 +00:00
|
|
|
|
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
|
2026-06-25 02:08:32 +00:00
|
|
|
|
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('transcript');
|
2026-03-27 05:50:01 +00:00
|
|
|
|
const [addingHotwords, setAddingHotwords] = useState(false);
|
|
|
|
|
|
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
|
|
|
|
|
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
|
|
|
|
|
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
2026-05-13 06:31:48 +00:00
|
|
|
|
const [userList, setUserList] = useState<SysUser[]>([]);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const { items: speakerLabels } = useDict('biz_speaker_label');
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const [sharePopoverOpen, setSharePopoverOpen] = useState(false);
|
|
|
|
|
|
const [shareSaving, setShareSaving] = useState(false);
|
|
|
|
|
|
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
|
|
|
|
|
|
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
|
2026-05-06 02:09:07 +00:00
|
|
|
|
const [highlightKeyword, setHighlightKeyword] = useState('');
|
2026-05-09 05:48:09 +00:00
|
|
|
|
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
|
|
|
|
|
|
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
|
2026-06-05 07:07:45 +00:00
|
|
|
|
const [meetingShareBaseUrl, setMeetingShareBaseUrl] = useState('');
|
2026-05-06 02:09:07 +00:00
|
|
|
|
|
|
|
|
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
|
|
|
|
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
|
|
|
|
|
const [audioDuration, setAudioDuration] = useState(0);
|
|
|
|
|
|
const [audioPlaying, setAudioPlaying] = useState(false);
|
|
|
|
|
|
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
|
|
|
|
|
|
|
2026-04-27 02:39:34 +00:00
|
|
|
|
const emptyTranscriptNoticeShownRef = useRef<number | null>(null);
|
|
|
|
|
|
const audioPlaybackErrorShownRef = useRef<string | null>(null);
|
2026-04-16 02:55:10 +00:00
|
|
|
|
|
2026-04-17 03:00:50 +00:00
|
|
|
|
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
2026-05-09 05:48:09 +00:00
|
|
|
|
const pendingTranscriptScrollIdRef = useRef<number | null>(null);
|
2026-04-17 03:00:50 +00:00
|
|
|
|
const leftColumnRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const transcriptSectionRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
|
|
|
|
|
|
const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null);
|
2026-05-14 01:20:11 +00:00
|
|
|
|
const [generationProgress, setGenerationProgress] = useState<MeetingProgress | null>(null);
|
|
|
|
|
|
const autoOpenedCatalogAttemptRef = useRef<string | null>(null);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
2026-04-20 03:30:26 +00:00
|
|
|
|
const fetchData = useCallback(async (meetingId: number) => {
|
|
|
|
|
|
try {
|
2026-06-08 08:19:40 +00:00
|
|
|
|
const [detailRes, transcriptRes, chapterRes, shareConfigRes, progressRes] = await Promise.all([
|
2026-05-09 05:48:09 +00:00
|
|
|
|
getMeetingDetail(meetingId),
|
|
|
|
|
|
getTranscripts(meetingId),
|
|
|
|
|
|
getMeetingChapters(meetingId),
|
2026-06-05 07:07:45 +00:00
|
|
|
|
getMeetingShareConfig().catch(() => null),
|
2026-06-08 08:19:40 +00:00
|
|
|
|
getMeetingProgress(meetingId, { suppressErrorToast: true }).catch(() => null),
|
2026-05-09 05:48:09 +00:00
|
|
|
|
]);
|
2026-04-20 03:30:26 +00:00
|
|
|
|
setMeeting(detailRes.data.data);
|
|
|
|
|
|
setTranscripts(transcriptRes.data.data || []);
|
2026-05-09 05:48:09 +00:00
|
|
|
|
setMeetingChapters(chapterRes.data.data || []);
|
2026-06-05 07:07:45 +00:00
|
|
|
|
setMeetingShareBaseUrl(shareConfigRes?.data?.data?.h5BaseUrl || '');
|
2026-06-08 08:19:40 +00:00
|
|
|
|
setGenerationProgress(progressRes?.data?.data || null);
|
2026-04-20 03:30:26 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const analysis = useMemo(
|
|
|
|
|
|
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''),
|
|
|
|
|
|
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
|
|
|
|
|
|
);
|
2026-07-02 01:30:48 +00:00
|
|
|
|
const expandKeywords = false;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9);
|
2026-05-06 01:41:07 +00:00
|
|
|
|
const meetingTags = useMemo(
|
|
|
|
|
|
() => (meeting?.tags?.split(',').map((item) => item.trim()).filter(Boolean) || []),
|
|
|
|
|
|
[meeting?.tags],
|
|
|
|
|
|
);
|
|
|
|
|
|
const discussionItems = useMemo(() => {
|
|
|
|
|
|
if (analysis.keyPoints.length) {
|
|
|
|
|
|
return analysis.keyPoints;
|
|
|
|
|
|
}
|
|
|
|
|
|
return analysis.chapters.map((item) => ({
|
|
|
|
|
|
title: item.title,
|
|
|
|
|
|
summary: item.summary,
|
|
|
|
|
|
time: item.time,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}, [analysis.chapters, analysis.keyPoints]);
|
2026-03-27 06:33:32 +00:00
|
|
|
|
const speakerLabelMap = useMemo(
|
|
|
|
|
|
() => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])),
|
|
|
|
|
|
[speakerLabels],
|
|
|
|
|
|
);
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const previewAccessPassword = useMemo(() => (meeting?.accessPassword || '').trim(), [meeting?.accessPassword]);
|
2026-04-27 07:16:08 +00:00
|
|
|
|
const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]);
|
|
|
|
|
|
const audioDownloadFileName = useMemo(() => getMeetingAudioDownloadName(meeting), [meeting]);
|
|
|
|
|
|
const audioDownloadFormatLabel = useMemo(
|
|
|
|
|
|
() => resolveAudioExtension(playbackAudioUrl).toUpperCase(),
|
|
|
|
|
|
[playbackAudioUrl],
|
|
|
|
|
|
);
|
2026-05-09 05:48:09 +00:00
|
|
|
|
const keywordItems = useMemo(
|
|
|
|
|
|
() => (analysis.keywords.length ? visibleKeywords : meetingTags),
|
|
|
|
|
|
[analysis.keywords.length, meetingTags, visibleKeywords],
|
|
|
|
|
|
);
|
|
|
|
|
|
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
|
|
|
|
|
|
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
|
2026-05-13 06:31:48 +00:00
|
|
|
|
const sourceChapters: MeetingChapterVO[] = meetingChapters.length
|
2026-05-09 05:48:09 +00:00
|
|
|
|
? meetingChapters
|
|
|
|
|
|
: analysis.chapters.map((item) => ({
|
|
|
|
|
|
title: item.title,
|
|
|
|
|
|
time: item.time,
|
2026-05-13 06:31:48 +00:00
|
|
|
|
} as MeetingChapterVO));
|
2026-05-09 05:48:09 +00:00
|
|
|
|
|
|
|
|
|
|
return sourceChapters.map((chapter, index) => {
|
|
|
|
|
|
let matchedTranscripts: MeetingTranscriptVO[] = [];
|
|
|
|
|
|
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
|
|
|
|
|
|
? chapter.sourceTranscriptIds
|
2026-05-13 06:31:48 +00:00
|
|
|
|
.map((item: number) => Number(item))
|
|
|
|
|
|
.filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item))
|
2026-05-09 05:48:09 +00:00
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
if (sourceTranscriptIds.length) {
|
|
|
|
|
|
matchedTranscripts = sourceTranscriptIds
|
2026-05-13 06:31:48 +00:00
|
|
|
|
.map((item: number) => transcripts[transcriptIdToIndex.get(item)!])
|
2026-05-09 05:48:09 +00:00
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
} else if (chapter.startTranscriptId && chapter.endTranscriptId) {
|
|
|
|
|
|
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
|
|
|
|
|
|
const endIndex = transcriptIdToIndex.get(Number(chapter.endTranscriptId));
|
|
|
|
|
|
if (startIndex !== undefined && endIndex !== undefined) {
|
|
|
|
|
|
matchedTranscripts = transcripts.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const startMs = typeof chapter.startTime === 'number' ? chapter.startTime : parseChapterTimeToMs(chapter.time);
|
|
|
|
|
|
const nextChapterStartMs = sourceChapters
|
|
|
|
|
|
.slice(index + 1)
|
2026-05-13 06:31:48 +00:00
|
|
|
|
.map((item: MeetingChapterVO) => (typeof item.startTime === 'number' ? item.startTime : parseChapterTimeToMs(item.time)))
|
2026-05-09 05:48:09 +00:00
|
|
|
|
.find((item): item is number => item !== null && startMs !== null && item > startMs);
|
|
|
|
|
|
|
|
|
|
|
|
if (startMs !== null) {
|
|
|
|
|
|
const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs);
|
|
|
|
|
|
if (firstTranscriptIndex >= 0) {
|
|
|
|
|
|
const lastTranscriptIndex = nextChapterStartMs === undefined
|
|
|
|
|
|
? transcripts.length
|
|
|
|
|
|
: transcripts.findIndex((item) => item.startTime >= nextChapterStartMs);
|
|
|
|
|
|
matchedTranscripts = transcripts.slice(
|
|
|
|
|
|
firstTranscriptIndex,
|
|
|
|
|
|
lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: `${chapter.chapterNo ?? index}-${chapter.title || 'chapter'}`,
|
|
|
|
|
|
title: chapter.title || `章节 ${index + 1}`,
|
|
|
|
|
|
timeLabel: chapter.time || '--:--',
|
|
|
|
|
|
transcriptIds: matchedTranscripts.map((item) => item.id),
|
|
|
|
|
|
firstTranscriptId: matchedTranscripts[0]?.id ?? null,
|
|
|
|
|
|
firstTranscriptStartTime: matchedTranscripts[0]?.startTime ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [analysis.chapters, meetingChapters, transcripts]);
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const sharePreviewUrl = useMemo(() => {
|
|
|
|
|
|
const meetingId = meeting?.id ?? (id ? Number(id) : NaN);
|
2026-06-05 07:07:45 +00:00
|
|
|
|
return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId);
|
|
|
|
|
|
}, [meetingShareBaseUrl, meeting?.id, id]);
|
2026-07-01 01:42:31 +00:00
|
|
|
|
const summaryModelDisplayName = useMemo(() => {
|
|
|
|
|
|
const matchedModel = llmModels.find((item) => item.id === meeting?.summaryModelId);
|
|
|
|
|
|
if (matchedModel?.modelName) {
|
|
|
|
|
|
return matchedModel.modelName;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (meeting?.summaryModelId) {
|
|
|
|
|
|
return `模型 #${meeting.summaryModelId}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return '未配置';
|
|
|
|
|
|
}, [llmModels, meeting?.summaryModelId]);
|
|
|
|
|
|
const promptDisplayName = useMemo(() => {
|
|
|
|
|
|
const matchedPrompt = prompts.find((item) => item.id === meeting?.promptId);
|
|
|
|
|
|
if (matchedPrompt?.templateName) {
|
|
|
|
|
|
return matchedPrompt.templateName;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (meeting?.promptId) {
|
|
|
|
|
|
return `模板 #${meeting.promptId}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return '未配置';
|
|
|
|
|
|
}, [meeting?.promptId, prompts]);
|
|
|
|
|
|
const hotWordGroupDisplayName = useMemo(() => {
|
|
|
|
|
|
if (meeting?.hotWordGroupName?.trim()) {
|
|
|
|
|
|
return meeting.hotWordGroupName.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (meeting?.hotWordGroupId) {
|
|
|
|
|
|
return `热词组 #${meeting.hotWordGroupId}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return '未使用';
|
|
|
|
|
|
}, [meeting?.hotWordGroupId, meeting?.hotWordGroupName]);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
|
|
|
|
|
|
const isOwner = useMemo(() => {
|
2026-03-02 11:59:47 +00:00
|
|
|
|
if (!meeting) return false;
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const profileStr = sessionStorage.getItem('userProfile');
|
2026-03-02 11:59:47 +00:00
|
|
|
|
if (profileStr) {
|
|
|
|
|
|
const profile = JSON.parse(profileStr);
|
2026-04-27 02:39:34 +00:00
|
|
|
|
return profile.isPlatformAdmin === true || profile.isTenantAdmin === true || profile.userId === meeting.creatorId;
|
2026-03-02 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}, [meeting]);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
|
2026-06-25 02:08:32 +00:00
|
|
|
|
const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false;
|
2026-05-27 09:44:15 +00:00
|
|
|
|
const canRetrySummary = isOwner
|
|
|
|
|
|
&& transcripts.length > 0
|
|
|
|
|
|
&& meeting?.status !== 1
|
|
|
|
|
|
&& meeting?.status !== 2
|
|
|
|
|
|
&& meeting?.latestSummaryAttemptStatus !== 3
|
2026-06-25 02:08:32 +00:00
|
|
|
|
&& (!aiCatalogEnabled || meeting?.latestChapterAttemptStatus !== 3);
|
2026-04-10 07:18:05 +00:00
|
|
|
|
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
2026-05-27 09:44:15 +00:00
|
|
|
|
const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2;
|
2026-06-25 02:08:32 +00:00
|
|
|
|
const canRetryFailedChapterTask = aiCatalogEnabled && isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2;
|
2026-06-01 05:42:50 +00:00
|
|
|
|
|
|
|
|
|
|
const canRetrySchedule = isOwner && meeting?.status === 0 && (!generationProgress || generationProgress.percent <= 0) && !!generationProgress?.queuedAt && dayjs().diff(dayjs(generationProgress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS;
|
|
|
|
|
|
|
|
|
|
|
|
const handleRetrySchedule = async () => {
|
|
|
|
|
|
if (!meeting) return;
|
|
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await retryScheduleMeeting(meeting.id);
|
|
|
|
|
|
if (response.data?.data) {
|
|
|
|
|
|
message.success("已触发重新调度");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.info("当前没有可重新调度的排队任务");
|
|
|
|
|
|
}
|
|
|
|
|
|
await fetchData(meeting.id);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error("重新调度失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-14 01:20:11 +00:00
|
|
|
|
const hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
|
2026-06-25 02:08:32 +00:00
|
|
|
|
const hasCatalogContent = aiCatalogEnabled && catalogChapterLinks.length > 0;
|
2026-05-14 01:20:11 +00:00
|
|
|
|
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
|
2026-05-11 02:54:33 +00:00
|
|
|
|
if (!meeting || meeting.status !== 4) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 01:20:11 +00:00
|
|
|
|
const hasFallbackContent = hasSummaryContent || hasCatalogContent;
|
2026-06-25 02:08:32 +00:00
|
|
|
|
if (aiCatalogEnabled && meeting.latestChapterAttemptStatus === 3) {
|
2026-05-14 01:20:11 +00:00
|
|
|
|
const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败';
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: hasFallbackContent ? '历史内容仍可查看' : '本次 AI 目录生成失败',
|
|
|
|
|
|
description: hasFallbackContent
|
|
|
|
|
|
? `最近一次 AI 目录生成失败,当前展示的是最近一次成功生成的内容。失败原因:${detail}`
|
|
|
|
|
|
: `AI 目录生成失败,当前没有可展示的历史目录或总结内容。失败原因:${detail}`,
|
|
|
|
|
|
type: hasFallbackContent ? 'info' : 'warning',
|
|
|
|
|
|
hasFallbackContent,
|
|
|
|
|
|
scope: 'catalog',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (meeting.latestSummaryAttemptStatus === 3) {
|
|
|
|
|
|
const detail = meeting.latestSummaryAttemptErrorMsg || '总结生成失败';
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: hasFallbackContent ? '历史总结可用' : '本次总结生成失败',
|
|
|
|
|
|
description: hasFallbackContent
|
|
|
|
|
|
? `最近一次重新总结失败,当前展示的是最近一次成功生成的总结内容。失败原因:${detail}`
|
|
|
|
|
|
: `总结生成失败,当前没有可展示的历史总结内容。失败原因:${detail}`,
|
|
|
|
|
|
type: hasFallbackContent ? 'info' : 'warning',
|
|
|
|
|
|
hasFallbackContent,
|
|
|
|
|
|
scope: 'summary',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-13 06:31:48 +00:00
|
|
|
|
title: '会议处理异常',
|
|
|
|
|
|
description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。',
|
2026-06-25 02:08:32 +00:00
|
|
|
|
type: hasFallbackContent ? 'info' : 'warning',
|
2026-05-13 06:31:48 +00:00
|
|
|
|
hasFallbackContent,
|
2026-06-25 02:08:32 +00:00
|
|
|
|
scope: 'global',
|
2026-05-13 06:31:48 +00:00
|
|
|
|
};
|
2026-06-25 02:08:32 +00:00
|
|
|
|
}, [aiCatalogEnabled, hasCatalogContent, hasSummaryContent, meeting]);
|
2026-05-14 01:20:11 +00:00
|
|
|
|
const summaryPanelNotice = useMemo<MeetingStateNotice | null>(() => {
|
|
|
|
|
|
if (!meeting || !hasSummaryContent) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (meeting.status === 2) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: '正在生成新版总结',
|
|
|
|
|
|
description: '当前展示的是上一版已成功生成的总结,系统会在新版完成后自动替换。',
|
|
|
|
|
|
type: 'info',
|
|
|
|
|
|
hasFallbackContent: true,
|
|
|
|
|
|
scope: 'summary',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}, [generationFailureNotice, hasSummaryContent, meeting]);
|
|
|
|
|
|
const catalogPanelNotice = useMemo<MeetingStateNotice | null>(() => {
|
2026-06-25 02:08:32 +00:00
|
|
|
|
if (!aiCatalogEnabled || !generationFailureNotice || generationFailureNotice.scope !== 'catalog') {
|
2026-05-14 01:20:11 +00:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (generationFailureNotice.hasFallbackContent && hasCatalogContent) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: '当前展示的是历史成功目录',
|
|
|
|
|
|
description: generationFailureNotice.description,
|
|
|
|
|
|
type: generationFailureNotice.type,
|
|
|
|
|
|
hasFallbackContent: true,
|
|
|
|
|
|
scope: 'catalog',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return generationFailureNotice;
|
2026-06-25 02:08:32 +00:00
|
|
|
|
}, [aiCatalogEnabled, generationFailureNotice, hasCatalogContent]);
|
2026-04-27 02:39:34 +00:00
|
|
|
|
const emptyTranscriptFailureNotice = useMemo(() => {
|
|
|
|
|
|
if (!meeting || meeting.status !== 4 || transcripts.length > 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: '识别已结束,但没有生成可用转录',
|
|
|
|
|
|
description: canRetryTranscription
|
|
|
|
|
|
? 'ASR 调用已返回,但当前没有写入任何转录文本,所以列表状态仍显示失败。你可以直接重新识别,或先检查录音是否静音、时长过短、音量过低。'
|
|
|
|
|
|
: 'ASR 调用已返回,但当前没有写入任何转录文本,所以列表状态仍显示失败。建议先检查录音是否静音、时长过短、音量过低或音质异常。',
|
|
|
|
|
|
hint: canRetryTranscription ? '可以直接点击“重新识别”继续处理。' : '当前没有可重试的音频入口,请先确认原始录音文件是否有效。',
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [canRetryTranscription, meeting, transcripts.length]);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
2026-05-14 01:20:11 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (meeting?.status !== 1 && meeting?.status !== 2) {
|
|
|
|
|
|
setGenerationProgress(null);
|
|
|
|
|
|
autoOpenedCatalogAttemptRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [meeting?.id, meeting?.status]);
|
|
|
|
|
|
|
2026-06-25 02:08:32 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!aiCatalogEnabled && workspaceTab === 'catalog') {
|
|
|
|
|
|
setWorkspaceTab('transcript');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [aiCatalogEnabled, workspaceTab]);
|
|
|
|
|
|
|
2026-05-14 01:20:11 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const attemptKey = String(meeting?.latestChapterAttemptTaskId ?? meeting?.latestSummaryAttemptTaskId ?? '');
|
|
|
|
|
|
if (meeting?.status !== 2 || !attemptKey) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-25 02:08:32 +00:00
|
|
|
|
if (!aiCatalogEnabled || (generationProgress?.percent ?? 0) < 88 || catalogChapterLinks.length === 0) {
|
2026-05-14 01:20:11 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (autoOpenedCatalogAttemptRef.current === attemptKey) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
autoOpenedCatalogAttemptRef.current = attemptKey;
|
|
|
|
|
|
setWorkspaceTab('catalog');
|
|
|
|
|
|
}, [
|
2026-06-25 02:08:32 +00:00
|
|
|
|
aiCatalogEnabled,
|
2026-05-14 01:20:11 +00:00
|
|
|
|
catalogChapterLinks.length,
|
|
|
|
|
|
generationProgress?.percent,
|
|
|
|
|
|
meeting?.latestChapterAttemptTaskId,
|
|
|
|
|
|
meeting?.latestSummaryAttemptTaskId,
|
|
|
|
|
|
meeting?.status,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-04-17 03:00:50 +00:00
|
|
|
|
useEffect(() => {
|
2026-04-27 07:16:08 +00:00
|
|
|
|
if (!playbackAudioUrl) {
|
2026-04-17 03:00:50 +00:00
|
|
|
|
setShowFloatingTranscriptPlayer(false);
|
|
|
|
|
|
setFloatingTranscriptPlayerLayout(null);
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateFloatingPlayerState = () => {
|
|
|
|
|
|
const target = transcriptSectionRef.current;
|
|
|
|
|
|
if (!target) {
|
|
|
|
|
|
setShowFloatingTranscriptPlayer(false);
|
|
|
|
|
|
setFloatingTranscriptPlayerLayout(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
|
|
|
|
const rootRect = leftColumnRef.current?.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
|
|
setFloatingTranscriptPlayerLayout({
|
|
|
|
|
|
left: rect.left,
|
|
|
|
|
|
width: rect.width,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (rootRect) {
|
|
|
|
|
|
const isVisible = rect.bottom > rootRect.top + 80 && rect.top < rootRect.bottom - 40;
|
|
|
|
|
|
setShowFloatingTranscriptPlayer(isVisible);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isVisible = rect.bottom > 120 && rect.top < window.innerHeight - 80;
|
|
|
|
|
|
setShowFloatingTranscriptPlayer(isVisible);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
updateFloatingPlayerState();
|
|
|
|
|
|
|
|
|
|
|
|
const target = transcriptSectionRef.current;
|
|
|
|
|
|
const root = leftColumnRef.current;
|
|
|
|
|
|
const resizeObserver = target ? new ResizeObserver(() => updateFloatingPlayerState()) : null;
|
|
|
|
|
|
if (target && resizeObserver) {
|
|
|
|
|
|
resizeObserver.observe(target);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', updateFloatingPlayerState);
|
|
|
|
|
|
window.addEventListener('scroll', updateFloatingPlayerState, { passive: true });
|
|
|
|
|
|
root?.addEventListener('scroll', updateFloatingPlayerState, { passive: true });
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('resize', updateFloatingPlayerState);
|
|
|
|
|
|
window.removeEventListener('scroll', updateFloatingPlayerState);
|
|
|
|
|
|
root?.removeEventListener('scroll', updateFloatingPlayerState);
|
|
|
|
|
|
resizeObserver?.disconnect();
|
|
|
|
|
|
};
|
2026-04-27 07:16:08 +00:00
|
|
|
|
}, [meeting?.status, playbackAudioUrl]);
|
2026-04-17 03:00:50 +00:00
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
useEffect(() => {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
if (!id) return;
|
|
|
|
|
|
fetchData(Number(id));
|
|
|
|
|
|
loadAiConfigs();
|
|
|
|
|
|
loadUsers();
|
2026-04-20 03:30:26 +00:00
|
|
|
|
}, [id, fetchData]);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
2026-03-27 05:50:01 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
|
|
|
|
|
|
}, [analysis.keywords]);
|
|
|
|
|
|
|
2026-05-09 05:48:09 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (workspaceTab !== 'transcript') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pendingId = pendingTranscriptScrollIdRef.current;
|
|
|
|
|
|
if (!pendingId) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const frameId = window.requestAnimationFrame(() => {
|
|
|
|
|
|
const target = transcriptItemRefs.current[pendingId];
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
|
pendingTranscriptScrollIdRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return () => window.cancelAnimationFrame(frameId);
|
|
|
|
|
|
}, [workspaceTab, transcripts, linkedTranscriptIds]);
|
|
|
|
|
|
|
2026-04-08 01:15:26 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (meeting?.audioSaveStatus === 'FAILED') {
|
|
|
|
|
|
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
|
|
|
|
|
|
|
2026-04-27 02:39:34 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!meeting?.id || !emptyTranscriptFailureNotice) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (emptyTranscriptNoticeShownRef.current === meeting.id) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
message.warning({
|
|
|
|
|
|
content: emptyTranscriptFailureNotice.title,
|
|
|
|
|
|
duration: 4,
|
|
|
|
|
|
});
|
|
|
|
|
|
emptyTranscriptNoticeShownRef.current = meeting.id;
|
|
|
|
|
|
}, [emptyTranscriptFailureNotice, meeting?.id, message]);
|
|
|
|
|
|
|
2026-04-16 02:55:10 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!sharePopoverOpen) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalizedPassword = normalizeAccessPasswordInput(meeting?.accessPassword);
|
|
|
|
|
|
setSharePasswordEnabled(!!normalizedPassword);
|
|
|
|
|
|
setSharePasswordDraft(normalizedPassword);
|
|
|
|
|
|
}, [sharePopoverOpen, meeting?.accessPassword]);
|
|
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const loadAiConfigs = async () => {
|
|
|
|
|
|
try {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const [modelRes, promptRes, defaultRes] = await Promise.all([
|
2026-03-02 11:59:47 +00:00
|
|
|
|
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
|
|
|
|
|
getPromptPage({ current: 1, size: 100 }),
|
2026-03-05 09:52:08 +00:00
|
|
|
|
getAiModelDefault('LLM'),
|
2026-03-02 11:59:47 +00:00
|
|
|
|
]);
|
2026-06-30 05:37:33 +00:00
|
|
|
|
setLlmModels((modelRes.data?.data?.records || []).filter((item) => item.status === 1));
|
|
|
|
|
|
setPrompts((promptRes.data?.data?.records || []).filter((item) => item.status === 1));
|
2026-03-27 02:30:48 +00:00
|
|
|
|
summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id });
|
|
|
|
|
|
} catch {
|
2026-03-05 09:52:08 +00:00
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadUsers = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const users = await listUsers();
|
|
|
|
|
|
setUserList(users || []);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch {
|
2026-03-05 09:52:08 +00:00
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditMeeting = () => {
|
|
|
|
|
|
if (!meeting || !isOwner) return;
|
|
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
...meeting,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
tags: meeting.tags?.split(',').filter(Boolean),
|
2026-03-02 11:59:47 +00:00
|
|
|
|
});
|
|
|
|
|
|
setEditVisible(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleUpdateBasic = async () => {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const values = await form.validateFields();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
2026-03-26 03:18:44 +00:00
|
|
|
|
await updateMeetingBasic({
|
2026-03-27 02:30:48 +00:00
|
|
|
|
...values,
|
2026-03-26 03:18:44 +00:00
|
|
|
|
meetingId: meeting?.id,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
tags: values.tags?.join(','),
|
2026-03-02 11:59:47 +00:00
|
|
|
|
});
|
|
|
|
|
|
message.success('会议信息已更新');
|
|
|
|
|
|
setEditVisible(false);
|
|
|
|
|
|
fetchData(Number(id));
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-06 05:45:56 +00:00
|
|
|
|
const handleSaveSummary = async () => {
|
|
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
2026-03-26 03:18:44 +00:00
|
|
|
|
await updateMeetingSummary({
|
2026-04-08 06:34:59 +00:00
|
|
|
|
meetingId: Number(id),
|
2026-03-06 05:45:56 +00:00
|
|
|
|
summaryContent: summaryDraft,
|
|
|
|
|
|
});
|
|
|
|
|
|
message.success('总结内容已更新');
|
|
|
|
|
|
setIsEditingSummary(false);
|
|
|
|
|
|
fetchData(Number(id));
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-06 05:45:56 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const handleReSummary = async () => {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const values = await summaryForm.validateFields();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await reSummary({
|
|
|
|
|
|
meetingId: Number(id),
|
2026-03-27 02:30:48 +00:00
|
|
|
|
summaryModelId: values.summaryModelId,
|
|
|
|
|
|
promptId: values.promptId,
|
2026-04-17 02:08:40 +00:00
|
|
|
|
userPrompt: values.userPrompt,
|
2026-05-13 06:31:48 +00:00
|
|
|
|
summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel,
|
2026-03-02 11:59:47 +00:00
|
|
|
|
});
|
2026-05-22 09:28:59 +00:00
|
|
|
|
setMeeting((current) => current ? {
|
|
|
|
|
|
...current,
|
|
|
|
|
|
status: 2,
|
|
|
|
|
|
latestSummaryAttemptStatus: 0,
|
|
|
|
|
|
latestSummaryAttemptErrorMsg: undefined,
|
|
|
|
|
|
} : current);
|
|
|
|
|
|
setGenerationProgress({
|
2026-05-27 09:44:15 +00:00
|
|
|
|
percent: 90,
|
2026-05-22 09:28:59 +00:00
|
|
|
|
message: '已重新发起总结任务',
|
|
|
|
|
|
updateAt: Date.now(),
|
|
|
|
|
|
eta: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
message.success('操作成功');
|
|
|
|
|
|
await fetchData(Number(id));
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-17 02:08:40 +00:00
|
|
|
|
const handleOpenSummaryDrawer = () => {
|
|
|
|
|
|
summaryForm.setFieldsValue({
|
|
|
|
|
|
summaryModelId:
|
|
|
|
|
|
summaryForm.getFieldValue('summaryModelId') ??
|
2026-06-10 12:43:35 +00:00
|
|
|
|
meeting?.summaryModelId ??
|
2026-04-17 02:08:40 +00:00
|
|
|
|
llmModels.find((model) => model.isDefault === 1)?.id ??
|
|
|
|
|
|
llmModels[0]?.id,
|
2026-06-10 12:43:35 +00:00
|
|
|
|
promptId:
|
|
|
|
|
|
summaryForm.getFieldValue('promptId') ??
|
|
|
|
|
|
meeting?.promptId ??
|
|
|
|
|
|
prompts[0]?.id,
|
2026-04-17 02:08:40 +00:00
|
|
|
|
userPrompt: meeting?.lastUserPrompt ?? '',
|
2026-05-13 06:31:48 +00:00
|
|
|
|
summaryDetailLevel:
|
|
|
|
|
|
summaryForm.getFieldValue('summaryDetailLevel') ??
|
|
|
|
|
|
meeting?.summaryDetailLevel ??
|
|
|
|
|
|
'STANDARD',
|
2026-04-17 02:08:40 +00:00
|
|
|
|
});
|
|
|
|
|
|
setSummaryVisible(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-06 02:09:07 +00:00
|
|
|
|
const seekTo = useCallback((timeMs: number) => {
|
|
|
|
|
|
if (!audioRef.current) return;
|
|
|
|
|
|
audioRef.current.currentTime = timeMs / 1000;
|
|
|
|
|
|
audioRef.current.play();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-09 05:48:09 +00:00
|
|
|
|
const handleTranscriptRowPlay = useCallback((timeMs: number) => {
|
|
|
|
|
|
setLinkedTranscriptIds([]);
|
|
|
|
|
|
setLinkedChapterKey(null);
|
|
|
|
|
|
seekTo(timeMs);
|
|
|
|
|
|
}, [seekTo]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleLocateChapterTranscript = useCallback((chapterIndex: number) => {
|
|
|
|
|
|
const targetLink = catalogChapterLinks[chapterIndex];
|
|
|
|
|
|
if (!targetLink || !targetLink.transcriptIds.length || targetLink.firstTranscriptId === null || targetLink.firstTranscriptStartTime === null) {
|
|
|
|
|
|
message.warning('当前章节暂未匹配到可关联的转录原文');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setWorkspaceTab('transcript');
|
|
|
|
|
|
setHighlightKeyword('');
|
|
|
|
|
|
setLinkedTranscriptIds(targetLink.transcriptIds);
|
|
|
|
|
|
setLinkedChapterKey(targetLink.key);
|
|
|
|
|
|
pendingTranscriptScrollIdRef.current = targetLink.firstTranscriptId;
|
|
|
|
|
|
seekTo(targetLink.firstTranscriptStartTime);
|
|
|
|
|
|
}, [catalogChapterLinks, message, seekTo]);
|
|
|
|
|
|
|
2026-04-10 07:18:05 +00:00
|
|
|
|
const handleRetryTranscription = async () => {
|
|
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await retryMeetingTranscription(Number(id));
|
|
|
|
|
|
message.success('已重新提交识别任务');
|
|
|
|
|
|
await fetchData(Number(id));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-27 09:44:15 +00:00
|
|
|
|
const handleRetrySummaryTask = async () => {
|
|
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await retryMeetingSummary(Number(id));
|
|
|
|
|
|
message.success('已重新提交总结任务');
|
|
|
|
|
|
setMeeting((current) => current ? {
|
|
|
|
|
|
...current,
|
|
|
|
|
|
status: 2,
|
|
|
|
|
|
latestSummaryAttemptStatus: 0,
|
|
|
|
|
|
latestSummaryAttemptErrorMsg: undefined,
|
|
|
|
|
|
} : current);
|
|
|
|
|
|
await fetchData(Number(id));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRetryChapterTask = async () => {
|
|
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await retryMeetingChapter(Number(id));
|
|
|
|
|
|
message.success('已重新提交 AI 目录任务');
|
|
|
|
|
|
setMeeting((current) => current ? {
|
|
|
|
|
|
...current,
|
|
|
|
|
|
status: 2,
|
|
|
|
|
|
latestChapterAttemptStatus: 0,
|
|
|
|
|
|
latestChapterAttemptErrorMsg: undefined,
|
|
|
|
|
|
} : current);
|
|
|
|
|
|
await fetchData(Number(id));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 05:50:01 +00:00
|
|
|
|
const handleKeywordToggle = (keyword: string, checked: boolean) => {
|
|
|
|
|
|
setSelectedKeywords((current) => {
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
|
return current.includes(keyword) ? current : [...current, keyword];
|
|
|
|
|
|
}
|
|
|
|
|
|
return current.filter((item) => item !== keyword);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleAddSelectedHotwords = async () => {
|
|
|
|
|
|
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
|
|
|
|
|
if (!keywords.length) {
|
2026-03-27 06:33:32 +00:00
|
|
|
|
message.warning('请先选择关键词');
|
2026-03-27 05:50:01 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setAddingHotwords(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' });
|
|
|
|
|
|
const existingWords = new Set(
|
|
|
|
|
|
(existingRes.data?.data?.records || [])
|
|
|
|
|
|
.map((item) => item.word?.trim())
|
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
|
);
|
|
|
|
|
|
const toCreate = keywords.filter((item) => !existingWords.has(item));
|
|
|
|
|
|
|
|
|
|
|
|
if (!toCreate.length) {
|
2026-03-27 06:33:32 +00:00
|
|
|
|
message.info('所选关键词已存在于热词库');
|
2026-03-27 05:50:01 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
|
toCreate.map((word) =>
|
2026-04-22 08:06:50 +00:00
|
|
|
|
(async () => {
|
|
|
|
|
|
let pinyinList: string[] = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pinyinRes = await getPinyinSuggestion(word);
|
|
|
|
|
|
pinyinList = (pinyinRes.data?.data || []).map((item) => item.trim()).filter(Boolean);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
pinyinList = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
return saveHotWord({
|
|
|
|
|
|
word,
|
|
|
|
|
|
pinyinList,
|
|
|
|
|
|
matchStrategy: 1,
|
|
|
|
|
|
category: '',
|
|
|
|
|
|
weight: 2,
|
|
|
|
|
|
status: 1,
|
2026-03-27 06:33:32 +00:00
|
|
|
|
remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词',
|
2026-04-22 08:06:50 +00:00
|
|
|
|
});
|
|
|
|
|
|
})(),
|
2026-03-27 05:50:01 +00:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const skippedCount = keywords.length - toCreate.length;
|
|
|
|
|
|
message.success(
|
|
|
|
|
|
skippedCount > 0
|
2026-03-27 06:33:32 +00:00
|
|
|
|
? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项`
|
|
|
|
|
|
: `已新增 ${toCreate.length} 个热词`,
|
2026-03-27 05:50:01 +00:00
|
|
|
|
);
|
|
|
|
|
|
setSelectedKeywords([]);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setAddingHotwords(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 06:33:32 +00:00
|
|
|
|
const handleStartEditTranscript = useCallback((item: MeetingTranscriptVO, event: React.MouseEvent) => {
|
2026-03-27 05:50:01 +00:00
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
setEditingTranscriptId(item.id);
|
2026-03-27 06:33:32 +00:00
|
|
|
|
}, []);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
|
2026-03-27 06:33:32 +00:00
|
|
|
|
const handleCancelEditTranscript = useCallback((event?: React.SyntheticEvent) => {
|
2026-03-27 05:50:01 +00:00
|
|
|
|
event?.stopPropagation();
|
|
|
|
|
|
setEditingTranscriptId(null);
|
2026-03-27 06:33:32 +00:00
|
|
|
|
}, []);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
|
2026-03-27 06:33:32 +00:00
|
|
|
|
const handleSaveTranscript = useCallback(async (item: MeetingTranscriptVO, nextContent?: string) => {
|
|
|
|
|
|
const content = (nextContent ?? item.content ?? '').trim();
|
2026-03-27 05:50:01 +00:00
|
|
|
|
if (!content) {
|
|
|
|
|
|
message.warning('转录内容不能为空');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!meeting) return;
|
|
|
|
|
|
if (content === (item.content || '').trim()) {
|
|
|
|
|
|
handleCancelEditTranscript();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSavingTranscriptId(item.id);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateMeetingTranscript({
|
|
|
|
|
|
meetingId: meeting.id,
|
|
|
|
|
|
transcriptId: item.id,
|
|
|
|
|
|
content,
|
|
|
|
|
|
});
|
|
|
|
|
|
message.success('原文已更新,如需同步摘要请重新总结');
|
|
|
|
|
|
handleCancelEditTranscript();
|
|
|
|
|
|
await fetchData(meeting.id);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSavingTranscriptId(null);
|
|
|
|
|
|
}
|
2026-03-27 06:33:32 +00:00
|
|
|
|
}, [fetchData, handleCancelEditTranscript, meeting]);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
|
2026-03-27 06:33:32 +00:00
|
|
|
|
const handleTranscriptDraftKeyDown = useCallback((item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
2026-03-27 05:50:01 +00:00
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
|
handleCancelEditTranscript();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
|
|
|
|
event.preventDefault();
|
2026-03-27 06:33:32 +00:00
|
|
|
|
void handleSaveTranscript(item, value);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
}
|
2026-03-27 06:33:32 +00:00
|
|
|
|
}, [handleCancelEditTranscript, handleSaveTranscript]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleTranscriptDraftBlur = useCallback((item: MeetingTranscriptVO, value: string) => {
|
|
|
|
|
|
void handleSaveTranscript(item, value);
|
|
|
|
|
|
}, [handleSaveTranscript]);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
|
2026-03-27 06:33:32 +00:00
|
|
|
|
const handleTranscriptSpeakerUpdated = useCallback(() => {
|
|
|
|
|
|
if (!meeting) return;
|
|
|
|
|
|
void fetchData(meeting.id);
|
|
|
|
|
|
}, [fetchData, meeting]);
|
|
|
|
|
|
|
2026-05-09 05:48:09 +00:00
|
|
|
|
const registerTranscriptRow = useCallback((transcriptId: number, node: HTMLDivElement | null) => {
|
|
|
|
|
|
transcriptItemRefs.current[transcriptId] = node;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-04-27 02:39:34 +00:00
|
|
|
|
const handleAudioPlaybackError = useCallback(() => {
|
2026-04-27 07:16:08 +00:00
|
|
|
|
const currentAudioUrl = playbackAudioUrl || '';
|
2026-04-27 02:39:34 +00:00
|
|
|
|
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalizedUrl = currentAudioUrl.split('#')[0]?.split('?')[0]?.toLowerCase() || '';
|
|
|
|
|
|
const isM4a = normalizedUrl.endsWith('.m4a');
|
|
|
|
|
|
message.warning(
|
|
|
|
|
|
isM4a
|
|
|
|
|
|
? '当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav,或下载到本地播放。'
|
|
|
|
|
|
: '当前音频文件无法播放,请检查文件是否损坏或格式是否兼容。',
|
|
|
|
|
|
);
|
|
|
|
|
|
audioPlaybackErrorShownRef.current = currentAudioUrl;
|
|
|
|
|
|
setAudioPlaying(false);
|
2026-04-27 07:16:08 +00:00
|
|
|
|
}, [message, playbackAudioUrl]);
|
2026-04-27 02:39:34 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const audio = audioRef.current;
|
|
|
|
|
|
if (!audio) return undefined;
|
|
|
|
|
|
|
|
|
|
|
|
const handleLoadedMetadata = () => {
|
|
|
|
|
|
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
|
|
|
|
|
|
setAudioCurrentTime(audio.currentTime || 0);
|
|
|
|
|
|
audio.playbackRate = audioPlaybackRate;
|
|
|
|
|
|
};
|
|
|
|
|
|
const handleTimeUpdate = () => {
|
|
|
|
|
|
setAudioCurrentTime(audio.currentTime || 0);
|
|
|
|
|
|
};
|
|
|
|
|
|
const handlePlay = () => setAudioPlaying(true);
|
|
|
|
|
|
const handlePause = () => setAudioPlaying(false);
|
|
|
|
|
|
const handleEnded = () => setAudioPlaying(false);
|
|
|
|
|
|
const handleError = () => handleAudioPlaybackError();
|
|
|
|
|
|
|
|
|
|
|
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
|
|
|
|
audio.addEventListener('durationchange', handleLoadedMetadata);
|
|
|
|
|
|
audio.addEventListener('canplay', handleLoadedMetadata);
|
|
|
|
|
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
|
|
|
|
|
audio.addEventListener('play', handlePlay);
|
|
|
|
|
|
audio.addEventListener('pause', handlePause);
|
|
|
|
|
|
audio.addEventListener('ended', handleEnded);
|
|
|
|
|
|
audio.addEventListener('error', handleError);
|
|
|
|
|
|
|
|
|
|
|
|
handleLoadedMetadata();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
|
|
|
|
audio.removeEventListener('durationchange', handleLoadedMetadata);
|
|
|
|
|
|
audio.removeEventListener('canplay', handleLoadedMetadata);
|
|
|
|
|
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
|
|
|
|
|
audio.removeEventListener('play', handlePlay);
|
|
|
|
|
|
audio.removeEventListener('pause', handlePause);
|
|
|
|
|
|
audio.removeEventListener('ended', handleEnded);
|
|
|
|
|
|
audio.removeEventListener('error', handleError);
|
|
|
|
|
|
};
|
2026-04-27 07:16:08 +00:00
|
|
|
|
}, [audioPlaybackRate, meeting?.status, playbackAudioUrl, handleAudioPlaybackError]);
|
2026-04-27 02:39:34 +00:00
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const toggleAudioPlayback = () => {
|
|
|
|
|
|
if (!audioRef.current) return;
|
|
|
|
|
|
if (audioRef.current.paused) {
|
2026-03-02 11:59:47 +00:00
|
|
|
|
audioRef.current.play();
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
audioRef.current.pause();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleAudioProgressChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
const nextTime = Number(event.target.value || 0);
|
|
|
|
|
|
setAudioCurrentTime(nextTime);
|
|
|
|
|
|
if (audioRef.current) {
|
|
|
|
|
|
audioRef.current.currentTime = nextTime;
|
2026-03-02 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const cyclePlaybackRate = () => {
|
|
|
|
|
|
if (!audioRef.current) return;
|
|
|
|
|
|
const rates = [1, 1.25, 1.5, 2];
|
|
|
|
|
|
const currentIndex = rates.findIndex((item) => item === audioPlaybackRate);
|
|
|
|
|
|
const nextRate = rates[(currentIndex + 1) % rates.length];
|
|
|
|
|
|
audioRef.current.playbackRate = nextRate;
|
|
|
|
|
|
setAudioPlaybackRate(nextRate);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const getFileNameFromDisposition = (disposition?: string, fallback?: string) => {
|
|
|
|
|
|
if (!disposition) return fallback || 'summary';
|
|
|
|
|
|
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
|
|
|
|
|
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const normalMatch = disposition.match(/filename="?([^";]+)"?/i);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
return normalMatch?.[1] || fallback || 'summary';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDownloadSummary = async (format: 'pdf' | 'word') => {
|
|
|
|
|
|
if (!meeting) return;
|
|
|
|
|
|
if (!meeting.summaryContent) {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
message.warning('当前暂无可下载的 AI 总结');
|
2026-03-05 09:52:08 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-06 01:59:29 +00:00
|
|
|
|
setDownloadLoading(format);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const res = await downloadMeetingSummary(meeting.id, format);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const contentType =
|
2026-03-05 09:52:08 +00:00
|
|
|
|
res.headers['content-type'] ||
|
|
|
|
|
|
(format === 'pdf'
|
|
|
|
|
|
? 'application/pdf'
|
|
|
|
|
|
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
|
|
|
|
|
|
|
|
|
|
if (contentType.includes('application/json')) {
|
|
|
|
|
|
const text = await (res.data as Blob).text();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const json = JSON.parse(text);
|
|
|
|
|
|
message.error(json?.msg || '下载失败');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('下载失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([res.data], { type: contentType });
|
|
|
|
|
|
const url = window.URL.createObjectURL(blob);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const anchor = document.createElement('a');
|
|
|
|
|
|
anchor.href = url;
|
|
|
|
|
|
anchor.download = getFileNameFromDisposition(
|
2026-03-05 09:52:08 +00:00
|
|
|
|
res.headers['content-disposition'],
|
2026-03-27 02:30:48 +00:00
|
|
|
|
`${(meeting.title || 'meeting').replace(/[\\/:*?"<>|\r\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
document.body.appendChild(anchor);
|
|
|
|
|
|
anchor.click();
|
|
|
|
|
|
anchor.remove();
|
2026-03-05 09:52:08 +00:00
|
|
|
|
window.URL.revokeObjectURL(url);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
message.error(`${format.toUpperCase()} 下载失败`);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setDownloadLoading(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-28 02:34:15 +00:00
|
|
|
|
const handleDownloadTranscript = async () => {
|
|
|
|
|
|
if (!meeting) return;
|
|
|
|
|
|
if (transcripts.length === 0) {
|
|
|
|
|
|
message.warning('当前暂无可下载的会议转录');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setDownloadLoading('transcript');
|
|
|
|
|
|
const res = await downloadMeetingTranscript(meeting.id);
|
|
|
|
|
|
const contentType = res.headers['content-type'] || 'text/markdown; charset=UTF-8';
|
|
|
|
|
|
|
|
|
|
|
|
if (contentType.includes('application/json')) {
|
|
|
|
|
|
const text = await (res.data as Blob).text();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const json = JSON.parse(text);
|
|
|
|
|
|
message.error(json?.msg || '下载失败');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('下载失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([res.data], { type: contentType });
|
|
|
|
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
|
|
const anchor = document.createElement('a');
|
|
|
|
|
|
anchor.href = url;
|
|
|
|
|
|
anchor.download = getFileNameFromDisposition(
|
|
|
|
|
|
res.headers['content-disposition'],
|
|
|
|
|
|
`${sanitizeDownloadFileName(meeting.title, 'meeting-transcript')}-Transcript.md`,
|
|
|
|
|
|
);
|
|
|
|
|
|
document.body.appendChild(anchor);
|
|
|
|
|
|
anchor.click();
|
|
|
|
|
|
anchor.remove();
|
|
|
|
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
message.error('转录下载失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setDownloadLoading(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-27 07:16:08 +00:00
|
|
|
|
const handleDownloadAudio = () => {
|
|
|
|
|
|
if (!playbackAudioUrl) {
|
|
|
|
|
|
message.warning('当前暂无可下载的会议录音');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const anchor = document.createElement('a');
|
|
|
|
|
|
anchor.href = playbackAudioUrl;
|
|
|
|
|
|
anchor.download = audioDownloadFileName;
|
|
|
|
|
|
anchor.target = '_blank';
|
|
|
|
|
|
anchor.rel = 'noopener noreferrer';
|
|
|
|
|
|
document.body.appendChild(anchor);
|
|
|
|
|
|
anchor.click();
|
|
|
|
|
|
anchor.remove();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const handleCopyPreviewLink = async () => {
|
|
|
|
|
|
if (!sharePreviewUrl) {
|
|
|
|
|
|
message.error('预览链接暂不可用');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await copyMeetingLink(sharePreviewUrl);
|
|
|
|
|
|
message.success('预览链接已复制');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
message.error('复制预览链接失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenPreview = () => {
|
|
|
|
|
|
if (!sharePreviewUrl) {
|
|
|
|
|
|
message.error('预览链接暂不可用');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.open(sharePreviewUrl, '_blank', 'noopener,noreferrer');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSharePopoverOpenChange = (open: boolean) => {
|
|
|
|
|
|
setSharePopoverOpen(open);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSharePasswordToggle = (checked: boolean) => {
|
|
|
|
|
|
setSharePasswordEnabled(checked);
|
|
|
|
|
|
setSharePasswordDraft((current) => {
|
|
|
|
|
|
const normalizedCurrent = normalizeAccessPasswordInput(current);
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
|
return normalizedCurrent || generateAccessPassword();
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRegenerateSharePassword = () => {
|
|
|
|
|
|
setSharePasswordEnabled(true);
|
|
|
|
|
|
setSharePasswordDraft(generateAccessPassword());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSaveShareAccess = async () => {
|
|
|
|
|
|
if (!meeting) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalizedPassword = normalizeAccessPasswordInput(sharePasswordDraft);
|
|
|
|
|
|
if (sharePasswordEnabled && !ACCESS_PASSWORD_PATTERN.test(normalizedPassword)) {
|
|
|
|
|
|
message.error('\u8bbf\u95ee\u5bc6\u7801\u9700\u4e3a 4 \u4f4d\u82f1\u6587\u6216\u6570\u5b57');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setShareSaving(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateMeetingBasic({
|
|
|
|
|
|
meetingId: meeting.id,
|
|
|
|
|
|
accessPassword: sharePasswordEnabled ? normalizedPassword : '',
|
2026-06-30 05:37:33 +00:00
|
|
|
|
summaryModelId: summaryForm.getFieldValue('summaryModelId') ?? meeting.summaryModelId,
|
2026-04-16 02:55:10 +00:00
|
|
|
|
});
|
|
|
|
|
|
const nextPassword = sharePasswordEnabled ? normalizedPassword : '';
|
|
|
|
|
|
setMeeting((current) => (current ? { ...current, accessPassword: nextPassword } : current));
|
|
|
|
|
|
message.success(
|
|
|
|
|
|
sharePasswordEnabled
|
|
|
|
|
|
? '\u9884\u89c8\u5bc6\u7801\u5df2\u66f4\u65b0'
|
|
|
|
|
|
: '\u9884\u89c8\u5bc6\u7801\u5df2\u5173\u95ed',
|
|
|
|
|
|
);
|
|
|
|
|
|
setSharePopoverOpen(false);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
message.error('\u4fdd\u5b58\u9884\u89c8\u5bc6\u7801\u5931\u8d25');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setShareSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const shareQrContent = sharePreviewUrl ? (
|
|
|
|
|
|
<div className="meeting-share-card">
|
|
|
|
|
|
|
|
|
|
|
|
{isOwner ? (
|
|
|
|
|
|
<div className="meeting-share-settings">
|
|
|
|
|
|
<div className="meeting-share-settings-row">
|
|
|
|
|
|
<div className="meeting-share-settings-copy">
|
|
|
|
|
|
<strong>{'\u9884\u89c8\u5bc6\u7801'}</strong>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{sharePasswordEnabled && previewAccessPassword
|
|
|
|
|
|
? `访问密码${previewAccessPassword}`
|
|
|
|
|
|
: '关闭后将不需要访问密码'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
checked={sharePasswordEnabled}
|
|
|
|
|
|
checkedChildren={'\u5f00\u542f'}
|
|
|
|
|
|
unCheckedChildren={'\u5173\u95ed'}
|
|
|
|
|
|
onChange={handleSharePasswordToggle}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{sharePasswordEnabled ? (
|
|
|
|
|
|
<div className="meeting-share-settings-actions">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={sharePasswordDraft}
|
|
|
|
|
|
maxLength={4}
|
|
|
|
|
|
placeholder={'\u4f8b\u5982 A7K2'}
|
|
|
|
|
|
onChange={(event) => setSharePasswordDraft(normalizeAccessPasswordInput(event.target.value))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button onClick={handleRegenerateSharePassword}>{'\u91cd\u7f6e\u9ed8\u8ba4'}</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<Button type="primary" loading={shareSaving} onClick={handleSaveShareAccess}>
|
|
|
|
|
|
{'\u4fdd\u5b58\u5bc6\u7801\u8bbe\u7f6e'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<div className="meeting-share-qr-wrap">
|
|
|
|
|
|
<QRCode
|
|
|
|
|
|
value={sharePreviewUrl}
|
|
|
|
|
|
type="svg"
|
|
|
|
|
|
size={172}
|
2026-04-16 05:05:50 +00:00
|
|
|
|
color="#1677ff"
|
|
|
|
|
|
bgColor="transparent"
|
2026-04-16 02:55:10 +00:00
|
|
|
|
errorLevel="H"
|
|
|
|
|
|
bordered={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meeting-share-caption">
|
|
|
|
|
|
{'\u4f7f\u7528\u624b\u673a\u626b\u7801\u540e\u5c06\u8df3\u8f6c\u5230\u4f1a\u8bae\u9884\u89c8\u9875\uff0c\u82e5\u5df2\u5f00\u542f\u5bc6\u7801\u9700\u624b\u52a8\u8f93\u5165\u3002'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meeting-share-link-box">
|
|
|
|
|
|
<LinkOutlined />
|
|
|
|
|
|
<span title={sharePreviewUrl}>{sharePreviewUrl}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meeting-share-actions">
|
|
|
|
|
|
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyPreviewLink}>
|
|
|
|
|
|
{'\u590d\u5236\u94fe\u63a5'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button size="small" type="primary" ghost onClick={handleOpenPreview}>
|
|
|
|
|
|
{'\u6253\u5f00\u9884\u89c8'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null;
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 24 }}>
|
|
|
|
|
|
<Skeleton active />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!meeting) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 24 }}>
|
|
|
|
|
|
<Empty description="会议不存在" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
2026-07-02 09:29:39 +00:00
|
|
|
|
const playerProgressPercent = audioDuration > 0
|
|
|
|
|
|
? Math.min(100, Math.max(0, (audioCurrentTime / audioDuration) * 100))
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
return (
|
2026-06-30 05:37:33 +00:00
|
|
|
|
<PageContainer title={null} className="meeting-detail-page-v2">
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
className="meeting-detail-section-card"
|
|
|
|
|
|
contentClassName="meeting-detail-section-content"
|
2026-05-06 01:41:07 +00:00
|
|
|
|
title={(
|
|
|
|
|
|
<div className="meeting-detail-title-wrap">
|
|
|
|
|
|
<div className="meeting-detail-title-icon">
|
|
|
|
|
|
<FileTextOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meeting-detail-title-copy">
|
|
|
|
|
|
<div className="meeting-detail-title-row">
|
|
|
|
|
|
<span className="meeting-detail-title-text">{meeting.title}</span>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
{isOwner && (
|
2026-05-06 01:41:07 +00:00
|
|
|
|
<button type="button" className="meeting-detail-title-edit" onClick={handleEditMeeting} aria-label="编辑会议信息">
|
|
|
|
|
|
<EditOutlined />
|
|
|
|
|
|
</button>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
)}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meeting-detail-meta-row">
|
|
|
|
|
|
<span>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</span>
|
2026-07-02 09:29:39 +00:00
|
|
|
|
<span>
|
|
|
|
|
|
<UserOutlined /> {meeting.participants || '未指定'}
|
|
|
|
|
|
</span>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</div>
|
2026-07-01 01:42:31 +00:00
|
|
|
|
<div className="meeting-detail-config-row">
|
|
|
|
|
|
<span className="meeting-detail-config-item">
|
|
|
|
|
|
<Text type="secondary">总结模型</Text>
|
|
|
|
|
|
<strong>{summaryModelDisplayName}</strong>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="meeting-detail-config-item">
|
|
|
|
|
|
<Text type="secondary">会议模板</Text>
|
|
|
|
|
|
<strong>{promptDisplayName}</strong>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="meeting-detail-config-item">
|
|
|
|
|
|
<Text type="secondary">热词组</Text>
|
|
|
|
|
|
<strong>{hotWordGroupDisplayName}</strong>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
extra={(
|
|
|
|
|
|
<Space size={10} wrap>
|
|
|
|
|
|
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
|
|
|
|
|
返回
|
|
|
|
|
|
</Button>
|
2026-06-01 05:42:50 +00:00
|
|
|
|
{canRetrySchedule && (
|
|
|
|
|
|
<Button icon={<SyncOutlined />} onClick={handleRetrySchedule} loading={actionLoading}>
|
|
|
|
|
|
重新调度
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
{canRetrySummary && (
|
|
|
|
|
|
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
|
|
|
|
|
|
重新总结
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-05-27 09:44:15 +00:00
|
|
|
|
{canRetryFailedChapterTask && (
|
|
|
|
|
|
<Button icon={<SyncOutlined />} onClick={handleRetryChapterTask} loading={actionLoading}>
|
|
|
|
|
|
重试 AI 目录
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{canRetryFailedSummaryTask && (
|
|
|
|
|
|
<Button icon={<SyncOutlined />} onClick={handleRetrySummaryTask} loading={actionLoading}>
|
|
|
|
|
|
重试总结
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
{canRetryTranscription && (
|
|
|
|
|
|
<Button icon={<SyncOutlined />} onClick={handleRetryTranscription} loading={actionLoading}>
|
|
|
|
|
|
重新识别
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isOwner && meeting.status === 2 && (
|
|
|
|
|
|
<Button icon={<LoadingOutlined />} disabled loading>
|
|
|
|
|
|
正在总结
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-05-14 01:20:11 +00:00
|
|
|
|
{(playbackAudioUrl || transcripts.length > 0 || hasSummaryContent) && (
|
2026-05-06 01:41:07 +00:00
|
|
|
|
<Dropdown
|
|
|
|
|
|
menu={{
|
|
|
|
|
|
items: [
|
|
|
|
|
|
...(playbackAudioUrl ? [{
|
|
|
|
|
|
key: 'audio',
|
|
|
|
|
|
label: `下载录音 (${audioDownloadFormatLabel})`,
|
|
|
|
|
|
icon: <AudioOutlined />,
|
|
|
|
|
|
onClick: handleDownloadAudio,
|
|
|
|
|
|
}] : []),
|
|
|
|
|
|
...(transcripts.length > 0 ? [{
|
|
|
|
|
|
key: 'transcript',
|
|
|
|
|
|
label: '下载转录 MD',
|
|
|
|
|
|
icon: <DownloadOutlined />,
|
|
|
|
|
|
onClick: handleDownloadTranscript,
|
|
|
|
|
|
disabled: downloadLoading === 'transcript',
|
|
|
|
|
|
}] : []),
|
2026-05-14 01:20:11 +00:00
|
|
|
|
...(hasSummaryContent ? [
|
2026-05-06 01:41:07 +00:00
|
|
|
|
{
|
|
|
|
|
|
key: 'pdf',
|
|
|
|
|
|
label: '下载 PDF',
|
|
|
|
|
|
icon: <FilePdfOutlined />,
|
|
|
|
|
|
onClick: () => handleDownloadSummary('pdf'),
|
|
|
|
|
|
disabled: downloadLoading === 'pdf',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'word',
|
|
|
|
|
|
label: '下载 Word',
|
|
|
|
|
|
icon: <FileWordOutlined />,
|
|
|
|
|
|
onClick: () => handleDownloadSummary('word'),
|
|
|
|
|
|
disabled: downloadLoading === 'word',
|
|
|
|
|
|
},
|
|
|
|
|
|
] : []),
|
|
|
|
|
|
],
|
|
|
|
|
|
}}
|
|
|
|
|
|
placement="bottomRight"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button icon={<DownloadOutlined />} loading={!!downloadLoading}>
|
2026-05-06 02:31:06 +00:00
|
|
|
|
下载
|
2026-04-10 07:18:05 +00:00
|
|
|
|
</Button>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</Dropdown>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{shareQrContent ? (
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
content={shareQrContent}
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
open={sharePopoverOpen}
|
|
|
|
|
|
onOpenChange={handleSharePopoverOpenChange}
|
|
|
|
|
|
placement="bottomRight"
|
|
|
|
|
|
overlayClassName="meeting-share-popover"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button type="primary" icon={<ShareAltOutlined />}>
|
|
|
|
|
|
分享
|
2026-03-05 01:36:41 +00:00
|
|
|
|
</Button>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</Popover>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
)}
|
2026-06-30 05:37:33 +00:00
|
|
|
|
>
|
|
|
|
|
|
<div className="meeting-detail-workspace">
|
2026-05-22 09:28:59 +00:00
|
|
|
|
{meeting.status === 0 || meeting.status === 1 ? (
|
2026-06-08 08:19:40 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ display: 'none' }}>
|
|
|
|
|
|
<MeetingProgressDisplay
|
|
|
|
|
|
meetingId={meeting.id}
|
|
|
|
|
|
onComplete={() => fetchData(meeting.id)}
|
|
|
|
|
|
onRefreshNeeded={() => { void fetchData(meeting.id); }}
|
|
|
|
|
|
onProgressChange={setGenerationProgress}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<UnifiedMeetingProgressDisplay
|
|
|
|
|
|
progress={generationProgress}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
) : (
|
2026-05-14 01:20:11 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<Row gutter={24} style={{ height: '100%' }}>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
<Col xs={24} xl={13} style={{ height: '100%' }}>
|
|
|
|
|
|
<div className="detail-side-column detail-left-column">
|
2026-05-13 06:31:48 +00:00
|
|
|
|
{(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && (
|
|
|
|
|
|
<Alert
|
2026-05-14 01:20:11 +00:00
|
|
|
|
type={generationFailureNotice?.type || 'warning'}
|
2026-05-13 06:31:48 +00:00
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginBottom: 16, borderRadius: 12 }}
|
|
|
|
|
|
message={
|
|
|
|
|
|
<div style={{ fontWeight: 600 }}>
|
|
|
|
|
|
{generationFailureNotice?.title || emptyTranscriptFailureNotice?.title || '会议处理提醒'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
description={
|
|
|
|
|
|
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
|
|
|
|
<div>{generationFailureNotice?.description || emptyTranscriptFailureNotice?.description}</div>
|
|
|
|
|
|
{meeting.audioSaveStatus === 'FAILED' && (
|
|
|
|
|
|
<div style={{ marginTop: 4, color: '#d48806' }}>
|
|
|
|
|
|
<InfoCircleOutlined style={{ marginRight: 4 }} />
|
|
|
|
|
|
{meeting.audioSaveMessage || '录音保存失败,当前无法播放'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{canRetryTranscription && (
|
|
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
|
|
|
|
{emptyTranscriptFailureNotice?.hint}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-04-08 06:34:59 +00:00
|
|
|
|
<Card className="left-flow-card summary-panel" variant="borderless">
|
2026-05-11 02:54:33 +00:00
|
|
|
|
|
2026-06-08 08:19:40 +00:00
|
|
|
|
{(meeting.status === 1 || meeting.status === 2) && (
|
|
|
|
|
|
<div style={{ display: 'none' }}>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
<MeetingProgressDisplay
|
|
|
|
|
|
meetingId={meeting.id}
|
|
|
|
|
|
onComplete={() => fetchData(meeting.id)}
|
2026-05-22 09:28:59 +00:00
|
|
|
|
onRefreshNeeded={() => { void fetchData(meeting.id); }}
|
2026-05-14 01:20:11 +00:00
|
|
|
|
onProgressChange={setGenerationProgress}
|
2026-06-08 08:19:40 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{meeting.status === 2 && !hasSummaryContent ? (
|
|
|
|
|
|
<div className="summary-progress-shell">
|
|
|
|
|
|
<UnifiedMeetingProgressDisplay
|
|
|
|
|
|
progress={generationProgress}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
compact
|
|
|
|
|
|
/>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
2026-05-14 01:20:11 +00:00
|
|
|
|
) : hasSummaryContent ? (
|
2026-05-09 07:19:49 +00:00
|
|
|
|
<div className="summary-content-box">
|
2026-06-30 07:17:18 +00:00
|
|
|
|
<div className="summary-head">
|
|
|
|
|
|
<div className="summary-title">会议总结</div>
|
|
|
|
|
|
</div>
|
2026-05-14 01:20:11 +00:00
|
|
|
|
{summaryPanelNotice && (
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
type={summaryPanelNotice.type}
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginBottom: 20, borderRadius: 14 }}
|
|
|
|
|
|
message={summaryPanelNotice.title}
|
|
|
|
|
|
description={
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div>{summaryPanelNotice.description}</div>
|
|
|
|
|
|
{meeting.status === 2 ? (
|
2026-06-08 08:19:40 +00:00
|
|
|
|
<UnifiedMeetingProgressDisplay
|
|
|
|
|
|
progress={generationProgress}
|
2026-05-14 01:20:11 +00:00
|
|
|
|
inline
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
{/* Keywords placed between title and overview */}
|
|
|
|
|
|
<div className="summary-section" style={{ marginBottom: 24 }}>
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
|
|
|
|
<div className="summary-section-title" style={{ marginBottom: 0 }}>关键词</div>
|
|
|
|
|
|
{isOwner && analysis.keywords.length > 0 && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
ghost
|
|
|
|
|
|
disabled={!selectedKeywords.length}
|
|
|
|
|
|
loading={addingHotwords}
|
|
|
|
|
|
onClick={handleAddSelectedHotwords}
|
|
|
|
|
|
>
|
|
|
|
|
|
加入热词 {selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="record-tags">
|
|
|
|
|
|
{keywordItems.length ? (
|
|
|
|
|
|
keywordItems.map((tag) => {
|
|
|
|
|
|
const isSelected = selectedKeywords.includes(tag);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={tag}
|
|
|
|
|
|
className={`tag selectable-tag ${isSelected ? 'selected' : ''}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (isOwner && analysis.keywords.length) {
|
|
|
|
|
|
handleKeywordToggle(tag, !isSelected);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-07-02 09:29:39 +00:00
|
|
|
|
<span>{normalizeTagDisplay(tag)}</span>
|
2026-05-09 07:19:49 +00:00
|
|
|
|
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Text type="secondary">暂无关键词</Text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
2026-05-09 07:19:49 +00:00
|
|
|
|
|
|
|
|
|
|
<div className="summary-section">
|
2026-06-30 07:17:18 +00:00
|
|
|
|
<div style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginBottom: 12
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<div className="summary-section-title" style={{marginBottom: 0}}>会议概述</div>
|
|
|
|
|
|
{isOwner ? (
|
|
|
|
|
|
isEditingSummary ? (
|
|
|
|
|
|
<Space size={8}>
|
|
|
|
|
|
<Button onClick={() => setIsEditingSummary(false)}>取消</Button>
|
|
|
|
|
|
<Button type="primary" onClick={handleSaveSummary} loading={actionLoading}>保存</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
ghost
|
|
|
|
|
|
icon={<EditOutlined/>}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSummaryDraft(meeting.summaryContent || '');
|
|
|
|
|
|
setIsEditingSummary(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
编辑
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
2026-05-09 07:19:49 +00:00
|
|
|
|
<div className="markdown-body summary-markdown">
|
|
|
|
|
|
{isEditingSummary ? (
|
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
|
value={summaryDraft}
|
|
|
|
|
|
onChange={(e) => setSummaryDraft(e.target.value)}
|
|
|
|
|
|
autoSize={{ minRows: 10 }}
|
|
|
|
|
|
className="summary-inline-edit"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ReactMarkdown>
|
|
|
|
|
|
{analysis.overview || meeting.summaryContent}
|
|
|
|
|
|
</ReactMarkdown>
|
2026-05-06 02:09:07 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-09 07:19:49 +00:00
|
|
|
|
</div>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
|
2026-05-09 07:19:49 +00:00
|
|
|
|
{discussionItems.length > 0 && !isEditingSummary && (
|
|
|
|
|
|
<div className="summary-section" style={{ marginTop: 24 }}>
|
|
|
|
|
|
<div className="summary-section-title">主要讨论点</div>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
<div className="discussion-list">
|
|
|
|
|
|
{discussionItems.map((item, index) => (
|
|
|
|
|
|
<div className="discussion-item" key={`${item.title}-${index}`}>
|
|
|
|
|
|
<div className="discussion-marker" />
|
|
|
|
|
|
<div className="discussion-body">
|
|
|
|
|
|
<div className="discussion-title-row">
|
|
|
|
|
|
<strong>{item.title || `讨论点 ${index + 1}`}</strong>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
2026-05-09 07:19:49 +00:00
|
|
|
|
<div className="discussion-copy">{item.summary}</div>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</div>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-09 07:19:49 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="summary-empty-state">
|
|
|
|
|
|
<Empty description="暂无智能总结内容" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</Card>
|
|
|
|
|
|
|
2026-04-27 02:39:34 +00:00
|
|
|
|
{!emptyTranscriptFailureNotice && (
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<div className="section-divider-note">
|
|
|
|
|
|
<div className="section-divider-line" />
|
|
|
|
|
|
<div className="section-divider-text">
|
2026-03-27 06:33:32 +00:00
|
|
|
|
智能内容由 AI 模型生成,我们不对内容准确性和完整性作任何保证,也不代表我们的观点或态度
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="section-divider-line" />
|
|
|
|
|
|
</div>
|
2026-04-27 02:39:34 +00:00
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
|
2026-04-17 03:00:50 +00:00
|
|
|
|
<div ref={transcriptSectionRef} className="transcript-player-anchor">
|
2026-07-01 14:00:19 +00:00
|
|
|
|
<Card className="left-flow-card" variant="borderless" title={<Space size={8}><AudioOutlined />原文</Space>}>
|
2026-04-27 07:16:08 +00:00
|
|
|
|
{playbackAudioUrl && (
|
2026-04-27 02:39:34 +00:00
|
|
|
|
<audio ref={audioRef} style={{ display: 'none' }} preload="auto">
|
2026-04-27 07:16:08 +00:00
|
|
|
|
<source src={playbackAudioUrl} type={resolveAudioMimeType(playbackAudioUrl)} />
|
2026-04-27 02:39:34 +00:00
|
|
|
|
</audio>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{emptyTranscriptFailureNotice && (
|
|
|
|
|
|
<div className="empty-transcript-inline-note">
|
|
|
|
|
|
<div className="empty-transcript-inline-note__title">当前没有可展示的转录内容</div>
|
|
|
|
|
|
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
<List
|
|
|
|
|
|
className="transcript-list"
|
|
|
|
|
|
dataSource={transcripts}
|
2026-04-27 07:16:08 +00:00
|
|
|
|
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
renderItem={(item, index) => {
|
|
|
|
|
|
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
|
|
|
|
|
|
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ActiveTranscriptRow
|
|
|
|
|
|
item={item}
|
|
|
|
|
|
meetingId={meeting.id}
|
|
|
|
|
|
isOwner={isOwner}
|
|
|
|
|
|
isEditing={editingTranscriptId === item.id}
|
|
|
|
|
|
isSaving={savingTranscriptId === item.id}
|
|
|
|
|
|
speakerLabelMap={speakerLabelMap}
|
2026-05-09 05:48:09 +00:00
|
|
|
|
onPlay={handleTranscriptRowPlay}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
onStartEdit={handleStartEditTranscript}
|
|
|
|
|
|
onDraftBlur={handleTranscriptDraftBlur}
|
|
|
|
|
|
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
|
|
|
|
|
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
2026-05-09 05:48:09 +00:00
|
|
|
|
registerRow={registerTranscriptRow}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
isActive={isActive}
|
2026-05-09 05:48:09 +00:00
|
|
|
|
isLinkedHighlight={linkedTranscriptIds.includes(item.id)}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
audioPlaying={audioPlaying}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Col>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
|
2026-05-06 01:41:07 +00:00
|
|
|
|
<Col xs={24} xl={11} style={{ height: '100%' }}>
|
|
|
|
|
|
<div ref={leftColumnRef} className="detail-side-column detail-right-column">
|
|
|
|
|
|
{(
|
|
|
|
|
|
<div ref={transcriptSectionRef} className="transcript-player-anchor">
|
|
|
|
|
|
<Card
|
|
|
|
|
|
className="left-flow-card transcript-workspace-card"
|
|
|
|
|
|
variant="borderless"
|
|
|
|
|
|
style={{ height: '100%' }}
|
|
|
|
|
|
styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 } }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{playbackAudioUrl && (
|
|
|
|
|
|
<audio ref={audioRef} style={{ display: 'none' }} preload="auto">
|
|
|
|
|
|
<source src={playbackAudioUrl} type={resolveAudioMimeType(playbackAudioUrl)} />
|
|
|
|
|
|
</audio>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-02 09:29:39 +00:00
|
|
|
|
<div className="transcript-panel-header">
|
|
|
|
|
|
<div className="transcript-stage-tabs" role="tablist" aria-label="内容切换">
|
|
|
|
|
|
{aiCatalogEnabled && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={workspaceTab === 'catalog'}
|
|
|
|
|
|
className={workspaceTab === 'catalog' ? 'active' : ''}
|
|
|
|
|
|
onClick={() => setWorkspaceTab('catalog')}
|
|
|
|
|
|
>
|
|
|
|
|
|
AI目录
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-06-25 02:08:32 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-07-02 09:29:39 +00:00
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={workspaceTab === 'transcript'}
|
|
|
|
|
|
className={workspaceTab === 'transcript' ? 'active' : ''}
|
|
|
|
|
|
onClick={() => setWorkspaceTab('transcript')}
|
2026-06-25 02:08:32 +00:00
|
|
|
|
>
|
2026-07-02 09:29:39 +00:00
|
|
|
|
转录原文
|
2026-06-25 02:08:32 +00:00
|
|
|
|
</button>
|
2026-07-02 09:29:39 +00:00
|
|
|
|
</div>
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="transcript-scroll-shell">
|
2026-06-25 02:08:32 +00:00
|
|
|
|
{aiCatalogEnabled && workspaceTab === 'catalog' ? (
|
2026-05-09 05:48:09 +00:00
|
|
|
|
<div className="catalog-list">
|
2026-05-14 01:20:11 +00:00
|
|
|
|
{catalogPanelNotice && (
|
2026-05-11 02:54:33 +00:00
|
|
|
|
<Alert
|
2026-05-14 01:20:11 +00:00
|
|
|
|
type={catalogPanelNotice.type}
|
2026-05-11 02:54:33 +00:00
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginBottom: 16 }}
|
|
|
|
|
|
message="AI 目录生成失败"
|
2026-05-14 01:20:11 +00:00
|
|
|
|
description={catalogPanelNotice.description}
|
2026-05-11 02:54:33 +00:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-09 05:48:09 +00:00
|
|
|
|
{catalogChapterLinks.length ? (
|
|
|
|
|
|
catalogChapterLinks.map((chapter, index) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={chapter.key}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
className={`catalog-item-container ${linkedChapterKey === chapter.key ? 'active' : ''}`}
|
2026-05-09 05:48:09 +00:00
|
|
|
|
>
|
2026-05-09 07:19:49 +00:00
|
|
|
|
<div className="catalog-timeline-axis">
|
|
|
|
|
|
<div className="catalog-timeline-dot" />
|
|
|
|
|
|
<div className="catalog-timeline-line" />
|
2026-05-09 05:48:09 +00:00
|
|
|
|
</div>
|
2026-05-09 07:19:49 +00:00
|
|
|
|
<div
|
|
|
|
|
|
className="catalog-item-card"
|
|
|
|
|
|
onClick={() => handleLocateChapterTranscript(index)}
|
|
|
|
|
|
style={{ cursor: 'pointer' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="catalog-item-time">{chapter.timeLabel}</div>
|
|
|
|
|
|
<div className="catalog-item-title-row" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginTop: 4 }}>
|
|
|
|
|
|
<div className="catalog-item-title">{chapter.title}</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="catalog-item-link"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleLocateChapterTranscript(index);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<LinkOutlined /> 关联原文
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div> ))
|
2026-05-14 01:20:11 +00:00
|
|
|
|
) : meeting.status === 2 ? (
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginBottom: 16 }}
|
|
|
|
|
|
message="AI 目录生成中"
|
|
|
|
|
|
description={generationProgress?.message || '正在生成 AI 目录,章节完成后会自动展示。'}
|
|
|
|
|
|
/>
|
2026-05-09 05:48:09 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 AI 目录" />
|
|
|
|
|
|
)}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</div>
|
2026-05-09 05:48:09 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{emptyTranscriptFailureNotice && (
|
|
|
|
|
|
<div className="empty-transcript-inline-note">
|
|
|
|
|
|
<div className="empty-transcript-inline-note__title">当前没有可展示的转录内容</div>
|
|
|
|
|
|
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<List
|
|
|
|
|
|
className="transcript-list"
|
|
|
|
|
|
dataSource={transcripts}
|
|
|
|
|
|
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
|
|
|
|
|
|
renderItem={(item, index) => {
|
|
|
|
|
|
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
|
|
|
|
|
|
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
|
2026-05-06 02:09:07 +00:00
|
|
|
|
|
2026-05-09 05:48:09 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<ActiveTranscriptRow
|
|
|
|
|
|
item={item}
|
|
|
|
|
|
meetingId={meeting.id}
|
|
|
|
|
|
isOwner={isOwner}
|
|
|
|
|
|
isEditing={editingTranscriptId === item.id}
|
|
|
|
|
|
isSaving={savingTranscriptId === item.id}
|
|
|
|
|
|
speakerLabelMap={speakerLabelMap}
|
|
|
|
|
|
onPlay={handleTranscriptRowPlay}
|
|
|
|
|
|
onStartEdit={handleStartEditTranscript}
|
|
|
|
|
|
onDraftBlur={handleTranscriptDraftBlur}
|
|
|
|
|
|
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
|
|
|
|
|
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
|
|
|
|
|
registerRow={registerTranscriptRow}
|
|
|
|
|
|
isActive={isActive}
|
|
|
|
|
|
isLinkedHighlight={linkedTranscriptIds.includes(item.id)}
|
|
|
|
|
|
audioPlaying={audioPlaying}
|
|
|
|
|
|
highlightKeyword={highlightKeyword}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Col>
|
2026-05-14 01:20:11 +00:00
|
|
|
|
</Row>
|
|
|
|
|
|
</>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
)}
|
2026-06-30 05:37:33 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
|
2026-04-27 07:16:08 +00:00
|
|
|
|
{playbackAudioUrl && showFloatingTranscriptPlayer && floatingTranscriptPlayerLayout && (
|
2026-04-17 03:00:50 +00:00
|
|
|
|
<div
|
|
|
|
|
|
className="transcript-player transcript-player--floating"
|
|
|
|
|
|
style={{ left: floatingTranscriptPlayerLayout.left, width: floatingTranscriptPlayerLayout.width }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button type="button" className="player-main-btn" onClick={toggleAudioPlayback} aria-label="toggle-audio">
|
|
|
|
|
|
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="player-progress-shell">
|
|
|
|
|
|
<div className="player-time-row">
|
|
|
|
|
|
<span>{formatPlayerTime(audioCurrentTime)}</span>
|
|
|
|
|
|
<span>{formatPlayerTime(audioDuration)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="player-range"
|
|
|
|
|
|
type="range"
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
max={audioDuration || 0}
|
|
|
|
|
|
step={0.1}
|
|
|
|
|
|
value={Math.min(audioCurrentTime, audioDuration || 0)}
|
2026-07-02 09:29:39 +00:00
|
|
|
|
style={{ '--player-progress': `${playerProgressPercent}%` } as React.CSSProperties}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
onChange={handleAudioProgressChange}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
|
|
|
|
|
|
<FastForwardOutlined />
|
|
|
|
|
|
{audioPlaybackRate}x
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-04 09:19:41 +00:00
|
|
|
|
<style>{`
|
2026-05-06 02:09:07 +00:00
|
|
|
|
@keyframes highlight-pulse {
|
|
|
|
|
|
0% { box-shadow: 0 0 0 rgba(95, 81, 255, 0); transform: scale(1); }
|
|
|
|
|
|
50% { box-shadow: 0 0 15px rgba(95, 81, 255, 0.3); transform: scale(1.02); }
|
|
|
|
|
|
100% { box-shadow: 0 0 0 rgba(95, 81, 255, 0); transform: scale(1); }
|
|
|
|
|
|
}
|
|
|
|
|
|
.highlight-text {
|
|
|
|
|
|
background: linear-gradient(120deg, rgba(95, 81, 255, 0.15) 0%, rgba(108, 140, 255, 0.1) 100%);
|
|
|
|
|
|
border-bottom: 2px solid #5f51ff;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
padding: 1px 4px;
|
|
|
|
|
|
color: #4335eb;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
line-height: 1.2;
|
|
|
|
|
|
margin: 0 1px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
animation: highlight-pulse 2s infinite ease-in-out;
|
|
|
|
|
|
}
|
|
|
|
|
|
.highlight-text:hover {
|
|
|
|
|
|
background: rgba(95, 81, 255, 0.25);
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
}
|
2026-06-08 08:19:40 +00:00
|
|
|
|
/* 当转录行处于活动状态时,调整高亮样式以保持可读性 */
|
2026-05-06 02:09:07 +00:00
|
|
|
|
.ant-list-item.transcript-row.active .highlight-text {
|
2026-07-02 09:29:39 +00:00
|
|
|
|
background: linear-gradient(120deg, rgba(95, 81, 255, 0.15) 0%, rgba(108, 140, 255, 0.1) 100%);
|
|
|
|
|
|
border-bottom-color: #5f51ff;
|
|
|
|
|
|
color: #4335eb;
|
|
|
|
|
|
text-shadow: none;
|
2026-05-06 02:09:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.summary-keyword-link {
|
|
|
|
|
|
color: #5f51ff;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
padding: 0 2px;
|
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-keyword-link:hover {
|
|
|
|
|
|
background: rgba(95, 81, 255, 0.1);
|
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
|
}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
.meeting-detail-page {
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
height: calc(100vh - 64px);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
background:
|
|
|
|
|
|
radial-gradient(circle at 20% 20%, rgba(95, 81, 255, 0.05) 0%, transparent 40%),
|
|
|
|
|
|
radial-gradient(circle at 80% 80%, rgba(108, 140, 255, 0.05) 0%, transparent 40%),
|
|
|
|
|
|
#fbfcfd;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-page::before {
|
|
|
|
|
|
content: "";
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3%3Ffilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
|
|
|
|
|
opacity: 0.015;
|
|
|
|
|
|
pointer-events: none;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-page-header {
|
|
|
|
|
|
margin-bottom: 18px;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
padding: 24px 32px;
|
|
|
|
|
|
border-radius: 28px;
|
|
|
|
|
|
border: 1px solid rgba(220, 226, 242, 0.5);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.8);
|
|
|
|
|
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.03);
|
|
|
|
|
|
backdrop-filter: blur(20px);
|
2026-05-06 01:41:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-title-wrap {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-title-icon {
|
|
|
|
|
|
width: 42px;
|
|
|
|
|
|
height: 42px;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
background: linear-gradient(135deg, #5f51ff, #6c8cff);
|
|
|
|
|
|
box-shadow: 0 10px 24px rgba(95, 81, 255, 0.22);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-title-copy {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-title-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-title-text {
|
2026-05-09 07:19:49 +00:00
|
|
|
|
color: #1a1f36;
|
|
|
|
|
|
font-size: 24px;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
line-height: 1.2;
|
|
|
|
|
|
font-weight: 800;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
letter-spacing: -0.03em;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-title-edit {
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: rgba(95, 81, 255, 0.08);
|
|
|
|
|
|
color: #5f51ff;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-meta-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 10px 16px;
|
|
|
|
|
|
color: #6e7695;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
2026-07-01 01:42:31 +00:00
|
|
|
|
.meeting-detail-config-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 10px 12px;
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-config-item {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
min-height: 34px;
|
|
|
|
|
|
padding: 6px 12px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: rgba(244, 246, 255, 0.92);
|
|
|
|
|
|
border: 1px solid rgba(227, 232, 247, 0.95);
|
|
|
|
|
|
color: #58627f;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-detail-config-item strong {
|
|
|
|
|
|
color: #313a5a;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
.meeting-detail-workspace {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.detail-side-column {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 0;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.detail-left-column {
|
|
|
|
|
|
overflow-y: auto;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
overflow-x: hidden;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
padding-right: 6px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
gap: 18px;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.detail-left-column > .section-divider-note,
|
|
|
|
|
|
.detail-left-column > .transcript-player-anchor {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-right-column {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.left-flow-card {
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
border-radius: 24px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
border: 1px solid rgba(228, 232, 245, 0.96);
|
2026-05-06 01:41:07 +00:00
|
|
|
|
box-shadow: 0 18px 38px rgba(127, 139, 186, 0.08);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 255, 0.98));
|
|
|
|
|
|
}
|
2026-03-27 02:52:01 +00:00
|
|
|
|
.left-flow-card .ant-card-head,
|
|
|
|
|
|
.left-flow-card .ant-card-body {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.summary-panel .ant-card-body {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
gap: 20px;
|
|
|
|
|
|
padding: 24px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.summary-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
margin-bottom: 4px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.summary-title {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
gap: 8px;
|
|
|
|
|
|
color: #1a1f36;
|
|
|
|
|
|
font-size: 18px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
.summary-content-box {
|
|
|
|
|
|
background: #f8faff;
|
|
|
|
|
|
border: 1px solid #eef1f9;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-section-title {
|
|
|
|
|
|
color: #9aa0bd;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
letter-spacing: 0.02em;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-markdown {
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
|
color: #2d3553;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-markdown p {
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-markdown ul {
|
|
|
|
|
|
padding-left: 20px;
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-markdown li {
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-markdown li::marker {
|
|
|
|
|
|
color: #5f51ff;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
2026-05-09 05:48:09 +00:00
|
|
|
|
.keyword-panel .ant-card-body {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
padding: 20px 24px 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.keyword-panel-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.keyword-panel-title {
|
|
|
|
|
|
color: #2d3553;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
|
}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
.brief-section {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
padding: 18px 18px 0;
|
|
|
|
|
|
border-top: 1px solid rgba(230, 235, 247, 0.95);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
.brief-section:first-of-type {
|
|
|
|
|
|
padding-top: 0;
|
|
|
|
|
|
border-top: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.brief-section-title {
|
|
|
|
|
|
color: #5d6685;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
letter-spacing: 0.06em;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
2026-04-16 02:55:10 +00:00
|
|
|
|
.meeting-share-popover .ant-popover-inner {
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
border-radius: 24px;
|
|
|
|
|
|
box-shadow: 0 24px 56px rgba(76, 61, 39, 0.18);
|
|
|
|
|
|
background:
|
|
|
|
|
|
radial-gradient(circle at top right, rgba(82, 164, 255, 0.12), transparent 32%),
|
|
|
|
|
|
radial-gradient(circle at top left, rgba(252, 208, 157, 0.18), transparent 26%),
|
|
|
|
|
|
linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 241, 231, 0.98));
|
|
|
|
|
|
}
|
2026-04-27 02:39:34 +00:00
|
|
|
|
.empty-transcript-hero {
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
|
border-radius: 24px;
|
|
|
|
|
|
border: 1px solid rgba(220, 170, 77, 0.24);
|
|
|
|
|
|
background:
|
|
|
|
|
|
radial-gradient(circle at top right, rgba(255, 233, 188, 0.72), transparent 34%),
|
|
|
|
|
|
linear-gradient(135deg, rgba(255, 252, 245, 0.98), rgba(255, 245, 223, 0.98));
|
|
|
|
|
|
box-shadow: 0 22px 48px rgba(188, 148, 68, 0.14);
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-hero__badge {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: rgba(122, 80, 14, 0.08);
|
|
|
|
|
|
color: #9a6700;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
letter-spacing: 0.12em;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-hero__content {
|
|
|
|
|
|
margin-top: 14px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-hero__title {
|
|
|
|
|
|
color: #7a4f0f;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-hero__description {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
max-width: 760px;
|
|
|
|
|
|
color: #7a5b27;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-hero__hint {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
color: #b26a00;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-hero__actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-player {
|
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
|
max-width: 480px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.8);
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-inline-note {
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
border: 1px solid rgba(220, 170, 77, 0.22);
|
|
|
|
|
|
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(255, 245, 224, 0.92));
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-inline-note__title {
|
|
|
|
|
|
color: #8a5a00;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-inline-note__text {
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
color: #876537;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
}
|
2026-04-16 02:55:10 +00:00
|
|
|
|
.meeting-share-popover .ant-popover-inner-content {
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-card {
|
|
|
|
|
|
width: min(82vw, 300px);
|
|
|
|
|
|
padding: 18px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
color: #33261c;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-card-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-kicker {
|
|
|
|
|
|
color: rgba(72, 53, 39, 0.64);
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
letter-spacing: 0.18em;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-title {
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
|
|
|
|
|
|
font-size: 22px;
|
|
|
|
|
|
line-height: 1.08;
|
|
|
|
|
|
color: #33261c;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-pill {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: rgba(49, 95, 139, 0.08);
|
|
|
|
|
|
color: #315f8b;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-qr-wrap {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
padding: 16px;
|
|
|
|
|
|
border-radius: 22px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.72);
|
|
|
|
|
|
border: 1px solid rgba(84, 57, 31, 0.08);
|
|
|
|
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-caption {
|
|
|
|
|
|
color: rgba(72, 53, 39, 0.76);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-settings {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
padding: 14px;
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.62);
|
|
|
|
|
|
border: 1px solid rgba(84, 57, 31, 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-settings-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-settings-copy {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-settings-copy strong {
|
|
|
|
|
|
color: #33261c;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-settings-copy span {
|
|
|
|
|
|
color: rgba(72, 53, 39, 0.72);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-settings-actions {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-link-box {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.72);
|
|
|
|
|
|
border: 1px solid rgba(84, 57, 31, 0.08);
|
|
|
|
|
|
color: #315f8b;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-link-box span {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.meeting-share-actions {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.record-tags {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.tag,
|
|
|
|
|
|
.summary-tag {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
padding: 6px 14px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: rgba(22, 119, 255, 0.06);
|
|
|
|
|
|
color: var(--app-primary-color);
|
|
|
|
|
|
border: 1px solid rgba(22, 119, 255, 0.15);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
font-size: 13px;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
font-weight: 500;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
2026-03-27 05:50:01 +00:00
|
|
|
|
.selectable-tag {
|
|
|
|
|
|
cursor: pointer;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
.selectable-tag:hover {
|
|
|
|
|
|
background: rgba(22, 119, 255, 0.12);
|
|
|
|
|
|
border-color: rgba(22, 119, 255, 0.3);
|
|
|
|
|
|
transform: translateY(-1px);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
.selectable-tag.selected {
|
2026-04-16 05:05:50 +00:00
|
|
|
|
background: linear-gradient(135deg, var(--app-primary-color), #36cfc9);
|
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
border-color: transparent;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.24);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
}
|
2026-04-16 05:05:50 +00:00
|
|
|
|
.selectable-tag.selected:hover {
|
|
|
|
|
|
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.35);
|
|
|
|
|
|
transform: translateY(-2px);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.summary-copy {
|
|
|
|
|
|
color: #465072;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
line-height: 1.92;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-fade {
|
|
|
|
|
|
max-height: 162px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-fade::after {
|
|
|
|
|
|
content: "";
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
height: 52px;
|
|
|
|
|
|
background: linear-gradient(180deg, rgba(248, 250, 255, 0), rgba(248, 250, 255, 0.98));
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-link {
|
|
|
|
|
|
width: fit-content;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: #6470ff;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
2026-05-06 01:41:07 +00:00
|
|
|
|
.discussion-list {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.discussion-item {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 12px minmax(0, 1fr);
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
.discussion-marker {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: linear-gradient(135deg, #5f51ff, #7f8dff);
|
|
|
|
|
|
box-shadow: 0 0 0 5px rgba(95, 81, 255, 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
.discussion-body {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
color: #4f5876;
|
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
|
}
|
|
|
|
|
|
.discussion-title-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.discussion-title-row strong {
|
|
|
|
|
|
color: #233053;
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.discussion-copy {
|
|
|
|
|
|
color: #4f5876;
|
|
|
|
|
|
line-height: 1.86;
|
|
|
|
|
|
}
|
|
|
|
|
|
.discussion-meta {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-workspace-card {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-keyword-bar {
|
|
|
|
|
|
padding: 18px 18px 12px;
|
|
|
|
|
|
border-bottom: 1px solid rgba(228, 232, 245, 0.92);
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 255, 0.92));
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-keyword-label {
|
|
|
|
|
|
color: #2d3553;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-keyword-body {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-keyword-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-stage-tabs {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
gap: 32px;
|
|
|
|
|
|
padding: 0 24px;
|
|
|
|
|
|
min-height: 56px;
|
|
|
|
|
|
border-bottom: 1px solid #eef1f9;
|
|
|
|
|
|
background: #ffffff;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-stage-tabs button {
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
color: #6e7695;
|
|
|
|
|
|
font-size: 16px;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
height: 100%;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
cursor: pointer;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
position: relative;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
transition: color 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-stage-tabs button:hover {
|
|
|
|
|
|
color: #5f51ff;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-stage-tabs button.active {
|
2026-05-09 07:19:49 +00:00
|
|
|
|
color: #5f51ff;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-stage-tabs button.active::after {
|
|
|
|
|
|
content: "";
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
bottom: 0;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
height: 3px;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
background: #5f51ff;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
border-radius: 99px 99px 0 0;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-scroll-shell {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
|
padding: 18px 18px 0;
|
|
|
|
|
|
}
|
2026-05-09 05:48:09 +00:00
|
|
|
|
.catalog-list {
|
2026-05-09 07:19:49 +00:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0;
|
|
|
|
|
|
padding: 10px 0 20px;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
.catalog-item-container {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
padding: 0 10px;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
.catalog-timeline-axis {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
width: 20px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
position: relative;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
.catalog-timeline-dot {
|
|
|
|
|
|
width: 10px;
|
|
|
|
|
|
height: 10px;
|
|
|
|
|
|
background: #5f51ff;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
margin-top: 24px;
|
|
|
|
|
|
z-index: 2;
|
|
|
|
|
|
transition: all 0.3s ease;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
.catalog-timeline-line {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
width: 2px;
|
|
|
|
|
|
background: #eef1f9;
|
|
|
|
|
|
z-index: 1;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
.catalog-item-container:first-child .catalog-timeline-line {
|
|
|
|
|
|
top: 24px;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
2026-05-09 07:19:49 +00:00
|
|
|
|
.catalog-item-container:last-child .catalog-timeline-line {
|
|
|
|
|
|
bottom: calc(100% - 34px);
|
|
|
|
|
|
}
|
|
|
|
|
|
.catalog-item-card {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
border: 1px solid #eef1f9;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 16px 20px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
.catalog-item-card:hover {
|
|
|
|
|
|
background: #f9faff;
|
|
|
|
|
|
border-color: rgba(95, 81, 255, 0.2);
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
.catalog-item-link {
|
2026-05-09 07:19:49 +00:00
|
|
|
|
opacity: 0;
|
|
|
|
|
|
visibility: hidden;
|
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
|
background: rgba(95, 81, 255, 0.06);
|
2026-05-09 05:48:09 +00:00
|
|
|
|
color: #5f51ff;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 600;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
cursor: pointer;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.catalog-item-container:hover .catalog-item-link {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
visibility: visible;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.summary-inline-edit {
|
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
border-radius: 12px !important;
|
|
|
|
|
|
border: 1px solid #d9d9d9 !important;
|
|
|
|
|
|
padding: 12px 16px !important;
|
|
|
|
|
|
font-size: 15px !important;
|
|
|
|
|
|
line-height: 1.8 !important;
|
|
|
|
|
|
color: #2d3553 !important;
|
|
|
|
|
|
background: #fff !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-inline-edit:focus {
|
|
|
|
|
|
border-color: #5f51ff !important;
|
|
|
|
|
|
box-shadow: 0 0 0 2px rgba(95, 81, 255, 0.1) !important;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
.catalog-item-link:hover {
|
2026-05-09 07:19:49 +00:00
|
|
|
|
background: rgba(95, 81, 255, 0.12);
|
|
|
|
|
|
border-color: rgba(95, 81, 255, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tag,
|
|
|
|
|
|
.summary-tag {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
padding: 5px 14px;
|
|
|
|
|
|
border-radius: 99px;
|
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
color: #6e7695;
|
|
|
|
|
|
border: 1px solid #eef1f9;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.selectable-tag:hover {
|
|
|
|
|
|
border-color: #5f51ff;
|
|
|
|
|
|
color: #5f51ff;
|
|
|
|
|
|
background: rgba(95, 81, 255, 0.02);
|
|
|
|
|
|
}
|
|
|
|
|
|
.selectable-tag.selected {
|
|
|
|
|
|
border-color: #5f51ff;
|
|
|
|
|
|
background: rgba(95, 81, 255, 0.06);
|
|
|
|
|
|
color: #5f51ff;
|
|
|
|
|
|
}
|
|
|
|
|
|
.selectable-tag.highlighted-tag {
|
|
|
|
|
|
border-color: #5f51ff;
|
|
|
|
|
|
background: rgba(95, 81, 255, 0.05);
|
|
|
|
|
|
color: #5f51ff;
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.segmented-tabs {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 28px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
padding-bottom: 8px;
|
|
|
|
|
|
border-bottom: 1px solid rgba(226, 231, 245, 0.95);
|
|
|
|
|
|
}
|
|
|
|
|
|
.segmented-tabs button {
|
|
|
|
|
|
padding: 0 0 10px;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: #9aa0bd;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
transition: color 0.2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.segmented-tabs button:hover {
|
|
|
|
|
|
color: #5a638f;
|
|
|
|
|
|
}
|
|
|
|
|
|
.segmented-tabs button.active {
|
|
|
|
|
|
color: #3a4476;
|
|
|
|
|
|
}
|
|
|
|
|
|
.segmented-tabs button.active::after {
|
|
|
|
|
|
content: "";
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
bottom: -9px;
|
|
|
|
|
|
height: 2px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: #6b73ff;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chapter-list,
|
|
|
|
|
|
.speaker-summary-list,
|
|
|
|
|
|
.keypoint-list,
|
|
|
|
|
|
.todo-list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
padding-top: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chapter-item {
|
2026-04-16 05:05:50 +00:00
|
|
|
|
display: flex;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
gap: 14px;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
2026-04-08 08:16:21 +00:00
|
|
|
|
.chapter-time {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
position: relative;
|
|
|
|
|
|
padding-top: 10px;
|
|
|
|
|
|
color: #58627f;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 700;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
width: 64px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
2026-04-08 08:16:21 +00:00
|
|
|
|
.chapter-time::after {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
content: "";
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
margin-left: 8px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: #6e76ff;
|
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chapter-card,
|
|
|
|
|
|
.speaker-summary-card,
|
|
|
|
|
|
.keypoint-card {
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
background: rgba(244, 246, 255, 0.96);
|
|
|
|
|
|
border: 1px solid rgba(231, 235, 248, 0.98);
|
|
|
|
|
|
}
|
|
|
|
|
|
.chapter-card {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chapter-card strong,
|
|
|
|
|
|
.keypoint-content strong {
|
|
|
|
|
|
color: #414b78;
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chapter-card span,
|
|
|
|
|
|
.speaker-summary-body,
|
|
|
|
|
|
.keypoint-content span {
|
|
|
|
|
|
color: #67718f;
|
|
|
|
|
|
line-height: 1.75;
|
|
|
|
|
|
}
|
|
|
|
|
|
.speaker-summary-card {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 96px minmax(0, 1fr);
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
align-items: start;
|
|
|
|
|
|
}
|
|
|
|
|
|
.speaker-summary-side {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.speaker-avatar {
|
|
|
|
|
|
width: 48px;
|
|
|
|
|
|
height: 48px;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
background: linear-gradient(135deg, #7480ff, #8c63ff);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
}
|
|
|
|
|
|
.speaker-summary-name {
|
|
|
|
|
|
color: #3d4774;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.speaker-summary-meta,
|
|
|
|
|
|
.keypoint-meta {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
color: #96a0c1;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.keypoint-card {
|
2026-04-16 05:05:50 +00:00
|
|
|
|
display: flex;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
gap: 14px;
|
|
|
|
|
|
align-items: start;
|
|
|
|
|
|
}
|
|
|
|
|
|
.keypoint-badge {
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 36px;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
flex-shrink: 0;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: rgba(107, 115, 255, 0.12);
|
|
|
|
|
|
color: #5c64ff;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.keypoint-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.todo-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
color: #495271;
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: rgba(247, 249, 255, 0.98);
|
|
|
|
|
|
border: 1px solid rgba(232, 236, 248, 0.98);
|
|
|
|
|
|
}
|
2026-03-27 02:52:01 +00:00
|
|
|
|
.todo-dot {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
margin-top: 9px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: #6e76ff;
|
|
|
|
|
|
flex: 0 0 auto;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.section-divider-note {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
color: #c0c6dc;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
padding: 2px 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.section-divider-line {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
height: 1px;
|
|
|
|
|
|
background: linear-gradient(90deg, rgba(220, 225, 240, 0), rgba(220, 225, 240, 0.92), rgba(220, 225, 240, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
.section-divider-text {
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ant-list-item.transcript-row {
|
2026-04-08 08:16:21 +00:00
|
|
|
|
display: flex !important;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
justify-content: flex-start !important;
|
|
|
|
|
|
align-items: flex-start !important;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
padding: 12px 0 !important;
|
|
|
|
|
|
border-bottom: 0 !important;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
2026-05-09 05:48:09 +00:00
|
|
|
|
.ant-list-item.transcript-row.linked .transcript-bubble,
|
|
|
|
|
|
.ant-list-item.transcript-row.linked .transcript-bubble-editing {
|
2026-07-02 09:29:39 +00:00
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
border-color: rgba(60, 112, 245, 0.28);
|
|
|
|
|
|
color: #2d3553;
|
|
|
|
|
|
box-shadow: 0 10px 24px rgba(60, 112, 245, 0.08);
|
2026-05-09 05:48:09 +00:00
|
|
|
|
}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
.ant-list-item.transcript-row.active .transcript-bubble,
|
|
|
|
|
|
.ant-list-item.transcript-row.active .transcript-bubble-editing {
|
2026-07-02 09:29:39 +00:00
|
|
|
|
border-color: rgba(60, 112, 245, 0.34);
|
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
color: #2d3553;
|
|
|
|
|
|
box-shadow: 0 12px 28px rgba(60, 112, 245, 0.12);
|
2026-04-17 03:00:50 +00:00
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.transcript-entry {
|
2026-04-08 08:16:21 +00:00
|
|
|
|
flex: 1;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
justify-self: start;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
display: flex;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 16px;
|
2026-03-27 05:50:01 +00:00
|
|
|
|
width: 100%;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
2026-04-16 05:05:50 +00:00
|
|
|
|
.transcript-content-wrap {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.transcript-meta {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
color: #8e98b8;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
font-size: 12px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-avatar {
|
2026-04-16 05:05:50 +00:00
|
|
|
|
flex-shrink: 0;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
background: linear-gradient(135deg, #d9ebff, #eaf2ff) !important;
|
|
|
|
|
|
color: #4362ff !important;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
margin-top: 4px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-speaker {
|
2026-04-16 05:05:50 +00:00
|
|
|
|
color: var(--app-text-main);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
font-weight: 700;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
font-size: 14px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-speaker.editable {
|
2026-04-16 05:05:50 +00:00
|
|
|
|
color: var(--app-primary-color);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble {
|
2026-03-27 05:50:01 +00:00
|
|
|
|
display: block;
|
|
|
|
|
|
align-self: stretch;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
box-sizing: border-box;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
padding: 16px 22px;
|
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
border: 1px solid rgba(228, 232, 245, 0.6);
|
|
|
|
|
|
color: #2d3553;
|
|
|
|
|
|
line-height: 1.8;
|
2026-04-16 05:05:50 +00:00
|
|
|
|
font-size: 15px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
white-space: pre-wrap;
|
2026-05-09 07:19:49 +00:00
|
|
|
|
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
2026-04-16 05:05:50 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble:hover {
|
2026-05-09 07:19:49 +00:00
|
|
|
|
border-color: rgba(95, 81, 255, 0.2);
|
|
|
|
|
|
box-shadow: 0 12px 24px rgba(95, 81, 255, 0.05);
|
|
|
|
|
|
transform: translateY(-1px);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
2026-03-27 05:50:01 +00:00
|
|
|
|
.transcript-bubble.editable {
|
|
|
|
|
|
cursor: text;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble.editable:hover {
|
|
|
|
|
|
border-color: rgba(100, 112, 255, 0.24);
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble-editing {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
align-self: stretch;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble-input {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble-input .ant-input-affix-wrapper,
|
|
|
|
|
|
.transcript-bubble-input .ant-input-textarea,
|
|
|
|
|
|
.transcript-bubble-editing .ant-input-textarea,
|
|
|
|
|
|
.transcript-bubble-editing .ant-input-textarea-show-count {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble-input .ant-input {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
min-height: 1.86em;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: #3f496a;
|
|
|
|
|
|
line-height: 1.86;
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
resize: none;
|
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
direction: ltr;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble-input .ant-input:focus,
|
|
|
|
|
|
.transcript-bubble-input .ant-input:focus-within {
|
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble-actions {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-bubble-hint {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
.transcript-player-anchor {
|
|
|
|
|
|
position: relative;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-player-anchor > .transcript-workspace-card {
|
|
|
|
|
|
flex: 1;
|
2026-04-17 03:00:50 +00:00
|
|
|
|
}
|
|
|
|
|
|
.transcript-list {
|
|
|
|
|
|
min-width: 0;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
padding-top: 4px;
|
2026-04-17 03:00:50 +00:00
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.transcript-player {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
padding: 14px 16px;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
border-radius: 20px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.92);
|
|
|
|
|
|
border: 1px solid rgba(225, 231, 246, 0.95);
|
|
|
|
|
|
box-shadow: 0 18px 44px rgba(72, 86, 128, 0.16);
|
2026-04-17 03:00:50 +00:00
|
|
|
|
backdrop-filter: blur(24px);
|
|
|
|
|
|
}
|
|
|
|
|
|
.transcript-player--floating {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
bottom: 84px;
|
|
|
|
|
|
z-index: 100;
|
|
|
|
|
|
max-width: calc(100vw - 32px);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.player-main-btn,
|
|
|
|
|
|
.player-ghost-btn {
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.player-main-btn {
|
2026-05-06 01:41:07 +00:00
|
|
|
|
width: 48px;
|
|
|
|
|
|
height: 48px;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
border-radius: 50%;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
background: linear-gradient(135deg, #5f51ff, #6b7dff);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 18px;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
box-shadow: 0 10px 24px rgba(95, 81, 255, 0.28);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.player-ghost-btn {
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
padding: 0 12px;
|
|
|
|
|
|
height: 38px;
|
|
|
|
|
|
border-radius: 12px;
|
2026-05-06 01:41:07 +00:00
|
|
|
|
background: rgba(95, 81, 255, 0.08);
|
|
|
|
|
|
color: #5f51ff;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.player-progress-shell {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.player-time-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
color: #8b94b5;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.player-range {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
appearance: none;
|
|
|
|
|
|
height: 6px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: linear-gradient(90deg, rgba(108, 117, 255, 0.92), rgba(108, 117, 255, 0.92)) 0/0% 100% no-repeat,
|
|
|
|
|
|
linear-gradient(90deg, rgba(219, 224, 247, 0.9), rgba(231, 235, 250, 0.95));
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.player-range::-webkit-slider-thumb {
|
|
|
|
|
|
appearance: none;
|
|
|
|
|
|
width: 16px;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 3px solid #6e75ff;
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(110, 117, 255, 0.24);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.player-range::-moz-range-thumb {
|
|
|
|
|
|
width: 16px;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 3px solid #6e75ff;
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(110, 117, 255, 0.24);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-markdown {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
line-height: 1.9;
|
|
|
|
|
|
color: #3e4768;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
|
padding-bottom: 12px;
|
|
|
|
|
|
box-sizing: border-box;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.markdown-body {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
|
color: #333;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
word-break: break-word;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.markdown-body p {
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.markdown-body h1,
|
|
|
|
|
|
.markdown-body h2,
|
|
|
|
|
|
.markdown-body h3 {
|
|
|
|
|
|
margin-top: 24px;
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
font-weight: 600;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
.markdown-body ul,
|
|
|
|
|
|
.markdown-body ol,
|
|
|
|
|
|
.markdown-body li,
|
|
|
|
|
|
.markdown-body blockquote,
|
|
|
|
|
|
.markdown-body table,
|
|
|
|
|
|
.markdown-body tr,
|
|
|
|
|
|
.markdown-body td,
|
|
|
|
|
|
.markdown-body th,
|
|
|
|
|
|
.markdown-body a,
|
|
|
|
|
|
.markdown-body code,
|
|
|
|
|
|
.markdown-body pre {
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
.markdown-body pre {
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: rgba(245, 247, 255, 0.96);
|
|
|
|
|
|
}
|
|
|
|
|
|
.markdown-body table {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
.markdown-body img {
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
height: auto;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 1200px) {
|
|
|
|
|
|
.detail-left-column {
|
|
|
|
|
|
padding-right: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.speaker-summary-card {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 992px) {
|
2026-04-27 02:39:34 +00:00
|
|
|
|
.empty-transcript-hero__content {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-transcript-hero__actions {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
2026-04-16 02:55:10 +00:00
|
|
|
|
.meeting-share-card {
|
|
|
|
|
|
width: min(86vw, 292px);
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.detail-side-column {
|
|
|
|
|
|
height: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-left-column {
|
|
|
|
|
|
overflow: visible;
|
|
|
|
|
|
}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
.transcript-player--floating {
|
|
|
|
|
|
bottom: 72px;
|
|
|
|
|
|
max-width: calc(100vw - 24px);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.section-divider-text {
|
|
|
|
|
|
white-space: normal;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-04 09:19:41 +00:00
|
|
|
|
`}</style>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
{isOwner && (
|
2026-04-08 08:16:21 +00:00
|
|
|
|
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
|
|
|
|
|
|
<Input />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item name="tags" label="业务标签">
|
|
|
|
|
|
<Select mode="tags" placeholder="输入标签后回车" />
|
|
|
|
|
|
</Form.Item>
|
2026-05-13 06:31:48 +00:00
|
|
|
|
<Form.Item name="participantIds" label="参会人员">
|
|
|
|
|
|
<Select mode="multiple" placeholder="请选择参会人" showSearch optionFilterProp="children">
|
|
|
|
|
|
{userList.map((u) => (
|
|
|
|
|
|
<Option key={u.userId} value={u.userId}>
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
<Avatar size="small" icon={<UserOutlined />} />
|
|
|
|
|
|
{u.displayName || u.username}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
2026-04-16 02:55:10 +00:00
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Form>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{isOwner && (
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Drawer
|
|
|
|
|
|
title="重新生成 AI 总结"
|
|
|
|
|
|
width={400}
|
|
|
|
|
|
onClose={() => setSummaryVisible(false)}
|
|
|
|
|
|
open={summaryVisible}
|
|
|
|
|
|
extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}>开始总结</Button>}
|
2026-04-08 08:16:21 +00:00
|
|
|
|
forceRender
|
2026-03-05 09:52:08 +00:00
|
|
|
|
>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Form form={summaryForm} layout="vertical">
|
|
|
|
|
|
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
|
|
|
|
|
|
<Select placeholder="选择 LLM 模型">
|
2026-03-27 02:30:48 +00:00
|
|
|
|
{llmModels.map((model) => (
|
|
|
|
|
|
<Option key={model.id} value={model.id}>
|
|
|
|
|
|
{model.modelName} {model.isDefault === 1 && <Tag color="gold" style={{ marginLeft: 4 }}>默认</Tag>}
|
2026-03-05 09:52:08 +00:00
|
|
|
|
</Option>
|
|
|
|
|
|
))}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Select placeholder="选择模板">
|
2026-03-27 02:30:48 +00:00
|
|
|
|
{prompts.map((prompt) => (
|
|
|
|
|
|
<Option key={prompt.id} value={prompt.id}>{prompt.templateName}</Option>
|
|
|
|
|
|
))}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
2026-05-13 06:31:48 +00:00
|
|
|
|
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
|
|
|
|
|
|
<Radio.Group>
|
|
|
|
|
|
<Radio value="DETAILED">详细</Radio>
|
|
|
|
|
|
<Radio value="STANDARD">标准</Radio>
|
|
|
|
|
|
<Radio value="BRIEF">简洁</Radio>
|
|
|
|
|
|
</Radio.Group>
|
|
|
|
|
|
</Form.Item>
|
2026-04-17 02:08:40 +00:00
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="userPrompt"
|
|
|
|
|
|
label="用户提示词"
|
|
|
|
|
|
extra="可选,用于补充本次重新总结的关注重点、表达偏好或输出侧重点"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
|
placeholder="例如:请重点突出结论、待办事项和风险点"
|
|
|
|
|
|
autoSize={{ minRows: 4, maxRows: 8 }}
|
|
|
|
|
|
showCount
|
|
|
|
|
|
maxLength={1000}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Divider />
|
2026-03-27 06:33:32 +00:00
|
|
|
|
<Text type="secondary">重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。</Text>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Form>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
)}
|
2026-06-30 05:37:33 +00:00
|
|
|
|
</PageContainer>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MeetingDetail;
|