小优化,产品愿景,视觉规范,交互和导航大版本

This commit is contained in:
Nostars Developer
2026-04-16 18:11:35 +08:00
parent 5878f7d9f4
commit 10f9c0061a
51 changed files with 5010 additions and 847 deletions

View File

@@ -1,8 +1,12 @@
"use client";
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
import { motion } from "framer-motion";
import { ModelSelector } from "./model-selector";
import { DataStream } from "@/components/ui/data-stream";
import { useRipple, RippleLayer } from "@/components/ui/ripple-effect";
import { uploadRefImage, type UploadProgress } from "@/lib/api";
import { sendBounce } from "@/components/ui/motion-presets";
type UploadStatus = "idle" | "uploading" | "done" | "error";
@@ -26,6 +30,8 @@ interface ChatInputProps {
onClearLastRefImage?: () => void;
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
onFileDrop?: () => void;
/** 是否正在生成图片(用于数据流光加速) */
isGeneratingImage?: boolean;
}
export interface ChatInputHandle {
@@ -34,7 +40,6 @@ export interface ChatInputHandle {
let _uploadCounter = 0;
/** 从剪贴板提取可上传的图片文件Ctrl+V / 右键粘贴共用) */
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
if (!data?.items?.length) return [];
const out: File[] = [];
@@ -49,25 +54,38 @@ function imageFilesFromClipboard(data: DataTransfer | null): File[] {
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop, isGeneratingImage },
ref
) {
const [text, setText] = useState("");
const [uploads, setUploads] = useState<UploadItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>("");
const [isFocused, setIsFocused] = useState(false);
const { ripples, trigger: triggerRipple } = useRipple();
const fileInputRef = useRef<HTMLInputElement>(null);
const sendBtnRef = useRef<HTMLButtonElement>(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 canSend = text.trim() && !disabled && !hasActiveUpload;
const handleSubmit = () => {
const trimmed = text.trim();
if (!trimmed || disabled) return;
if (hasActiveUpload) return;
// 投石入水涟漪
if (sendBtnRef.current) {
const rect = sendBtnRef.current.getBoundingClientRect();
const parent = sendBtnRef.current.offsetParent as HTMLElement | null;
const parentRect = parent?.getBoundingClientRect() ?? rect;
triggerRipple(rect.left - parentRect.left + rect.width / 2, rect.top - parentRect.top + rect.height / 2);
}
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
onSend(trimmed, refUrls, selectedModel || null);
setText("");
@@ -183,7 +201,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
return (
<div
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 ${
transition-colors ${
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
}`}
onDragOver={handleDragOver}
@@ -191,192 +209,210 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
onDrop={handleDrop}
onPasteCapture={handlePasteCapture}
>
{isDragging && (
<div className="mb-2 text-center text-xs text-[var(--accent)]">
</div>
)}
{/* 数据流光 */}
<DataStream active={disabled} speed={isGeneratingImage ? "fast" : "slow"} />
{/* 沿用上次参考图提示 */}
{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
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)]"
/>
))}
<div className="relative px-3 md:px-5 py-2.5 md:py-3">
{isDragging && (
<div className="mb-2 text-center text-xs text-[var(--accent)]">
</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>
)}
)}
{/* 参考图上传状态区(多图) */}
{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 && (
{/* 沿用上次参考图提示 */}
{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={item.previewUrl}
alt="参考图"
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
item.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(46,139,122,0.1)]"
/>
))}
</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>
)}
{/* 参考图上传状态区(多图) */}
{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" : ""
}`}
/>
)}
{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(46,139,122,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(46,139,122,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(46,139,122,0.3)]" />
...
</span>
)}
{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>
{allDone && (
<span className="text-xs text-[var(--accent)]">
{doneUrls.length}
</span>
)}
{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>
{uploads.some((u) => u.status === "error") && (
<span className="text-xs text-[var(--hot)]">
{uploads.filter((u) => u.status === "error").length}
</span>
)}
</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>
)}
{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 className="flex items-end gap-2">
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
{/* 上传参考图按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
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(--accent)] hover:border-[var(--accent)]/40
hover:bg-[var(--accent)]/5
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer btn-hover-lift ${
doneUrls.length > 0
? "border-[var(--accent)]/40 text-[var(--accent)]"
: "border-[var(--border)]"
}`}
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"
/>
{/* 文字输入框 — 水面感应 */}
<div className={`relative flex-1 ${isFocused ? "water-focus-active" : ""}`}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="描述你想要的美术资源...(可粘贴图片)"
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
disabled={disabled}
rows={1}
className="w-full 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)]/50
disabled:opacity-50 transition-all
min-h-[42px] max-h-[120px]"
style={{
fieldSizing: "content",
boxShadow: isFocused
? `0 0 ${8 + Math.min(text.length, 50) * 0.3}px rgba(46,139,122,${0.04 + Math.min(text.length, 50) * 0.002})`
: "none",
} as React.CSSProperties}
/>
<div className="water-focus-line" />
</div>
{/* 发送按钮 — 投石入水 */}
<motion.button
ref={sendBtnRef}
onClick={handleSubmit}
disabled={!canSend}
whileTap={canSend ? sendBounce : undefined}
className={`flex-shrink-0 w-10 h-10 rounded-xl
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_15px_rgba(46,139,122,0.25)]
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer
${canSend ? "breath-pulse" : ""}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</motion.button>
</div>
)}
<div className="flex items-end gap-2">
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
{/* 上传参考图按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
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(--accent)] hover:border-[var(--accent)]/40
hover:bg-[var(--accent)]/5
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer ${
doneUrls.length > 0
? "border-[var(--accent)]/40 text-[var(--accent)]"
: "border-[var(--border)]"
}`}
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"
/>
{/* 文字输入框 */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="描述你想要的美术资源...(可粘贴图片)"
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
disabled={disabled}
rows={1}
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)]/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}
/>
{/* 发送按钮 */}
<button
onClick={handleSubmit}
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)]
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">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
{/* 涟漪层 */}
<RippleLayer ripples={ripples} />
</div>
</div>
);