feat: 添加浅色主题支持
- 配置浅色主题配色方案和 CSS 变量 - 修复组件在浅色模式下的样式适配 - 统一文字颜色类名使用 CSS 变量 - 优化玻璃效果在浅色主题下的显示
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user