This commit is contained in:
海龙 2025-08-26 15:53:05 +08:00
commit a4a41da618
2 changed files with 165 additions and 85 deletions

View File

@ -1,7 +1,22 @@
import React, { useRef, useState, useEffect } from "react";
import React, { useRef, useState, useEffect, useCallback } from "react";
import { Image as ImageIcon, Send, Trash2, ArrowUp } from "lucide-react";
import { MessageBlock } from "./types";
import { useUploadFile } from "@/app/service/domain/service";
import { motion, AnimatePresence } from "framer-motion";
// 防抖函数
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: Parameters<T>) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(...args);
timeout = null;
}, wait);
};
};
interface InputBarProps {
onSend: (blocks: MessageBlock[], videoId?: string) => void;
@ -21,18 +36,47 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { uploadFile } = useUploadFile();
const [isComposing, setIsComposing] = useState(false);
const adjustHeight = () => {
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const newHeight = Math.min(Math.max(textarea.scrollHeight, 48), 120);
textarea.style.height = `${newHeight}px`;
// 检查是否超过一行48px 是单行高度)
setIsMultiline(newHeight > 48);
if (!textarea || isComposing) return;
// 保存当前滚动位置
const scrollPos = window.scrollY;
// 重置高度以获取实际内容高度
textarea.style.height = '0px';
// 强制浏览器重排,获取准确的 scrollHeight
const scrollHeight = textarea.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, 48), 120);
// 设置新高度
textarea.style.height = `${newHeight}px`;
// 恢复滚动位置,避免页面跳动
window.scrollTo(0, scrollPos);
// 更新布局状态
if (!text.trim()) {
setIsMultiline(false);
} else if (!isMultiline && newHeight > 48) {
setIsMultiline(true);
}
};
}, [text, isMultiline, isComposing]);
// 使用防抖包装 adjustHeight但确保在输入完成时立即调整
const debouncedAdjustHeight = useCallback(
debounce(() => {
requestAnimationFrame(() => {
if (!isComposing) {
adjustHeight();
}
});
}, 16), // 使用一帧的时间作为防抖时间
[adjustHeight, isComposing]
);
// 监听初始视频 URL 和 ID 的变化
useEffect(() => {
@ -44,8 +88,8 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
// 监听文本变化和组件挂载时调整高度
useEffect(() => {
adjustHeight();
}, [text]);
debouncedAdjustHeight();
}, [text, debouncedAdjustHeight]);
const handleSend = () => {
const blocks: MessageBlock[] = [];
@ -172,81 +216,117 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
</div>
)}
<div className={`${isMultiline ? 'flex flex-col' : 'flex items-center'} gap-2 px-3 m-3 border border-gray-700 rounded-[2rem]`}>
{/* 图片上传按钮 - 单行时显示在左侧,多行时显示在底部 */}
{!isMultiline && (
<label
className={`cursor-pointer inline-flex items-center gap-2 p-2 my-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
data-alt="file-upload"
>
<ImageIcon size={16} />
<input
className="hidden"
type="file"
accept="image/*"
onChange={onFileChange}
disabled={isUploading}
/>
</label>
)}
<motion.div
layout
className="px-3 m-3 border border-gray-700 rounded-[2rem]"
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<motion.div
layout
className={`${isMultiline ? 'flex flex-col' : 'flex items-center'} gap-2`}
>
<AnimatePresence mode="popLayout">
{/* 图片上传按钮 - 单行时显示在左侧 */}
{!isMultiline && (
<motion.label
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className={`cursor-pointer inline-flex items-center gap-2 p-2 my-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
data-alt="file-upload"
>
<ImageIcon size={16} />
<input
className="hidden"
type="file"
accept="image/*"
onChange={onFileChange}
disabled={isUploading}
/>
</motion.label>
)}
{/* 文本输入 */}
<textarea
ref={textareaRef}
placeholder="输入文字…"
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
data-alt="text-input"
/>
{isMultiline ? (
// 多行模式:底部按钮区域
<div className="flex justify-between items-center pb-2">
{/* 图片上传 */}
<label
className={`cursor-pointer inline-flex items-center gap-2 p-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
data-alt="file-upload"
>
<ImageIcon size={16} />
<input
className="hidden"
type="file"
accept="image/*"
onChange={onFileChange}
disabled={isUploading}
{/* 文本输入 */}
<motion.div layout className="flex-1">
<textarea
ref={textareaRef}
placeholder="输入文字…"
className="w-full pl-[10px] pr-[10px] py-[1rem] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => {
setIsComposing(false);
// 输入法完成后等待下一帧再调整高度,确保文本内容已更新
requestAnimationFrame(() => {
requestAnimationFrame(adjustHeight);
});
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !isComposing) {
e.preventDefault();
handleSend();
}
}}
data-alt="text-input"
/>
</label>
</motion.div>
{/* 发送按钮 必须有文字 */}
<button
onClick={handleSend}
className="inline-flex items-center gap-2 p-2 rounded-full bg-[#f1f3f4] text-[#25294b] shadow disabled:text-white/25 disabled:border disabled:border-white/10 disabled:bg-[#1b1b1b80] disabled:cursor-not-allowed"
data-alt="send-button"
disabled={!text.trim()}
>
<ArrowUp size={18} />
</button>
</div>
) : (
// 单行模式:发送按钮
<button
onClick={handleSend}
className="inline-flex items-center gap-2 p-2 my-2 rounded-full bg-[#f1f3f4] text-[#25294b] shadow disabled:text-white/25 disabled:border disabled:border-white/10 disabled:bg-[#1b1b1b80] disabled:cursor-not-allowed"
data-alt="send-button"
disabled={!text.trim()}
>
<ArrowUp size={18} />
</button>
)}
</div>
{isMultiline ? (
// 多行模式:底部按钮区域
<motion.div
layout
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="flex justify-between items-center w-full pb-2"
>
{/* 图片上传 */}
<motion.label
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={`cursor-pointer inline-flex items-center gap-2 p-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
data-alt="file-upload"
>
<ImageIcon size={16} />
<input
className="hidden"
type="file"
accept="image/*"
onChange={onFileChange}
disabled={isUploading}
/>
</motion.label>
{/* 发送按钮 必须有文字 */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleSend}
className="inline-flex items-center gap-2 p-2 rounded-full bg-[#f1f3f4] text-[#25294b] shadow disabled:text-white/25 disabled:border disabled:border-white/10 disabled:bg-[#1b1b1b80] disabled:cursor-not-allowed"
data-alt="send-button"
disabled={!text.trim()}
>
<ArrowUp size={18} />
</motion.button>
</motion.div>
) : (
// 单行模式:发送按钮
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleSend}
className="inline-flex items-center gap-2 p-2 my-2 rounded-full bg-[#f1f3f4] text-[#25294b] shadow disabled:text-white/25 disabled:border disabled:border-white/10 disabled:bg-[#1b1b1b80] disabled:cursor-not-allowed"
data-alt="send-button"
disabled={!text.trim()}
>
<ArrowUp size={18} />
</motion.button>
)}
</AnimatePresence>
</motion.div>
</motion.div>
</div>
);
}

View File

@ -38,7 +38,7 @@ const stageIconMap = {
}
}
const TAG_COLORS = ['#FF5733', '#126821', '#8d3913', '#FF33A1', '#A133FF', '#FF3333', '#3333FF', '#A1A1A1', '#a1115e', '#30527f'];
const TAG_COLORS = ['#126821', '#A133FF', '#3333FF', '#a1115e'];
// 阶段图标组件
const StageIcons = ({ currentStage, isExpanded, isPauseWorkFlow }: { currentStage: number, isExpanded: boolean, isPauseWorkFlow: boolean }) => {