feat: 添加浅色主题支持

- 配置浅色主题配色方案和 CSS 变量
- 修复组件在浅色模式下的样式适配
- 统一文字颜色类名使用 CSS 变量
- 优化玻璃效果在浅色主题下的显示
This commit is contained in:
freedakgmail
2026-07-09 22:10:49 +08:00
commit b43e3725ee
34 changed files with 7911 additions and 0 deletions
+330
View File
@@ -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>
)
}