feat(组件): 添加提示词优化工具组件

- 新增 PromptOptimizer 浮动按钮组件
- 新增 PromptOptimizerModal 优化弹窗组件
- 支持简洁版和详细版两种优化模式
- 支持快捷键 Cmd/Ctrl + Shift + P 快速打开
- 支持优化历史记录查看和管理
- 支持一键使用优化后的提示词
This commit is contained in:
gaoziman 2025-12-22 00:07:06 +08:00
parent 4b4732a583
commit 31d227dca9
2 changed files with 562 additions and 0 deletions

View File

@ -0,0 +1,485 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
X,
Sparkles,
Zap,
FileText,
Copy,
Check,
Loader2,
History,
Trash2,
ArrowRight,
Wand2,
Command,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface PromptOptimization {
id: number;
originalPrompt: string;
optimizedPrompt: string;
mode: string;
createdAt: string;
}
interface PromptOptimizerModalProps {
isOpen: boolean;
onClose: () => void;
onUsePrompt: (prompt: string) => void;
}
type TabType = 'optimize' | 'history';
type ModeType = 'concise' | 'detailed';
export function PromptOptimizerModal({
isOpen,
onClose,
onUsePrompt,
}: PromptOptimizerModalProps) {
const [activeTab, setActiveTab] = useState<TabType>('optimize');
const [mode, setMode] = useState<ModeType>('detailed');
const [inputText, setInputText] = useState('');
const [optimizedText, setOptimizedText] = useState('');
const [isOptimizing, setIsOptimizing] = useState(false);
const [copied, setCopied] = useState(false);
const [history, setHistory] = useState<PromptOptimization[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// 加载历史记录
const loadHistory = useCallback(async () => {
try {
setIsLoadingHistory(true);
const response = await fetch('/api/prompt/history');
if (response.ok) {
const data = await response.json();
setHistory(data.data || []);
}
} catch (err) {
console.error('加载历史记录失败:', err);
} finally {
setIsLoadingHistory(false);
}
}, []);
// 打开弹窗时聚焦输入框
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [isOpen]);
// 切换到历史标签时加载历史
useEffect(() => {
if (activeTab === 'history' && history.length === 0) {
loadHistory();
}
}, [activeTab, history.length, loadHistory]);
// 优化提示词
const handleOptimize = async () => {
if (!inputText.trim()) return;
setError(null);
setIsOptimizing(true);
setOptimizedText('');
try {
const response = await fetch('/api/prompt/optimize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
originalPrompt: inputText,
mode,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '优化失败');
}
setOptimizedText(data.optimizedPrompt);
// 刷新历史记录
loadHistory();
} catch (err) {
setError(err instanceof Error ? err.message : '优化失败,请重试');
} finally {
setIsOptimizing(false);
}
};
// 复制优化结果
const handleCopy = async () => {
await navigator.clipboard.writeText(optimizedText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// 使用优化后的提示词
const handleUse = () => {
onUsePrompt(optimizedText);
onClose();
};
// 删除历史记录
const handleDeleteHistory = async (id: number) => {
try {
const response = await fetch(`/api/prompt/history?id=${id}`, {
method: 'DELETE',
});
if (response.ok) {
setHistory((prev) => prev.filter((item) => item.id !== id));
}
} catch (err) {
console.error('删除失败:', err);
}
};
// 使用历史记录中的提示词
const handleUseHistoryPrompt = (prompt: string) => {
onUsePrompt(prompt);
onClose();
};
// 键盘快捷键
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleOptimize();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩 */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* 弹窗主体 */}
<div
className="relative w-full max-w-2xl mx-4 bg-white rounded-md shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"
style={{
background: 'linear-gradient(180deg, #FFFAF7 0%, #FFFFFF 100%)',
}}
>
{/* 头部 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[#E06B3E]/10">
<div className="flex items-center gap-3">
<div
className="p-2 rounded-xl shadow-lg"
style={{
background: 'linear-gradient(135deg, #E06B3E 0%, #D05A2E 100%)',
boxShadow: '0 4px 14px 0 rgba(224, 107, 62, 0.3)'
}}
>
<Wand2 size={20} className="text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-800"></h2>
<p className="text-xs text-gray-500"> AI </p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* 标签切换 */}
<div className="flex px-6 pt-4 gap-2">
<button
onClick={() => setActiveTab('optimize')}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all',
activeTab === 'optimize'
? 'text-white shadow-md'
: 'text-gray-600 hover:bg-gray-100'
)}
style={activeTab === 'optimize' ? {
background: '#E06B3E',
boxShadow: '0 4px 6px -1px rgba(224, 107, 62, 0.3)'
} : {}}
>
<Sparkles size={16} />
</button>
<button
onClick={() => setActiveTab('history')}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all',
activeTab === 'history'
? 'text-white shadow-md'
: 'text-gray-600 hover:bg-gray-100'
)}
style={activeTab === 'history' ? {
background: '#E06B3E',
boxShadow: '0 4px 6px -1px rgba(224, 107, 62, 0.3)'
} : {}}
>
<History size={16} />
</button>
</div>
{/* 内容区域 */}
<div className="p-6">
{activeTab === 'optimize' ? (
<div className="space-y-4">
{/* 输入区域 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
ref={inputRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="例如:帮我写一个登录页面..."
className="w-full h-28 px-4 py-3 bg-white border border-gray-200 rounded resize-none focus:outline-none focus:ring-2 focus:ring-[#E06B3E]/20 focus:border-[#E06B3E] transition-all placeholder:text-gray-400"
/>
<div className="flex justify-end mt-1">
<span className="text-xs text-gray-400 flex items-center gap-1">
<Command size={12} />
+ Enter
</span>
</div>
</div>
{/* 模式选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setMode('concise')}
className={cn(
'flex items-center gap-3 p-4 rounded border-2 transition-all',
mode === 'concise'
? 'border-[#E06B3E] bg-[#E06B3E]/5 shadow-md'
: 'border-gray-200 hover:border-gray-300 bg-white'
)}
>
<div
className="p-2 rounded-sm"
style={mode === 'concise' ? { background: '#E06B3E' } : { background: '#f3f4f6' }}
>
<Zap
size={18}
className={mode === 'concise' ? 'text-white' : 'text-gray-500'}
/>
</div>
<div className="text-left">
<div
className={cn(
'font-medium',
mode === 'concise' ? 'text-[#E06B3E]' : 'text-gray-700'
)}
>
</div>
<div className="text-xs text-gray-500"></div>
</div>
</button>
<button
onClick={() => setMode('detailed')}
className={cn(
'flex items-center gap-3 p-4 rounded border-2 transition-all',
mode === 'detailed'
? 'border-[#E06B3E] bg-[#E06B3E]/5 shadow-md'
: 'border-gray-200 hover:border-gray-300 bg-white'
)}
>
<div
className="p-2 rounded-sm"
style={mode === 'detailed' ? { background: '#E06B3E' } : { background: '#f3f4f6' }}
>
<FileText
size={18}
className={mode === 'detailed' ? 'text-white' : 'text-gray-500'}
/>
</div>
<div className="text-left">
<div
className={cn(
'font-medium',
mode === 'detailed' ? 'text-[#E06B3E]' : 'text-gray-700'
)}
>
</div>
<div className="text-xs text-gray-500"></div>
</div>
</button>
</div>
</div>
{/* 优化按钮 */}
<button
onClick={handleOptimize}
disabled={!inputText.trim() || isOptimizing}
className={cn(
'w-full py-3 rounded-md font-medium flex items-center justify-center gap-2 transition-all',
inputText.trim() && !isOptimizing
? 'text-white shadow-lg hover:shadow-xl active:scale-[0.98]'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
)}
style={inputText.trim() && !isOptimizing ? {
background: 'linear-gradient(to right, #E06B3E, #D05A2E)',
boxShadow: '0 10px 15px -3px rgba(224, 107, 62, 0.3)'
} : {}}
>
{isOptimizing ? (
<>
<Loader2 size={18} className="animate-spin" />
...
</>
) : (
<>
<Sparkles size={18} />
</>
)}
</button>
{/* 错误提示 */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-600">
{error}
</div>
)}
{/* 优化结果 */}
{optimizedText && (
<div className="space-y-3 animate-in slide-in-from-bottom-2 duration-300">
<label className="block text-sm font-medium text-gray-700">
🎯
</label>
{/* 结果内容区域 - 固定最大高度,内部滚动 */}
<div className="relative">
<div className="max-h-[200px] overflow-y-auto p-4 bg-gradient-to-br from-green-50 to-emerald-50 border border-green-200 rounded-md scrollbar-thin">
<p className="text-gray-700 whitespace-pre-wrap text-sm leading-relaxed pr-2">
{optimizedText}
</p>
</div>
{/* 底部渐变遮罩 - 提示可滚动 */}
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-emerald-50/90 to-transparent pointer-events-none rounded-b-md" />
</div>
{/* 操作按钮 - 始终可见 */}
<div className="flex gap-2">
<button
onClick={handleCopy}
className="flex-1 py-2.5 px-4 border border-gray-200 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50 flex items-center justify-center gap-2 transition-colors"
>
{copied ? (
<>
<Check size={16} className="text-green-500" />
</>
) : (
<>
<Copy size={16} />
</>
)}
</button>
<button
onClick={handleUse}
className="flex-1 py-2.5 px-4 text-white rounded-md text-sm font-medium hover:shadow-lg flex items-center justify-center gap-2 transition-all active:scale-[0.98]"
style={{
background: 'linear-gradient(to right, #E06B3E, #D05A2E)',
boxShadow: '0 4px 6px -1px rgba(224, 107, 62, 0.3)'
}}
>
<ArrowRight size={16} />
使
</button>
</div>
</div>
)}
</div>
) : (
/* 历史记录 */
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{isLoadingHistory ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-[#E06B3E]" />
</div>
) : history.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<History size={40} className="mx-auto mb-3 text-gray-300" />
<p></p>
</div>
) : (
history.map((item) => (
<div
key={item.id}
className="p-4 bg-white border border-gray-200 rounded-md hover:border-[#E06B3E]/30 hover:shadow-sm transition-all group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span
className={cn(
'px-2 py-0.5 text-xs rounded-full',
item.mode === 'concise'
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'
)}
>
{item.mode === 'concise' ? '简洁版' : '详细版'}
</span>
<span className="text-xs text-gray-400">
{new Date(item.createdAt).toLocaleString('zh-CN')}
</span>
</div>
<p className="text-sm text-gray-500 line-clamp-1 mb-1">
{item.originalPrompt}
</p>
<p className="text-sm text-gray-700 line-clamp-2">
{item.optimizedPrompt}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleUseHistoryPrompt(item.optimizedPrompt)}
className="p-2 text-[#E06B3E] hover:bg-[#E06B3E]/10 rounded-lg transition-colors"
title="使用此提示词"
>
<ArrowRight size={16} />
</button>
<button
onClick={() => handleDeleteHistory(item.id)}
className="p-2 text-red-400 hover:bg-red-50 rounded-lg transition-colors"
title="删除"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Wand2 } from 'lucide-react';
import { PromptOptimizerModal } from './PromptOptimizerModal';
interface PromptOptimizerProps {
onUsePrompt?: (prompt: string) => void;
}
export function PromptOptimizer({ onUsePrompt }: PromptOptimizerProps) {
const [isOpen, setIsOpen] = useState(false);
// 全局快捷键 Cmd/Ctrl + Shift + P
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
setIsOpen((prev) => !prev);
}
// ESC 关闭
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
}, [isOpen]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const handleUsePrompt = (prompt: string) => {
onUsePrompt?.(prompt);
};
return (
<>
{/* 浮动按钮 - 右下角,避开输入框 */}
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-28 right-6 z-50 group"
title="提示词优化工具 (⌘+Shift+P)"
>
<div className="relative">
{/* 光晕效果 */}
<div
className="absolute inset-0 rounded-full blur-lg opacity-40 group-hover:opacity-60 transition-opacity"
style={{ background: 'linear-gradient(to right, #E06B3E, #D05A2E)' }}
/>
{/* 按钮主体 */}
<div
className="relative flex items-center gap-2 px-4 py-3 text-white rounded-full shadow-lg hover:shadow-xl transition-all group-hover:scale-105 active:scale-95"
style={{
background: 'linear-gradient(to right, #E06B3E, #D05A2E)',
boxShadow: '0 10px 15px -3px rgba(224, 107, 62, 0.3), 0 4px 6px -4px rgba(224, 107, 62, 0.3)'
}}
>
<Wand2 size={18} className="group-hover:rotate-12 transition-transform" />
<span className="text-sm font-medium"></span>
</div>
{/* 快捷键提示 */}
<div className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
+ Shift + P
</div>
</div>
</button>
{/* 优化弹窗 */}
<PromptOptimizerModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onUsePrompt={handleUsePrompt}
/>
</>
);
}