claude-code-cchui/src/components/features/ChatInput.tsx
gaoziman 4eab17155e style(字体): 优化字体系统使用相对单位
- 移除 ChatInput 和 MessageBubble 中的固定字体大小类
- MarkdownRenderer 标题和内容使用 em 相对单位
- 表格字体大小改为相对单位保持比例
- 聊天输入框添加 z-20 层级避免被代码块遮挡
2025-12-21 02:47:13 +08:00

203 lines
6.7 KiB
TypeScript

'use client';
import { useState, useRef } from 'react';
import { Plus, ArrowUp, Upload } from 'lucide-react';
import { ModelSelector } from './ModelSelector';
import { ToolsDropdown } from './ToolsDropdown';
import { FilePreviewList } from './FilePreviewList';
import { useFileUpload } from '@/hooks/useFileUpload';
import { cn } from '@/lib/utils';
import type { Model, Tool } from '@/types';
import type { UploadFile } from '@/types/file-upload';
interface ChatInputProps {
models: Model[];
selectedModel: Model;
onModelSelect: (model: Model) => void;
tools: Tool[];
onToolToggle: (toolId: string) => void;
onEnableAllTools: (enabled: boolean) => void;
onSend: (message: string, files?: UploadFile[]) => void;
placeholder?: string;
className?: string;
}
export function ChatInput({
models,
selectedModel,
onModelSelect,
tools,
onToolToggle,
onEnableAllTools,
onSend,
placeholder = 'How can I help you today?',
className,
}: ChatInputProps) {
const [message, setMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// 使用文件上传 Hook
const {
files,
isDragging,
addFiles,
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
} = useFileUpload();
const handleSend = () => {
if (message.trim() || files.length > 0) {
onSend(message, files.length > 0 ? files : undefined);
setMessage('');
clearFiles();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// 检查是否正在使用输入法组合(如中文输入法选词时按回车)
// isComposing 为 true 时,表示用户正在用输入法选词,此时不应发送消息
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
// 处理文件选择
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (selectedFiles && selectedFiles.length > 0) {
addFiles(selectedFiles);
}
// 重置 input 以允许重复选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// 打开文件选择对话框
const openFileDialog = () => {
fileInputRef.current?.click();
};
return (
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
<div
className={cn(
'relative flex flex-col bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-md p-4 shadow-[var(--shadow-input)]',
'transition-all duration-150',
'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]',
isDragging && 'border-[var(--color-primary)] border-2 bg-[var(--color-primary)]/5'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 拖拽覆盖层 */}
{isDragging && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[var(--color-bg-primary)]/90 rounded-md border-2 border-dashed border-[var(--color-primary)]">
<Upload className="w-8 h-8 text-[var(--color-primary)] mb-2" />
<p className="text-sm font-medium text-[var(--color-primary)]">
</p>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
PDF
</p>
</div>
)}
{/* 文件预览区域 */}
{files.length > 0 && (
<div className="mb-3">
<FilePreviewList files={files} onRemove={removeFile} />
</div>
)}
{/* 第一行:输入区域 */}
<div className="w-full mb-3">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={files.length > 0 ? '添加描述(可选)...' : placeholder}
className="w-full border-none outline-none text-[var(--color-text-primary)] bg-transparent py-2 placeholder:text-[var(--color-text-placeholder)]"
/>
</div>
{/* 第二行:功能按钮区域 */}
<div className="flex items-center justify-between w-full">
{/* 左侧按钮 */}
<div className="flex items-center gap-1">
{/* 添加附件 */}
<button
onClick={openFileDialog}
className={cn(
'w-8 h-8 flex items-center justify-center rounded-lg transition-colors',
'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]',
files.length > 0 && 'text-[var(--color-primary)]'
)}
title="添加附件"
>
<Plus size={20} />
</button>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.md,.json,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.go,.rs,.html,.css"
onChange={handleFileSelect}
className="hidden"
/>
{/* 工具下拉 */}
<ToolsDropdown
tools={tools}
onToolToggle={onToolToggle}
onEnableAllToggle={onEnableAllTools}
/>
</div>
{/* 右侧按钮 */}
<div className="flex items-center gap-3">
{/* 模型选择器 */}
<ModelSelector
models={models}
selectedModel={selectedModel}
onSelect={onModelSelect}
/>
{/* 发送按钮 */}
<button
onClick={handleSend}
disabled={!message.trim() && files.length === 0}
className={cn(
'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-xl transition-all duration-150',
message.trim() || files.length > 0
? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5'
: 'opacity-50 cursor-not-allowed'
)}
title="Send message"
>
<ArrowUp size={18} />
</button>
</div>
</div>
</div>
{/* 提示文字 */}
<p className="text-xs text-[var(--color-text-tertiary)] text-center mt-2">
使 Ctrl+V
</p>
</div>
);
}