feat(web): 重构组件样式并添加确认对话框组件
- 优化组件视觉风格: 使用渐变色、阴影和圆角增强现代感 - 添加 ConfirmDialog 组件和 useConfirm hook 用于确认操作 - 改进聊天界面: 添加滚动到底部按钮、斜杠命令自动弹出 - 统一组件间距和颜色方案,提升 UI 一致性
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Video, Image, Square } from 'lucide-react';
|
import { Video, Image } from 'lucide-react';
|
||||||
import type { Account } from '@/types';
|
import type { Account } from '@/types';
|
||||||
|
|
||||||
const MODEL_LABELS: Record<string, string> = {
|
const MODEL_LABELS: Record<string, string> = {
|
||||||
@@ -23,16 +23,16 @@ export function AccountCard({ account, isSelected, onClick }: Props) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`w-full text-left rounded-xl border p-4 transition-all cursor-pointer group
|
className={`w-full text-left rounded-xl p-4 transition-all duration-200 cursor-pointer group
|
||||||
${isSelected
|
${isSelected
|
||||||
? 'border-indigo-400 bg-indigo-50 shadow-sm'
|
? 'bg-indigo-50/80 ring-1 ring-indigo-200 shadow-sm shadow-indigo-100/50'
|
||||||
: 'border-zinc-200 bg-white hover:border-indigo-300 hover:shadow-md'}`}
|
: 'bg-white border border-zinc-100 hover:border-zinc-200 hover:shadow-lg hover:shadow-zinc-100/80 hover:-translate-y-0.5'}`}
|
||||||
>
|
>
|
||||||
{/* Top: name + format badge */}
|
{/* Top: name + format badge */}
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<h3 className="font-semibold text-sm text-zinc-800 truncate flex-1 mr-2">{account.name}</h3>
|
<h3 className="font-semibold text-sm text-zinc-800 truncate flex-1 mr-2">{account.name}</h3>
|
||||||
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
<span className={`text-[10px] font-mono px-2 py-0.5 rounded-full ${
|
||||||
isSelected ? 'bg-indigo-200 text-indigo-700' : 'bg-zinc-100 text-zinc-500'
|
isSelected ? 'bg-indigo-100 text-indigo-600' : 'bg-zinc-100 text-zinc-500'
|
||||||
}`}>
|
}`}>
|
||||||
{formatBadge}
|
{formatBadge}
|
||||||
</span>
|
</span>
|
||||||
@@ -40,21 +40,21 @@ export function AccountCard({ account, isSelected, onClick }: Props) {
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{account.description && (
|
{account.description && (
|
||||||
<p className="text-xs text-zinc-500 line-clamp-2 mb-3">{account.description}</p>
|
<p className="text-xs text-zinc-400 line-clamp-2 mb-3 leading-relaxed">{account.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<span className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
|
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-blue-50/80 text-blue-600">
|
||||||
<Image size={10} />
|
<Image size={10} />
|
||||||
{MODEL_LABELS[account.imageModel] || account.imageModel}
|
{MODEL_LABELS[account.imageModel] || account.imageModel}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
|
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-violet-50/80 text-violet-600">
|
||||||
<Video size={10} />
|
<Video size={10} />
|
||||||
{MODEL_LABELS[account.videoModel] || account.videoModel}
|
{MODEL_LABELS[account.videoModel] || account.videoModel}
|
||||||
</span>
|
</span>
|
||||||
{account.ttsVoice && (
|
{account.ttsVoice && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-50 text-green-600">
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-50/80 text-emerald-600">
|
||||||
TTS
|
TTS
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
|
|||||||
{subtitle && <span className="text-[10px] text-zinc-400">{subtitle}</span>}
|
{subtitle && <span className="text-[10px] text-zinc-400">{subtitle}</span>}
|
||||||
<div className="flex items-center gap-2 ml-auto text-[10px] text-zinc-400">
|
<div className="flex items-center gap-2 ml-auto text-[10px] text-zinc-400">
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-blue-50/60 text-blue-500">
|
||||||
<ImageIcon size={10} /> {images.length}
|
<ImageIcon size={10} /> {images.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{videos.length > 0 && (
|
{videos.length > 0 && (
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-violet-50/60 text-violet-500">
|
||||||
<Film size={10} /> {videos.length}
|
<Film size={10} /> {videos.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -34,11 +34,11 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Asset strip */}
|
{/* Asset strip */}
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
<div className="flex gap-2.5 overflow-x-auto pb-2">
|
||||||
{assets.map((asset) => (
|
{assets.map((asset) => (
|
||||||
<div
|
<div
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className="group relative flex-shrink-0 w-24 h-24 bg-zinc-100 rounded-lg overflow-hidden cursor-pointer border border-zinc-200 hover:border-indigo-300 hover:shadow-sm transition-all"
|
className="group relative flex-shrink-0 w-28 h-28 bg-zinc-100 rounded-xl overflow-hidden cursor-pointer border border-zinc-100 hover:shadow-lg hover:shadow-zinc-200/60 hover:-translate-y-0.5 transition-all duration-200"
|
||||||
onClick={() => onPreview(asset)}
|
onClick={() => onPreview(asset)}
|
||||||
>
|
>
|
||||||
{asset.type === 'image' ? (
|
{asset.type === 'image' ? (
|
||||||
@@ -49,8 +49,8 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-zinc-800">
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
|
||||||
<Film size={20} className="text-zinc-400" />
|
<Film size={20} className="text-zinc-500" />
|
||||||
<video
|
<video
|
||||||
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
@@ -61,12 +61,12 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm('确定删除这个资产?')) onDelete(asset.id); }}
|
onClick={(e) => { e.stopPropagation(); if (confirm('确定删除这个资产?')) onDelete(asset.id); }}
|
||||||
className="absolute top-1 right-1 p-0.5 rounded bg-white/80 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
|
className="absolute top-1.5 right-1.5 p-1 rounded-lg bg-white/90 opacity-0 group-hover:opacity-100 transition-all duration-200 shadow-sm hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={10} className="text-red-500" />
|
<Trash2 size={10} className="text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/40 to-transparent px-1 py-0.5">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/50 to-transparent px-1.5 py-1">
|
||||||
<span className="text-[8px] text-white">
|
<span className="text-[9px] text-white/80 font-medium">
|
||||||
{asset.type === 'image' ? 'IMG' : 'VID'} #{asset.shot_index ?? '?'}
|
{asset.type === 'image' ? 'IMG' : 'VID'} #{asset.shot_index ?? '?'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Send, Terminal, Image, Play, FileText, ArrowUp } from 'lucide-react';
|
import { Terminal, Image, Play, FileText, ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
const SLASH_COMMANDS = [
|
const SLASH_COMMANDS = [
|
||||||
{ cmd: '/run', desc: '执行 pipeline 阶段', icon: Play },
|
{ cmd: '/run', desc: '执行 pipeline 阶段', icon: Play },
|
||||||
@@ -19,6 +19,16 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
|
|||||||
? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0]))
|
? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0]))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Auto-show/hide slash command menu
|
||||||
|
useEffect(() => {
|
||||||
|
if (input.startsWith('/') && matchingCmds.length > 0) {
|
||||||
|
setShowCmds(true);
|
||||||
|
setCmdIdx(0);
|
||||||
|
} else {
|
||||||
|
setShowCmds(false);
|
||||||
|
}
|
||||||
|
}, [input, matchingCmds.length]);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!input.trim() || disabled) return;
|
if (!input.trim() || disabled) return;
|
||||||
onSend(input.trim());
|
onSend(input.trim());
|
||||||
|
|||||||
@@ -60,19 +60,21 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
|
|||||||
<div className={cn('flex gap-3 mb-5 group/msg', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
<div className={cn('flex gap-3 mb-5 group/msg', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-xs',
|
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-xs',
|
||||||
isUser ? 'bg-indigo-600 text-white' : 'bg-zinc-100 text-zinc-500'
|
isUser
|
||||||
|
? 'bg-gradient-to-br from-indigo-500 to-indigo-600 text-white shadow-sm'
|
||||||
|
: 'bg-zinc-100 text-zinc-500'
|
||||||
)}>
|
)}>
|
||||||
{isUser ? <User size={13} /> : <Bot size={13} />}
|
{isUser ? <User size={14} /> : <Bot size={14} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className={cn('flex-1 max-w-[78%]', isUser && 'flex flex-col items-end')}>
|
<div className={cn('flex-1 max-w-[78%]', isUser && 'flex flex-col items-end')}>
|
||||||
{/* Thinking indicator */}
|
{/* Thinking indicator */}
|
||||||
{state === 'thinking' && !message.content && !message.reasoningContent && (
|
{state === 'thinking' && !message.content && !message.reasoningContent && (
|
||||||
<div className="rounded-2xl px-3.5 py-2.5 bg-zinc-50 border border-zinc-100 rounded-tl-md">
|
<div className="rounded-2xl px-4 py-3 bg-gradient-to-br from-indigo-50 to-zinc-50 border border-indigo-100/50 rounded-tl-sm">
|
||||||
<div className="flex items-center gap-2 text-zinc-400 text-sm">
|
<div className="flex items-center gap-2 text-zinc-400 text-sm">
|
||||||
<Brain size={14} className="animate-pulse" />
|
<Brain size={14} className="animate-pulse text-indigo-400" />
|
||||||
<span>深度思考中...</span>
|
<span>深度思考中...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +98,7 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{(reasoningOpen || state !== 'done') && (
|
{(reasoningOpen || state !== 'done') && (
|
||||||
<div className="mt-1 px-3 py-2 rounded-lg bg-indigo-50/50 border border-indigo-100 text-xs text-zinc-500 leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap">
|
<div className="mt-1 px-3 py-2.5 rounded-xl bg-gradient-to-br from-indigo-50/80 to-violet-50/40 border border-indigo-100/50 text-xs text-zinc-500 leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap">
|
||||||
{message.reasoningContent}
|
{message.reasoningContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -106,16 +108,16 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
|
|||||||
{/* Main content bubble */}
|
{/* Main content bubble */}
|
||||||
{!(message.content === '(调用工具)' && state === 'done' && message.reasoningContent) && (
|
{!(message.content === '(调用工具)' && state === 'done' && message.reasoningContent) && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed',
|
'rounded-2xl px-4 py-2.5 text-sm leading-relaxed',
|
||||||
isUser
|
isUser
|
||||||
? 'bg-indigo-600 text-white rounded-tr-md'
|
? 'bg-gradient-to-br from-indigo-500 to-indigo-600 text-white rounded-tr-sm shadow-sm'
|
||||||
: 'bg-zinc-50 text-zinc-700 border border-zinc-100 rounded-tl-md'
|
: 'bg-zinc-50/80 text-zinc-700 border border-zinc-100 rounded-tl-sm'
|
||||||
)}>
|
)}>
|
||||||
{state === 'streaming' && !message.content ? (
|
{state === 'streaming' && !message.content ? (
|
||||||
<div className="flex items-center gap-1 text-zinc-400">
|
<div className="flex items-center gap-1.5 text-zinc-400">
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse" />
|
<span className="inline-block w-1.5 h-1.5 bg-indigo-300 rounded-full animate-pulse" />
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse [animation-delay:100ms]" />
|
<span className="inline-block w-1.5 h-1.5 bg-indigo-300 rounded-full animate-pulse [animation-delay:150ms]" />
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse [animation-delay:200ms]" />
|
<span className="inline-block w-1.5 h-1.5 bg-indigo-300 rounded-full animate-pulse [animation-delay:300ms]" />
|
||||||
</div>
|
</div>
|
||||||
) : isUser ? (
|
) : isUser ? (
|
||||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||||
@@ -125,27 +127,27 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions - visible on hover or when done */}
|
{/* Actions */}
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<div className="flex gap-0.5 mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity px-1">
|
<div className="flex gap-0.5 mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 px-1">
|
||||||
<button onClick={handleCopy}
|
<button onClick={handleCopy}
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||||
<Copy size={10} />复制
|
<Copy size={10} />复制
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onQuote?.(message.content)}
|
<button onClick={() => onQuote?.(message.content)}
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||||
<Quote size={10} />引用
|
<Quote size={10} />引用
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleDelete}
|
<button onClick={handleDelete}
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors">
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors">
|
||||||
<Trash2 size={10} />删除
|
<Trash2 size={10} />删除
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onRegenerate?.(message.id)}
|
<button onClick={() => onRegenerate?.(message.id)}
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||||
<RefreshCw size={10} />重新生成
|
<RefreshCw size={10} />重新生成
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onContinue?.()}
|
<button onClick={() => onContinue?.()}
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
|
||||||
<ArrowRight size={10} />继续
|
<ArrowRight size={10} />继续
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ChatMessage } from './ChatMessage';
|
|||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { PipelineProgress } from './PipelineProgress';
|
import { PipelineProgress } from './PipelineProgress';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, Loader2, StopCircle, X, MessageCircle } from 'lucide-react';
|
import { RefreshCw, Loader2, StopCircle, X, MessageCircle, ArrowDown } from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Account, Message } from '@/types';
|
import type { Account, Message } from '@/types';
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ export function ChatView() {
|
|||||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
const [quote, setQuote] = useState<string | null>(null);
|
const [quote, setQuote] = useState<string | null>(null);
|
||||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||||
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
const creatingRef = useRef(false);
|
const creatingRef = useRef(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const userScrolledUp = useRef(false);
|
const userScrolledUp = useRef(false);
|
||||||
@@ -54,6 +55,7 @@ export function ChatView() {
|
|||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||||
userScrolledUp.current = !atBottom;
|
userScrolledUp.current = !atBottom;
|
||||||
|
setShowScrollBtn(!atBottom);
|
||||||
};
|
};
|
||||||
el.addEventListener('scroll', onScroll, { passive: true });
|
el.addEventListener('scroll', onScroll, { passive: true });
|
||||||
return () => el.removeEventListener('scroll', onScroll);
|
return () => el.removeEventListener('scroll', onScroll);
|
||||||
@@ -62,6 +64,7 @@ export function ChatView() {
|
|||||||
// Reset scroll state on conversation switch
|
// Reset scroll state on conversation switch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
userScrolledUp.current = false;
|
userScrolledUp.current = false;
|
||||||
|
setShowScrollBtn(false);
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
// After navigating to a new conversation, send the pending message once history is loaded
|
// After navigating to a new conversation, send the pending message once history is loaded
|
||||||
@@ -137,9 +140,12 @@ export function ChatView() {
|
|||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
const recentConvs = conversations.slice(0, 5);
|
const recentConvs = conversations.slice(0, 5);
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center bg-white">
|
<div className="flex-1 flex flex-col items-center justify-center bg-gradient-to-b from-white to-zinc-50/30">
|
||||||
<div className="w-full max-w-xl px-4">
|
<div className="w-full max-w-xl px-4">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-50 to-violet-50 flex items-center justify-center mx-auto mb-4 shadow-sm">
|
||||||
|
<MessageCircle size={24} className="text-indigo-400" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-zinc-800">开始新对话</h2>
|
<h2 className="text-lg font-semibold text-zinc-800">开始新对话</h2>
|
||||||
<p className="text-sm text-zinc-400 mt-1">输入消息开始创作,对话将自动创建</p>
|
<p className="text-sm text-zinc-400 mt-1">输入消息开始创作,对话将自动创建</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +156,7 @@ export function ChatView() {
|
|||||||
<select
|
<select
|
||||||
value={selectedAccountId || ''}
|
value={selectedAccountId || ''}
|
||||||
onChange={(e) => useAppStore.getState().setSelectedAccountId(e.target.value || null)}
|
onChange={(e) => useAppStore.getState().setSelectedAccountId(e.target.value || null)}
|
||||||
className="h-7 rounded-md border border-zinc-200 bg-white px-2.5 text-xs text-zinc-600"
|
className="h-7 rounded-lg border border-zinc-100 bg-white px-2.5 text-xs text-zinc-600 hover:border-zinc-200 transition-colors"
|
||||||
>
|
>
|
||||||
<option value="">不指定</option>
|
<option value="">不指定</option>
|
||||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
@@ -164,7 +170,7 @@ export function ChatView() {
|
|||||||
<button
|
<button
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
onClick={() => navigate(`/chat/${conv.id}`)}
|
onClick={() => navigate(`/chat/${conv.id}`)}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left hover:bg-zinc-50 transition-colors group"
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl text-left hover:bg-zinc-50/80 transition-colors group"
|
||||||
>
|
>
|
||||||
<MessageCircle size={13} className="text-zinc-300 flex-shrink-0" />
|
<MessageCircle size={13} className="text-zinc-300 flex-shrink-0" />
|
||||||
<span className="text-sm text-zinc-600 truncate flex-1 group-hover:text-zinc-800">{conv.title}</span>
|
<span className="text-sm text-zinc-600 truncate flex-1 group-hover:text-zinc-800">{conv.title}</span>
|
||||||
@@ -184,19 +190,19 @@ export function ChatView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-white h-full">
|
<div className="flex-1 flex flex-col bg-white h-full">
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div className="px-4 py-2 border-b border-zinc-200 flex items-center justify-between flex-shrink-0">
|
<div className="px-4 py-2 border-b border-zinc-100 flex items-center justify-between flex-shrink-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-400'}`} />
|
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-emerald-400 shadow-sm shadow-emerald-200' : 'bg-red-400 animate-pulse'}`} />
|
||||||
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
|
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{manifestPath && (
|
{manifestPath && (
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleResume}>
|
<Button size="sm" variant="outline" className="h-7 text-xs rounded-lg" onClick={handleResume}>
|
||||||
<RefreshCw size={12} className="mr-1" />断点续跑
|
<RefreshCw size={12} className="mr-1" />断点续跑
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{thinking && (
|
{thinking && (
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50" onClick={handleStop}>
|
<Button size="sm" variant="outline" className="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 rounded-lg" onClick={handleStop}>
|
||||||
<StopCircle size={12} className="mr-1" />停止
|
<StopCircle size={12} className="mr-1" />停止
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -204,7 +210,7 @@ export function ChatView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat area */}
|
{/* Chat area */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Message list */}
|
{/* Message list */}
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
@@ -235,15 +241,29 @@ export function ChatView() {
|
|||||||
|
|
||||||
{thinking && !pipeline && (
|
{thinking && !pipeline && (
|
||||||
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin text-indigo-400" />
|
||||||
{toolStatus || '思考中...'}
|
{toolStatus || '思考中...'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to bottom FAB */}
|
||||||
|
{showScrollBtn && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||||
|
userScrolledUp.current = false;
|
||||||
|
setShowScrollBtn(false);
|
||||||
|
}}
|
||||||
|
className="absolute bottom-4 left-1/2 -translate-x-1/2 w-9 h-9 rounded-full bg-white border border-zinc-200 shadow-lg shadow-zinc-200/60 flex items-center justify-center text-zinc-400 hover:text-zinc-700 hover:border-zinc-300 hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quote bar */}
|
{/* Quote bar */}
|
||||||
{quote && (
|
{quote && (
|
||||||
<div className="mx-4 px-3 py-1.5 bg-zinc-50 border border-zinc-200 rounded-lg flex items-center gap-2 text-xs text-zinc-500 flex-shrink-0">
|
<div className="mx-4 px-3 py-1.5 bg-indigo-50/60 border border-indigo-100/50 rounded-xl flex items-center gap-2 text-xs text-zinc-500 flex-shrink-0">
|
||||||
<span className="flex-1 truncate">引用: {quote}</span>
|
<span className="flex-1 truncate">引用: {quote}</span>
|
||||||
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
|
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,24 +18,24 @@ export function PipelineProgress({ phase, progress, currentItem, totalItems, sta
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 my-2">
|
<div className="bg-gradient-to-br from-zinc-900 to-zinc-800 border border-zinc-700/50 rounded-xl p-3.5 my-2 shadow-lg shadow-zinc-900/20">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs text-zinc-400">
|
<span className="text-xs text-zinc-400 font-medium">
|
||||||
{phaseLabel[phase] || phase}
|
{phaseLabel[phase] || phase}
|
||||||
{currentItem && totalItems ? ` (${currentItem}/${totalItems})` : ''}
|
{currentItem && totalItems ? ` (${currentItem}/${totalItems})` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-zinc-500">{progress}%</span>
|
<span className="text-xs font-mono text-zinc-500">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
<div className="w-full h-1.5 bg-zinc-700/50 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full rounded-full transition-all duration-500',
|
'h-full rounded-full transition-all duration-700 ease-out',
|
||||||
progress < 100 ? 'bg-blue-500' : 'bg-green-500'
|
progress < 100 ? 'bg-gradient-to-r from-blue-500 to-indigo-400' : 'bg-gradient-to-r from-emerald-500 to-green-400'
|
||||||
)}
|
)}
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{status && <p className="text-xs text-zinc-500 mt-1">{status}</p>}
|
{status && <p className="text-xs text-zinc-500 mt-1.5">{status}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
<div className="h-screen flex bg-white text-zinc-900 overflow-hidden">
|
<div className="h-screen flex bg-white text-zinc-900 overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
{isChat && <MiddlePanel />}
|
{isChat && <MiddlePanel />}
|
||||||
<main className="flex-1 flex flex-col min-w-0 bg-zinc-50">
|
<main className="flex-1 flex flex-col min-w-0 bg-gradient-to-br from-zinc-50/50 to-white">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<ToastContainer toasts={toasts} dismiss={dismiss} />
|
<ToastContainer toasts={toasts} dismiss={dismiss} />
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import { Plus, Pin, Trash2, MessageCircle, Search } from 'lucide-react';
|
import { Plus, Pin, Trash2, MessageCircle, Search } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { ConfirmDialog } from '@/components/ui/confirm';
|
||||||
import { useAppStore } from '@/store';
|
import { useAppStore } from '@/store';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
|
||||||
export function MiddlePanel() {
|
export function MiddlePanel() {
|
||||||
const { conversations, setConversations } = useAppStore();
|
const { conversations, setConversations } = useAppStore();
|
||||||
@@ -13,6 +15,8 @@ export function MiddlePanel() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { confirmState, confirm, handleConfirm, handleCancel } = useConfirm();
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
const conversationId = useMemo(() => {
|
const conversationId = useMemo(() => {
|
||||||
const match = location.pathname.match(/^\/chat\/(.+)$/);
|
const match = location.pathname.match(/^\/chat\/(.+)$/);
|
||||||
@@ -65,20 +69,20 @@ export function MiddlePanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 flex flex-col border-r border-zinc-200 bg-white">
|
<aside className="w-60 flex flex-col border-r border-zinc-100 bg-white">
|
||||||
<div className="p-3 flex items-center gap-2 border-b border-zinc-100">
|
<div className="p-3 flex items-center gap-2 border-b border-zinc-100/80">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-300" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索对话..."
|
placeholder="搜索对话..."
|
||||||
className="h-8 text-xs bg-zinc-50 border-zinc-200 pl-8"
|
className="h-8 text-xs bg-zinc-50/80 border-zinc-100 pl-8 focus:bg-white"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/chat')}
|
onClick={() => navigate('/chat')}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors whitespace-nowrap"
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors whitespace-nowrap shadow-sm shadow-indigo-200/50"
|
||||||
>
|
>
|
||||||
<Plus size={13} />
|
<Plus size={13} />
|
||||||
新对话
|
新对话
|
||||||
@@ -89,27 +93,27 @@ export function MiddlePanel() {
|
|||||||
<div
|
<div
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
onClick={() => navigate(`/chat/${conv.id}`)}
|
onClick={() => navigate(`/chat/${conv.id}`)}
|
||||||
className={`group w-full text-left px-3 py-2.5 border-b border-zinc-50 transition-colors cursor-pointer
|
className={`group w-full text-left px-3 py-2.5 transition-all duration-150 cursor-pointer
|
||||||
${conv.id === conversationId
|
${conv.id === conversationId
|
||||||
? 'bg-indigo-50 border-l-[3px] border-l-indigo-600'
|
? 'bg-indigo-50/60 border-l-2 border-l-indigo-500'
|
||||||
: 'hover:bg-zinc-50 border-l-[3px] border-l-transparent'}`}
|
: 'hover:bg-zinc-50/80 border-l-2 border-l-transparent'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{conv.pinned ? <Pin size={11} className="text-indigo-500 flex-shrink-0" /> : null}
|
{conv.pinned ? <Pin size={11} className="text-indigo-400 flex-shrink-0" /> : null}
|
||||||
<div className={`text-sm truncate flex-1 ${conv.id === conversationId ? 'text-indigo-700 font-medium' : 'text-zinc-700'}`}>
|
<div className={`text-sm truncate flex-1 ${conv.id === conversationId ? 'text-indigo-700 font-medium' : 'text-zinc-600'}`}>
|
||||||
{conv.title}
|
{conv.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="opacity-0 group-hover:opacity-100 flex gap-0.5 transition-opacity flex-shrink-0">
|
<div className="opacity-0 group-hover:opacity-100 flex gap-0.5 transition-opacity duration-150 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleTogglePin(conv.id, e)}
|
onClick={(e) => handleTogglePin(conv.id, e)}
|
||||||
className={`p-1 rounded hover:bg-zinc-200 transition-colors ${conv.pinned ? 'text-indigo-500' : 'text-zinc-400'}`}
|
className={`p-1 rounded-md transition-colors ${conv.pinned ? 'text-indigo-400' : 'text-zinc-300 hover:text-zinc-500 hover:bg-zinc-100'}`}
|
||||||
title={conv.pinned ? '取消置顶' : '置顶'}
|
title={conv.pinned ? '取消置顶' : '置顶'}
|
||||||
>
|
>
|
||||||
<Pin size={12} />
|
<Pin size={12} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDelete(conv.id, e)}
|
onClick={(e) => handleDelete(conv.id, e)}
|
||||||
className="p-1 rounded hover:bg-red-100 text-zinc-400 hover:text-red-500 transition-colors"
|
className="p-1 rounded-md text-zinc-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||||
title="删除"
|
title="删除"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
@@ -124,10 +128,10 @@ export function MiddlePanel() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{conversations.length === 0 && (
|
{conversations.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center mt-12 text-zinc-400">
|
<div className="flex flex-col items-center justify-center mt-16 text-zinc-300">
|
||||||
<MessageCircle size={24} className="mb-2 opacity-40" />
|
<MessageCircle size={28} className="mb-3 opacity-30" strokeWidth={1.5} />
|
||||||
<p className="text-xs">暂无对话</p>
|
<p className="text-xs text-zinc-400">暂无对话</p>
|
||||||
<p className="text-[10px] mt-1">点击上方「新对话」开始</p>
|
<p className="text-[10px] mt-1 text-zinc-300">点击上方「新对话」开始</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -11,21 +11,22 @@ const navItems = [
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
return (
|
return (
|
||||||
<aside className="w-16 flex flex-col items-center py-4 gap-1 border-r border-zinc-200 bg-zinc-50">
|
<aside className="w-16 flex flex-col items-center py-5 gap-1.5 border-r border-zinc-100 bg-white">
|
||||||
{navItems.map(({ to, icon: Icon, label }) => (
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
|
title={label}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'w-12 h-12 flex flex-col items-center justify-center gap-0.5 rounded-lg transition-colors text-[10px] font-medium',
|
'w-11 h-11 flex flex-col items-center justify-center gap-0.5 rounded-xl transition-all duration-200 text-[10px] font-medium',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-indigo-50 text-indigo-600'
|
? 'bg-indigo-50 text-indigo-600 shadow-sm shadow-indigo-100'
|
||||||
: 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100'
|
: 'text-zinc-400 hover:text-zinc-600 hover:bg-zinc-50'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={17} strokeWidth={1.8} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|||||||
74
web/client/src/components/ui/confirm.tsx
Normal file
74
web/client/src/components/ui/confirm.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
variant?: 'danger' | 'default';
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
variant = 'default',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) cancelRef.current?.focus();
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
if (open) document.addEventListener('keydown', handleKey);
|
||||||
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
|
}, [open, onCancel]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/20 backdrop-blur-[2px]" onClick={onCancel} />
|
||||||
|
{/* Dialog */}
|
||||||
|
<div className="relative bg-white rounded-2xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-5 w-[320px] animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="absolute top-3.5 right-3.5 text-zinc-300 hover:text-zinc-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-800 mb-1">{title}</h3>
|
||||||
|
{description && <p className="text-xs text-zinc-400 leading-relaxed mb-4">{description}</p>}
|
||||||
|
<div className="flex items-center justify-end gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
ref={cancelRef}
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-3.5 py-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-50 transition-colors"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-3.5 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
variant === 'danger'
|
||||||
|
? 'bg-red-500 text-white hover:bg-red-600'
|
||||||
|
: 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/client/src/hooks/useConfirm.ts
Normal file
43
web/client/src/hooks/useConfirm.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
variant?: 'danger' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmState extends ConfirmOptions {
|
||||||
|
open: boolean;
|
||||||
|
resolve: ((value: boolean) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ConfirmState = {
|
||||||
|
open: false,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
resolve: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useConfirm() {
|
||||||
|
const [state, setState] = useState<ConfirmState>(initialState);
|
||||||
|
|
||||||
|
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setState({ ...options, open: true, resolve });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
state.resolve?.(true);
|
||||||
|
setState(initialState);
|
||||||
|
}, [state.resolve]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
state.resolve?.(false);
|
||||||
|
setState(initialState);
|
||||||
|
}, [state.resolve]);
|
||||||
|
|
||||||
|
return { confirmState: state, confirm, handleConfirm, handleCancel };
|
||||||
|
}
|
||||||
@@ -4,30 +4,37 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-zinc-200;
|
@apply border-zinc-200/60;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-white text-zinc-900;
|
@apply bg-white text-zinc-900 antialiased;
|
||||||
}
|
}
|
||||||
/* Custom scrollbar */
|
/* Modern scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 5px;
|
||||||
height: 6px;
|
height: 5px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #d4d4d8;
|
background: rgba(161, 161, 170, 0.35);
|
||||||
border-radius: 3px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #a1a1aa;
|
background: rgba(161, 161, 170, 0.6);
|
||||||
}
|
}
|
||||||
/* Firefox scrollbar */
|
/* Firefox scrollbar */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #d4d4d8 transparent;
|
scrollbar-color: rgba(161, 161, 170, 0.35) transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Skeleton shimmer animation */
|
||||||
|
.skeleton {
|
||||||
|
@apply bg-zinc-100 rounded-md animate-pulse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user