Compare commits

..

8 Commits

Author SHA1 Message Date
gaoziman
b400781b89 refactor(sidebar): 用户区域集成弹出菜单
- 移除原有的用户信息链接样式
- 集成 UserMenu 组件替代原有设计
- 支持主题切换、设置导航、登出等功能
2025-12-19 13:58:54 +08:00
gaoziman
f81a1f0f2d refactor(settings): 重构设置页面布局和交互
- 优化页面布局,移除侧边栏采用独立页面设计
- 集成 ModelCardSelector 替代下拉选择
- 集成 FontSizePicker 支持字体大小调整
- 顶部导航栏添加主题切换按钮
- 移除冗余的偏好设置和危险区域板块
- 优化整体视觉效果和交互体验
2025-12-19 13:58:22 +08:00
gaoziman
6d1bf7275b style(css): 添加字体大小 CSS 变量和弹出动画
- 新增 --font-size-base CSS 变量支持全局字体大小
- body 使用 CSS 变量控制字体大小
- 添加 popUp 动画效果用于弹出菜单
2025-12-19 13:57:52 +08:00
gaoziman
5444e7a579 feat(providers): 添加全局设置提供者
- 新增 SettingsProvider 组件用于管理全局设置状态
- 在 Layout 中集成 SettingsProvider
- 应用启动时自动加载字体大小和主题设置
2025-12-19 13:57:22 +08:00
gaoziman
29b2d99a82 feat(ui): 新增设置相关 UI 组件
- FontSizePicker: 字体大小选择器,支持实时预览
- ModelCardSelector: 模型卡片选择组件(Haiku/Sonnet/Opus)
- UserMenu: 用户菜单弹出组件,支持主题切换和设置导航
2025-12-19 13:56:22 +08:00
gaoziman
2de8cd64e3 feat(hooks): useSettings 添加 fontSize 支持
- Settings 接口新增 fontSize 属性
- 默认设置中添加 fontSize: 15
2025-12-19 13:55:52 +08:00
gaoziman
9a27a11385 feat(api): Settings API 支持字体大小设置
- GET 接口返回 fontSize 字段
- PUT 接口支持更新 fontSize 设置
- 添加字体大小范围限制 (12-20)
2025-12-19 13:55:24 +08:00
gaoziman
b869a443e2 feat(database): 添加全局字体大小设置字段
- 在 user_settings 表中新增 font_size 字段 (12-20)
- 添加数据库迁移文件支持字段升级
- 默认字体大小设置为 15px
2025-12-19 13:54:56 +08:00
14 changed files with 1201 additions and 134 deletions

View File

@ -21,6 +21,7 @@ export async function GET() {
temperature: '0.7',
theme: 'light',
language: 'zh-CN',
fontSize: 15,
enableThinking: false,
saveChatHistory: true,
});
@ -36,6 +37,7 @@ export async function GET() {
temperature: settings.temperature || '0.7',
theme: settings.theme,
language: settings.language,
fontSize: settings.fontSize || 15,
enableThinking: settings.enableThinking,
saveChatHistory: settings.saveChatHistory,
});
@ -61,6 +63,7 @@ export async function PUT(request: Request) {
temperature,
theme,
language,
fontSize,
enableThinking,
saveChatHistory,
} = body;
@ -110,6 +113,11 @@ export async function PUT(request: Request) {
updateData.language = language;
}
if (fontSize !== undefined) {
// 限制字体大小在 12-20 之间
updateData.fontSize = Math.min(20, Math.max(12, fontSize));
}
if (enableThinking !== undefined) {
updateData.enableThinking = enableThinking;
}
@ -151,6 +159,7 @@ export async function PUT(request: Request) {
temperature: updatedSettings?.temperature || '0.7',
theme: updatedSettings?.theme,
language: updatedSettings?.language,
fontSize: updatedSettings?.fontSize || 15,
enableThinking: updatedSettings?.enableThinking,
saveChatHistory: updatedSettings?.saveChatHistory,
});

View File

@ -59,6 +59,9 @@
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
--transition-slow: 0.3s ease;
/* 字体大小 */
--font-size-base: 15px;
}
@theme inline {
@ -92,6 +95,7 @@ body {
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: var(--font-size-base);
line-height: 1.5;
min-height: 100vh;
}
@ -151,6 +155,17 @@ body {
}
}
@keyframes popUp {
from {
opacity: 0;
transform: translateY(8px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in {
animation: fadeIn var(--transition-base);
}
@ -163,6 +178,10 @@ body {
animation: slideDown var(--transition-fast);
}
.animate-pop-up {
animation: popUp 0.15s ease-out;
}
/* ========================================
响应式设计
======================================== */

View File

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { SettingsProvider } from "@/components/providers/SettingsProvider";
export const metadata: Metadata = {
title: "cchcode - AI 智能助手",
@ -17,7 +18,9 @@ export default function RootLayout({
return (
<html lang="zh-CN">
<body className="antialiased">
<SettingsProvider>
{children}
</SettingsProvider>
</body>
</html>
);

View File

@ -2,11 +2,11 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft, Download, Check, Loader2, Eye, EyeOff, RotateCcw } from 'lucide-react';
import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar';
import { ArrowLeft, Download, Check, Loader2, Eye, EyeOff, RotateCcw, Moon, Sun, Sparkles, Trash2 } from 'lucide-react';
import { Toggle } from '@/components/ui/Toggle';
import { ModelCardSelector } from '@/components/ui/ModelCardSelector';
import { FontSizePicker } from '@/components/ui/FontSizePicker';
import { cn } from '@/lib/utils';
import { currentUser, chatHistories } from '@/data/mock';
import { useSettings, useModels, useTools } from '@/hooks/useSettings';
// 默认系统提示词
@ -44,7 +44,6 @@ const DEFAULT_SYSTEM_PROMPT = `你是一个专业、友好的 AI 助手。请遵
- `;
export default function SettingsPage() {
const [sidebarOpen, setSidebarOpen] = useState(true);
const { settings, loading, saving, updateSettings } = useSettings();
const { models, loading: modelsLoading } = useModels();
const { tools, loading: toolsLoading } = useTools();
@ -105,6 +104,17 @@ export default function SettingsPage() {
}
};
// 更新字体大小
const handleFontSizeChange = async (fontSize: number) => {
try {
await updateSettings({ fontSize });
// 实时应用到页面
document.documentElement.style.setProperty('--font-size-base', `${fontSize}px`);
} catch (error) {
console.error('Failed to update font size:', error);
}
};
// 切换工具
const handleToolToggle = async (toolId: string) => {
const currentTools = settings?.defaultTools || [];
@ -177,6 +187,17 @@ export default function SettingsPage() {
setTemperature(value);
};
// 切换主题
const handleToggleTheme = async () => {
const newTheme = settings?.theme === 'dark' ? 'light' : 'dark';
try {
await updateSettings({ theme: newTheme });
document.documentElement.setAttribute('data-theme', newTheme);
} catch (error) {
console.error('Failed to update theme:', error);
}
};
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
@ -185,50 +206,41 @@ export default function SettingsPage() {
);
}
const isDarkMode = settings?.theme === 'dark';
return (
<div className="flex min-h-screen">
{/* 侧边栏 */}
<Sidebar
user={currentUser}
chatHistories={chatHistories}
isOpen={sidebarOpen}
onToggle={() => setSidebarOpen(!sidebarOpen)}
/>
{/* 主内容区 */}
<main
className={cn(
'flex-1 min-h-screen transition-all duration-300',
sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0'
)}
>
{/* Header */}
<header className="h-[var(--header-height)] px-4 flex items-center">
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
</header>
{/* Body */}
<div className="px-8 pb-8">
<div className="max-w-[800px] mx-auto">
{/* 返回链接 */}
<div className="min-h-screen bg-[var(--color-bg-secondary)]">
{/* 顶部导航栏 */}
<header className="sticky top-0 z-10 bg-[var(--color-bg-primary)] border-b border-[var(--color-border-light)]">
<div className="max-w-[900px] mx-auto px-6 h-16 flex items-center justify-between">
{/* 左侧:返回按钮和标题 */}
<div className="flex items-center gap-4">
<Link
href="/"
className="inline-flex items-center gap-2 text-[var(--color-text-secondary)] text-sm mb-4 hover:text-[var(--color-text-primary)] transition-colors"
className="p-2 -ml-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] transition-colors"
title="返回聊天"
>
<ArrowLeft size={16} />
<span></span>
<ArrowLeft size={20} />
</Link>
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-2xl font-semibold text-[var(--color-text-primary)] mb-2">
</h1>
<p className="text-sm text-[var(--color-text-secondary)]">
</p>
<div className="flex items-center gap-2">
<Sparkles size={24} className="text-[var(--color-primary)]" />
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Settings</h1>
</div>
</div>
{/* 右侧:主题切换 */}
<button
onClick={handleToggleTheme}
className="p-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] transition-colors"
title={isDarkMode ? '切换到浅色模式' : '切换到深色模式'}
>
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
</button>
</div>
</header>
{/* 主内容区 */}
<main className="max-w-[900px] mx-auto px-6 py-8">
{/* CCH 配置 */}
<SettingsSection
title="CCH 服务配置"
@ -315,23 +327,36 @@ export default function SettingsPage() {
title="AI 配置"
description="配置默认模型和工具"
>
<SettingsItem
label="默认模型"
description="为新对话选择默认 AI 模型"
>
<select
className="settings-select"
{/* 模型卡片选择 */}
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-2">
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mb-4">
AI
</div>
<ModelCardSelector
value={settings?.defaultModel || ''}
onChange={(e) => handleModelChange(e.target.value)}
onChange={handleModelChange}
disabled={modelsLoading || saving}
>
{models.map((model) => (
<option key={model.modelId} value={model.modelId}>
{model.displayName}
</option>
))}
</select>
</SettingsItem>
models={models}
/>
</div>
{/* 字体大小设置 */}
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-2">
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mb-4">
</div>
<FontSizePicker
value={settings?.fontSize || 15}
onChange={handleFontSizeChange}
disabled={saving}
/>
</div>
<SettingsItem
label="启用思考模式"
@ -361,7 +386,7 @@ export default function SettingsPage() {
onClick={() => handleToolToggle(tool.toolId)}
disabled={saving}
className={cn(
'inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all',
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all',
settings?.defaultTools?.includes(tool.toolId)
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-secondary)]'
@ -465,45 +490,6 @@ export default function SettingsPage() {
</div>
</SettingsSection>
{/* 偏好设置 */}
<SettingsSection
title="偏好设置"
description="自定义您的体验"
>
<SettingsItem
label="主题"
description="选择您喜欢的主题"
>
<select
className="settings-select"
value={settings?.theme || 'light'}
onChange={(e) => handleThemeChange(e.target.value)}
disabled={saving}
>
<option value="system"></option>
<option value="light"></option>
<option value="dark"></option>
</select>
</SettingsItem>
<SettingsItem
label="语言"
description="选择您的首选语言"
>
<select
className="settings-select"
value={settings?.language || 'zh-CN'}
onChange={(e) => handleLanguageChange(e.target.value)}
disabled={saving}
>
<option value="en">English</option>
<option value="zh-CN"></option>
<option value="zh-TW"></option>
<option value="ja"></option>
</select>
</SettingsItem>
</SettingsSection>
{/* 数据与隐私 */}
<SettingsSection
title="数据与隐私"
@ -534,27 +520,12 @@ export default function SettingsPage() {
label="清除所有聊天"
description="删除所有对话历史"
>
<button className="btn-ghost text-red-600 hover:text-red-700">
<button className="btn-ghost text-red-600 hover:text-red-700 inline-flex items-center gap-2">
<Trash2 size={16} />
</button>
</SettingsItem>
</SettingsSection>
{/* 危险区域 */}
<SettingsSection
title="危险区域"
description="不可逆操作"
variant="danger"
>
<SettingsItem
label="删除账户"
description="永久删除您的账户和所有数据"
>
<button className="btn-danger"></button>
</SettingsItem>
</SettingsSection>
</div>
</div>
</main>
</div>
);
@ -572,7 +543,7 @@ function SettingsSection({ title, description, children, variant = 'default' }:
return (
<section
className={cn(
'bg-white border rounded-xl mb-6 overflow-hidden',
'bg-white border rounded-md mb-6 overflow-hidden',
variant === 'danger' ? 'border-red-200' : 'border-[var(--color-border)]'
)}
>

View File

@ -2,8 +2,8 @@
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Plus, ChevronDown, PanelLeft, Trash2, MoreHorizontal, Loader2 } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2 } from 'lucide-react';
import { UserMenu } from '@/components/ui/UserMenu';
import { cn } from '@/lib/utils';
import { useConversations } from '@/hooks/useConversations';
import { useSettings } from '@/hooks/useSettings';
@ -164,19 +164,9 @@ export function Sidebar({ user, isOpen = true }: SidebarProps) {
)}
</nav>
{/* 用户信息 Footer */}
{/* 用户信息 Footer - 使用 UserMenu 弹出菜单 */}
<footer className="p-4 border-t border-[var(--color-border-light)] mt-auto">
<Link
href="/settings"
className="flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors"
>
<Avatar name={user.name} size="md" />
<div className="flex-1 min-w-0">
<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>
<ChevronDown size={16} className="text-[var(--color-text-tertiary)]" />
</Link>
<UserMenu user={user} />
</footer>
</aside>

View File

@ -0,0 +1,32 @@
'use client';
import { useEffect, type ReactNode } from 'react';
import { useSettings } from '@/hooks/useSettings';
interface SettingsProviderProps {
children: ReactNode;
}
export function SettingsProvider({ children }: SettingsProviderProps) {
const { settings, loading } = useSettings();
// 应用全局设置
useEffect(() => {
if (!loading && settings) {
// 应用字体大小
if (settings.fontSize) {
document.documentElement.style.setProperty(
'--font-size-base',
`${settings.fontSize}px`
);
}
// 应用主题
if (settings.theme) {
document.documentElement.setAttribute('data-theme', settings.theme);
}
}
}, [settings, loading]);
return <>{children}</>;
}

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>
);
}

View File

@ -0,0 +1 @@
ALTER TABLE "user_settings" ADD COLUMN "font_size" integer DEFAULT 15;

View File

@ -0,0 +1,603 @@
{
"id": "25c992de-4501-4c60-97bb-c5cbfd4ef130",
"prevId": "dc7ae80d-12dc-4e13-9c7b-101b9f79b198",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.conversations": {
"name": "conversations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "'新对话'"
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"tools": {
"name": "tools",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"enable_thinking": {
"name": "enable_thinking",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"system_prompt": {
"name": "system_prompt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"temperature": {
"name": "temperature",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false
},
"message_count": {
"name": "message_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"total_tokens": {
"name": "total_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"is_archived": {
"name": "is_archived",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"is_pinned": {
"name": "is_pinned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_message_at": {
"name": "last_message_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"conversations_conversation_id_unique": {
"name": "conversations_conversation_id_unique",
"nullsNotDistinct": false,
"columns": [
"conversation_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.messages": {
"name": "messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"message_id": {
"name": "message_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thinking_content": {
"name": "thinking_content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"thinking_collapsed": {
"name": "thinking_collapsed",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"tool_calls": {
"name": "tool_calls",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"tool_results": {
"name": "tool_results",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"input_tokens": {
"name": "input_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"output_tokens": {
"name": "output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"status": {
"name": "status",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"default": "'completed'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"feedback": {
"name": "feedback",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"messages_message_id_unique": {
"name": "messages_message_id_unique",
"nullsNotDistinct": false,
"columns": [
"message_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.models": {
"name": "models",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"supports_tools": {
"name": "supports_tools",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"supports_thinking": {
"name": "supports_thinking",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"supports_vision": {
"name": "supports_vision",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"max_tokens": {
"name": "max_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 8192
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 200000
},
"is_enabled": {
"name": "is_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"is_default": {
"name": "is_default",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"models_model_id_unique": {
"name": "models_model_id_unique",
"nullsNotDistinct": false,
"columns": [
"model_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tools": {
"name": "tools",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"tool_id": {
"name": "tool_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"input_schema": {
"name": "input_schema",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"is_enabled": {
"name": "is_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"is_default": {
"name": "is_default",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tools_tool_id_unique": {
"name": "tools_tool_id_unique",
"nullsNotDistinct": false,
"columns": [
"tool_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_settings": {
"name": "user_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"cch_url": {
"name": "cch_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"default": "'http://localhost:13500'"
},
"cch_api_key": {
"name": "cch_api_key",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"cch_api_key_configured": {
"name": "cch_api_key_configured",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"default_model": {
"name": "default_model",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"default": "'claude-sonnet-4-20250514'"
},
"default_tools": {
"name": "default_tools",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[\"web_search\",\"code_execution\",\"web_fetch\"]'::jsonb"
},
"system_prompt": {
"name": "system_prompt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"temperature": {
"name": "temperature",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false,
"default": "'0.7'"
},
"theme": {
"name": "theme",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"default": "'light'"
},
"language": {
"name": "language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false,
"default": "'zh-CN'"
},
"font_size": {
"name": "font_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 15
},
"enable_thinking": {
"name": "enable_thinking",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"save_chat_history": {
"name": "save_chat_history",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -15,6 +15,13 @@
"when": 1765991206886,
"tag": "0001_daffy_paladin",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1766110727907,
"tag": "0002_bizarre_sunfire",
"breakpoints": true
}
]
}

View File

@ -28,6 +28,7 @@ export const userSettings = pgTable('user_settings', {
// 偏好设置
theme: varchar('theme', { length: 20 }).default('light'),
language: varchar('language', { length: 10 }).default('zh-CN'),
fontSize: integer('font_size').default(15), // 全局字体大小 (12-20)
enableThinking: boolean('enable_thinking').default(false),
saveChatHistory: boolean('save_chat_history').default(true),
// 时间戳

View File

@ -11,6 +11,7 @@ export interface Settings {
temperature: string;
theme: string;
language: string;
fontSize: number;
enableThinking: boolean;
saveChatHistory: boolean;
}
@ -53,6 +54,7 @@ const defaultSettings: Settings = {
temperature: '0.7',
theme: 'light',
language: 'zh-CN',
fontSize: 15,
enableThinking: false,
saveChatHistory: true,
};