claude-code-cchui/src/app/settings/page.tsx
gaoziman f405f298aa refactor(pages): 重构核心页面支持实时数据
首页 (page.tsx):
- 集成 useConversations 和 useSettings hooks
- 实现快捷操作创建新会话
- 添加加载状态处理

聊天页 (chat/[id]/page.tsx):
- 集成 useStreamChat 实现流式对话
- 支持 AI 思考内容展示
- 优化消息发送和模型切换
- 添加错误处理和重试机制

设置页 (settings/page.tsx):
- 重构为完整的设置管理界面
- 支持 API 配置(URL、密钥)
- 支持默认模型和工具选择
- 支持主题和语言设置
- 添加设置保存和同步功能
2025-12-18 11:43:59 +08:00

626 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
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 { Toggle } from '@/components/ui/Toggle';
import { cn } from '@/lib/utils';
import { currentUser, chatHistories } from '@/data/mock';
import { useSettings, useModels, useTools } from '@/hooks/useSettings';
// 默认系统提示词
const DEFAULT_SYSTEM_PROMPT = `你是一个专业、友好的 AI 助手。请遵循以下规则来回复用户:
## 回复风格
- 使用中文回复,除非用户明确要求其他语言
- 回复要详细、有深度,不要过于简短
- 语气友好、专业,像一个耐心的老师
## 格式规范
- 使用 Markdown 格式化回复
- 对于代码,使用代码块并标明语言,例如 \`\`\`python
- 使用标题(##、###)来组织长回复
- 使用列表(有序或无序)来列举要点
- 使用粗体或斜体强调重要内容
## 回答结构
当回答技术问题时,请按以下结构:
1. **简要概述**:先给出简洁的答案
2. **详细解释**:深入解释原理或概念
3. **代码示例**:如果适用,提供完整、可运行的代码
4. **注意事项**:指出常见错误或最佳实践
5. **延伸阅读**:如果有相关主题,可以简要提及
## 代码规范
- 代码要完整、可运行,不要省略关键部分
- 添加适当的注释解释关键逻辑
- 使用有意义的变量名
- 如果代码较长,先展示完整代码,再逐段解释
## 特别注意
- 如果问题不明确,先确认理解是否正确
- 如果有多种方案,说明各自的优缺点
- 承认不确定的地方,不要编造信息`;
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();
// CCH 配置状态
const [cchUrl, setCchUrl] = useState('');
const [cchApiKey, setCchApiKey] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
// AI 行为设置状态
const [systemPrompt, setSystemPrompt] = useState('');
const [temperature, setTemperature] = useState('0.7');
const [promptSaveStatus, setPromptSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
// 当设置加载完成后,更新本地状态
useEffect(() => {
if (settings) {
setCchUrl(settings.cchUrl || '');
setSystemPrompt(settings.systemPrompt || '');
setTemperature(settings.temperature || '0.7');
}
}, [settings]);
// 保存 CCH 配置
const handleSaveCchConfig = async () => {
setSaveStatus('saving');
try {
const updates: Record<string, string> = { cchUrl };
if (cchApiKey) {
updates.cchApiKey = cchApiKey;
}
await updateSettings(updates);
setSaveStatus('saved');
setCchApiKey(''); // 清除输入的 API Key
setTimeout(() => setSaveStatus('idle'), 2000);
} catch {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 2000);
}
};
// 清除 API Key
const handleClearApiKey = async () => {
try {
await updateSettings({ cchApiKey: '' });
} catch (error) {
console.error('Failed to clear API key:', error);
}
};
// 更新默认模型
const handleModelChange = async (modelId: string) => {
try {
await updateSettings({ defaultModel: modelId });
} catch (error) {
console.error('Failed to update model:', error);
}
};
// 切换工具
const handleToolToggle = async (toolId: string) => {
const currentTools = settings?.defaultTools || [];
const newTools = currentTools.includes(toolId)
? currentTools.filter((t) => t !== toolId)
: [...currentTools, toolId];
try {
await updateSettings({ defaultTools: newTools });
} catch (error) {
console.error('Failed to update tools:', error);
}
};
// 切换思考模式
const handleThinkingToggle = async () => {
try {
await updateSettings({ enableThinking: !settings?.enableThinking });
} catch (error) {
console.error('Failed to toggle thinking:', error);
}
};
// 切换聊天历史
const handleChatHistoryToggle = async () => {
try {
await updateSettings({ saveChatHistory: !settings?.saveChatHistory });
} catch (error) {
console.error('Failed to toggle chat history:', error);
}
};
// 更新主题
const handleThemeChange = async (theme: string) => {
try {
await updateSettings({ theme });
} catch (error) {
console.error('Failed to update theme:', error);
}
};
// 更新语言
const handleLanguageChange = async (language: string) => {
try {
await updateSettings({ language });
} catch (error) {
console.error('Failed to update language:', error);
}
};
// 保存 System Prompt 和温度设置
const handleSavePromptSettings = async () => {
setPromptSaveStatus('saving');
try {
await updateSettings({ systemPrompt, temperature });
setPromptSaveStatus('saved');
setTimeout(() => setPromptSaveStatus('idle'), 2000);
} catch {
setPromptSaveStatus('error');
setTimeout(() => setPromptSaveStatus('idle'), 2000);
}
};
// 重置为默认 System Prompt
const handleResetSystemPrompt = () => {
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
};
// 更新温度(实时显示但不立即保存)
const handleTemperatureChange = (value: string) => {
setTemperature(value);
};
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-[var(--color-primary)]" />
</div>
);
}
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">
{/* 返回链接 */}
<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"
>
<ArrowLeft size={16} />
<span></span>
</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>
{/* CCH 配置 */}
<SettingsSection
title="CCH 服务配置"
description="配置 Claude Code Hub 服务连接"
>
<SettingsItem
label="CCH 服务地址"
description="Claude Code Hub 服务的 URL"
>
<input
type="text"
className="settings-input w-80"
value={cchUrl}
onChange={(e) => setCchUrl(e.target.value)}
placeholder="http://localhost:13500"
/>
</SettingsItem>
<SettingsItem
label="API Key"
description={
settings?.cchApiKeyConfigured
? '已配置 API Key'
: '设置访问 CCH 服务的 API Key'
}
>
<div className="flex items-center gap-2">
{settings?.cchApiKeyConfigured ? (
<>
<span className="inline-flex items-center gap-1 text-sm text-green-600">
<Check size={14} />
</span>
<button
onClick={handleClearApiKey}
className="btn-ghost text-red-600 hover:text-red-700 text-sm"
disabled={saving}
>
</button>
</>
) : (
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
className="settings-input pr-10 w-80"
value={cchApiKey}
onChange={(e) => setCchApiKey(e.target.value)}
placeholder="输入 API Key"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
</div>
</SettingsItem>
<div className="px-5 py-4 border-t border-[var(--color-border-light)]">
<button
onClick={handleSaveCchConfig}
disabled={saveStatus === 'saving'}
className="btn-primary inline-flex items-center gap-2"
>
{saveStatus === 'saving' ? (
<Loader2 size={16} className="animate-spin" />
) : saveStatus === 'saved' ? (
<Check size={16} />
) : null}
{saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : '保存配置'}
</button>
{saveStatus === 'error' && (
<span className="ml-3 text-sm text-red-600"></span>
)}
</div>
</SettingsSection>
{/* 模型和工具设置 */}
<SettingsSection
title="AI 配置"
description="配置默认模型和工具"
>
<SettingsItem
label="默认模型"
description="为新对话选择默认 AI 模型"
>
<select
className="settings-select"
value={settings?.defaultModel || ''}
onChange={(e) => handleModelChange(e.target.value)}
disabled={modelsLoading || saving}
>
{models.map((model) => (
<option key={model.modelId} value={model.modelId}>
{model.displayName}
</option>
))}
</select>
</SettingsItem>
<SettingsItem
label="启用思考模式"
description="让 AI 在回答前展示思考过程"
>
<Toggle
checked={settings?.enableThinking || false}
onChange={handleThinkingToggle}
disabled={saving}
/>
</SettingsItem>
<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-3">
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mb-4">
</div>
<div className="flex flex-wrap gap-2">
{toolsLoading ? (
<span className="text-sm text-[var(--color-text-tertiary)]">...</span>
) : (
tools.map((tool) => (
<button
key={tool.toolId}
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',
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)]'
)}
>
{settings?.defaultTools?.includes(tool.toolId) && (
<Check size={14} />
)}
{tool.displayName}
</button>
))
)}
</div>
</div>
</SettingsSection>
{/* AI 行为设置 */}
<SettingsSection
title="AI 行为设置"
description="自定义 AI 的回复风格和行为"
>
{/* 温度参数 */}
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
<div className="flex justify-between items-center mb-3">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
(Temperature)
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1">
</div>
</div>
<div className="text-lg font-mono font-semibold text-[var(--color-primary)]">
{temperature}
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-[var(--color-text-tertiary)]"></span>
<input
type="range"
min="0"
max="1"
step="0.1"
value={temperature}
onChange={(e) => handleTemperatureChange(e.target.value)}
className="flex-1 h-2 bg-[var(--color-bg-tertiary)] rounded-lg appearance-none cursor-pointer accent-[var(--color-primary)]"
/>
<span className="text-xs text-[var(--color-text-tertiary)]"></span>
</div>
</div>
{/* System Prompt 编辑器 */}
<div className="px-5 py-4 border-b border-[var(--color-border-light)]">
<div className="flex justify-between items-start mb-3">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
(System Prompt)
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1">
AI 使
</div>
</div>
<button
onClick={handleResetSystemPrompt}
className="btn-ghost text-sm inline-flex items-center gap-1"
title="重置为默认提示词"
>
<RotateCcw size={14} />
</button>
</div>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="留空使用默认提示词..."
rows={12}
className="w-full px-3 py-2 text-sm border border-[var(--color-border)] rounded-lg bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent resize-y font-mono"
/>
<div className="mt-2 text-xs text-[var(--color-text-tertiary)]">
使 Markdown {systemPrompt.length}
</div>
</div>
{/* 保存按钮 */}
<div className="px-5 py-4">
<button
onClick={handleSavePromptSettings}
disabled={promptSaveStatus === 'saving'}
className="btn-primary inline-flex items-center gap-2"
>
{promptSaveStatus === 'saving' ? (
<Loader2 size={16} className="animate-spin" />
) : promptSaveStatus === 'saved' ? (
<Check size={16} />
) : null}
{promptSaveStatus === 'saving' ? '保存中...' : promptSaveStatus === 'saved' ? '已保存' : '保存 AI 设置'}
</button>
{promptSaveStatus === 'error' && (
<span className="ml-3 text-sm text-red-600"></span>
)}
</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="数据与隐私"
description="管理您的数据和隐私设置"
>
<SettingsItem
label="聊天历史"
description="保存您的对话记录"
>
<Toggle
checked={settings?.saveChatHistory || false}
onChange={handleChatHistoryToggle}
disabled={saving}
/>
</SettingsItem>
<SettingsItem
label="导出数据"
description="下载所有聊天历史"
>
<button className="btn-ghost inline-flex items-center gap-2">
<Download size={16} />
</button>
</SettingsItem>
<SettingsItem
label="清除所有聊天"
description="删除所有对话历史"
>
<button className="btn-ghost text-red-600 hover:text-red-700">
</button>
</SettingsItem>
</SettingsSection>
{/* 危险区域 */}
<SettingsSection
title="危险区域"
description="不可逆操作"
variant="danger"
>
<SettingsItem
label="删除账户"
description="永久删除您的账户和所有数据"
>
<button className="btn-danger"></button>
</SettingsItem>
</SettingsSection>
</div>
</div>
</main>
</div>
);
}
// 设置区域组件
interface SettingsSectionProps {
title: string;
description: string;
children: React.ReactNode;
variant?: 'default' | 'danger';
}
function SettingsSection({ title, description, children, variant = 'default' }: SettingsSectionProps) {
return (
<section
className={cn(
'bg-white border rounded-xl mb-6 overflow-hidden',
variant === 'danger' ? 'border-red-200' : 'border-[var(--color-border)]'
)}
>
<div
className={cn(
'px-5 py-4 border-b',
variant === 'danger'
? 'bg-red-50 border-red-200'
: 'border-[var(--color-border-light)]'
)}
>
<h2
className={cn(
'text-base font-semibold',
variant === 'danger' ? 'text-red-600' : 'text-[var(--color-text-primary)]'
)}
>
{title}
</h2>
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
{description}
</p>
</div>
<div>{children}</div>
</section>
);
}
// 设置项组件
interface SettingsItemProps {
label: string;
description: React.ReactNode;
children: React.ReactNode;
}
function SettingsItem({ label, description, children }: SettingsItemProps) {
return (
<div className="flex justify-between items-center px-5 py-4 border-b border-[var(--color-border-light)] last:border-b-0 hover:bg-[var(--color-bg-tertiary)] transition-colors">
<div className="flex-1">
<div className="text-sm font-medium text-[var(--color-text-primary)] mb-1">
{label}
</div>
<div className="text-xs text-[var(--color-text-tertiary)]">
{description}
</div>
</div>
<div className="ml-4">{children}</div>
</div>
);
}