feat: 添加会议预览页面和样式
- 新增 `MeetingPreviewView` 组件,用于展示会议预览页面 - 添加 `MeetingPreviewView.css` 样式文件,定义会议预览页面的样式 - 实现会议基本信息、AI 纪要、AI 目录和转录原文的展示 - 支持分享设置、复制链接和章节跳转功能dev_na
parent
fee7f2a0b3
commit
877a4a0654
|
|
@ -0,0 +1,561 @@
|
||||||
|
.meeting-preview-page {
|
||||||
|
--primary-blue: #5f51ff;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #5f51ff, #6c8cff);
|
||||||
|
--bg-surface: #ffffff;
|
||||||
|
--bg-app: #fbfcfd;
|
||||||
|
--border-color: rgba(228, 232, 245, 0.8);
|
||||||
|
--text-main: #1a1f36;
|
||||||
|
--text-secondary: #6e7695;
|
||||||
|
--card-shadow: 0 10px 30px rgba(127, 139, 186, 0.08);
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-app);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-shell {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-top-hero {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-hero-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-hero-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-status-tag {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-status-tag.is-complete {
|
||||||
|
background: #e6f4ea;
|
||||||
|
color: #1e8e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-status-tag.is-processing {
|
||||||
|
background: #e8f0fe;
|
||||||
|
color: #1a73e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-status-tag.is-warning {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #b76e00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-collapsible-section,
|
||||||
|
.meeting-preview-share-settings,
|
||||||
|
.meeting-preview-content-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-collapsible-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-collapsible-trigger {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #f8faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-collapsible-trigger .trigger-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-collapsible-content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-collapsible-content.is-expanded {
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-metrics-grid {
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tag {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #f0f2ff;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-settings {
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-settings-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-settings-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-settings-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-settings-row .ant-input-affix-wrapper {
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-bar .ant-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn-primary,
|
||||||
|
.share-btn-ghost {
|
||||||
|
height: 52px !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn-ghost {
|
||||||
|
border: 1px solid var(--border-color) !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-tabs-container {
|
||||||
|
padding: 8px 20px 0;
|
||||||
|
background: #f8faff;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-tabs-container .ant-tabs-nav {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-tabs-container .ant-tabs-tab {
|
||||||
|
padding: 16px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-tab-content {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-summary-box {
|
||||||
|
background: #f8faff;
|
||||||
|
border: 1px solid #eef1f9;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-summary-section-title {
|
||||||
|
color: #9aa0bd;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-record-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-record-tag {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #e6e8f5;
|
||||||
|
background: #fff;
|
||||||
|
color: #4c5a86;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-list,
|
||||||
|
.meeting-preview-transcript-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-item-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-timeline-axis {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-timeline-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--primary-blue);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 24px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-timeline-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #eef1f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-item-card {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-item-container.active .meeting-preview-catalog-item-card {
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
background: #f9faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-item-time {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-item-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-item-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-catalog-item-link {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(95, 81, 255, 0.08);
|
||||||
|
color: var(--primary-blue);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-section-extra {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-list {
|
||||||
|
gap: 18px;
|
||||||
|
padding-bottom: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-item.is-linked {
|
||||||
|
background: rgba(95, 81, 255, 0.04);
|
||||||
|
border-color: rgba(95, 81, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-item.is-active {
|
||||||
|
background: #fff;
|
||||||
|
border-color: rgba(95, 81, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-speaker {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-time {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
background: #f1f3f7;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-text {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #3e4766;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-markdown {
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-audio-player-inline {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
max-width: 720px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 24px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(95, 81, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-play-btn {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: none;
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-progress-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-time {
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-range {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-speed-btn {
|
||||||
|
background: #f1f3f7;
|
||||||
|
border: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-footer {
|
||||||
|
margin-top: 48px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-disclaimer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f1f3f7;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.meeting-preview-shell {
|
||||||
|
padding: 24px 14px 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-hero-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-metrics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-bar,
|
||||||
|
.meeting-preview-share-settings-row,
|
||||||
|
.meeting-preview-catalog-item-title-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-share-bar .ant-btn,
|
||||||
|
.meeting-preview-share-settings-row .ant-btn,
|
||||||
|
.meeting-preview-share-settings-row .ant-input-affix-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-tab-content {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-preview-transcript-meta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,631 @@
|
||||||
|
import {useMemo, useRef, useState} from "react";
|
||||||
|
import {
|
||||||
|
AudioOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
CaretRightFilled,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
PauseOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UpOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import {Alert, Button, Empty, Input, Tabs, message} from "antd";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
import {resolveAudioMimeType, resolveMeetingPlaybackAudioUrl} from "@/api/meeting";
|
||||||
|
import {buildMeetingAnalysis} from "@/components/preview/meetingAnalysis";
|
||||||
|
import type {MeetingChapterVO, MeetingTranscriptVO, MeetingVO} from "@/types";
|
||||||
|
import "./MeetingPreviewView.css";
|
||||||
|
|
||||||
|
type PreviewPageTab = "summary" | "catalog" | "transcript";
|
||||||
|
|
||||||
|
type ChapterTranscriptLink = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
timeLabel: string;
|
||||||
|
transcriptIds: number[];
|
||||||
|
firstTranscriptId: number | null;
|
||||||
|
firstTranscriptStartTime: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MeetingPreviewViewProps {
|
||||||
|
meeting: MeetingVO;
|
||||||
|
transcripts: MeetingTranscriptVO[];
|
||||||
|
meetingChapters: MeetingChapterVO[];
|
||||||
|
shareUrl: string;
|
||||||
|
editableShare?: boolean;
|
||||||
|
sharePasswordDraft?: string;
|
||||||
|
shareSaving?: boolean;
|
||||||
|
onSharePasswordDraftChange?: (value: string) => void;
|
||||||
|
onSaveSharePassword?: () => void;
|
||||||
|
onCopyShareLink?: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT = {
|
||||||
|
statusTranscribing: "转写中",
|
||||||
|
statusSummarizing: "总结中",
|
||||||
|
statusCompleted: "已完成",
|
||||||
|
statusPending: "待处理",
|
||||||
|
pageSummary: "AI 纪要",
|
||||||
|
pageCatalog: "AI 目录",
|
||||||
|
pageTranscript: "转录原文",
|
||||||
|
copyLink: "复制链接",
|
||||||
|
shareNow: "立即分享",
|
||||||
|
shareCopied: "预览链接已复制",
|
||||||
|
shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接",
|
||||||
|
shareFailed: "分享失败,请先复制链接",
|
||||||
|
basicInfo: "基本信息",
|
||||||
|
meetingTime: "会议时间",
|
||||||
|
hostCreator: "主持/创建",
|
||||||
|
participantsCount: "参会人数",
|
||||||
|
tags: "会议标签",
|
||||||
|
noSummary: "暂无会议纪要",
|
||||||
|
noCatalog: "暂无 AI 目录",
|
||||||
|
noTranscript: "暂无转录内容",
|
||||||
|
noDuration: "暂无时长",
|
||||||
|
audioUnavailable: "音频文件不可用,仅展示转录内容。",
|
||||||
|
transcriptTitle: "逐段转录",
|
||||||
|
keywordSection: "关键词",
|
||||||
|
linkToTranscript: "关联原文",
|
||||||
|
shareSettings: "分享访问设置",
|
||||||
|
shareSettingsHint: "当前登录用户可直接查看,访问密码仅对分享出去的 H5 预览链接生效。",
|
||||||
|
saveSharePassword: "保存访问密码",
|
||||||
|
passwordPlaceholder: "为空表示取消访问密码",
|
||||||
|
disclaimer: "智能内容由用户会议内容与 AI 模型生成,我们不对内容准确性和完整性做任何保证。",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_META: Record<number, { label: string; className: string }> = {
|
||||||
|
1: {label: TEXT.statusTranscribing, className: "is-processing"},
|
||||||
|
2: {label: TEXT.statusSummarizing, className: "is-processing"},
|
||||||
|
3: {label: TEXT.statusCompleted, className: "is-complete"},
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 formatTotalDuration(ms: number) {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeetingPreviewView({
|
||||||
|
meeting,
|
||||||
|
transcripts,
|
||||||
|
meetingChapters,
|
||||||
|
shareUrl,
|
||||||
|
editableShare = false,
|
||||||
|
sharePasswordDraft = "",
|
||||||
|
shareSaving = false,
|
||||||
|
onSharePasswordDraftChange,
|
||||||
|
onSaveSharePassword,
|
||||||
|
onCopyShareLink,
|
||||||
|
}: MeetingPreviewViewProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
|
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
|
||||||
|
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
|
||||||
|
const [audioPlaying, setAudioPlaying] = useState(false);
|
||||||
|
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
||||||
|
const [audioDuration, setAudioDuration] = useState(0);
|
||||||
|
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
|
||||||
|
const [isMetricsExpanded, setIsMetricsExpanded] = useState(false);
|
||||||
|
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
|
||||||
|
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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 playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]);
|
||||||
|
const statusMeta = STATUS_META[meeting?.status || 0] || {
|
||||||
|
label: TEXT.statusPending,
|
||||||
|
className: "is-warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
const meetingDuration = useMemo(() => {
|
||||||
|
if (transcripts.length > 0) {
|
||||||
|
return transcripts[transcripts.length - 1]?.endTime || 0;
|
||||||
|
}
|
||||||
|
return meeting.duration || 0;
|
||||||
|
}, [meeting.duration, transcripts]);
|
||||||
|
|
||||||
|
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
|
||||||
|
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
|
||||||
|
const sourceChapters: MeetingChapterVO[] = meetingChapters.length
|
||||||
|
? meetingChapters
|
||||||
|
: analysis.chapters.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
time: item.time,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return sourceChapters.map((chapter, index) => {
|
||||||
|
let matchedTranscripts: MeetingTranscriptVO[] = [];
|
||||||
|
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
|
||||||
|
? chapter.sourceTranscriptIds
|
||||||
|
.map((item: number) => Number(item))
|
||||||
|
.filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (sourceTranscriptIds.length) {
|
||||||
|
matchedTranscripts = sourceTranscriptIds
|
||||||
|
.map((item: number) => transcripts[transcriptIdToIndex.get(item)!])
|
||||||
|
.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)
|
||||||
|
.map((item) => (typeof item.startTime === "number" ? item.startTime : parseChapterTimeToMs(item.time)))
|
||||||
|
.find((item): item is number => item !== null && startMs !== null && item > startMs);
|
||||||
|
|
||||||
|
if (startMs !== null) {
|
||||||
|
const firstTranscriptIndex = transcripts.findIndex((item) => (item.endTime || 0) > startMs);
|
||||||
|
if (firstTranscriptIndex >= 0) {
|
||||||
|
const lastTranscriptIndex =
|
||||||
|
nextChapterStartMs === undefined
|
||||||
|
? transcripts.length
|
||||||
|
: transcripts.findIndex((item) => (item.startTime || 0) >= 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]);
|
||||||
|
|
||||||
|
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
|
||||||
|
void audioRef.current.play().catch(() => {
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocateChapterTranscript = (index: number) => {
|
||||||
|
const link = catalogChapterLinks[index];
|
||||||
|
if (!link || !link.firstTranscriptId) return;
|
||||||
|
|
||||||
|
setPageTab("transcript");
|
||||||
|
setLinkedTranscriptIds(link.transcriptIds);
|
||||||
|
setLinkedChapterKey(link.key);
|
||||||
|
setActiveTranscriptId(link.firstTranscriptId);
|
||||||
|
|
||||||
|
const target = transcriptItemRefs.current[link.firstTranscriptId];
|
||||||
|
target?.scrollIntoView({behavior: "smooth", block: "center"});
|
||||||
|
|
||||||
|
if (audioRef.current && link.firstTranscriptStartTime !== null) {
|
||||||
|
audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000);
|
||||||
|
void audioRef.current.play().catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioTimeUpdate = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const currentSeconds = audioRef.current.currentTime;
|
||||||
|
setAudioCurrentTime(currentSeconds);
|
||||||
|
if (audioRef.current.duration && audioDuration !== audioRef.current.duration) {
|
||||||
|
setAudioDuration(audioRef.current.duration);
|
||||||
|
}
|
||||||
|
const currentMs = currentSeconds * 1000;
|
||||||
|
const currentItem = transcripts.find(
|
||||||
|
(item) => currentMs >= (item.startTime || 0) && currentMs <= (item.endTime || 0),
|
||||||
|
);
|
||||||
|
setActiveTranscriptId(currentItem?.id || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
if (onCopyShareLink) {
|
||||||
|
await onCopyShareLink();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await copyText(shareUrl);
|
||||||
|
message.success(TEXT.shareCopied);
|
||||||
|
} catch {
|
||||||
|
message.error(TEXT.shareFailed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareNow = async () => {
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: meeting?.title || "会议预览",
|
||||||
|
text: "我向你分享了一个会议预览链接",
|
||||||
|
url: shareUrl,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await copyText(shareUrl);
|
||||||
|
message.success(TEXT.shareFallbackCopied);
|
||||||
|
} catch {
|
||||||
|
message.error(TEXT.shareFailed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="meeting-preview-page">
|
||||||
|
<div className="meeting-preview-container">
|
||||||
|
<div className="meeting-preview-shell">
|
||||||
|
<div className="meeting-preview-top-hero">
|
||||||
|
<div className="meeting-preview-hero-logo">
|
||||||
|
<RobotOutlined/>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-hero-content">
|
||||||
|
<h1 className="meeting-preview-hero-title">{meeting.title || "未命名会议"}</h1>
|
||||||
|
<div className="meeting-preview-hero-meta">
|
||||||
|
<span className={`meeting-preview-status-tag ${statusMeta.className}`}>{statusMeta.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meeting-preview-collapsible-section">
|
||||||
|
<div className="meeting-preview-collapsible-trigger"
|
||||||
|
onClick={() => setIsMetricsExpanded((value) => !value)}>
|
||||||
|
<div className="trigger-left">
|
||||||
|
<FileTextOutlined/>
|
||||||
|
<span>{TEXT.basicInfo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="trigger-right">{isMetricsExpanded ? <UpOutlined/> : <DownOutlined/>}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`meeting-preview-collapsible-content ${isMetricsExpanded ? "is-expanded" : ""}`}>
|
||||||
|
<div className="meeting-preview-metrics-grid">
|
||||||
|
<div className="metric-item">
|
||||||
|
<div className="metric-label">{TEXT.meetingTime}</div>
|
||||||
|
<div className="metric-value">
|
||||||
|
<CalendarOutlined style={{marginRight: 8, color: "var(--primary-blue)"}}/>
|
||||||
|
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : "未设置"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric-item">
|
||||||
|
<div className="metric-label">{TEXT.hostCreator}</div>
|
||||||
|
<div className="metric-value">
|
||||||
|
<UserOutlined style={{marginRight: 8, color: "var(--primary-blue)"}}/>
|
||||||
|
{meeting.creatorName || "未设置"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric-item">
|
||||||
|
<div className="metric-label">{TEXT.participantsCount}</div>
|
||||||
|
<div className="metric-value">
|
||||||
|
<TeamOutlined style={{marginRight: 8, color: "var(--primary-blue)"}}/>
|
||||||
|
{participants.length} 人
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="metric-item">
|
||||||
|
<div className="metric-label">会议时长</div>
|
||||||
|
<div className="metric-value">
|
||||||
|
<ClockCircleOutlined style={{marginRight: 8, color: "var(--primary-blue)"}}/>
|
||||||
|
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : "未设置"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="metric-item metric-item-full">
|
||||||
|
<div className="metric-label">{TEXT.tags}</div>
|
||||||
|
<div className="metric-tags">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag} className="metric-tag">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editableShare ? (
|
||||||
|
<div className="meeting-preview-share-settings">
|
||||||
|
<div className="meeting-preview-share-settings-title">{TEXT.shareSettings}</div>
|
||||||
|
<div className="meeting-preview-share-settings-desc">{TEXT.shareSettingsHint}</div>
|
||||||
|
<div className="meeting-preview-share-settings-row">
|
||||||
|
<Input.Password
|
||||||
|
value={sharePasswordDraft}
|
||||||
|
placeholder={TEXT.passwordPlaceholder}
|
||||||
|
prefix={<LockOutlined/>}
|
||||||
|
onChange={(event) => onSharePasswordDraftChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="primary" loading={shareSaving} onClick={onSaveSharePassword}>
|
||||||
|
{TEXT.saveSharePassword}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="meeting-preview-share-bar">
|
||||||
|
<Button type="primary" size="large" icon={<ShareAltOutlined/>} onClick={() => void handleShareNow()}
|
||||||
|
className="share-btn-primary">
|
||||||
|
{TEXT.shareNow}
|
||||||
|
</Button>
|
||||||
|
<Button size="large" icon={<CopyOutlined/>} onClick={() => void handleCopyLink()}
|
||||||
|
className="share-btn-ghost">
|
||||||
|
{TEXT.copyLink}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meeting-preview-content-card">
|
||||||
|
<div className="meeting-preview-tabs-container">
|
||||||
|
<Tabs
|
||||||
|
activeKey={pageTab}
|
||||||
|
onChange={(key) => setPageTab(key as PreviewPageTab)}
|
||||||
|
items={[
|
||||||
|
{key: "summary", label: TEXT.pageSummary},
|
||||||
|
{key: "catalog", label: TEXT.pageCatalog},
|
||||||
|
{key: "transcript", label: TEXT.pageTranscript},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meeting-preview-tab-content">
|
||||||
|
{pageTab === "summary" ? (
|
||||||
|
<>
|
||||||
|
<div className="meeting-preview-summary-box">
|
||||||
|
<div className="meeting-preview-summary-section-title">{TEXT.keywordSection}</div>
|
||||||
|
<div className="meeting-preview-record-tags">
|
||||||
|
{analysis.keywords.length ? (
|
||||||
|
analysis.keywords.map((item) => (
|
||||||
|
<div key={item} className="meeting-preview-record-tag">
|
||||||
|
<span>#{item}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="meeting-preview-keywords-empty">暂无关键词</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-markdown">
|
||||||
|
{meeting.summaryContent ? <ReactMarkdown>{meeting.summaryContent}</ReactMarkdown> :
|
||||||
|
<Empty description={TEXT.noSummary}/>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pageTab === "catalog" ? (
|
||||||
|
<div className="meeting-preview-catalog-list">
|
||||||
|
{catalogChapterLinks.length ? (
|
||||||
|
catalogChapterLinks.map((chapter, index) => (
|
||||||
|
<div
|
||||||
|
key={chapter.key}
|
||||||
|
className={`meeting-preview-catalog-item-container ${linkedChapterKey === chapter.key ? "active" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="meeting-preview-catalog-timeline-axis">
|
||||||
|
<div className="meeting-preview-catalog-timeline-dot"/>
|
||||||
|
<div className="meeting-preview-catalog-timeline-line"/>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-catalog-item-card"
|
||||||
|
onClick={() => handleLocateChapterTranscript(index)}>
|
||||||
|
<div className="meeting-preview-catalog-item-time">{chapter.timeLabel}</div>
|
||||||
|
<div className="meeting-preview-catalog-item-title-row">
|
||||||
|
<div className="meeting-preview-catalog-item-title">{chapter.title}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="meeting-preview-catalog-item-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleLocateChapterTranscript(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LinkOutlined/> {TEXT.linkToTranscript}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={TEXT.noCatalog}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pageTab === "transcript" ? (
|
||||||
|
<>
|
||||||
|
<div className="meeting-preview-section-header">
|
||||||
|
<div className="meeting-preview-section-title">{TEXT.transcriptTitle}</div>
|
||||||
|
<div className="meeting-preview-section-extra">
|
||||||
|
<ClockCircleOutlined style={{marginRight: 6}}/>
|
||||||
|
{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meeting.audioSaveStatus === "FAILED" ? (
|
||||||
|
<Alert type="warning" showIcon message={meeting.audioSaveMessage || TEXT.audioUnavailable}
|
||||||
|
style={{marginBottom: 16}}/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="meeting-preview-transcript-list">
|
||||||
|
{transcripts.length ? (
|
||||||
|
transcripts.map((item) => {
|
||||||
|
const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker";
|
||||||
|
const isLinked = linkedTranscriptIds.includes(item.id);
|
||||||
|
const isActive = activeTranscriptId === item.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
ref={(node) => {
|
||||||
|
transcriptItemRefs.current[item.id] = node;
|
||||||
|
}}
|
||||||
|
className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
handleTranscriptSeek(item);
|
||||||
|
setLinkedTranscriptIds([]);
|
||||||
|
setLinkedChapterKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="meeting-preview-transcript-avatar"
|
||||||
|
style={{backgroundColor: transcriptColorSeed(speakerKey)}}>
|
||||||
|
{speakerKey.slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-transcript-content">
|
||||||
|
<div className="meeting-preview-transcript-meta">
|
||||||
|
<span className="meeting-preview-transcript-speaker">{speakerKey}</span>
|
||||||
|
<span
|
||||||
|
className="meeting-preview-transcript-time">{formatDurationRange(item.startTime, item.endTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-preview-transcript-text">{item.content || TEXT.noTranscript}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Empty description={TEXT.noTranscript}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meeting-preview-footer">
|
||||||
|
<div className="meeting-preview-disclaimer">
|
||||||
|
<RobotOutlined/>
|
||||||
|
<span>{TEXT.disclaimer}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playbackAudioUrl ? (
|
||||||
|
<div className="meeting-preview-audio-player-inline"
|
||||||
|
style={{display: pageTab === "transcript" ? "flex" : "none"}}>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
onTimeUpdate={handleAudioTimeUpdate}
|
||||||
|
onLoadedMetadata={() => setAudioDuration(audioRef.current?.duration || 0)}
|
||||||
|
onPlay={() => setAudioPlaying(true)}
|
||||||
|
onPause={() => setAudioPlaying(false)}
|
||||||
|
onEnded={() => setAudioPlaying(false)}
|
||||||
|
style={{display: "none"}}
|
||||||
|
preload="auto"
|
||||||
|
>
|
||||||
|
<source src={playbackAudioUrl} type={resolveAudioMimeType(playbackAudioUrl)}/>
|
||||||
|
</audio>
|
||||||
|
<div className="audio-player-content">
|
||||||
|
<button type="button" className="audio-play-btn" onClick={() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (audioPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
void audioRef.current.play().catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{audioPlaying ? <PauseOutlined/> : <CaretRightFilled/>}
|
||||||
|
</button>
|
||||||
|
<div className="audio-progress-container">
|
||||||
|
<div className="audio-time">{formatTotalDuration(audioCurrentTime * 1000)}</div>
|
||||||
|
<input
|
||||||
|
className="audio-range"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={audioDuration || 0}
|
||||||
|
step={0.1}
|
||||||
|
value={Math.min(audioCurrentTime, audioDuration || 0)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const time = parseFloat(e.target.value);
|
||||||
|
audioRef.current.currentTime = time;
|
||||||
|
setAudioCurrentTime(time);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="audio-time">{formatTotalDuration(audioDuration * 1000)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="audio-speed-btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const nextRate = audioPlaybackRate === 1 ? 1.5 : audioPlaybackRate === 1.5 ? 2 : 1;
|
||||||
|
audioRef.current.playbackRate = nextRate;
|
||||||
|
setAudioPlaybackRate(nextRate);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{audioPlaybackRate}x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -47,7 +47,7 @@ export function getSummarySnippet(meeting: Pick<MeetingVO, "summaryContent" | "a
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMeetingPreviewUrl(meetingId: number, accessPassword?: string) {
|
export function buildMeetingPreviewUrl(meetingId: number, accessPassword?: string) {
|
||||||
const url = new URL(`/meetings/${meetingId}/preview`, window.location.origin);
|
const url = new URL(`/H5/meetings/${meetingId}/preview`, window.location.origin);
|
||||||
const normalizedPassword = (accessPassword || "").trim();
|
const normalizedPassword = (accessPassword || "").trim();
|
||||||
if (normalizedPassword) {
|
if (normalizedPassword) {
|
||||||
url.searchParams.set("accessPassword", normalizedPassword);
|
url.searchParams.set("accessPassword", normalizedPassword);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue