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