feat(会议预览): 增强分享弹窗样式并添加音频播放器

- 为会议分享弹窗添加渐变背景和卡片样式
- 在会议预览页面添加自定义音频播放器,支持播放/暂停、进度控制和倍速切换
- 优化转录文本的显示布局,将发言人头像和时间信息整合到内容区域
- 更新免责声明文本,明确AI生成内容的性质
- 调整会议详情页的关键词选择交互,使用勾选图标替代复选框
- 统一多个页面的配色方案,使用主色调变量
dev_na
alanpaine 2026-04-16 13:05:50 +08:00
parent 7c64cdf7a2
commit 5a5f71d465
4 changed files with 717 additions and 335 deletions

View File

@ -873,7 +873,131 @@ body::after {
margin-inline-start: 12px;
}
/* 智能会议专属全局加载动画 */
.meeting-share-popover .ant-popover-inner {
padding: 0;
border-radius: 16px;
overflow: hidden;
}
.meeting-share-card {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
width: 320px;
padding: 20px;
border-radius: 16px;
box-shadow: none;
border: none;
background: var(--app-bg-main);
overflow: hidden;
z-index: 1;
}
.meeting-share-card::before {
content: "";
position: absolute;
top: -40px;
right: -40px;
width: 120px;
height: 120px;
border-radius: 50%;
background: radial-gradient(circle, rgba(22, 119, 255, 0.15) 0%, transparent 70%);
z-index: -1;
pointer-events: none;
}
.meeting-share-card::after {
content: "";
position: absolute;
bottom: -40px;
left: -40px;
width: 100px;
height: 100px;
border-radius: 30%;
background: radial-gradient(circle, rgba(54, 207, 201, 0.15) 0%, transparent 70%);
z-index: -1;
pointer-events: none;
}
.meeting-share-settings {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--app-border-color);
}
.meeting-share-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.meeting-share-settings-copy {
display: flex;
flex-direction: column;
font-size: 13px;
}
.meeting-share-settings-copy strong {
color: var(--app-text-main);
font-weight: 600;
margin-bottom: 4px;
}
.meeting-share-settings-copy span {
color: var(--app-text-secondary);
font-size: 12px;
}
.meeting-share-settings-actions {
display: flex;
gap: 8px;
}
.meeting-share-qr-wrap {
display: flex;
justify-content: center;
align-items: center;
background: #ffffff;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--app-border-color);
}
.meeting-share-caption {
font-size: 12px;
color: var(--app-text-secondary);
text-align: center;
line-height: 1.5;
}
.meeting-share-link-box {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: var(--app-bg-surface);
border-radius: 8px;
border: 1px solid var(--app-border-color);
font-size: 12px;
color: var(--app-text-main);
}
.meeting-share-link-box span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meeting-share-actions {
display: flex;
gap: 12px;
}
.meeting-share-actions .ant-btn {
flex: 1;
}
.ai-meeting-loader {
position: fixed;
inset: 0;

View File

@ -17,6 +17,8 @@ import {
RobotOutlined,
SyncOutlined,
UserOutlined,
PlusOutlined,
CheckCircleFilled,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
@ -473,60 +475,62 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
return (
<List.Item className="transcript-row" onClick={() => onSeek(item.startTime)}>
<div className="transcript-entry">
<div className="transcript-meta">
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
{isOwner ? (
<Popover
content={(
<SpeakerEditor
meetingId={meetingId}
speakerId={item.speakerId}
initialName={item.speakerName}
initialLabel={item.speakerLabel}
onSuccess={onSpeakerUpdated}
/>
)}
title="编辑发言人"
trigger="click"
<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()}
>
<span className="transcript-speaker editable" onClick={(event) => event.stopPropagation()}>
{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: 12 }} />
</span>
</Popover>
<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>
) : (
<span className="transcript-speaker">{item.speakerName || item.speakerId || '发言人'}</span>
<div
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined}
>
{item.content}
</div>
)}
<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()}
>
<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>
) : (
<div
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined}
>
{item.content}
</div>
)}
</div>
</List.Item>
);
@ -1118,8 +1122,8 @@ const MeetingDetail: React.FC = () => {
value={sharePreviewUrl}
type="svg"
size={172}
color="#315f8b"
bgColor="#fffaf4"
color="#1677ff"
bgColor="transparent"
errorLevel="H"
bordered={false}
/>
@ -1254,18 +1258,6 @@ const MeetingDetail: React.FC = () => {
<div className="summary-title">
<RobotOutlined />
<span></span>
{isOwner && analysis.keywords.length > 0 && (
<Button
size="small"
type="primary"
ghost
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={handleAddSelectedHotwords}
>
</Button>
)}
</div>
<div className="summary-actions">
<span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span>
@ -1275,18 +1267,36 @@ const MeetingDetail: React.FC = () => {
<div className="summary-block">
<div className="summary-section-head">
<span></span>
{isOwner && analysis.keywords.length > 0 && (
<Button
size="small"
type="primary"
shape="round"
icon={<PlusOutlined />}
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={handleAddSelectedHotwords}
>
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
</Button>
)}
</div>
{analysis.keywords.length ? (
<>
<div className="record-tags">
{visibleKeywords.map((tag) => (
<label key={tag} className={`tag selectable-tag ${selectedKeywords.includes(tag) ? 'selected' : ''}`}>
{isOwner && (
<Checkbox checked={selectedKeywords.includes(tag)} onChange={(event) => handleKeywordToggle(tag, event.target.checked)} />
)}
<span>{tag}</span>
</label>
))}
{visibleKeywords.map((tag) => {
const isSelected = selectedKeywords.includes(tag);
return (
<div
key={tag}
className={`tag selectable-tag ${isSelected ? 'selected' : ''}`}
onClick={() => isOwner && handleKeywordToggle(tag, !isSelected)}
>
<span>{tag}</span>
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
</div>
);
})}
</div>
{analysis.keywords.length > 9 && (
<button type="button" className="summary-link" onClick={() => setExpandKeywords((value) => !value)}>
@ -1765,23 +1775,33 @@ const MeetingDetail: React.FC = () => {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 10px;
background: rgba(107, 115, 255, 0.08);
color: #6a72ff;
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);
font-size: 13px;
font-weight: 600;
font-weight: 500;
}
.selectable-tag {
border: 1px solid transparent;
cursor: pointer;
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);
}
.selectable-tag.selected {
border-color: rgba(106, 114, 255, 0.28);
background: rgba(107, 115, 255, 0.14);
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);
}
.selectable-tag .ant-checkbox {
margin-inline-end: 2px;
.selectable-tag.selected:hover {
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.35);
transform: translateY(-2px);
}
.summary-copy {
color: #465072;
@ -1859,8 +1879,7 @@ const MeetingDetail: React.FC = () => {
padding-top: 12px;
}
.chapter-item {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
display: flex;
gap: 14px;
align-items: flex-start;
}
@ -1870,6 +1889,8 @@ const MeetingDetail: React.FC = () => {
color: #58627f;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
width: 64px;
}
.chapter-time::after {
content: "";
@ -1942,14 +1963,14 @@ const MeetingDetail: React.FC = () => {
font-size: 12px;
}
.keypoint-card {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
display: flex;
gap: 14px;
align-items: start;
}
.keypoint-badge {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 12px;
background: rgba(107, 115, 255, 0.12);
color: #5c64ff;
@ -2012,12 +2033,18 @@ const MeetingDetail: React.FC = () => {
justify-self: start;
text-align: left;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
align-items: flex-start;
gap: 16px;
width: 100%;
min-width: 0;
}
.transcript-content-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.transcript-meta {
display: flex;
align-items: center;
@ -2026,14 +2053,17 @@ const MeetingDetail: React.FC = () => {
color: #8e98b8;
}
.transcript-avatar {
background: linear-gradient(135deg, #7a84ff, #9363ff) !important;
flex-shrink: 0;
background: linear-gradient(135deg, #1677ff, #36cfc9) !important;
margin-top: 4px;
}
.transcript-speaker {
color: #5e698d;
color: var(--app-text-main);
font-weight: 700;
font-size: 14px;
}
.transcript-speaker.editable {
color: #6470ff;
color: var(--app-primary-color);
cursor: pointer;
}
.transcript-bubble {
@ -2043,13 +2073,18 @@ const MeetingDetail: React.FC = () => {
max-width: 100%;
box-sizing: border-box;
padding: 14px 18px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(234, 238, 248, 1);
box-shadow: 0 12px 28px rgba(137, 149, 193, 0.08);
color: #3f496a;
line-height: 1.86;
border-radius: 4px 16px 16px 16px;
background: var(--app-bg-surface);
border: 1px solid var(--app-border-color);
color: var(--app-text-main);
line-height: 1.6;
font-size: 15px;
white-space: pre-wrap;
transition: all 0.3s ease;
}
.transcript-bubble:hover {
border-color: var(--app-primary-color);
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.05);
}
.transcript-bubble.editable {
cursor: text;
@ -2113,9 +2148,9 @@ const MeetingDetail: React.FC = () => {
margin-top: 18px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(249, 250, 255, 0.98), rgba(239, 242, 255, 0.98));
border: 1px solid rgba(224, 229, 247, 0.98);
box-shadow: 0 14px 30px rgba(145, 158, 212, 0.18);
background: var(--app-bg-surface);
border: 1px solid var(--app-border-color);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(14px);
}
.player-main-btn,
@ -2130,18 +2165,18 @@ const MeetingDetail: React.FC = () => {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #6e75ff, #8268ff);
background: linear-gradient(135deg, var(--app-primary-color), #36cfc9);
color: #fff;
font-size: 18px;
box-shadow: 0 12px 24px rgba(108, 117, 255, 0.28);
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.2);
}
.player-ghost-btn {
gap: 6px;
padding: 0 12px;
height: 38px;
border-radius: 12px;
background: rgba(107, 115, 255, 0.08);
color: #5e67ff;
background: var(--app-bg-surface-soft);
color: var(--app-primary-color);
font-weight: 700;
}
.player-progress-shell {

View File

@ -1,17 +1,14 @@
.meeting-preview-page {
--preview-bg:
radial-gradient(circle at top left, rgba(252, 208, 157, 0.24), transparent 32%),
radial-gradient(circle at top right, rgba(82, 164, 255, 0.18), transparent 28%),
linear-gradient(180deg, #fffaf4 0%, #f8f1e7 52%, #efe6da 100%);
--preview-ink: #33261c;
--preview-muted: rgba(72, 53, 39, 0.72);
--preview-line: rgba(84, 57, 31, 0.12);
--preview-card: rgba(255, 250, 244, 0.74);
--preview-card-strong: rgba(255, 252, 247, 0.88);
--preview-shadow: 0 24px 64px rgba(105, 72, 40, 0.12);
--preview-accent: #b86432;
--preview-accent-soft: rgba(184, 100, 50, 0.14);
--preview-cool: #315f8b;
--preview-bg: var(--app-bg-main);
--preview-ink: var(--app-text-main);
--preview-muted: var(--app-text-secondary);
--preview-line: var(--app-border-color);
--preview-card: var(--app-bg-card);
--preview-card-strong: var(--app-bg-surface-soft);
--preview-shadow: var(--app-shadow-lg);
--preview-accent: var(--app-primary-color);
--preview-accent-soft: rgba(22, 119, 255, 0.1);
--preview-cool: #1677ff;
position: relative;
min-height: 100vh;
padding: 24px 16px 40px;
@ -36,7 +33,7 @@
border-radius: 50%;
background:
radial-gradient(circle, rgba(255, 255, 255, 0.84) 0%, rgba(255, 255, 255, 0) 72%),
radial-gradient(circle at 36% 36%, rgba(184, 100, 50, 0.18), rgba(184, 100, 50, 0) 56%);
radial-gradient(circle at 36% 36%, rgba(22, 119, 255, 0.18), rgba(22, 119, 255, 0) 56%);
filter: blur(2px);
}
@ -46,9 +43,9 @@
width: 160px;
height: 160px;
border-radius: 32px;
border: 1px solid rgba(49, 95, 139, 0.14);
border: 1px solid rgba(22, 119, 255, 0.14);
background:
linear-gradient(135deg, rgba(49, 95, 139, 0.12), rgba(49, 95, 139, 0)),
linear-gradient(135deg, rgba(22, 119, 255, 0.12), rgba(22, 119, 255, 0)),
rgba(255, 255, 255, 0.32);
transform: rotate(16deg);
}
@ -70,19 +67,9 @@
position: relative;
overflow: hidden;
border: 1px solid var(--preview-line);
border-radius: 28px;
border-radius: 24px;
background: var(--preview-card);
box-shadow: var(--preview-shadow);
backdrop-filter: blur(18px);
}
.meeting-preview-card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(120deg, rgba(255, 255, 255, 0.66), rgba(255, 255, 255, 0.1) 42%, transparent 70%);
opacity: 0.8;
pointer-events: none;
}
.meeting-preview-hero,
@ -110,8 +97,8 @@
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(84, 57, 31, 0.08);
background: var(--app-bg-surface);
border: 1px solid var(--preview-line);
color: var(--preview-muted);
font-size: 12px;
letter-spacing: 0.12em;
@ -129,18 +116,18 @@
}
.meeting-preview-status.is-complete {
background: rgba(59, 130, 89, 0.12);
color: #1f6a43;
background: rgba(82, 196, 26, 0.12);
color: #52c41a;
}
.meeting-preview-status.is-processing {
background: rgba(49, 95, 139, 0.12);
color: var(--preview-cool);
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
}
.meeting-preview-status.is-warning {
background: rgba(184, 100, 50, 0.12);
color: var(--preview-accent);
background: rgba(250, 173, 20, 0.12);
color: #faad14;
}
.meeting-preview-title {
@ -186,7 +173,7 @@
padding: 14px 14px 12px;
border-radius: 18px;
background: var(--preview-card-strong);
border: 1px solid rgba(84, 57, 31, 0.08);
border: 1px solid var(--preview-line);
}
.meeting-preview-metric-label {
@ -228,13 +215,63 @@
}
.meeting-preview-password-gate {
display: grid;
gap: 18px;
max-width: 480px;
margin: 10vh auto;
padding: 40px 32px;
text-align: center;
border-radius: 24px;
background: var(--preview-card);
border: 1px solid var(--preview-line);
box-shadow: var(--preview-shadow);
backdrop-filter: blur(24px);
}
.meeting-preview-password-gate .meeting-preview-section-header {
justify-content: center;
margin-bottom: 12px;
}
.meeting-preview-password-gate .meeting-preview-section-kicker {
justify-content: center;
margin-bottom: 8px;
}
.meeting-preview-password-gate .meeting-preview-section-title {
font-size: 28px;
}
.meeting-preview-password-form {
display: grid;
gap: 12px;
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 28px;
margin-bottom: 24px;
}
.meeting-preview-password-form .ant-input-affix-wrapper {
padding: 10px 16px;
border-radius: 12px;
background: var(--app-bg-surface-soft);
border-color: var(--preview-line);
}
.meeting-preview-password-form .ant-input-affix-wrapper-focused {
border-color: var(--preview-accent);
box-shadow: 0 0 0 2px var(--preview-accent-soft);
background: var(--app-bg-surface);
}
.meeting-preview-password-form .ant-input {
background: transparent;
font-size: 16px;
letter-spacing: 2px;
}
.meeting-preview-password-form .ant-btn {
height: 48px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
}
.meeting-preview-section-header {
@ -284,7 +321,7 @@
padding: 8px 12px;
border-radius: 999px;
background: var(--preview-card-strong);
border: 1px solid rgba(84, 57, 31, 0.08);
border: 1px solid var(--preview-line);
color: var(--preview-ink);
font-size: 13px;
}
@ -293,18 +330,19 @@
margin-top: 16px;
padding: 18px;
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.4)),
rgba(255, 255, 255, 0.32);
border: 1px solid rgba(84, 57, 31, 0.08);
background: var(--app-bg-surface-soft);
border: 1px solid var(--preview-line);
}
.meeting-preview-overview-label {
margin-bottom: 8px;
color: var(--preview-muted);
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
.meeting-preview-disclaimer {
display: flex;
align-items: center;
justify-content: center;
margin-top: 32px;
padding: 16px;
color: var(--app-text-secondary);
font-size: 13px;
text-align: center;
}
.meeting-preview-overview-copy {
@ -323,7 +361,7 @@
width: 100%;
padding: 5px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
background: var(--app-bg-surface-soft);
}
.meeting-preview-analysis-tabs .ant-segmented-item {
@ -344,30 +382,32 @@
.meeting-preview-keypoint,
.meeting-preview-speaker-card,
.meeting-preview-todo {
border: 1px solid rgba(84, 57, 31, 0.08);
border: 1px solid var(--preview-line);
background: var(--preview-card-strong);
border-radius: 22px;
}
.meeting-preview-chapter,
.meeting-preview-keypoint {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 12px;
padding: 14px;
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px;
}
.meeting-preview-chapter-time,
.meeting-preview-keypoint-index {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: center;
padding: 10px 8px;
border-radius: 16px;
width: 52px;
height: 52px;
flex-shrink: 0;
border-radius: 14px;
background: var(--preview-accent-soft);
color: var(--preview-accent);
font-weight: 700;
font-size: 13px;
font-size: 16px;
}
.meeting-preview-item-title {
@ -397,8 +437,8 @@
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(49, 95, 139, 0.1);
color: var(--preview-cool);
background: var(--preview-accent-soft);
color: var(--preview-accent);
font-size: 12px;
}
@ -414,222 +454,307 @@
}
.meeting-preview-speaker-avatar {
display: grid;
place-items: center;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: rgba(49, 95, 139, 0.14);
color: var(--preview-cool);
border-radius: 12px;
background: var(--preview-accent-soft);
color: var(--preview-accent);
font-weight: 700;
font-size: 16px;
}
.meeting-preview-speaker-name {
font-size: 15px;
font-weight: 700;
font-size: 15px;
margin-bottom: 4px;
}
.meeting-preview-speaker-role {
margin-top: 2px;
color: var(--preview-muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.meeting-preview-todo {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 16px;
gap: 12px;
padding: 16px;
}
.meeting-preview-todo-dot {
flex: none;
flex-shrink: 0;
width: 10px;
height: 10px;
margin-top: 8px;
margin-top: 6px;
border-radius: 50%;
background: var(--preview-accent);
box-shadow: 0 0 0 6px rgba(184, 100, 50, 0.12);
border: 2px solid var(--preview-accent);
box-shadow: 0 0 0 6px var(--preview-accent-soft);
}
.meeting-preview-markdown {
padding: 4px 0 0;
}
.meeting-preview-markdown .ant-empty {
padding: 24px 0 8px;
font-size: 15px;
line-height: 1.8;
color: var(--preview-ink);
}
.meeting-preview-markdown h1,
.meeting-preview-markdown h2,
.meeting-preview-markdown h3,
.meeting-preview-markdown h4 {
margin-top: 1.4em;
margin-bottom: 0.7em;
font-family: Georgia, "Times New Roman", "Songti SC", serif;
line-height: 1.2;
letter-spacing: -0.03em;
.meeting-preview-markdown h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 700;
color: var(--preview-ink);
}
.meeting-preview-markdown p,
.meeting-preview-markdown li {
color: var(--preview-ink);
font-size: 15px;
line-height: 1.92;
.meeting-preview-markdown p {
margin-bottom: 1em;
}
.meeting-preview-markdown ul,
.meeting-preview-markdown ol {
margin-bottom: 1em;
padding-left: 20px;
}
.meeting-preview-markdown blockquote {
margin: 18px 0;
padding: 12px 16px;
border-left: 3px solid rgba(184, 100, 50, 0.4);
border-radius: 0 16px 16px 0;
background: rgba(184, 100, 50, 0.06);
}
.meeting-preview-markdown pre {
overflow: auto;
padding: 14px;
border-radius: 18px;
background: rgba(51, 38, 28, 0.92);
}
.meeting-preview-markdown code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
.meeting-preview-transcript-audio {
width: 100%;
margin-bottom: 16px;
margin: 0 0 1em;
padding: 10px 16px;
border-left: 3px solid var(--preview-accent);
border-radius: 0 8px 8px 0;
background: var(--preview-accent-soft);
}
.meeting-preview-transcript-list {
display: grid;
gap: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.meeting-preview-transcript-item {
width: 100%;
padding: 14px;
border: 1px solid rgba(84, 57, 31, 0.08);
border-radius: 22px;
background: var(--preview-card-strong);
text-align: left;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-radius: 16px;
border: 1px solid transparent;
transition: all 0.3s ease;
cursor: pointer;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
}
.meeting-preview-transcript-item:hover {
transform: translateY(-2px);
border-color: rgba(184, 100, 50, 0.22);
box-shadow: 0 12px 26px rgba(105, 72, 40, 0.08);
background: var(--app-bg-surface-soft);
border-color: var(--preview-line);
}
.meeting-preview-transcript-item.is-active {
transform: translateY(-2px);
border-color: rgba(184, 100, 50, 0.28);
box-shadow: 0 12px 26px rgba(105, 72, 40, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 244, 235, 0.92));
}
.meeting-preview-transcript-item.is-active .meeting-preview-transcript-copy {
color: #5a3113;
font-weight: 600;
}
.meeting-preview-transcript-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.meeting-preview-transcript-speaker {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
background: var(--app-bg-surface);
border-color: var(--preview-accent);
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.08);
}
.meeting-preview-transcript-avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 13px;
font-weight: 700;
background: linear-gradient(135deg, #1677ff, #36cfc9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
}
.meeting-preview-transcript-name {
.meeting-preview-transcript-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
font-size: 14px;
}
.meeting-preview-transcript-meta {
display: flex;
align-items: center;
gap: 8px;
}
.meeting-preview-transcript-speaker {
font-weight: 700;
font-size: 14px;
color: var(--app-text-main);
}
.meeting-preview-transcript-time {
flex: none;
color: var(--preview-muted);
font-size: 12px;
color: var(--app-text-secondary);
}
.meeting-preview-transcript-copy {
color: var(--preview-ink);
font-size: 14px;
line-height: 1.82;
.meeting-preview-transcript-text {
padding: 14px 18px;
border-radius: 4px 16px 16px 16px;
background: var(--app-bg-surface);
border: 1px solid var(--app-border-color);
font-size: 15px;
line-height: 1.6;
color: var(--app-text-main);
white-space: pre-wrap;
}
.meeting-preview-disclaimer {
margin-top: 18px;
padding: 0 8px;
color: rgba(72, 53, 39, 0.64);
.meeting-preview-transcript-item.is-active .meeting-preview-transcript-text {
border-color: var(--preview-accent-soft);
background: #f0f5ff;
}
.meeting-preview-transcript-text mark {
background: var(--preview-accent-soft);
color: var(--preview-accent);
border-radius: 4px;
padding: 0 4px;
}
.meeting-preview-page .transcript-player {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: min(calc(100% - 32px), 860px);
z-index: 100;
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
border-radius: 18px;
background: var(--app-bg-surface);
border: 1px solid var(--app-border-color);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.12);
backdrop-filter: blur(24px);
}
.player-main-btn,
.player-ghost-btn {
border: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.player-main-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, var(--app-primary-color), #36cfc9);
color: #fff;
font-size: 18px;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.2);
}
.player-ghost-btn {
gap: 6px;
padding: 0 12px;
height: 38px;
border-radius: 12px;
background: var(--app-bg-surface-soft);
color: var(--app-primary-color);
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: var(--app-text-secondary);
font-size: 12px;
line-height: 1.8;
text-align: center;
font-weight: 700;
}
.player-range {
width: 100%;
appearance: none;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--app-primary-color), var(--app-primary-color)) 0/0% 100% no-repeat,
var(--app-bg-surface-soft);
outline: none;
}
.player-range::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--app-primary-color);
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.24);
cursor: pointer;
}
.player-range::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--app-primary-color);
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.24);
cursor: pointer;
}
@keyframes preview-rise {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
0% { transform: translateY(30px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@media (min-width: 768px) {
@media (max-width: 768px) {
.meeting-preview-page {
padding: 36px 28px 56px;
padding: 16px 12px 32px;
}
.meeting-preview-hero,
.meeting-preview-section {
padding-left: 24px;
padding-right: 24px;
.meeting-preview-hero {
padding: 20px 16px;
}
.meeting-preview-title {
font-size: 28px;
}
.meeting-preview-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.meeting-preview-panels {
gap: 22px;
}
}
@media (max-width: 767px) {
.meeting-preview-hero-actions {
display: grid;
grid-template-columns: 1fr;
}
.meeting-preview-chapter,
.meeting-preview-keypoint {
flex-direction: column;
padding: 16px;
gap: 12px;
}
.meeting-preview-chapter-time,
.meeting-preview-keypoint-index {
width: auto;
height: 36px;
padding: 0 12px;
font-size: 14px;
border-radius: 8px;
}
.meeting-preview-transcript-item {
gap: 8px;
}
.meeting-preview-audio-player {
flex-direction: column;
align-items: stretch;
border-radius: 16px;
padding: 16px;
}
.meeting-preview-download-btn {
width: 100%;
border-radius: 8px;
}
}

View File

@ -4,10 +4,13 @@ import { useParams, useSearchParams } from "react-router-dom";
import {
AudioOutlined,
CalendarOutlined,
CaretRightFilled,
ClockCircleOutlined,
CopyOutlined,
FastForwardOutlined,
FileTextOutlined,
LockOutlined,
PauseOutlined,
RobotOutlined,
ShareAltOutlined,
TeamOutlined,
@ -98,7 +101,7 @@ const TEXT = {
audioUnavailable: "\u97f3\u9891\u6587\u4ef6\u4e0d\u53ef\u7528\uff0c\u4ec5\u5c55\u793a\u8f6c\u5f55\u5185\u5bb9\u3002",
noTranscript: "\u6682\u65e0\u8f6c\u5f55\u5185\u5bb9",
unknownSpeaker: "\u672a\u77e5\u53d1\u8a00\u4eba",
disclaimer: "\u8be5\u9875\u9762\u4e3a\u4f1a\u8bae\u5206\u4eab\u9884\u89c8\u9875\uff0c\u672a\u5f00\u653e\u7f16\u8f91\u3001\u5bfc\u51fa\u548c\u5176\u4ed6\u53d7\u4fdd\u62a4\u64cd\u4f5c\u3002",
disclaimer: "智能内容由用户会议内容 + AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度",
shareText: "\u6211\u5411\u4f60\u5206\u4eab\u4e86\u4e00\u4e2a\u4f1a\u8bae\u9884\u89c8\u94fe\u63a5",
audioSaved: "\u5df2\u4fdd\u5b58",
audioSaveFailed: "\u4fdd\u5b58\u5931\u8d25",
@ -170,6 +173,10 @@ export default function MeetingPreview() {
const [passwordVerified, setPasswordVerified] = useState(false);
const [accessPassword, setAccessPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [audioPlaying, setAudioPlaying] = useState(false);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
);
@ -309,6 +316,14 @@ export default function MeetingPreview() {
const participantCountValue =
isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length;
const meetingDuration = useMemo(() => {
if (transcripts.length > 0) {
const last = transcripts[transcripts.length - 1];
return last.endTime || 0;
}
return 0;
}, [transcripts]);
useEffect(() => {
if (!activeTranscriptId) {
return;
@ -331,12 +346,49 @@ export default function MeetingPreview() {
audioRef.current.play().catch(() => {});
};
const toggleAudioPlayback = () => {
if (!audioRef.current) return;
if (audioPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play().catch(() => {});
}
};
const handleAudioProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!audioRef.current) return;
const time = parseFloat(e.target.value);
audioRef.current.currentTime = time;
setAudioCurrentTime(time);
};
const cyclePlaybackRate = () => {
if (!audioRef.current) return;
const nextRate = audioPlaybackRate === 1 ? 1.5 : audioPlaybackRate === 1.5 ? 2 : 1;
audioRef.current.playbackRate = nextRate;
setAudioPlaybackRate(nextRate);
};
const formatPlayerTime = (seconds: number) => {
if (!seconds || isNaN(seconds)) return '00:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
const handleAudioTimeUpdate = () => {
if (!audioRef.current || transcripts.length === 0) {
return;
if (!audioRef.current) return;
const currentSeconds = audioRef.current.currentTime;
setAudioCurrentTime(currentSeconds);
// Also update duration if it's available now
if (audioRef.current.duration && audioDuration !== audioRef.current.duration) {
setAudioDuration(audioRef.current.duration);
}
const currentMs = audioRef.current.currentTime * 1000;
if (transcripts.length === 0) return;
const currentMs = currentSeconds * 1000;
const currentItem = transcripts.find(
(item) => currentMs >= (item.startTime || 0) && currentMs <= (item.endTime || 0),
);
@ -344,6 +396,18 @@ export default function MeetingPreview() {
setActiveTranscriptId(currentItem?.id || null);
};
const handleAudioEnded = () => {
setAudioPlaying(false);
};
const handleAudioPlay = () => setAudioPlaying(true);
const handleAudioPause = () => setAudioPlaying(false);
const handleAudioLoadedMetadata = () => {
if (audioRef.current) {
setAudioDuration(audioRef.current.duration);
}
};
const renderMeetingTitle = (title?: string) => {
const safeTitle = title || TEXT.untitledMeeting;
return safeTitle.split(/(\d+)/).map((part, index) =>
@ -700,7 +764,7 @@ export default function MeetingPreview() {
</div>
<div className="meeting-preview-section-extra">
<ClockCircleOutlined style={{ marginRight: 6 }} />
{meeting.duration ? formatDurationRange(0, meeting.duration) : TEXT.noDuration}
{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
</div>
</div>
@ -713,21 +777,10 @@ export default function MeetingPreview() {
/>
) : null}
{meeting.audioUrl ? (
<audio
ref={audioRef}
className="meeting-preview-transcript-audio"
controls
src={meeting.audioUrl}
onTimeUpdate={handleAudioTimeUpdate}
/>
) : null}
<div className="meeting-preview-transcript-list">
{transcripts.length > 0 ? (
transcripts.map((item) => {
const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker";
const avatarColor = transcriptColorSeed(speakerKey);
return (
<div
key={item.id}
@ -737,20 +790,20 @@ export default function MeetingPreview() {
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 }}>
{(speakerKey || "S").slice(0, 1)}
</div>
<div className="meeting-preview-transcript-name">
{item.speakerName || item.speakerLabel || item.speakerId || TEXT.unknownSpeaker}
</div>
<div className="meeting-preview-transcript-avatar">
{(speakerKey || "S").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-time">
{formatDurationRange(item.startTime, item.endTime)}
<div className="meeting-preview-transcript-text">
{item.content || TEXT.noTranscript}
</div>
</div>
<div className="meeting-preview-transcript-copy">{item.content || TEXT.noTranscript}</div>
</div>
);
})
@ -826,10 +879,55 @@ export default function MeetingPreview() {
</div>
<div className="meeting-preview-disclaimer">
<UserOutlined style={{ marginRight: 8 }} />
<RobotOutlined style={{ marginRight: 8 }} />
{TEXT.disclaimer}
</div>
</div>
{meeting.audioUrl && (
<audio
ref={audioRef}
src={meeting.audioUrl}
onTimeUpdate={handleAudioTimeUpdate}
onPlay={handleAudioPlay}
onPause={handleAudioPause}
onEnded={handleAudioEnded}
onLoadedMetadata={handleAudioLoadedMetadata}
style={{ display: 'none' }}
preload="metadata"
/>
)}
{meeting.audioUrl && pageTab === 'transcript' ? (
<>
<div style={{ height: 100, flexShrink: 0, pointerEvents: 'none' }} />
<div className="transcript-player">
<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)}
onChange={handleAudioProgressChange}
style={{ backgroundSize: `${audioDuration ? (audioCurrentTime / audioDuration) * 100 : 0}% 100%` }}
/>
</div>
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
<FastForwardOutlined />
{audioPlaybackRate}x
</button>
</div>
</>
) : null}
</div>
);
}