b43e3725ee
- 配置浅色主题配色方案和 CSS 变量 - 修复组件在浅色模式下的样式适配 - 统一文字颜色类名使用 CSS 变量 - 优化玻璃效果在浅色主题下的显示
330 lines
12 KiB
React
330 lines
12 KiB
React
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>
|
||
)
|
||
} |