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

330 lines
12 KiB
React
Raw 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.
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ArrowLeft, Download, Calendar, Clock, Users, Search,
FileText, AlignLeft, Loader2, User, Sparkles, Edit2, Check, X,
ChevronDown, Copy, CheckCheck
} from 'lucide-react'
import { useAppStore } from '../store/useAppStore'
import { getMeeting, downloadMarkdown, formatDuration, formatTimestamp, generateSummary, updateMeetingTitle } from '../api/meeting'
import Button from '../components/Button'
export default function MeetingDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { currentMeeting, loading, setCurrentMeeting, refreshMeetings } = useAppStore()
const [search, setSearch] = useState('')
const [summarizing, setSummarizing] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleInput, setTitleInput] = useState('')
const [summaryCollapsed, setSummaryCollapsed] = useState(false)
const [briefCopied, setBriefCopied] = useState(false)
const [detailedCopied, setDetailedCopied] = useState(false)
useEffect(() => {
loadMeeting()
}, [id])
const loadMeeting = async () => {
const data = await getMeeting(id)
setCurrentMeeting(data)
}
const handleGenerateSummary = async () => {
setSummarizing(true)
try {
const updated = await generateSummary(id)
setCurrentMeeting(updated)
} catch (err) {
alert('生成摘要失败: ' + err.message)
} finally {
setSummarizing(false)
}
}
const handleEditTitle = () => {
setTitleInput(currentMeeting?.title || '')
setEditingTitle(true)
}
const handleSaveTitle = async () => {
if (!titleInput.trim()) return
try {
const updated = await updateMeetingTitle(id, titleInput.trim())
setCurrentMeeting(updated)
refreshMeetings()
} catch (err) {
alert('保存失败: ' + err.message)
}
setEditingTitle(false)
}
const handleCancelTitle = () => {
setEditingTitle(false)
setTitleInput('')
}
const copyToClipboard = async (text, type) => {
await navigator.clipboard.writeText(text)
if (type === 'brief') {
setBriefCopied(true)
setTimeout(() => setBriefCopied(false), 2000)
} else {
setDetailedCopied(true)
setTimeout(() => setDetailedCopied(false), 2000)
}
}
if (loading || !currentMeeting) {
return (
<div className="animate-in">
<div className="h-6 w-24 skeleton rounded mb-6" />
<div className="h-40 skeleton rounded-xl mb-4" />
<div className="grid grid-cols-2 gap-3">
<div className="h-32 skeleton rounded-xl" />
<div className="h-32 skeleton rounded-xl" />
</div>
</div>
)
}
const meeting = currentMeeting
const segments = meeting.segments || []
const filteredSegments = search
? segments.filter(s => s.text.toLowerCase().includes(search.toLowerCase()))
: segments
return (
<div className="animate-in">
{/* 返回按钮 */}
<button
onClick={() => navigate('/meetings')}
className="flex items-center gap-1.5 text-xs text-muted hover:text-primary mb-5 transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
返回
</button>
{/* 详情头部 */}
<div className="glass-card rounded-xl p-5 mb-4 group">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
{editingTitle ? (
<div className="flex items-center gap-2">
<input
type="text"
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
className="text-lg font-semibold text-primary glass-input rounded-lg px-3 py-1 focus:outline-none w-full max-w-sm"
autoFocus
/>
<button onClick={handleSaveTitle} className="p-1.5 text-success hover:text-success/80 transition-colors">
<Check className="w-4 h-4" />
</button>
<button onClick={handleCancelTitle} className="p-1.5 text-error hover:text-error/80 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-primary">{meeting.title}</h1>
<button
onClick={handleEditTitle}
className="p-1 text-muted hover:text-accent transition-colors opacity-0 group-hover:opacity-100"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
)}
<div className="flex items-center gap-4 text-xs text-muted mt-2">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{meeting.date?.slice(0, 10) || '未知'}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDuration(meeting.duration)}
</span>
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{meeting.speaker_count || 0}
</span>
</div>
</div>
{meeting.status === 'completed' && (
<div className="flex items-center gap-2">
<Button
onClick={handleGenerateSummary}
disabled={summarizing}
variant={meeting.brief_summary ? 'secondary' : 'primary'}
size="sm"
>
{summarizing ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
生成中
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5" />
{meeting.brief_summary ? '重新生成' : '抓取摘要'}
</>
)}
</Button>
<Button onClick={() => downloadMarkdown(meeting)} variant="secondary" size="sm">
<Download className="w-3.5 h-3.5" />
</Button>
</div>
)}
</div>
{/* 音频播放器 */}
{meeting.audio_path && meeting.status === 'completed' && (
<div className="mt-3">
<audio controls className="w-full" src={`/api/audio/${meeting.meeting_id}`}>
您的浏览器不支持音频播放
</audio>
</div>
)}
</div>
{/* 处理中状态 */}
{meeting.status === 'processing' && (
<div className="glass-card rounded-xl p-8 text-center mb-4">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-info/10 flex items-center justify-center">
<Loader2 className="w-5 h-5 text-info animate-spin" />
</div>
<h3 className="text-sm font-medium text-primary mb-1">正在转录中...</h3>
<p className="text-xs text-muted">请稍候预计需要几分钟时间</p>
</div>
)}
{/* 摘要区域 */}
{(meeting.brief_summary || meeting.detailed_summary) && (
<div className="mb-4">
<button
onClick={() => setSummaryCollapsed(!summaryCollapsed)}
className="flex items-center gap-1.5 text-xs text-muted hover:text-primary mb-3 transition-colors"
>
{summaryCollapsed ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5 rotate-180" />}
摘要 {meeting.brief_summary && meeting.detailed_summary ? '(2)' : '(1)'}
</button>
{!summaryCollapsed && (
<div className="space-y-3">
{meeting.brief_summary && (
<div className="glass-card rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-primary flex items-center gap-2">
<FileText className="w-4 h-4 text-accent" />
简要摘要
</h3>
<button
onClick={() => copyToClipboard(meeting.brief_summary, 'brief')}
className="flex items-center gap-1 text-[10px] text-muted hover:text-accent transition-colors"
>
{briefCopied ? (
<>
<CheckCheck className="w-3 h-3 text-success" />
<span className="text-success">已复制</span>
</>
) : (
<>
<Copy className="w-3 h-3" />
复制
</>
)}
</button>
</div>
<p className="text-xs text-secondary leading-relaxed whitespace-pre-wrap">
{meeting.brief_summary}
</p>
</div>
)}
{meeting.detailed_summary && (
<div className="glass-card rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-primary flex items-center gap-2">
<AlignLeft className="w-4 h-4 text-success" />
详细纪要
</h3>
<button
onClick={() => copyToClipboard(meeting.detailed_summary, 'detailed')}
className="flex items-center gap-1 text-[10px] text-muted hover:text-accent transition-colors"
>
{detailedCopied ? (
<>
<CheckCheck className="w-3 h-3 text-success" />
<span className="text-success">已复制</span>
</>
) : (
<>
<Copy className="w-3 h-3" />
复制
</>
)}
</button>
</div>
<p className="text-xs text-secondary leading-relaxed whitespace-pre-wrap">
{meeting.detailed_summary}
</p>
</div>
)}
</div>
)}
</div>
)}
{/* 转写内容 */}
{meeting.status === 'completed' && (
<div className="glass-card rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-primary flex items-center gap-2">
<AlignLeft className="w-4 h-4 text-info" />
转写内容
</h3>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted" />
<input
type="text"
placeholder="搜索..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-8 pr-3 py-1.5 glass-input rounded-md text-xs text-primary
placeholder-muted focus:outline-none w-40"
/>
</div>
</div>
<p className="text-[10px] text-muted mb-3">
{filteredSegments.length} / {segments.length}
</p>
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
{filteredSegments.map((seg, i) => (
<div
key={i}
className="bg-white/[0.02] dark:bg-white/[0.02] rounded-lg p-3 border border-white/[0.04] dark:border-white/[0.04]
hover:border-accent/20 transition-colors"
>
<div className="flex items-center justify-between mb-1.5">
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-medium">
<User className="w-2.5 h-2.5" />
说话人 {seg.speaker}
</span>
<span className="text-[10px] text-muted font-mono">
{formatTimestamp(seg.start)}
</span>
</div>
<p className="text-xs text-secondary leading-relaxed">{seg.text}</p>
</div>
))}
</div>
</div>
)}
</div>
)
}