加了一堆模型和一堆功能
This commit is contained in:
@@ -1,52 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, type KeyboardEvent, type DragEvent } from "react";
|
||||
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { uploadRefImage, type UploadProgress } from "@/lib/api";
|
||||
|
||||
type UploadStatus = "idle" | "uploading" | "done" | "error";
|
||||
|
||||
interface UploadState {
|
||||
interface UploadItem {
|
||||
id: string;
|
||||
status: UploadStatus;
|
||||
/** 本地预览 URL */
|
||||
previewUrl: string | null;
|
||||
/** 上传成功后的服务端路径 */
|
||||
serverUrl: string | null;
|
||||
/** 上传进度 0-100 */
|
||||
progress: number;
|
||||
/** 错误信息 */
|
||||
errorMsg: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_UPLOAD: UploadState = {
|
||||
status: "idle",
|
||||
previewUrl: null,
|
||||
serverUrl: null,
|
||||
progress: 0,
|
||||
errorMsg: null,
|
||||
};
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, refImageServerUrl: string | null, imageModel: string | null) => void;
|
||||
onSend: (text: string, refImageServerUrls: string[], imageModel: string | null) => void;
|
||||
disabled: boolean;
|
||||
/** 上次使用的参考图服务端路径列表,用于自动沿用 */
|
||||
lastRefServerUrls?: string[];
|
||||
/** 上次参考图的完整可预览 URL 列表 */
|
||||
lastRefPreviewUrls?: string[];
|
||||
/** 用户主动清除沿用参考图时的回调 */
|
||||
onClearLastRefImage?: () => void;
|
||||
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
|
||||
onFileDrop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
export interface ChatInputHandle {
|
||||
uploadFile: (file: File) => void;
|
||||
}
|
||||
|
||||
let _uploadCounter = 0;
|
||||
|
||||
/** 从剪贴板提取可上传的图片文件(Ctrl+V / 右键粘贴共用) */
|
||||
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
|
||||
if (!data?.items?.length) return [];
|
||||
const out: File[] = [];
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const it = data.items[i];
|
||||
if (it.kind === "file" && it.type.startsWith("image/")) {
|
||||
const f = it.getAsFile();
|
||||
if (f) out.push(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
|
||||
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
|
||||
ref
|
||||
) {
|
||||
const [text, setText] = useState("");
|
||||
const [upload, setUpload] = useState<UploadState>(INITIAL_UPLOAD);
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const abortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
const hasActiveUpload = uploads.some((u) => u.status === "uploading");
|
||||
const allDone = uploads.length > 0 && uploads.every((u) => u.status === "done" || u.status === "error");
|
||||
const doneUrls = uploads.filter((u) => u.status === "done" && u.serverUrl).map((u) => u.serverUrl!);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
if (upload.status === "uploading") return;
|
||||
if (hasActiveUpload) return;
|
||||
|
||||
onSend(trimmed, upload.serverUrl, selectedModel || null);
|
||||
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
|
||||
onSend(trimmed, refUrls, selectedModel || null);
|
||||
setText("");
|
||||
clearUpload();
|
||||
clearAllUploads();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -56,247 +81,263 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const clearUpload = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setUpload((prev) => {
|
||||
if (prev.previewUrl) URL.revokeObjectURL(prev.previewUrl);
|
||||
return INITIAL_UPLOAD;
|
||||
const clearAllUploads = useCallback(() => {
|
||||
abortRefs.current.forEach((c) => c.abort());
|
||||
abortRefs.current.clear();
|
||||
setUploads((prev) => {
|
||||
prev.forEach((u) => { if (u.previewUrl) URL.revokeObjectURL(u.previewUrl); });
|
||||
return [];
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const removeUpload = useCallback((id: string) => {
|
||||
abortRefs.current.get(id)?.abort();
|
||||
abortRefs.current.delete(id);
|
||||
setUploads((prev) => {
|
||||
const item = prev.find((u) => u.id === id);
|
||||
if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
||||
return prev.filter((u) => u.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startUpload = useCallback(async (file: File) => {
|
||||
abortRef.current?.abort();
|
||||
const id = `upload-${++_uploadCounter}`;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
abortRefs.current.set(id, controller);
|
||||
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setUpload({
|
||||
const newItem: UploadItem = {
|
||||
id,
|
||||
status: "uploading",
|
||||
previewUrl,
|
||||
serverUrl: null,
|
||||
progress: 0,
|
||||
errorMsg: null,
|
||||
});
|
||||
};
|
||||
setUploads((prev) => [...prev, newItem]);
|
||||
|
||||
try {
|
||||
const result = await uploadRefImage(
|
||||
file,
|
||||
(p: UploadProgress) => {
|
||||
setUpload((prev) => ({ ...prev, progress: p.percent }));
|
||||
setUploads((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, progress: p.percent } : u))
|
||||
);
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
setUpload((prev) => ({
|
||||
...prev,
|
||||
status: "done",
|
||||
serverUrl: result.url,
|
||||
progress: 100,
|
||||
}));
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === id ? { ...u, status: "done", serverUrl: result.url, progress: 100 } : u
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
const msg = err instanceof Error ? err.message : "上传失败";
|
||||
setUpload((prev) => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
progress: 0,
|
||||
errorMsg: msg,
|
||||
}));
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === id ? { ...u, status: "error", progress: 0, errorMsg: msg } : u
|
||||
)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ uploadFile: startUpload }), [startUpload]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
startUpload(file);
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
Array.from(files).forEach((f) => startUpload(f));
|
||||
}
|
||||
// 重置 input value,确保选同一文件也能触发
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const retryUpload = useCallback(() => {
|
||||
if (fileInputRef.current) fileInputRef.current.click();
|
||||
}, []);
|
||||
|
||||
// 拖拽
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const handleDragLeave = () => setIsDragging(false);
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
startUpload(file);
|
||||
}
|
||||
onFileDrop?.();
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
files.forEach((f) => startUpload(f));
|
||||
};
|
||||
|
||||
const statusLabel = (() => {
|
||||
switch (upload.status) {
|
||||
case "uploading":
|
||||
return `上传中 ${upload.progress}%`;
|
||||
case "done":
|
||||
return "参考图已就绪";
|
||||
case "error":
|
||||
return upload.errorMsg || "上传失败";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const statusColor = (() => {
|
||||
switch (upload.status) {
|
||||
case "uploading":
|
||||
return "text-[var(--accent)]";
|
||||
case "done":
|
||||
return "text-green-400";
|
||||
case "error":
|
||||
return "text-red-400";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const handlePasteCapture = useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
if (disabled) return;
|
||||
const files = imageFilesFromClipboard(e.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
files.forEach((f) => startUpload(f));
|
||||
},
|
||||
[disabled, startUpload]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)] px-3 md:px-4 py-2 md:py-3 transition-colors ${
|
||||
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]" : ""
|
||||
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
|
||||
px-3 md:px-5 py-2.5 md:py-3 transition-colors ${
|
||||
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
{/* 拖拽提示 */}
|
||||
{isDragging && (
|
||||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||||
松开以添加参考图
|
||||
松开以添加参考图(可多张)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考图上传状态区 */}
|
||||
{upload.status !== "idle" && (
|
||||
<div className="mb-3 flex items-start gap-2">
|
||||
<div className="relative flex-shrink-0">
|
||||
{upload.previewUrl && (
|
||||
{/* 沿用上次参考图提示 */}
|
||||
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
{lastRefPreviewUrls.map((url, i) => (
|
||||
<img
|
||||
src={upload.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-lg object-cover border border-[var(--border)] transition-opacity ${
|
||||
upload.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
key={i}
|
||||
src={url}
|
||||
alt={`沿用参考图 ${i + 1}`}
|
||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.1)]"
|
||||
/>
|
||||
)}
|
||||
{/* 上传中的遮罩进度环 */}
|
||||
{upload.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle
|
||||
cx="18" cy="18" r="14"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle
|
||||
cx="18" cy="18" r="14"
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${upload.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* 成功 ✓ 角标 */}
|
||||
{upload.status === "done" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-green-500
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{/* 失败 ! 角标 */}
|
||||
{upload.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{/* 删除按钮(非上传中时显示) */}
|
||||
{upload.status !== "uploading" && (
|
||||
<button
|
||||
onClick={clearUpload}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-red-500 hover:text-white hover:border-red-500
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
沿用上次参考图({lastRefPreviewUrls.length} 张)
|
||||
</span>
|
||||
<button
|
||||
onClick={onClearLastRefImage}
|
||||
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
title="清除参考图"
|
||||
>
|
||||
✕ 清除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 mt-0.5 min-w-0">
|
||||
<span className={`text-xs ${statusColor} flex items-center gap-1.5`}>
|
||||
{upload.status === "uploading" && (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" />
|
||||
)}
|
||||
{statusLabel}
|
||||
</span>
|
||||
{/* 进度条 */}
|
||||
{upload.status === "uploading" && (
|
||||
<div className="w-32 h-1 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--accent)] rounded-full transition-all duration-200"
|
||||
style={{ width: `${upload.progress}%` }}
|
||||
{/* 参考图上传状态区(多图) */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{uploads.map((item) => (
|
||||
<div key={item.id} className="relative flex-shrink-0">
|
||||
{item.previewUrl && (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
|
||||
item.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(77,184,164,0.12)" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="14" fill="none"
|
||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={`${item.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "done" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.3)]">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{item.status !== "uploading" && (
|
||||
<button
|
||||
onClick={() => removeUpload(item.id)}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* 汇总状态 */}
|
||||
<div className="flex flex-col justify-center gap-1 min-w-0">
|
||||
{hasActiveUpload && (
|
||||
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_6px_rgba(77,184,164,0.3)]" />
|
||||
上传中...
|
||||
</span>
|
||||
)}
|
||||
{/* 失败时显示重试 */}
|
||||
{upload.status === "error" && (
|
||||
<button
|
||||
onClick={retryUpload}
|
||||
className="text-xs text-[var(--accent)] hover:underline cursor-pointer w-fit"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
{allDone && (
|
||||
<span className="text-xs text-[var(--accent)]">
|
||||
{doneUrls.length} 张参考图已就绪
|
||||
</span>
|
||||
)}
|
||||
{uploads.some((u) => u.status === "error") && (
|
||||
<span className="text-xs text-[var(--hot)]">
|
||||
{uploads.filter((u) => u.status === "error").length} 张上传失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 模型选择器 */}
|
||||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||
|
||||
{/* 上传参考图按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || upload.status === "uploading"}
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-lg border
|
||||
disabled={disabled || hasActiveUpload}
|
||||
className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:border-[var(--accent)]
|
||||
flex items-center justify-center transition-colors
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
hover:bg-[var(--accent)]/5
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer ${
|
||||
upload.status === "done"
|
||||
? "border-green-500/50 text-green-400"
|
||||
doneUrls.length > 0
|
||||
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
title="上传参考图"
|
||||
title="上传参考图(可多选)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
{doneUrls.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
|
||||
{doneUrls.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -306,15 +347,17 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述你想要的美术资源..."
|
||||
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||||
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-[var(--border)]
|
||||
className="flex-1 resize-none rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-secondary)]
|
||||
px-4 py-2.5 text-sm leading-relaxed
|
||||
focus:outline-none focus:border-[var(--accent)]
|
||||
disabled:opacity-50 transition-colors
|
||||
focus:outline-none focus:border-[var(--accent)]/50
|
||||
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
|
||||
disabled:opacity-50 transition-all
|
||||
min-h-[42px] max-h-[120px]"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
/>
|
||||
@@ -322,11 +365,12 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !text.trim() || upload.status === "uploading"}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg
|
||||
bg-[var(--accent)] text-white
|
||||
disabled={disabled || !text.trim() || hasActiveUpload}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-xl
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
flex items-center justify-center transition-colors
|
||||
hover:shadow-[0_0_15px_rgba(77,184,164,0.25)]
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -336,4 +380,4 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user