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
+21
View File
@@ -0,0 +1,21 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Meetings from './pages/Meetings'
import MeetingDetail from './pages/MeetingDetail'
import Upload from './pages/Upload'
import Settings from './pages/Settings'
export default function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/meetings" element={<Meetings />} />
<Route path="/meetings/:id" element={<MeetingDetail />} />
<Route path="/upload" element={<Upload />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
)
}
+119
View File
@@ -0,0 +1,119 @@
import axios from 'axios'
const API_BASE = '/api'
const api = axios.create({
baseURL: API_BASE,
timeout: 300000, // 5分钟超时
})
// 获取所有会议
export const getMeetings = async () => {
const res = await api.get('/meetings')
return res.data
}
// 获取单个会议
export const getMeeting = async (meetingId) => {
const res = await api.get(`/meetings/${meetingId}`)
return res.data
}
// 上传会议
export const uploadMeeting = (file, title, onProgress) => {
const formData = new FormData()
formData.append('file', file)
formData.append('title', title)
return api.post('/meetings/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress?.(percent)
},
})
}
// 删除会议
export const deleteMeeting = async (meetingId) => {
const res = await api.delete(`/meetings/${meetingId}`)
return res.data
}
// 生成摘要
export const generateSummary = async (meetingId) => {
const res = await api.post(`/meetings/${meetingId}/summarize`)
return res.data
}
// 更新会议标题
export const updateMeetingTitle = async (meetingId, title) => {
const formData = new FormData()
formData.append('title', title)
const res = await api.patch(`/meetings/${meetingId}`, formData)
return res.data
}
// 搜索会议
export const searchMeetings = async (keyword) => {
const res = await api.get('/meetings/search', { params: { keyword } })
return res.data
}
// 下载 Markdown
export const downloadMarkdown = (meeting) => {
let content = `# ${meeting.title}\n\n`
content += `**日期**: ${meeting.date?.slice(0, 10) || '未知'}\n`
content += `**时长**: ${formatDuration(meeting.duration)}\n`
content += `**说话人数**: ${meeting.speaker_count || 0}\n\n`
if (meeting.brief_summary) {
content += `## 📋 会议摘要\n\n${meeting.brief_summary}\n\n`
}
if (meeting.detailed_summary) {
content += `## 📝 详细纪要\n\n${meeting.detailed_summary}\n\n`
}
content += `---\n\n## 🎤 转写内容\n\n`
let currentSpeaker = null
meeting.segments?.forEach(seg => {
if (seg.speaker !== currentSpeaker) {
content += `### 说话人 ${seg.speaker}\n\n`
currentSpeaker = seg.speaker
}
content += `[${formatTimestamp(seg.start)}] ${seg.text}\n\n`
})
content += `---\n\n*本记录由 FunASR 自动生成*`
const blob = new Blob([content], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${meeting.title}.md`
a.click()
URL.revokeObjectURL(url)
}
// 工具函数
export const formatDuration = (seconds = 0) => {
if (seconds < 60) return `${seconds.toFixed(0)}`
if (seconds < 3600) {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}${secs}`
}
const hours = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
return `${hours}小时${mins}`
}
export const formatTimestamp = (seconds = 0) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
export default api
+54
View File
@@ -0,0 +1,54 @@
import clsx from 'clsx'
import { Loader2 } from 'lucide-react'
const variants = {
primary: [
'bg-accent/20 text-accent border-accent/30 hover:bg-accent/30 hover:border-accent/50',
],
secondary: [
'bg-black/5 dark:bg-white/10 text-primary border-black/10 dark:border-white/10 hover:bg-black/10 dark:hover:bg-white/15',
],
ghost: [
'bg-transparent text-secondary border-transparent hover:bg-black/5 dark:hover:bg-white/10 hover:text-primary',
],
danger: [
'bg-error/15 text-error border-error/30 hover:bg-error/25 hover:border-error/50',
],
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
}
export default function Button({
children,
variant = 'primary',
size = 'md',
className,
loading,
disabled,
...props
}) {
const baseClass = variants[variant]?.[0] || variants.primary[0]
return (
<button
className={clsx(
'inline-flex items-center justify-center gap-2 font-medium rounded-lg border transition-all duration-200',
'disabled:opacity-40 disabled:cursor-not-allowed',
baseClass,
sizes[size],
className
)}
disabled={disabled || loading}
{...props}
>
{loading && (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
)}
{children}
</button>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, FileText, Plus, Settings, Mic } from 'lucide-react'
import clsx from 'clsx'
import ThemeToggle from './ThemeToggle'
const navItems = [
{ path: '/', icon: LayoutDashboard, label: '概览' },
{ path: '/meetings', icon: FileText, label: '会议' },
{ path: '/upload', icon: Plus, label: '新建' },
{ path: '/settings', icon: Settings, label: '设置' },
]
export default function Layout({ children }) {
const location = useLocation()
return (
<div className="min-h-screen bg-primary flex">
{/* 侧边栏 */}
<aside className="w-56 flex flex-col h-screen sticky top-0 border-r border-theme">
{/* Logo 区域 */}
<div className="p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent to-accent-muted flex items-center justify-center shadow-glow">
<Mic className="w-4 h-4 text-white" />
</div>
<div>
<h1 className="text-sm font-semibold text-primary tracking-tight">会议记录</h1>
<p className="text-[10px] text-muted">智能转录系统</p>
</div>
</div>
<ThemeToggle />
</div>
</div>
{/* 导航 */}
<nav className="flex-1 px-3 space-y-0.5">
{navItems.map(({ path, icon: Icon, label }) => {
const isActive = location.pathname === path
return (
<Link
key={path}
to={path}
className={clsx(
'flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all duration-200',
isActive
? 'bg-accent/15 text-accent font-medium'
: 'text-secondary hover:text-primary hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Icon className={clsx('w-4 h-4', isActive && 'text-accent')} />
<span>{label}</span>
</Link>
)
})}
</nav>
{/* 底部 */}
<div className="p-4 border-t border-theme">
<div className="text-[10px] text-muted text-center">
FunASR + DeepSeek
</div>
</div>
</aside>
{/* 主内容区 */}
<main className="flex-1 min-h-screen">
<div className="p-8">
{children}
</div>
</main>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import clsx from 'clsx'
import {
Calendar, Clock, Users, FileText, CheckCircle2, Loader2, AlertCircle
} from 'lucide-react'
import { formatDuration } from '../api/meeting'
const statusConfig = {
pending: {
label: '待处理',
class: 'bg-warning/10 text-warning border-warning/20',
icon: AlertCircle
},
processing: {
label: '处理中',
class: 'bg-info/10 text-info border-info/20',
icon: Loader2
},
completed: {
label: '已完成',
class: 'bg-success/10 text-success border-success/20',
icon: CheckCircle2
},
failed: {
label: '失败',
class: 'bg-error/10 text-error border-error/20',
icon: AlertCircle
},
}
export default function MeetingCard({ meeting, onClick }) {
const status = statusConfig[meeting.status] || statusConfig.pending
const StatusIcon = status.icon
const hasSummary = meeting.brief_summary || meeting.detailed_summary
return (
<div
onClick={() => onClick?.(meeting)}
className="glass-card rounded-xl p-4 cursor-pointer
hover:glass-card-hover transition-all duration-300
group"
>
{/* 头部 */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-medium text-primary truncate
group-hover:text-accent transition-colors">
{meeting.title}
</h3>
{hasSummary && meeting.status === 'completed' && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5
rounded bg-accent/10 text-accent mt-1.5">
<FileText className="w-3.5 h-3.5" />
有摘要
</span>
)}
</div>
<span className={clsx(
'inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border',
status.class
)}>
<StatusIcon className="w-3.5 h-3.5" />
{status.label}
</span>
</div>
{/* 元信息 */}
<div className="flex items-center gap-5 text-sm text-muted">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
{meeting.date?.slice(0, 10) || '未知'}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{formatDuration(meeting.duration)}
</span>
<span className="flex items-center gap-1">
<Users className="w-3.5 h-3.5" />
{meeting.speaker_count || 0}
</span>
</div>
{/* 进度条 */}
{meeting.status === 'processing' && (
<div className="mt-3">
<div className="h-0.5 bg-black/5 dark:bg-white/10 rounded-full overflow-hidden">
<div className="h-full bg-accent rounded-full animate-pulse w-1/2" />
</div>
</div>
)}
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import clsx from 'clsx'
import { FileText, CheckCircle2, Loader2, Timer } from 'lucide-react'
import { formatDuration } from '../api/meeting'
const config = {
total: {
icon: FileText,
color: 'text-accent',
bgColor: 'bg-accent/10',
},
completed: {
icon: CheckCircle2,
color: 'text-success',
bgColor: 'bg-success/10',
},
processing: {
icon: Loader2,
color: 'text-warning',
bgColor: 'bg-warning/10',
},
duration: {
icon: Timer,
color: 'text-info',
bgColor: 'bg-info/10',
},
}
export default function StatsCard({ type, value, label }) {
const { icon: Icon, color, bgColor } = config[type] || config.total
return (
<div className="glass-card rounded-xl p-5 hover:glass-card-hover transition-all duration-300">
<div className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center mb-4',
bgColor
)}>
<Icon className={clsx('w-5 h-5', color)} />
</div>
<div className="text-2xl font-semibold text-primary tracking-tight mb-1">
{type === 'duration' ? formatDuration(value) : value}
</div>
<div className="text-sm text-muted">
{label}
</div>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react'
import { Sun, Moon } from 'lucide-react'
import clsx from 'clsx'
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(true)
useEffect(() => {
// 从 localStorage 读取主题
const saved = localStorage.getItem('theme')
if (saved) {
setIsDark(saved === 'dark')
document.documentElement.classList.toggle('light', saved === 'light')
} else {
// 检测系统偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setIsDark(prefersDark)
if (!prefersDark) {
document.documentElement.classList.add('light')
}
}
}, [])
const toggleTheme = () => {
const newIsDark = !isDark
setIsDark(newIsDark)
document.documentElement.classList.toggle('light', !newIsDark)
localStorage.setItem('theme', newIsDark ? 'dark' : 'light')
}
return (
<button
onClick={toggleTheme}
className="p-2 rounded-lg transition-all duration-200 hover:bg-white/10 text-secondary hover:text-primary"
title={isDark ? '切换到浅色模式' : '切换到深色模式'}
>
{isDark ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</button>
)
}
+31
View File
@@ -0,0 +1,31 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Meetings from './pages/Meetings'
import MeetingDetail from './pages/MeetingDetail'
import Upload from './pages/Upload'
import Settings from './pages/Settings'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/meetings" element={<Meetings />} />
<Route path="/meetings/:id" element={<MeetingDetail />} />
<Route path="/upload" element={<Upload />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
</BrowserRouter>
</React.StrictMode>,
)
+91
View File
@@ -0,0 +1,91 @@
import { useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { ArrowRight, Plus, FileText } from 'lucide-react'
import { useAppStore } from '../store/useAppStore'
import StatsCard from '../components/StatsCard'
import MeetingCard from '../components/MeetingCard'
import Button from '../components/Button'
export default function Dashboard() {
const navigate = useNavigate()
const { meetings, stats, loading, refreshMeetings } = useAppStore()
useEffect(() => {
refreshMeetings()
}, [])
const recentMeetings = meetings.slice(0, 4)
const handleCardClick = (meeting) => {
navigate(`/meetings/${meeting.meeting_id}`)
}
return (
<div className="animate-in">
{/* 页面标题 */}
<div className="flex items-center justify-between mb-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold text-primary tracking-tight">仪表盘</h1>
<p className="text-sm text-muted mt-1">会议记录概览</p>
</div>
<Link to="/upload">
<Button size="sm">
<Plus className="w-3.5 h-3.5" />
新建
</Button>
</Link>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
<StatsCard type="total" value={stats.total} label="总会议数" />
<StatsCard type="completed" value={stats.completed} label="已完成" />
<StatsCard type="processing" value={stats.processing} label="处理中" />
<StatsCard type="duration" value={stats.totalDuration} label="总时长" />
</div>
{/* 最近会议 */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium text-secondary flex items-center gap-2">
<FileText className="w-4 h-4 text-accent" />
最近会议
</h2>
<Link
to="/meetings"
className="flex items-center gap-1 text-xs text-accent hover:text-accent-hover transition-colors"
>
查看全部
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 rounded-xl skeleton" />
))}
</div>
) : recentMeetings.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{recentMeetings.map(meeting => (
<MeetingCard key={meeting.meeting_id} meeting={meeting} onClick={handleCardClick} />
))}
</div>
) : (
<div className="glass-card rounded-xl p-8 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-white/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
<FileText className="w-5 h-5 text-muted" />
</div>
<h3 className="text-sm font-medium text-primary mb-1">暂无会议记录</h3>
<p className="text-xs text-muted mb-4">开始您的第一次会议转录</p>
<Link to="/upload">
<Button size="sm">
<Plus className="w-3.5 h-3.5" />
新建会议
</Button>
</Link>
</div>
)}
</div>
)
}
+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>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, FileText } from 'lucide-react'
import { useAppStore } from '../store/useAppStore'
import MeetingCard from '../components/MeetingCard'
import Button from '../components/Button'
export default function Meetings() {
const navigate = useNavigate()
const { meetings, loading, refreshMeetings } = useAppStore()
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
useEffect(() => {
refreshMeetings()
}, [])
const filteredMeetings = meetings.filter(m => {
const matchSearch = m.title.toLowerCase().includes(search.toLowerCase())
const matchStatus = statusFilter === 'all' || m.status === statusFilter
return matchSearch && matchStatus
})
return (
<div className="animate-in">
{/* 页面标题 */}
<div className="mb-6">
<h1 className="text-2xl font-semibold text-primary tracking-tight">会议列表</h1>
<p className="text-sm text-muted mt-1">管理所有会议记录</p>
</div>
{/* 搜索和筛选 */}
<div className="flex flex-col sm:flex-row gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
type="text"
placeholder="搜索会议..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 glass-input rounded-lg
text-sm text-primary placeholder-muted focus:outline-none"
/>
</div>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="px-3 py-2 glass-input rounded-lg text-sm text-primary focus:outline-none cursor-pointer"
>
<option value="all">全部</option>
<option value="pending">待处理</option>
<option value="processing">处理中</option>
<option value="completed">已完成</option>
<option value="failed">失败</option>
</select>
<Button
onClick={refreshMeetings}
variant="secondary"
size="md"
className="!p-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 结果统计 */}
<p className="text-xs text-muted mb-3">
{filteredMeetings.length} 个会议
</p>
{/* 会议列表 */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 rounded-xl skeleton" />
))}
</div>
) : filteredMeetings.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filteredMeetings.map(meeting => (
<MeetingCard
key={meeting.meeting_id}
meeting={meeting}
onClick={() => navigate(`/meetings/${meeting.meeting_id}`)}
/>
))}
</div>
) : (
<div className="glass-card rounded-xl p-8 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-white/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
<Search className="w-5 h-5 text-muted" />
</div>
<h3 className="text-sm font-medium text-primary mb-1">未找到匹配的会议</h3>
<p className="text-xs text-muted">尝试调整搜索条件</p>
</div>
)}
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { Cpu, Database, Info, Monitor } from 'lucide-react'
export default function Settings() {
return (
<div className="animate-in max-w-xl">
{/* 页面标题 */}
<div className="mb-6">
<h1 className="text-2xl font-semibold text-primary tracking-tight">系统设置</h1>
<p className="text-sm text-muted mt-1">系统信息与配置</p>
</div>
{/* 运行环境 */}
<div className="glass-card rounded-xl p-4 mb-3">
<h2 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
<Cpu className="w-4 h-4 text-accent" />
运行环境
</h2>
<div className="space-y-0">
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
<span className="text-xs text-muted flex items-center gap-2">
<Monitor className="w-3.5 h-3.5" />
计算设备
</span>
<span className="text-xs text-primary">CPU</span>
</div>
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
<span className="text-xs text-muted">ASR 模型</span>
<span className="text-xs text-primary">SenseVoiceSmall</span>
</div>
<div className="flex items-center justify-between py-2.5">
<span className="text-xs text-muted">FunASR 版本</span>
<span className="text-xs text-primary">1.3.14</span>
</div>
</div>
</div>
{/* 存储信息 */}
<div className="glass-card rounded-xl p-4 mb-3">
<h2 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
<Database className="w-4 h-4 text-success" />
存储信息
</h2>
<div className="space-y-0">
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
<span className="text-xs text-muted">数据库类型</span>
<span className="text-xs text-primary">SQLite</span>
</div>
<div className="flex items-center justify-between py-2.5">
<span className="text-xs text-muted">数据库路径</span>
<span className="text-[10px] text-secondary font-mono">data/meetings.db</span>
</div>
</div>
</div>
{/* 关于 */}
<div className="glass-card rounded-xl p-4">
<h2 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
<Info className="w-4 h-4 text-info" />
关于
</h2>
<div className="space-y-0">
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
<span className="text-xs text-muted">版本</span>
<span className="text-xs text-primary">v1.0.0</span>
</div>
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
<span className="text-xs text-muted">前端框架</span>
<span className="text-xs text-primary">React 18 + Vite</span>
</div>
<div className="flex items-center justify-between py-2.5">
<span className="text-xs text-muted">技术栈</span>
<span className="text-xs text-primary">FunASR + DeepSeek</span>
</div>
</div>
</div>
</div>
)
}
+535
View File
@@ -0,0 +1,535 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Upload, FileAudio, Mic, X, Square, Circle, Zap, FileText } from 'lucide-react'
import { clsx } from 'clsx'
import { useAppStore } from '../store/useAppStore'
import Button from '../components/Button'
import { formatDuration, formatTimestamp } from '../api/meeting'
const RECORD_MODES = {
OFFLINE: 'offline',
REALTIME: 'realtime'
}
export default function UploadPage() {
const navigate = useNavigate()
const { uploadMeeting, pollMeetingStatus, refreshMeetings } = useAppStore()
const fileInputRef = useRef()
const [file, setFile] = useState(null)
const [title, setTitle] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [transcribing, setTranscribing] = useState(false)
const [transcribeProgress, setTranscribeProgress] = useState(0)
const [dragOver, setDragOver] = useState(false)
// 录音相关
const [recordMode, setRecordMode] = useState(RECORD_MODES.OFFLINE)
const [isRecording, setIsRecording] = useState(false)
const [recordingTime, setRecordingTime] = useState(0)
const [audioLevel, setAudioLevel] = useState(0)
const [showRecorder, setShowRecorder] = useState(false)
const [recordTitle, setRecordTitle] = useState('')
// 实时转写
const [realtimeSegments, setRealtimeSegments] = useState([])
const [wsConnected, setWsConnected] = useState(false)
const [savedMeetingId, setSavedMeetingId] = useState(null)
const mediaRecorderRef = useRef(null)
const audioContextRef = useRef(null)
const analyserRef = useRef(null)
const timerRef = useRef(null)
const canvasRef = useRef(null)
const streamRef = useRef(null)
const animationRef = useRef(null)
const wsRef = useRef(null)
const handleFileSelect = (e) => {
const selected = e.target.files?.[0]
if (selected) {
setFile(selected)
if (!title) {
setTitle(selected.name.replace(/\.[^/.]+$/, ''))
}
}
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
const dropped = e.dataTransfer.files?.[0]
if (dropped && dropped.type.startsWith('audio/')) {
setFile(dropped)
if (!title) {
setTitle(dropped.name.replace(/\.[^/.]+$/, ''))
}
}
}
const handleUpload = async () => {
if (!file || !title) return
setUploading(true)
setTranscribing(false)
setUploadProgress(0)
setTranscribeProgress(0)
try {
const result = await uploadMeeting(file, title, (progress) => {
setUploadProgress(progress)
})
setUploading(false)
setTranscribing(true)
setTranscribeProgress(10)
await pollMeetingStatus(result.meeting_id, (status) => {
if (status === 'processing') {
setTranscribeProgress(prev => Math.min(prev + 5, 90))
}
})
setTranscribeProgress(100)
await refreshMeetings()
navigate('/meetings')
} catch (err) {
alert('处理失败: ' + err.message)
setUploading(false)
setTranscribing(false)
}
}
const clearFile = () => {
setFile(null)
setTitle('')
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const connectWebSocket = () => {
const sessionId = `realtime_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const ws = new WebSocket(`ws://localhost:8501/ws/realtime/${sessionId}`)
ws.onopen = () => {
setWsConnected(true)
const titleMsg = new Uint8Array(8 + title.length)
titleMsg.set(new TextEncoder().encode('TYPE'), 0)
titleMsg.set(new TextEncoder().encode('TITLE'), 4)
titleMsg.set(new TextEncoder().encode(title || '实时录音'), 8)
ws.send(titleMsg)
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'segment') {
setRealtimeSegments(prev => [...prev, {
start: data.start,
end: data.end,
speaker: data.speaker,
text: data.text
}])
} else if (data.type === 'saved') {
setSavedMeetingId(data.meeting_id)
}
}
ws.onclose = () => setWsConnected(false)
wsRef.current = ws
}
const startRealtimeRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true }
})
streamRef.current = stream
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)()
const source = audioContextRef.current.createMediaStreamSource(stream)
analyserRef.current = audioContextRef.current.createAnalyser()
analyserRef.current.fftSize = 256
source.connect(analyserRef.current)
visualizeAudio()
connectWebSocket()
setIsRecording(true)
setRecordingTime(0)
setRealtimeSegments([])
timerRef.current = setInterval(() => setRecordingTime(prev => prev + 1), 1000)
} catch (err) {
alert('无法访问麦克风')
}
}
const startOfflineRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)()
const source = audioContextRef.current.createMediaStreamSource(stream)
analyserRef.current = audioContextRef.current.createAnalyser()
analyserRef.current.fftSize = 256
source.connect(analyserRef.current)
visualizeAudio()
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
const chunks = []
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/webm' })
const audioFile = new File([blob], `recording_${Date.now()}.webm`, { type: 'audio/webm' })
setFile(audioFile)
if (!recordTitle) {
setRecordTitle(`录音_${new Date().toLocaleString()}`)
}
}
mediaRecorder.start(1000)
setIsRecording(true)
setRecordingTime(0)
timerRef.current = setInterval(() => setRecordingTime(prev => prev + 1), 1000)
} catch (err) {
alert('无法访问麦克风')
}
}
const visualizeAudio = () => {
const analyser = analyserRef.current
const canvas = canvasRef.current
if (!analyser || !canvas) return
const ctx = canvas.getContext('2d')
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
const draw = () => {
if (!isRecording) return
animationRef.current = requestAnimationFrame(draw)
analyser.getByteFrequencyData(dataArray)
const average = dataArray.reduce((a, b) => a + b, 0) / bufferLength
setAudioLevel(average / 255)
ctx.fillStyle = 'rgba(9, 9, 11, 0.3)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const barWidth = (canvas.width / bufferLength) * 2.5
let x = 0
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height
ctx.fillStyle = `rgba(129, 140, 248, ${0.5 + dataArray[i] / 512})`
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight)
x += barWidth + 1
}
}
draw()
}
const stopRecording = () => {
if (recordMode === RECORD_MODES.REALTIME) {
if (wsRef.current?.readyState === WebSocket.OPEN) {
const endMsg = new Uint8Array(12)
endMsg.set(new TextEncoder().encode('TYPE'), 0)
endMsg.set(new TextEncoder().encode('END___'), 4)
wsRef.current.send(endMsg)
}
} else {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop()
}
}
setIsRecording(false)
clearInterval(timerRef.current)
cancelAnimationFrame(animationRef.current)
streamRef.current?.getTracks().forEach(track => track.stop())
audioContextRef.current?.close()
setAudioLevel(0)
}
const cancelRecording = () => {
wsRef.current?.close()
stopRecording()
setRecordingTime(0)
setRealtimeSegments([])
}
const handleSaveRecording = () => {
setTitle(recordTitle)
setShowRecorder(false)
}
useEffect(() => {
return () => {
clearInterval(timerRef.current)
cancelAnimationFrame(animationRef.current)
streamRef.current?.getTracks().forEach(track => track.stop())
wsRef.current?.close()
}
}, [])
return (
<div className="animate-in max-w-2xl">
{/* 页面标题 */}
<div className="mb-6">
<h1 className="text-2xl font-semibold text-primary tracking-tight">新建会议</h1>
<p className="text-sm text-muted mt-1">上传音频或开始录音</p>
</div>
{/* 上传区域 */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={clsx(
'glass-card rounded-xl p-8 text-center cursor-pointer mb-4 transition-all duration-200',
dragOver && 'glass-card-hover border-accent/30'
)}
>
<input ref={fileInputRef} type="file" accept="audio/*" onChange={handleFileSelect} className="hidden" />
<div className="flex flex-col items-center">
<div className={clsx(
'w-12 h-12 rounded-xl flex items-center justify-center mb-4 transition-colors',
dragOver ? 'bg-accent/20' : 'bg-white/[0.04] dark:bg-white/[0.04]'
)}>
<Upload className={clsx('w-5 h-5', dragOver ? 'text-accent' : 'text-muted')} />
</div>
<h3 className="text-sm font-medium text-primary mb-1">
{dragOver ? '释放以上传' : '拖拽音频文件或点击上传'}
</h3>
<p className="text-xs text-muted">WAV, MP3, M4A, FLAC, OGG</p>
</div>
</div>
{/* 已选文件 */}
{file && (
<div className="glass-card rounded-xl p-4 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
<FileAudio className="w-5 h-5 text-accent" />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-primary truncate">{file.name}</h4>
<p className="text-xs text-muted">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
<button onClick={clearFile} className="p-1.5 text-muted hover:text-error transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<audio controls className="w-full mt-3" src={URL.createObjectURL(file)} />
<div className="mt-3">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="会议标题"
className="w-full px-3 py-2 glass-input rounded-lg text-sm text-primary
placeholder-muted focus:outline-none"
/>
</div>
{uploading ? (
<div className="mt-4">
<div className="flex justify-between text-xs text-muted mb-1">
<span>上传中...</span>
<span className="text-accent">{uploadProgress}%</span>
</div>
<div className="h-1 bg-white/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
) : transcribing ? (
<div className="mt-4">
<div className="flex justify-between text-xs text-muted mb-1">
<span>转录中...</span>
<span className="text-success">{transcribeProgress}%</span>
</div>
<div className="h-1 bg-white/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div className="h-full bg-success rounded-full animate-pulse" style={{ width: `${transcribeProgress}%` }} />
</div>
</div>
) : (
<Button onClick={handleUpload} disabled={!file || !title} className="w-full mt-4">
<Upload className="w-4 h-4" />
开始转录
</Button>
)}
</div>
)}
{/* 录音功能 */}
<div className={clsx(
'glass-card rounded-xl p-4 transition-all duration-300',
showRecorder && 'border-accent/30'
)}>
{showRecorder ? (
<div>
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium text-primary flex items-center gap-2">
<Mic className="w-4 h-4 text-accent" />
录音模式
</h4>
<button onClick={() => { cancelRecording(); setShowRecorder(false); }} className="p-1 text-muted hover:text-primary">
<X className="w-4 h-4" />
</button>
</div>
{!isRecording && realtimeSegments.length === 0 && (
<div className="flex gap-2 mb-4">
<button
onClick={() => setRecordMode(RECORD_MODES.OFFLINE)}
className={clsx(
'flex-1 p-3 rounded-lg border text-xs font-medium transition-all',
recordMode === RECORD_MODES.OFFLINE
? 'bg-accent/15 text-accent border-accent/30'
: 'bg-white/[0.04] dark:bg-white/[0.04] text-secondary border-white/[0.08] dark:border-white/[0.08] hover:bg-white/[0.06] dark:hover:bg-white/[0.06]'
)}
>
<FileAudio className="w-4 h-4 mx-auto mb-1" />
录音转写
</button>
<button
onClick={() => setRecordMode(RECORD_MODES.REALTIME)}
className={clsx(
'flex-1 p-3 rounded-lg border text-xs font-medium transition-all',
recordMode === RECORD_MODES.REALTIME
? 'bg-success/15 text-success border-success/30'
: 'bg-white/[0.04] dark:bg-white/[0.04] text-secondary border-white/[0.08] dark:border-white/[0.08] hover:bg-white/[0.06] dark:hover:bg-white/[0.06]'
)}
>
<Zap className="w-4 h-4 mx-auto mb-1" />
实时转写
</button>
</div>
)}
<canvas ref={canvasRef} className="w-full h-14 bg-white/[0.02] dark:bg-white/[0.02] rounded-lg mb-4" />
<div className="text-center mb-4">
<div className={clsx(
'text-3xl font-mono font-semibold mb-1 tracking-tight',
isRecording ? 'text-error' : 'text-primary'
)}>
{formatDuration(recordingTime)}
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted">
{isRecording && (
<span className="flex items-center gap-1 text-error">
<span className="w-1.5 h-1.5 bg-error rounded-full animate-pulse" />
{recordMode === RECORD_MODES.REALTIME ? '实时转写中' : '录音中'}
</span>
)}
{recordMode === RECORD_MODES.REALTIME && wsConnected && !isRecording && (
<span className="text-success">已保存</span>
)}
</div>
</div>
<div className="flex justify-center gap-2">
{isRecording ? (
<>
<Button onClick={stopRecording} variant="danger" size="sm">
<Square className="w-3.5 h-3.5" />
停止
</Button>
<Button onClick={cancelRecording} variant="ghost" size="sm">取消</Button>
</>
) : (
<Button onClick={recordMode === RECORD_MODES.REALTIME ? startRealtimeRecording : startOfflineRecording}>
<Circle className="w-3.5 h-3.5" />
开始录音
</Button>
)}
</div>
{recordMode === RECORD_MODES.REALTIME && realtimeSegments.length > 0 && (
<div className="mt-4 pt-4 border-t border-white/[0.06] dark:border-white/[0.06]">
<div className="flex items-center justify-between mb-3">
<h5 className="text-xs font-medium text-secondary flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-success" />
实时转写
</h5>
<span className="text-[10px] text-muted">{realtimeSegments.length} </span>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto">
{realtimeSegments.map((seg, i) => (
<div key={i} className="bg-white/[0.02] dark:bg-white/[0.02] rounded-lg p-2.5">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-accent">说话人 {seg.speaker}</span>
<span className="text-[10px] text-muted font-mono">{formatTimestamp(seg.start)}</span>
</div>
<p className="text-xs text-secondary">{seg.text}</p>
</div>
))}
</div>
</div>
)}
{recordMode === RECORD_MODES.OFFLINE && file && !isRecording && (
<div className="mt-4 pt-4 border-t border-white/[0.06] dark:border-white/[0.06]">
<p className="text-xs text-muted mb-2">录音预览</p>
<audio controls className="w-full mb-3" src={URL.createObjectURL(file)} />
<input
type="text"
value={recordTitle}
onChange={(e) => setRecordTitle(e.target.value)}
placeholder="录音标题"
className="w-full px-3 py-2 glass-input rounded-lg text-sm text-primary
placeholder-muted focus:outline-none mb-3"
/>
<div className="flex gap-2">
<Button onClick={handleSaveRecording} disabled={!recordTitle.trim()} className="flex-1">
使用此录音
</Button>
<Button onClick={() => { setFile(null); setRecordTitle(''); }} variant="ghost" size="sm">
重录
</Button>
</div>
</div>
)}
{recordMode === RECORD_MODES.REALTIME && savedMeetingId && !isRecording && (
<div className="mt-4 pt-4 border-t border-white/[0.06] dark:border-white/[0.06]">
<div className="flex items-center gap-2 text-success text-xs mb-3">
<FileText className="w-4 h-4" />
录音已保存 {realtimeSegments.length} 条转写
</div>
<Button onClick={() => navigate(`/meetings/${savedMeetingId}`)} className="w-full">
查看会议
</Button>
</div>
)}
</div>
) : (
<div
onClick={() => setShowRecorder(true)}
className="flex items-center gap-3 cursor-pointer hover:bg-white/[0.03] dark:hover:bg-white/[0.03] rounded-lg p-2 -m-2 transition-colors"
>
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
<Mic className="w-5 h-5 text-accent" />
</div>
<div>
<h4 className="text-sm font-medium text-primary">录音转写</h4>
<p className="text-xs text-muted">上传音频或开始录音</p>
</div>
</div>
)}
</div>
</div>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { create } from 'zustand'
export const useAppStore = create((set, get) => ({
// 会议列表
meetings: [],
setMeetings: (meetings) => set({ meetings }),
// 当前选中的会议
currentMeeting: null,
setCurrentMeeting: (meeting) => set({ currentMeeting: meeting }),
// 加载状态
loading: false,
setLoading: (loading) => set({ loading }),
// 错误信息
error: null,
setError: (error) => set({ error }),
// 上传进度
uploadProgress: 0,
setUploadProgress: (progress) => set({ uploadProgress: progress }),
// 转录状态
isTranscribing: false,
setTranscribing: (val) => set({ isTranscribing: val }),
// 统计数据
stats: {
total: 0,
completed: 0,
processing: 0,
totalDuration: 0,
},
setStats: (stats) => set({ stats }),
// 刷新会议列表
refreshMeetings: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/meetings')
const data = await res.json()
set({
meetings: data.meetings || [],
stats: {
total: data.meetings?.length || 0,
completed: data.meetings?.filter(m => m.status === 'completed').length || 0,
processing: data.meetings?.filter(m => m.status === 'processing').length || 0,
totalDuration: data.meetings?.reduce((acc, m) => acc + (m.duration || 0), 0) || 0,
},
loading: false
})
} catch (err) {
set({ error: err.message, loading: false })
}
},
// 获取单个会议详情
fetchMeetingDetail: async (meetingId) => {
set({ loading: true, error: null })
try {
const res = await fetch(`/api/meetings/${meetingId}`)
const data = await res.json()
set({ currentMeeting: data, loading: false })
return data
} catch (err) {
set({ error: err.message, loading: false })
return null
}
},
// 上传并处理会议
uploadMeeting: async (file, title, onProgress) => {
set({ loading: true, error: null, uploadProgress: 0, isTranscribing: false })
try {
const formData = new FormData()
formData.append('file', file)
formData.append('title', title)
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100)
set({ uploadProgress: progress })
onProgress?.(progress)
}
})
return new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText)
set({ uploadProgress: 100, loading: false, isTranscribing: true })
resolve({ ...result, needsPolling: true })
} else {
set({ loading: false, isTranscribing: false })
reject(new Error(xhr.statusText))
}
}
xhr.onerror = () => {
set({ loading: false, isTranscribing: false })
reject(new Error('Network error'))
}
xhr.open('POST', '/api/meetings/upload')
xhr.send(formData)
})
} catch (err) {
set({ error: err.message, loading: false, isTranscribing: false })
throw err
}
},
// 轮询会议状态直到完成
pollMeetingStatus: async (meetingId, onStatusChange) => {
const maxAttempts = 60 // 最多轮询60次(约2分钟)
let attempts = 0
const poll = async () => {
if (attempts >= maxAttempts) {
throw new Error('转录超时')
}
attempts++
try {
const res = await fetch(`/api/meetings/${meetingId}`)
const data = await res.json()
onStatusChange?.(data.status)
if (data.status === 'completed' || data.status === 'failed') {
return data
}
// 继续轮询
await new Promise(r => setTimeout(r, 2000)) // 每2秒轮询一次
return poll()
} catch (err) {
throw err
}
}
return poll()
},
}))
+226
View File
@@ -0,0 +1,226 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 主题变量 */
:root {
--bg-primary: #09090b;
--bg-secondary: #18181b;
--bg-tertiary: #27272a;
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--border-color: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.15);
--accent: #818cf8;
--accent-muted: #6366f1;
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-bg-hover: rgba(255, 255, 255, 0.06);
--glass-border: rgba(255, 255, 255, 0.08);
}
/* 浅色主题 */
.light {
--bg-primary: #ffffff;
--bg-secondary: #f4f4f5;
--bg-tertiary: #e4e4e7;
--text-primary: #09090b;
--text-secondary: #52525b;
--text-muted: #a1a1aa;
--border-color: rgba(0, 0, 0, 0.08);
--border-hover: rgba(0, 0, 0, 0.15);
--glass-bg: rgba(0, 0, 0, 0.02);
--glass-bg-hover: rgba(0, 0, 0, 0.04);
--glass-border: rgba(0, 0, 0, 0.06);
}
/* 基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Helvetica Neue', 'Pretendard', 'Noto Sans SC', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
background: var(--bg-primary);
color: var(--text-primary);
overflow-x: hidden;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 文字选择 */
::selection {
background: rgba(129, 140, 248, 0.3);
color: var(--text-primary);
}
/* 滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* 焦点样式 */
*:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.5);
}
/* 玻璃效果 */
@layer utilities {
.glass {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
box-shadow: 0 4px 24px -1px rgba(0, 0, 0, 0.1);
}
.glass-hover {
background: var(--glass-bg-hover);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-hover);
box-shadow: 0 8px 32px -1px rgba(0, 0, 0, 0.15);
}
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
box-shadow: 0 4px 24px -1px rgba(0, 0, 0, 0.08);
border-radius: 16px;
}
.glass-card-hover {
background: var(--glass-bg-hover);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-hover);
box-shadow: 0 8px 32px -1px rgba(0, 0, 0, 0.12);
}
.glass-button {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
box-shadow: 0 2px 12px -1px rgba(0, 0, 0, 0.1);
}
.glass-button-hover {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.3);
box-shadow: 0 4px 16px -1px rgba(129, 140, 248, 0.2);
}
.glass-input {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.glass-input:focus {
background: var(--glass-bg-hover);
border-color: rgba(129, 140, 248, 0.5);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 0 0 3px rgba(129, 140, 248, 0.1);
}
/* 深色主题下内阴影使用白色 */
.dark .glass-input {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.dark .glass-input:focus {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 0 0 3px rgba(129, 140, 248, 0.1);
}
/* 文字颜色 */
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-muted { color: var(--text-muted); }
.text-accent { color: var(--accent); }
.bg-primary { background-color: var(--bg-primary); }
.bg-secondary { background-color: var(--bg-secondary); }
.bg-tertiary { background-color: var(--bg-tertiary); }
.border-theme { border-color: var(--border-color); }
}
/* 动画 */
@layer utilities {
.animate-in {
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
}
/* 加载骨架屏 */
.skeleton {
background: linear-gradient(
90deg,
var(--glass-bg) 25%,
var(--glass-bg-hover) 50%,
var(--glass-bg) 75%
);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 音频播放器 */
audio {
width: 100%;
height: 36px;
border-radius: 8px;
}
audio::-webkit-media-controls-panel {
background: var(--bg-secondary);
}
/* 过渡 */
.transition-fast {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.transition-base {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.transition-smooth {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}