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

596 lines
26 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tag } from "antd";
import {
AudioOutlined,
CalendarOutlined,
ClockCircleOutlined,
FileTextOutlined,
LockOutlined,
RobotOutlined,
TeamOutlined,
UserOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import ReactMarkdown from "react-markdown";
import {
getMeetingPreviewAccess,
getPublicMeetingPreview,
type MeetingTranscriptVO,
type MeetingVO,
} from "../../api/business/meeting";
import { buildMeetingAnalysis } from "./meetingAnalysis";
import "./MeetingPreview.css";
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
1: { label: "閺夌儐鍓欓崯鎾寸▔?, className: "is-processing", hint: "ùТ弿? },
2: { label: "闁诡剝宕电划銊︾▔?, className: "is-processing", hint: "AI ? },
3: { label: "鐎瑰憡褰冮悾顒勫箣?, className: "is-complete", hint: "? },
};
function formatDurationRange(startTime?: number, endTime?: number) {
const format = (milliseconds?: number) => {
const safeMs = Math.max(0, milliseconds || 0);
const totalSeconds = Math.floor(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return `${format(startTime)} - ${format(endTime)}`;
}
function splitDisplayItems(value?: string) {
return (value || "")
.split(/[??)
.map((item) => item.trim())
.filter(Boolean);
}
function transcriptColorSeed(speakerKey: string) {
const palette = ["#315f8b", "#b86432", "#557a46", "#6d4fa7", "#a33f57", "#0f766e"];
const score = Array.from(speakerKey).reduce((sum, char) => sum + char.charCodeAt(0), 0);
return palette[score % palette.length];
}
export default function MeetingPreview() {
const { id } = useParams();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("chapters");
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
const [passwordRequired, setPasswordRequired] = useState(false);
const [passwordVerified, setPasswordVerified] = useState(false);
const [accessPassword, setAccessPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
useEffect(() => {
let mounted = true;
const fetchData = async () => {
if (!id) {
setError("?);
setLoading(false);
return;
}
setLoading(true);
setError("");
setMeeting(null);
setTranscripts([]);
setPasswordRequired(false);
setPasswordVerified(false);
setAccessPassword("");
setPasswordError("");
try {
const meetingId = Number(id);
const accessRes = await getMeetingPreviewAccess(meetingId);
if (!mounted) {
return;
}
const requiresPassword = !!accessRes.data.data.passwordRequired;
setPasswordRequired(requiresPassword);
if (requiresPassword) {
setLoading(false);
return;
}
const previewRes = await getPublicMeetingPreview(meetingId);
if (!mounted) {
return;
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setPasswordVerified(true);
} catch (requestError: any) {
if (!mounted) {
return;
}
setError(requestError?.response?.data?.msg || "Ь?);
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchData();
return () => {
mounted = false;
};
}, [id]);
const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
);
const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]);
const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]);
const statusMeta = STATUS_META[meeting?.status || 0] || {
label: "ˇ?,
className: "is-warning",
hint: "ùУуХ?,
};
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
if (!audioRef.current) {
return;
}
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
audioRef.current.play().catch(() => {});
};
const handleAudioTimeUpdate = () => {
if (!audioRef.current || transcripts.length === 0) {
return;
}
const currentMs = audioRef.current.currentTime * 1000;
const currentItem = transcripts.find(
(item) => currentMs >= (item.startTime || 0) && currentMs <= (item.endTime || 0),
);
setActiveTranscriptId(currentItem?.id || null);
};
const handlePasswordSubmit = async () => {
if (!id) {
return;
}
setLoading(true);
setPasswordError("");
try {
const previewRes = await getPublicMeetingPreview(Number(id), accessPassword.trim());
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setPasswordVerified(true);
} catch (requestError: any) {
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || "閻犱礁娼″Λ鍓佲偓闈涙閻栨粓鏌ㄥ▎鎺濆殩");
} finally {
setLoading(false);
}
};
if (loading && (!passwordRequired || passwordVerified)) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-loading">
<div className="meeting-preview-card meeting-preview-hero">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 10 }} />
</div>
</div>
</div>
);
}
if (passwordRequired && !passwordVerified) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section meeting-preview-password-gate">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<LockOutlined />
Access Check
</div>
<h2 className="meeting-preview-section-title">Password Required</h2>
</div>
</div>
<p className="meeting-preview-subtitle">
Enter access_password to view this meeting preview.
</p>
<div className="meeting-preview-password-form">
<Input.Password
value={accessPassword}
placeholder="Enter access_password"
onChange={(event) => setAccessPassword(event.target.value)}
onPressEnter={handlePasswordSubmit}
/>
<Button type="primary" onClick={handlePasswordSubmit} loading={loading} disabled={!accessPassword.trim()}>
Open Preview
</Button>
</div>
{passwordError ? <Alert type="error" showIcon message={passwordError} /> : null}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Result status="error" title="Ь? subTitle={error} />
</div>
</div>
</div>
);
}
if (!meeting) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Empty description="? />
</div>
</div>
</div>
);
}
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell">
<section className="meeting-preview-card meeting-preview-hero">
<div className="meeting-preview-eyebrow">
<div className="meeting-preview-eyebrow-label">
<FileTextOutlined />
?
</div>
<span className={`meeting-preview-status ${statusMeta.className}`}>{statusMeta.label}</span>
</div>
<h1 className="meeting-preview-title">{meeting.title || "?}</h1>
<p className="meeting-preview-subtitle">{statusMeta.hint}</p>
<div className="meeting-preview-metrics">
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY.MM.DD HH:mm") : "?}
</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">??/span>
<span className="meeting-preview-metric-value">{meeting.hostName || meeting.creatorName || "?}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">ɑ?/span>
<span className="meeting-preview-metric-value">{participants.length || "?}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">{tags.length || "?}</span>
</div>
</div>
</section>
<div className="meeting-preview-panels">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<CalendarOutlined />
?
</div>
<h2 className="meeting-preview-section-title">?/h2>
</div>
</div>
<div className="meeting-preview-metrics">
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">{meeting.creatorName || "?}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">{meeting.hostName || "?}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">
{meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : "?}
</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">?/span>
<span className="meeting-preview-metric-value">{meeting.audioSaveStatus || "NONE"}</span>
</div>
</div>
{participants.length > 0 ? (
<div className="meeting-preview-overview">
<div className="meeting-preview-overview-label">?/div>
<div className="meeting-preview-tags">
{participants.map((item) => (
<span key={item} className="meeting-preview-tag">
<TeamOutlined style={{ marginRight: 8 }} />
{item}
</span>
))}
</div>
</div>
) : null}
{tags.length > 0 ? (
<div className="meeting-preview-overview">
<div className="meeting-preview-overview-label">?/div>
<div className="meeting-preview-tags">
{tags.map((item) => (
<Tag key={item} bordered={false} className="meeting-preview-tag">
{item}
</Tag>
))}
</div>
</div>
) : null}
</section>
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<RobotOutlined />
</div>
<h2 className="meeting-preview-section-title">?/h2>
</div>
<div className="meeting-preview-section-extra">?/div>
</div>
{meeting.status < 3 ? (
<Alert
className="meeting-preview-alert"
type="info"
showIcon
message="?
description="ù广?
/>
) : null}
{analysis.keywords.length > 0 ? (
<div className="meeting-preview-tags">
{analysis.keywords.map((item) => (
<span key={item} className="meeting-preview-tag">
{item}
</span>
))}
</div>
) : null}
<div className="meeting-preview-overview">
<div className="meeting-preview-overview-label">稿?/div>
<p className="meeting-preview-overview-copy">{analysis.overview || "Λ?}</p>
</div>
<div className="meeting-preview-analysis-tabs">
<Segmented<AnalysisTab>
block
value={analysisTab}
onChange={(value) => setAnalysisTab(value)}
options={[
{ label: "缂佹梻濮炬俊?, value: "chapters" },
{ label: "闁告瑦鍨奸埢?, value: "speakers" },
{ label: "閻熸洑鑳堕崑?, value: "actions" },
{ label: "鐎垫澘鎳庢慨?, value: "todos" },
]}
/>
</div>
<div className="meeting-preview-analysis-panel">
{analysisTab === "chapters" &&
(analysis.chapters.length ? (
analysis.chapters.map((item, index) => (
<div className="meeting-preview-chapter" key={`${item.title}-${index}`}>
<div className="meeting-preview-chapter-time">{item.time || "--:--"}</div>
<div>
<strong className="meeting-preview-item-title">{item.title || `缂佹梻濮炬俊?${index + 1}`}</strong>
<span className="meeting-preview-item-copy">{item.summary || "Λ?}</span>
</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Λ? />
</div>
))}
{analysisTab === "speakers" &&
(analysis.speakerSummaries.length ? (
analysis.speakerSummaries.map((item, index) => (
<div className="meeting-preview-speaker-card" key={`${item.speaker}-${index}`}>
<div className="meeting-preview-speaker-head">
<div className="meeting-preview-speaker-avatar">{(item.speaker || "?).slice(0, 1)}</div>
<div>
<div className="meeting-preview-speaker-name">{item.speaker || `闁告瑦鍨奸埢鍫熺?${index + 1}`}</div>
<div className="meeting-preview-speaker-role">?/div>
</div>
</div>
<div className="meeting-preview-item-copy">{item.summary || "Λ?}</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Λ? />
</div>
))}
{analysisTab === "actions" &&
(analysis.keyPoints.length ? (
analysis.keyPoints.map((item, index) => (
<div className="meeting-preview-keypoint" key={`${item.title}-${index}`}>
<div className="meeting-preview-keypoint-index">{String(index + 1).padStart(2, "0")}</div>
<div>
<strong className="meeting-preview-item-title">{item.title || `閻熸洑鑳堕崑?${index + 1}`}</strong>
<span className="meeting-preview-item-copy">{item.summary || "Λх?}</span>
{(item.speaker || item.time) && (
<div className="meeting-preview-item-meta">
{item.speaker ? <span className="meeting-preview-meta-pill">{item.speaker}</span> : null}
{item.time ? <span className="meeting-preview-meta-pill">{item.time}</span> : null}
</div>
)}
</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Λу? />
</div>
))}
{analysisTab === "todos" &&
(analysis.todos.length ? (
analysis.todos.map((item, index) => (
<div className="meeting-preview-todo" key={`${item}-${index}`}>
<span className="meeting-preview-todo-dot" />
<span className="meeting-preview-item-copy">{item}</span>
</div>
))
) : (
<div className="meeting-preview-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Λù? />
</div>
))}
</div>
</section>
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<FileTextOutlined />
AI ?
</div>
<h2 className="meeting-preview-section-title">?/h2>
</div>
</div>
<div className="meeting-preview-markdown">
{meeting.summaryContent ? (
<div className="markdown-body">
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
</div>
) : (
<Empty description="Λ? />
)}
</div>
</section>
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<AudioOutlined />
?
</div>
<h2 className="meeting-preview-section-title">?/h2>
</div>
<div className="meeting-preview-section-extra">
<ClockCircleOutlined style={{ marginRight: 6 }} />
?
</div>
</div>
{meeting.audioSaveStatus === "FAILED" ? (
<Alert
className="meeting-preview-alert"
type="warning"
showIcon
message="Т?
description={meeting.audioSaveMessage || "хуΥ?}
/>
) : null}
{meeting.audioUrl ? (
<audio
ref={audioRef}
className="meeting-preview-transcript-audio"
src={meeting.audioUrl}
controls
preload="metadata"
onTimeUpdate={handleAudioTimeUpdate}
/>
) : null}
<div className="meeting-preview-transcript-list">
{transcripts.length ? (
transcripts.map((item) => {
const speakerName = item.speakerLabel || item.speakerName || item.speakerId || "?;
const avatarColor = transcriptColorSeed(speakerName);
return (
<button
key={item.id}
type="button"
className={`meeting-preview-transcript-item ${activeTranscriptId === item.id ? "is-active" : ""}`}
onClick={() => handleTranscriptSeek(item)}
>
<div className="meeting-preview-transcript-head">
<div className="meeting-preview-transcript-speaker">
<div className="meeting-preview-transcript-avatar" style={{ backgroundColor: avatarColor }}>
{(speakerName || "?).slice(0, 1)}
</div>
<div className="meeting-preview-transcript-name">
<UserOutlined style={{ marginRight: 6, color: avatarColor }} />
{speakerName}
</div>
</div>
<div className="meeting-preview-transcript-time">
{formatDurationRange(item.startTime, item.endTime)}
</div>
</div>
<div className="meeting-preview-transcript-copy">{item.content || "闁哄棗鍊瑰Λ銈嗘姜椤掆偓缂嶅秹宕橀崨顓у晣"}</div>
</button>
);
})
) : (
<Empty description="闁哄棗鍊瑰Λ銈嗘姜椤掆偓缂嶅秹宕橀崨顓у晣" />
)}
</div>
</section>
</div>
<div className="meeting-preview-disclaimer">
у?AI 娿ùхùΣ?
</div>
</div>
</div>
);
}