b43e3725ee
- 配置浅色主题配色方案和 CSS 变量 - 修复组件在浅色模式下的样式适配 - 统一文字颜色类名使用 CSS 变量 - 优化玻璃效果在浅色主题下的显示
1171 lines
33 KiB
Python
1171 lines
33 KiB
Python
"""
|
||
会议记录系统 - 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() |