Files
freedakgmail b43e3725ee feat: 添加浅色主题支持
- 配置浅色主题配色方案和 CSS 变量
- 修复组件在浅色模式下的样式适配
- 统一文字颜色类名使用 CSS 变量
- 优化玻璃效果在浅色主题下的显示
2026-07-09 22:10:49 +08:00

1171 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
会议记录系统 - Streamlit 主界面 (专业美观版)
功能: 音频上传/录音 → FunASR 转录 → 播放对照 → Markdown 导出
"""
import os
import sys
import time
import uuid
import json
import base64
import tempfile
from datetime import datetime
from typing import Optional
import streamlit as st
import streamlit.components.v1 as components
# 导入项目模块
from processor import MeetingProcessor, get_device
from database import Database, init_db
# ============ 页面配置 ============
st.set_page_config(
page_title="会议记录系统",
page_icon="🎙️",
layout="wide",
initial_sidebar_state="collapsed"
)
# ============ 自定义 CSS - 现代设计风格 ============
st.markdown("""
<style>
/* 全局字体和基础样式 */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--primary: #6366f1;
--primary-light: #818cf8;
--primary-dark: #4f46e5;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--bg-dark: #0f172a;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border: #334155;
}
* {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* 隐藏 Streamlit 默认元素 */
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}
.stDeployButton {display: none;}
/* 主容器样式 */
.main-content {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
/* 顶部导航栏 */
.top-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 100%);
border-radius: 16px;
margin-bottom: 2rem;
box-shadow: 0 10px 40px rgba(99, 102, 241, 0.3);
}
.nav-title {
font-size: 1.5rem;
font-weight: 700;
color: white;
display: flex;
align-items: center;
gap: 0.75rem;
}
.nav-tabs {
display: flex;
gap: 0.5rem;
}
.nav-tab {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 10px;
color: white;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.nav-tab:hover {
background: rgba(255, 255, 255, 0.2);
}
.nav-tab.active {
background: white;
color: var(--primary);
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card-hover) 100%);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--border);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.stat-icon.primary { background: rgba(99, 102, 241, 0.2); }
.stat-icon.success { background: rgba(16, 185, 129, 0.2); }
.stat-icon.warning { background: rgba(245, 158, 11, 0.2); }
.stat-icon.info { background: rgba(59, 130, 246, 0.2); }
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* 会议卡片 */
.meeting-card {
background: var(--bg-card);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--border);
margin-bottom: 1rem;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.meeting-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--primary);
transition: all 0.3s ease;
}
.meeting-card:hover {
background: var(--bg-card-hover);
transform: translateX(4px);
}
.meeting-card:hover::before {
width: 6px;
background: linear-gradient(180deg, var(--primary) 0%, var(--primary-light) 100%);
}
.meeting-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.meeting-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.meeting-meta {
display: flex;
gap: 1.5rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.meeting-meta span {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 状态标签 */
.status-badge {
padding: 0.375rem 0.875rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-pending { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
.status-processing { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
.status-completed { background: rgba(16, 185, 129, 0.2); color: #10b981; }
.status-failed { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
/* 详情页面 */
.detail-header {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card-hover) 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
border: 1px solid var(--border);
}
.detail-title {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
}
.detail-meta {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.detail-meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
}
.detail-meta-item strong {
color: var(--text-primary);
font-weight: 600;
}
/* 音频播放器区域 */
.audio-player-card {
background: var(--bg-card);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--border);
margin-bottom: 1.5rem;
}
.audio-wave {
height: 80px;
background: linear-gradient(90deg,
rgba(99, 102, 241, 0.1) 0%,
rgba(99, 102, 241, 0.3) 50%,
rgba(99, 102, 241, 0.1) 100%);
border-radius: 8px;
margin: 1rem 0;
position: relative;
overflow: hidden;
}
/* 摘要卡片 */
.summary-card {
background: var(--bg-card);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--border);
margin-bottom: 1rem;
}
.summary-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.summary-content {
color: var(--text-secondary);
line-height: 1.75;
font-size: 0.9375rem;
}
/* 转写片段 */
.transcript-segment {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border);
transition: all 0.2s ease;
cursor: pointer;
}
.transcript-segment:hover {
background: var(--bg-card-hover);
border-color: var(--primary);
}
.segment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.speaker-badge {
background: var(--primary);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.segment-time {
color: var(--text-secondary);
font-size: 0.8125rem;
font-family: 'SF Mono', Monaco, monospace;
}
.segment-text {
color: var(--text-primary);
line-height: 1.6;
font-size: 0.9375rem;
}
/* 上传区域 */
.upload-zone {
border: 2px dashed var(--border);
border-radius: 16px;
padding: 3rem;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
}
.upload-zone:hover {
border-color: var(--primary);
background: rgba(99, 102, 241, 0.05);
}
.upload-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.upload-text {
color: var(--text-secondary);
font-size: 1rem;
}
/* 导出按钮组 */
.export-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-top: 1.5rem;
}
.export-btn {
background: var(--bg-card-hover);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.export-btn:hover {
background: var(--primary);
border-color: var(--primary);
}
.export-btn-icon {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.export-btn-text {
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
}
/* 搜索和筛选 */
.filter-bar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 250px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* 情感标签 */
.emotion-tag {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
}
.emotion-neutral { background: rgba(148, 163, 184, 0.2); color: #94a3b8; }
.emotion-happy { background: rgba(16, 185, 129, 0.2); color: #10b981; }
.emotion-sad { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
.emotion-angry { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.emotion-surprise { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
/* 进度条 */
.progress-container {
background: var(--bg-card);
border-radius: 16px;
padding: 2rem;
text-align: center;
border: 1px solid var(--border);
}
.progress-text {
color: var(--text-secondary);
margin-top: 1rem;
}
/* 响应式 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.nav-tabs {
flex-wrap: wrap;
}
.detail-meta {
flex-direction: column;
gap: 0.75rem;
}
}
</style>
""", unsafe_allow_html=True)
# ============ 会话状态初始化 ============
def init_session_state():
"""初始化会话状态"""
if 'processor' not in st.session_state:
st.session_state.processor = None
if 'db' not in st.session_state:
st.session_state.db = None
if 'current_page' not in st.session_state:
st.session_state.current_page = "dashboard"
if 'current_meeting' not in st.session_state:
st.session_state.current_meeting = None
if 'selected_segment' not in st.session_state:
st.session_state.selected_segment = None
@st.cache_resource
def get_processor():
"""获取处理器(带缓存)"""
device = get_device()
return MeetingProcessor(device=device)
@st.cache_resource
def get_database():
"""获取数据库(带缓存)"""
return init_db()
# ============ 工具函数 ============
def format_duration(seconds: float) -> str:
"""格式化时长"""
if seconds < 60:
return f"{seconds:.0f}"
elif seconds < 3600:
mins = int(seconds // 60)
secs = int(seconds % 60)
return f"{mins}{secs}"
else:
hours = int(seconds // 3600)
mins = int((seconds % 3600) // 60)
return f"{hours}小时{mins}"
def format_timestamp(seconds: float) -> str:
"""格式化时间戳"""
mins = int(seconds // 60)
secs = int(seconds % 60)
return f"{mins:02d}:{secs:02d}"
def save_uploaded_file(uploaded_file) -> str:
"""保存上传的文件"""
project_root = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.join(project_root, "data", "audio")
os.makedirs(data_dir, exist_ok=True)
ext = os.path.splitext(uploaded_file.name)[1] or ".wav"
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}{ext}"
filepath = os.path.join(data_dir, filename)
with open(filepath, "wb") as f:
f.write(uploaded_file.getbuffer())
return filepath
def export_to_markdown(meeting: dict) -> str:
"""导出为 Markdown(包含摘要)"""
lines = [
f"# {meeting['title']}",
"",
f"**日期**: {meeting.get('date', '未知')[:10]}",
f"**时长**: {format_duration(meeting.get('duration', 0))}",
f"**说话人数**: {meeting.get('speaker_count', 0)}",
"",
]
brief_summary = meeting.get('brief_summary')
if brief_summary:
lines.extend(["## 📋 会议摘要", "", brief_summary, ""])
detailed_summary = meeting.get('detailed_summary')
if detailed_summary:
lines.extend(["## 📝 详细纪要", "", detailed_summary, ""])
lines.extend(["---", "", "## 🎤 转写内容", ""])
segments = meeting.get('segments', [])
current_speaker = None
for seg in segments:
speaker = seg.get('speaker', 0)
if speaker != current_speaker:
lines.append(f"### 说话人 {speaker}")
lines.append("")
current_speaker = speaker
start = format_timestamp(seg.get('start', 0))
text = seg.get('text', '')
lines.append(f"[{start}] {text}")
lines.append("")
lines.extend(["---", "", "*本记录由 FunASR 自动生成*"])
return "\n".join(lines)
# ============ 页面组件 ============
def render_top_nav():
"""渲染顶部导航"""
pages = [
("dashboard", "📊 概览", "首页"),
("meetings", "📋 会议", "会议列表"),
("upload", " 新建", "新建会议"),
("settings", "⚙️ 设置", "设置"),
]
cols = st.columns([2, 1])
with cols[0]:
st.markdown("""
<div class="nav-title">
<span style="font-size: 2rem;">🎙️</span>
<span>智能会议记录系统</span>
</div>
""", unsafe_allow_html=True)
with cols[1]:
selected = st.radio(
"导航",
[p[0] for p in pages],
format_func=lambda x: next(p[1] for p in pages if p[0] == x),
horizontal=True,
label_visibility="collapsed",
key="nav_radio"
)
# 同步 session_state
st.session_state.current_page = selected
return selected
def render_dashboard():
"""仪表盘页面"""
db = get_database()
meetings = db.get_all_meetings(limit=1000)
# 统计卡片
total = len(meetings)
completed = sum(1 for m in meetings if m['status'] == 'completed')
processing = sum(1 for m in meetings if m['status'] == 'processing')
total_duration = sum(m.get('duration', 0) for m in meetings)
st.markdown("""
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">📋</div>
<div class="stat-value">{}</div>
<div class="stat-label">总会议数</div>
</div>
<div class="stat-card">
<div class="stat-icon success">✅</div>
<div class="stat-value">{}</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">🔄</div>
<div class="stat-value">{}</div>
<div class="stat-label">处理中</div>
</div>
<div class="stat-card">
<div class="stat-icon info">⏱️</div>
<div class="stat-value">{}</div>
<div class="stat-label">总时长</div>
</div>
</div>
""".format(total, completed, processing, format_duration(total_duration)), unsafe_allow_html=True)
# 最近会议
st.markdown("### 📌 最近会议")
recent = meetings[:5]
if not recent:
st.markdown("""
<div class="empty-state">
<div class="empty-icon">📋</div>
<p>暂无会议记录</p>
<p style="font-size: 0.875rem;">点击上方「新建会议」开始</p>
</div>
""", unsafe_allow_html=True)
else:
for meeting in recent:
render_meeting_card(meeting)
def render_meeting_card(meeting: dict):
"""渲染会议卡片"""
status_map = {
"pending": ("待处理", "status-pending"),
"processing": ("处理中", "status-processing"),
"completed": ("已完成", "status-completed"),
"failed": ("失败", "status-failed"),
}
status_text, status_class = status_map.get(meeting['status'], ("未知", ""))
has_brief = bool(meeting.get('brief_summary'))
has_detailed = bool(meeting.get('detailed_summary'))
summary_tag = ""
if meeting['status'] == 'completed':
if has_brief and has_detailed:
summary_tag = '<span style="background: rgba(99,102,241,0.2);color:#818cf8;padding:0.2rem 0.5rem;border-radius:4px;font-size:0.7rem;margin-left:0.5rem;">📋📝</span>'
elif has_brief:
summary_tag = '<span style="background: rgba(99,102,241,0.2);color:#818cf8;padding:0.2rem 0.5rem;border-radius:4px;font-size:0.7rem;margin-left:0.5rem;">📋</span>'
st.markdown(f"""
<div class="meeting-card">
<div class="meeting-header">
<div>
<div class="meeting-title">{meeting['title']}{summary_tag}</div>
<div class="meeting-meta">
<span>📅 {meeting.get('date', '未知')[:10]}</span>
<span>⏱️ {format_duration(meeting.get('duration', 0))}</span>
<span>👥 {meeting.get('speaker_count', 0)} 人</span>
</div>
</div>
<span class="status-badge {status_class}">{status_text}</span>
</div>
</div>
""", unsafe_allow_html=True)
# 隐藏按钮用于点击卡片
col1, col2, col3 = st.columns([4, 1, 1])
with col1:
pass
with col2:
if st.button("查看详情", key=f"view_{meeting['meeting_id']}"):
st.session_state.current_meeting = meeting['meeting_id']
st.session_state.current_page = "detail"
st.rerun()
with col3:
if meeting['status'] == 'completed':
if st.button("转写", key=f"trans_{meeting['meeting_id']}"):
st.session_state.current_meeting = meeting['meeting_id']
st.session_state.current_page = "detail"
st.rerun()
def render_meetings_page():
"""会议列表页面"""
st.markdown("### 📋 全部会议")
db = get_database()
meetings = db.get_all_meetings()
# 搜索和筛选
col1, col2, col3 = st.columns([2, 1, 1])
with col1:
search = st.text_input("🔍 搜索会议", placeholder="输入关键词...", label_visibility="collapsed")
with col2:
status_filter = st.selectbox("状态", ["全部", "待处理", "处理中", "已完成", "失败"], label_visibility="collapsed")
with col3:
if st.button("🔄 刷新"):
st.rerun()
# 过滤
if search:
meetings = db.search_meetings(search)
if status_filter != "全部":
status_map = {"待处理": "pending", "处理中": "processing", "已完成": "completed", "失败": "failed"}
meetings = [m for m in meetings if m['status'] == status_map[status_filter]]
# 显示列表
st.markdown(f"**共 {len(meetings)} 个会议**")
for meeting in meetings:
render_meeting_card(meeting)
st.markdown("") # 间距
def render_upload_page():
"""新建会议页面"""
st.markdown("### 新建会议")
tab1, tab2 = st.tabs(["📤 上传音频文件", "🎤 实时录音"])
with tab1:
render_upload_tab()
with tab2:
render_record_tab()
def render_upload_tab():
"""上传音频标签页"""
col1, col2 = st.columns([1, 1])
with col1:
st.markdown("""
<div class="upload-zone">
<div class="upload-icon">📁</div>
<div class="upload-text">拖拽文件或点击上传</div>
<div class="upload-text" style="font-size: 0.8rem; margin-top: 0.5rem;">
支持 WAV, MP3, M4A, FLAC, OGG
</div>
</div>
""", unsafe_allow_html=True)
uploaded_file = st.file_uploader(
"选择音频文件",
type=['wav', 'mp3', 'm4a', 'flac', 'ogg'],
help="拖拽或点击上传"
)
with col2:
if uploaded_file:
st.audio(uploaded_file)
title = st.text_input("会议标题", value=uploaded_file.name.rsplit('.', 1)[0])
if st.button("🚀 开始转录", type="primary", use_container_width=True):
process_uploaded_file(uploaded_file, title)
def render_record_tab():
"""录音标签页"""
st.info("🎤 实时录音功能开发中,请先使用上传功能")
col1, col2 = st.columns([1, 1])
with col1:
title = st.text_input("会议标题", value=f"录音会议_{datetime.now().strftime('%H%M%S')}")
with col2:
max_duration = st.number_input("最大时长(秒)", value=3600, min_value=60, max_value=7200)
st.markdown("""
<div style="text-align: center; padding: 2rem; background: var(--bg-card); border-radius: 16px; margin-top: 1rem;">
<div style="font-size: 4rem; margin-bottom: 1rem;">🎙️</div>
<p style="color: var(--text-secondary);">点击下方按钮开始录音</p>
</div>
""", unsafe_allow_html=True)
col1, col2, col3 = st.columns([1, 1, 1])
with col2:
if st.button("🔴 开始录音", type="primary", use_container_width=True):
st.warning("录音功能开发中")
def render_detail_page():
"""会议详情页面"""
if not st.session_state.current_meeting:
st.session_state.current_page = "meetings"
st.rerun()
return
db = get_database()
meeting = db.get_meeting(st.session_state.current_meeting)
if not meeting:
st.error("会议不存在")
if st.button("返回列表"):
st.session_state.current_page = "meetings"
st.rerun()
return
# 返回按钮
if st.button("← 返回会议列表"):
st.session_state.current_page = "meetings"
st.rerun()
# 详情头部
status_map = {
"pending": ("待处理", "status-pending"),
"processing": ("处理中", "status-processing"),
"completed": ("已完成", "status-completed"),
"failed": ("失败", "status-failed"),
}
status_text, status_class = status_map.get(meeting['status'], ("未知", ""))
st.markdown(f"""
<div class="detail-header">
<div class="detail-title">{meeting['title']}</div>
<div class="detail-meta">
<div class="detail-meta-item">
<span>📅</span>
<span>日期: <strong>{meeting.get('date', '未知')[:10]}</strong></span>
</div>
<div class="detail-meta-item">
<span>⏱️</span>
<span>时长: <strong>{format_duration(meeting.get('duration', 0))}</strong></span>
</div>
<div class="detail-meta-item">
<span>👥</span>
<span>说话人: <strong>{meeting.get('speaker_count', 0)} 人</strong></span>
</div>
<div class="detail-meta-item">
<span>📊</span>
<span>状态: <strong class="status-badge {status_class}">{status_text}</strong></span>
</div>
</div>
</div>
""", unsafe_allow_html=True)
# 根据状态显示不同内容
if meeting['status'] == 'completed':
render_completed_detail(meeting)
elif meeting['status'] == 'processing':
render_processing_state()
else:
render_pending_state(meeting)
def render_completed_detail(meeting: dict):
"""已完成会议详情"""
col1, col2 = st.columns([1, 2])
with col1:
# 音频播放器
audio_path = meeting.get('audio_path')
if audio_path and os.path.exists(audio_path):
st.markdown("#### 🔊 音频播放")
st.audio(open(audio_path, "rb").read(), format="audio/wav")
else:
st.warning("音频文件不存在")
# 导出
st.markdown("#### 📥 导出")
md_content = export_to_markdown(meeting)
st.download_button(
"📄 完整 Markdown",
md_content,
file_name=f"{meeting['title']}.md",
mime="text/markdown",
use_container_width=True
)
brief_summary = meeting.get('brief_summary')
detailed_summary = meeting.get('detailed_summary')
if brief_summary or detailed_summary:
summary_lines = [f"# {meeting['title']}", ""]
if brief_summary:
summary_lines.extend(["## 📋 会议摘要", "", brief_summary, ""])
if detailed_summary:
summary_lines.extend(["## 📝 详细纪要", "", detailed_summary, ""])
st.download_button(
"📋 仅导出摘要",
"\n".join(summary_lines),
file_name=f"{meeting['title']}_摘要.md",
mime="text/markdown",
use_container_width=True
)
with col2:
# 摘要区域
brief_summary = meeting.get('brief_summary')
detailed_summary = meeting.get('detailed_summary')
if brief_summary or detailed_summary:
with st.expander("📋 会议摘要", expanded=True):
if brief_summary:
st.markdown("**简要摘要**")
st.markdown(brief_summary)
if detailed_summary:
st.markdown("---")
st.markdown("**详细纪要**")
st.markdown(detailed_summary)
# 转写内容
st.markdown("#### 🎤 转写内容")
segments = meeting.get('segments', [])
if segments:
# 搜索
search_text = st.text_input("🔍 搜索转写内容", placeholder="输入关键词...", label_visibility="collapsed")
if search_text:
filtered = [s for s in segments if search_text.lower() in s.get('text', '').lower()]
else:
filtered = segments
st.caption(f"{len(filtered)} / {len(segments)}")
for seg in filtered:
render_transcript_segment(seg)
else:
st.info("暂无转写内容")
def render_transcript_segment(seg: dict):
"""渲染转写片段"""
start = seg.get('start', 0)
end = seg.get('end', 0)
speaker = seg.get('speaker', 0)
text = seg.get('text', '')
emotion = seg.get('emotion', 'NEUTRAL')
emotion_map = {
"NEUTRAL": ("neutral", "中性"),
"happy": ("happy", "开心"),
"sad": ("sad", "悲伤"),
"angry": ("angry", "愤怒"),
"surprise": ("surprise", "惊讶"),
}
emotion_class, emotion_text = emotion_map.get(emotion, ("neutral", "中性"))
st.markdown(f"""
<div class="transcript-segment">
<div class="segment-header">
<span class="speaker-badge">👤 说话人 {speaker}</span>
<span>
<span class="segment-time">{format_timestamp(start)} - {format_timestamp(end)}</span>
<span class="emotion-tag emotion-{emotion_class}">{emotion_text}</span>
</span>
</div>
<div class="segment-text">{text}</div>
</div>
""", unsafe_allow_html=True)
def render_processing_state():
"""处理中状态"""
st.markdown("""
<div class="progress-container">
<div style="font-size: 3rem; margin-bottom: 1rem;">🔄</div>
<div style="font-size: 1.25rem; font-weight: 600;">正在转录中...</div>
<div class="progress-text">请稍候,预计需要几分钟时间</div>
</div>
""", unsafe_allow_html=True)
st.progress(0.5, text="处理中...")
def render_pending_state(meeting: dict):
"""待处理状态"""
st.markdown("""
<div class="progress-container">
<div style="font-size: 3rem; margin-bottom: 1rem;">⏳</div>
<div style="font-size: 1.25rem; font-weight: 600;">会议待转录</div>
<div class="progress-text">点击下方按钮开始转录</div>
</div>
""", unsafe_allow_html=True)
if st.button("🚀 开始转录", type="primary"):
process_meeting(st.session_state.current_meeting)
def render_settings_page():
"""设置页面"""
st.markdown("### ⚙️ 系统设置")
col1, col2 = st.columns(2)
with col1:
st.markdown("#### 🖥️ 运行环境")
device = get_device()
device_icon = "🖥️" if device == "cpu" else "🎮"
st.info(f"计算设备: **{device_icon} {device.upper()}**")
st.info("FunASR 版本: **1.3.14**")
with col2:
st.markdown("#### 📁 存储信息")
db = get_database()
st.info(f"数据库: `{db.db_path}`")
st.markdown("---")
# 清理操作
st.markdown("#### 🗑️ 数据管理")
col1, col2 = st.columns([1, 1])
with col1:
if st.button("清理已完成会议", type="secondary"):
st.warning("功能开发中")
st.markdown("---")
# 关于
st.markdown("""
<div style="text-align: center; padding: 2rem; color: var(--text-secondary);">
<p style="font-size: 0.875rem;">智能会议记录系统 v1.0</p>
<p style="font-size: 0.75rem;">基于 FunASR + DeepSeek AI</p>
</div>
""", unsafe_allow_html=True)
# ============ 业务逻辑 ============
def process_uploaded_file(uploaded_file, title: str):
"""处理上传的文件"""
if not uploaded_file:
st.error("请先上传音频文件")
return
with st.spinner("保存文件并开始转录..."):
audio_path = save_uploaded_file(uploaded_file)
db = get_database()
meeting_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
db.create_meeting(
meeting_id=meeting_id,
title=title,
audio_path=audio_path,
status="processing"
)
try:
processor = get_processor()
result = processor.process_audio(audio_path, title=title)
db.update_meeting(
meeting_id=meeting_id,
segments=[s.__dict__ for s in result.segments],
status="completed",
speaker_count=result.speaker_count,
duration=result.duration
)
st.success("✅ 转录完成!")
st.session_state.current_meeting = meeting_id
st.session_state.current_page = "detail"
st.rerun()
except Exception as e:
db.update_meeting(meeting_id=meeting_id, status="failed")
st.error(f"转录失败: {str(e)}")
def process_meeting(meeting_id: str):
"""处理会议转录"""
db = get_database()
meeting = db.get_meeting(meeting_id)
if not meeting:
return
try:
processor = get_processor()
result = processor.process_audio(meeting['audio_path'], title=meeting['title'])
db.update_meeting(
meeting_id=meeting_id,
segments=[s.__dict__ for s in result.segments],
status="completed",
speaker_count=result.speaker_count,
duration=result.duration
)
st.success("✅ 转录完成!")
st.rerun()
except Exception as e:
db.update_meeting(meeting_id=meeting_id, status="failed")
st.error(f"转录失败: {str(e)}")
# ============ 主函数 ============
def main():
init_session_state()
# 顶部导航
current_page = render_top_nav()
st.markdown("<div style='margin-top: 1rem;'></div>", unsafe_allow_html=True)
# 根据当前页面渲染
if current_page == "dashboard":
render_dashboard()
elif current_page == "meetings":
render_meetings_page()
elif current_page == "upload":
render_upload_page()
elif current_page == "settings":
render_settings_page()
elif current_page == "detail":
render_detail_page()
if __name__ == "__main__":
main()