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