feat(ui): 新增设置相关 UI 组件

- FontSizePicker: 字体大小选择器,支持实时预览
- ModelCardSelector: 模型卡片选择组件(Haiku/Sonnet/Opus)
- UserMenu: 用户菜单弹出组件,支持主题切换和设置导航
This commit is contained in:
gaoziman 2025-12-19 13:56:22 +08:00
parent 2de8cd64e3
commit 29b2d99a82
3 changed files with 429 additions and 0 deletions

View File

@ -0,0 +1,138 @@
'use client';
import { useState, useEffect } from 'react';
import { Minus, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
interface FontSizePickerProps {
value: number;
onChange: (size: number) => void;
min?: number;
max?: number;
disabled?: boolean;
}
export function FontSizePicker({
value,
onChange,
min = 12,
max = 20,
disabled = false,
}: FontSizePickerProps) {
const [previewVisible, setPreviewVisible] = useState(false);
const [localValue, setLocalValue] = useState(value);
// 同步外部值
useEffect(() => {
setLocalValue(value);
}, [value]);
// 实时预览 - 修改 CSS 变量
useEffect(() => {
document.documentElement.style.setProperty('--font-size-base', `${localValue}px`);
}, [localValue]);
const handleDecrease = () => {
if (localValue > min && !disabled) {
const newValue = localValue - 1;
setLocalValue(newValue);
onChange(newValue);
}
};
const handleIncrease = () => {
if (localValue < max && !disabled) {
const newValue = localValue + 1;
setLocalValue(newValue);
onChange(newValue);
}
};
const togglePreview = () => {
setPreviewVisible(!previewVisible);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
{/* 减号按钮 */}
<button
onClick={handleDecrease}
disabled={disabled || localValue <= min}
className={cn(
'w-12 h-12 flex items-center justify-center',
'rounded-md border-2 border-[var(--color-border)]',
'bg-[var(--color-bg-primary)]',
'text-[var(--color-text-secondary)]',
'hover:border-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2',
(disabled || localValue <= min) && 'opacity-50 cursor-not-allowed'
)}
aria-label="减小字体"
>
<Minus size={20} strokeWidth={2.5} />
</button>
{/* 数字显示 */}
<div className="flex flex-col items-center min-w-[60px]">
<span className="text-3xl font-semibold text-[var(--color-text-primary)]">
{localValue}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">px</span>
</div>
{/* 加号按钮 */}
<button
onClick={handleIncrease}
disabled={disabled || localValue >= max}
className={cn(
'w-12 h-12 flex items-center justify-center',
'rounded-md border-2 border-[var(--color-border)]',
'bg-[var(--color-bg-primary)]',
'text-[var(--color-text-secondary)]',
'hover:border-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2',
(disabled || localValue >= max) && 'opacity-50 cursor-not-allowed'
)}
aria-label="增大字体"
>
<Plus size={20} strokeWidth={2.5} />
</button>
{/* Preview 按钮 */}
<button
onClick={togglePreview}
className={cn(
'px-4 h-12 rounded-md',
'bg-[var(--color-bg-tertiary)]',
'text-sm font-medium text-[var(--color-text-secondary)]',
'hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2',
previewVisible && 'bg-[var(--color-primary-light)] text-[var(--color-primary)]'
)}
>
Preview
</button>
</div>
{/* 预览区域 */}
{previewVisible && (
<div
className="p-4 rounded-lg bg-[var(--color-bg-tertiary)] border border-[var(--color-border-light)] animate-fade-in"
>
<p
className="text-[var(--color-text-primary)] leading-relaxed"
style={{ fontSize: `${localValue}px` }}
>
<br />
The quick brown fox jumps over the lazy dog.
</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,108 @@
'use client';
import { cn } from '@/lib/utils';
// 模型卡片配置
const MODEL_CARDS = [
{
id: 'haiku',
name: 'Haiku',
description: 'Fast & efficient',
modelIdPattern: 'haiku',
},
{
id: 'sonnet',
name: 'Sonnet',
description: 'Balanced',
modelIdPattern: 'sonnet',
},
{
id: 'opus',
name: 'Opus',
description: 'Most capable',
modelIdPattern: 'opus',
},
];
interface ModelCardSelectorProps {
value: string;
onChange: (modelId: string) => void;
disabled?: boolean;
models?: { modelId: string; displayName: string }[];
}
export function ModelCardSelector({
value,
onChange,
disabled = false,
models = [],
}: ModelCardSelectorProps) {
// 根据当前选中的模型ID判断选中的卡片
const getSelectedCard = (modelId: string): string => {
const lowerModelId = modelId.toLowerCase();
if (lowerModelId.includes('haiku')) return 'haiku';
if (lowerModelId.includes('opus')) return 'opus';
return 'sonnet'; // 默认 sonnet
};
// 根据卡片类型找到对应的实际模型ID
const findModelIdByCard = (cardId: string): string => {
const model = models.find((m) =>
m.modelId.toLowerCase().includes(cardId)
);
return model?.modelId || value;
};
const selectedCard = getSelectedCard(value);
return (
<div className="flex gap-3">
{MODEL_CARDS.map((card) => {
const isSelected = selectedCard === card.id;
return (
<button
key={card.id}
onClick={() => {
if (!disabled) {
const newModelId = findModelIdByCard(card.id);
onChange(newModelId);
}
}}
disabled={disabled}
className={cn(
'flex flex-col items-center justify-center',
'w-[120px] h-[72px] rounded-md',
'border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--color-primary)]',
isSelected
? 'bg-[var(--color-primary-light)] border-[var(--color-primary)]'
: 'bg-[var(--color-bg-tertiary)] border-transparent hover:border-[var(--color-border)]',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<span
className={cn(
'text-sm font-semibold',
isSelected
? 'text-[var(--color-primary)]'
: 'text-[var(--color-text-primary)]'
)}
>
{card.name}
</span>
<span
className={cn(
'text-xs mt-1',
isSelected
? 'text-[var(--color-primary)]'
: 'text-[var(--color-text-tertiary)]'
)}
>
{card.description}
</span>
</button>
);
})}
</div>
);
}

View File

@ -0,0 +1,183 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Moon, Sun, Settings, LogOut, ChevronUp, ChevronDown } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import { useSettings } from '@/hooks/useSettings';
import { cn } from '@/lib/utils';
import type { User } from '@/types';
interface UserMenuProps {
user: User;
}
export function UserMenu({ user }: UserMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { settings, updateSettings } = useSettings();
// 点击外部关闭菜单
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// ESC 键关闭菜单
useEffect(() => {
function handleEscKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('keydown', handleEscKey);
}
return () => {
document.removeEventListener('keydown', handleEscKey);
};
}, [isOpen]);
// 切换主题
const handleToggleTheme = async () => {
const newTheme = settings.theme === 'dark' ? 'light' : 'dark';
try {
await updateSettings({ theme: newTheme });
// 可选:更新 document 的 class 来应用主题
document.documentElement.setAttribute('data-theme', newTheme);
} catch (error) {
console.error('Failed to update theme:', error);
}
};
// 进入设置页面
const handleGoToSettings = () => {
setIsOpen(false);
router.push('/settings');
};
// 登出
const handleLogout = () => {
setIsOpen(false);
// TODO: 实现实际的登出逻辑
// 暂时跳转到首页
console.log('Logging out...');
router.push('/');
};
const isDarkMode = settings.theme === 'dark';
return (
<div ref={menuRef} className="relative">
{/* 触发器 - 用户信息区域 */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center gap-3 w-full p-2 rounded-lg cursor-pointer transition-colors',
'hover:bg-[var(--color-bg-hover)]',
isOpen && 'bg-[var(--color-bg-hover)]'
)}
aria-expanded={isOpen}
aria-haspopup="true"
>
<Avatar name={user.name} size="md" />
<div className="flex-1 min-w-0 text-left">
<div className="text-sm text-[var(--color-text-primary)] truncate">
{user.email}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] capitalize">
{user.plan} plan
</div>
</div>
{isOpen ? (
<ChevronUp size={16} className="text-[var(--color-text-tertiary)] flex-shrink-0" />
) : (
<ChevronDown size={16} className="text-[var(--color-text-tertiary)] flex-shrink-0" />
)}
</button>
{/* 弹出菜单 - 向上弹出 */}
{isOpen && (
<div
className={cn(
'absolute bottom-full left-0 right-0 mb-2',
'bg-[var(--color-bg-primary)] rounded-xl',
'border border-[var(--color-border-light)]',
'shadow-[var(--shadow-dropdown)]',
'overflow-hidden',
'animate-pop-up',
'z-50'
)}
role="menu"
aria-orientation="vertical"
>
{/* 菜单项 */}
<div className="py-1">
{/* Dark mode 切换 */}
<button
onClick={handleToggleTheme}
className={cn(
'flex items-center gap-3 w-full px-4 py-3',
'text-sm text-[var(--color-text-primary)]',
'hover:bg-[var(--color-bg-hover)] transition-colors'
)}
role="menuitem"
>
{isDarkMode ? (
<Sun size={18} className="text-[var(--color-text-secondary)]" />
) : (
<Moon size={18} className="text-[var(--color-text-secondary)]" />
)}
<span>{isDarkMode ? 'Light mode' : 'Dark mode'}</span>
</button>
{/* Settings */}
<button
onClick={handleGoToSettings}
className={cn(
'flex items-center gap-3 w-full px-4 py-3',
'text-sm text-[var(--color-text-primary)]',
'hover:bg-[var(--color-bg-hover)] transition-colors'
)}
role="menuitem"
>
<Settings size={18} className="text-[var(--color-text-secondary)]" />
<span>Settings</span>
</button>
{/* 分隔线 */}
<div className="my-1 border-t border-[var(--color-border-light)]" />
{/* Log out */}
<button
onClick={handleLogout}
className={cn(
'flex items-center gap-3 w-full px-4 py-3',
'text-sm text-red-500',
'hover:bg-red-50 transition-colors'
)}
role="menuitem"
>
<LogOut size={18} />
<span>Log out</span>
</button>
</div>
</div>
)}
</div>
);
}