feat(ui): 新增设置相关 UI 组件
- FontSizePicker: 字体大小选择器,支持实时预览 - ModelCardSelector: 模型卡片选择组件(Haiku/Sonnet/Opus) - UserMenu: 用户菜单弹出组件,支持主题切换和设置导航
This commit is contained in:
parent
2de8cd64e3
commit
29b2d99a82
138
src/components/ui/FontSizePicker.tsx
Normal file
138
src/components/ui/FontSizePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/components/ui/ModelCardSelector.tsx
Normal file
108
src/components/ui/ModelCardSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
src/components/ui/UserMenu.tsx
Normal file
183
src/components/ui/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user