feat(web): 添加全局 Toast 通知系统和资产预览导航功能
- 新增 ToastProvider 和 useToast hook,支持全局成功/错误/信息提示 - 资产预览增加左右导航按钮、键盘快捷键(方向键)和计数器显示 - 资产库增加图片/视频类型筛选标签页及计数 - 对话列表增加最近对话展示、搜索优化和删除确认 - 消息增加删除确认对话框 - 优化聊天自动滚动行为,仅在用户未手动滚动时跟随新内容 - 新增删除消息 API 端点 - 优化消息历史清理逻辑,过滤错误消息和孤儿 tool 消息 - 添加自定义滚动条样式 - 优化账户参考图显示逻辑,支持本地文件显示 - 修复对话创建流程,直接导航到新创建的对话
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { RefreshCw, FolderOpen } from 'lucide-react';
|
||||
import { RefreshCw, FolderOpen, Image as ImageIcon, Film } from 'lucide-react';
|
||||
import { useAssets } from '@/hooks/useAssets';
|
||||
import { AssetProjectGroup } from './AssetProjectGroup';
|
||||
import { AssetPreview } from './AssetPreview';
|
||||
@@ -14,8 +14,11 @@ interface ManifestGroup {
|
||||
latest_at: string;
|
||||
}
|
||||
|
||||
type TypeTab = 'all' | 'image' | 'video';
|
||||
|
||||
export function AssetGallery() {
|
||||
const [accountFilter, setAccountFilter] = useState('');
|
||||
const [typeTab, setTypeTab] = useState<TypeTab>('all');
|
||||
const [accounts, setAccounts] = useState<{ id: string; name: string }[]>([]);
|
||||
const [manifests, setManifests] = useState<ManifestGroup[]>([]);
|
||||
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
|
||||
@@ -28,16 +31,25 @@ export function AssetGallery() {
|
||||
fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Group assets by manifest_path
|
||||
// Filter by type tab
|
||||
const filtered = useMemo(() => {
|
||||
if (typeTab === 'all') return assets;
|
||||
return assets.filter((a) => a.type === typeTab);
|
||||
}, [assets, typeTab]);
|
||||
|
||||
// Group filtered assets by manifest_path
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Asset[]>();
|
||||
for (const asset of assets) {
|
||||
for (const asset of filtered) {
|
||||
const key = asset.manifest_path || 'ungrouped';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(asset);
|
||||
}
|
||||
return map;
|
||||
}, [assets]);
|
||||
}, [filtered]);
|
||||
|
||||
const imageCount = assets.filter((a) => a.type === 'image').length;
|
||||
const videoCount = assets.filter((a) => a.type === 'video').length;
|
||||
|
||||
const handleScan = async () => {
|
||||
await fetch('/api/assets/scan', { method: 'POST' });
|
||||
@@ -46,7 +58,6 @@ export function AssetGallery() {
|
||||
|
||||
const getGroupName = (manifestPath: string) => {
|
||||
const parts = manifestPath.replace(/\\/g, '/').split('/');
|
||||
// output/<name>/manifest.json → <name>
|
||||
const dir = parts.length >= 2 ? parts[parts.length - 2] : manifestPath;
|
||||
return dir;
|
||||
};
|
||||
@@ -72,10 +83,36 @@ export function AssetGallery() {
|
||||
});
|
||||
}, [grouped, manifests, accountFilter]);
|
||||
|
||||
const tabs: { key: TypeTab; label: string; icon: typeof ImageIcon; count: number }[] = [
|
||||
{ key: 'all', label: '全部', icon: FolderOpen, count: assets.length },
|
||||
{ key: 'image', label: '图片', icon: ImageIcon, count: imageCount },
|
||||
{ key: 'video', label: '视频', icon: Film, count: videoCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-200 bg-white flex-shrink-0">
|
||||
{/* Type tabs */}
|
||||
<div className="flex rounded-lg border border-zinc-200 overflow-hidden">
|
||||
{tabs.map(({ key, label, icon: Icon, count }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTypeTab(key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors
|
||||
${typeTab === key
|
||||
? 'bg-indigo-50 text-indigo-700 border-r border-zinc-200 last:border-r-0'
|
||||
: 'bg-white text-zinc-500 hover:bg-zinc-50 border-r border-zinc-200 last:border-r-0'}`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{label}
|
||||
<span className={`text-[10px] px-1 rounded-full ${typeTab === key ? 'bg-indigo-100 text-indigo-600' : 'bg-zinc-100 text-zinc-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={accountFilter}
|
||||
onChange={(e) => setAccountFilter(e.target.value)}
|
||||
@@ -86,7 +123,7 @@ export function AssetGallery() {
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[10px] text-zinc-400">{assets.length} 个资产</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={handleScan}>
|
||||
<RefreshCw size={12} className="mr-1" />
|
||||
@@ -101,7 +138,7 @@ export function AssetGallery() {
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center mt-16 text-zinc-400">
|
||||
<FolderOpen size={28} className="mb-2 opacity-40" />
|
||||
<p className="text-sm">暂无资产</p>
|
||||
<p className="text-sm">{typeTab === 'all' ? '暂无资产' : `暂无${typeTab === 'image' ? '图片' : '视频'}`}</p>
|
||||
<p className="text-xs mt-1">点击"扫描"从 output 目录导入</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -118,7 +155,7 @@ export function AssetGallery() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewAsset && <AssetPreview asset={previewAsset} onClose={() => setPreviewAsset(null)} />}
|
||||
{previewAsset && <AssetPreview asset={previewAsset} allAssets={filtered} onClose={() => setPreviewAsset(null)} onNavigate={setPreviewAsset} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { Asset } from '@/types';
|
||||
|
||||
export function AssetPreview({ asset, onClose }: { asset: Asset; onClose: () => void }) {
|
||||
interface Props {
|
||||
asset: Asset;
|
||||
allAssets: Asset[];
|
||||
onClose: () => void;
|
||||
onNavigate: (asset: Asset) => void;
|
||||
}
|
||||
|
||||
export function AssetPreview({ asset, allAssets, onClose, onNavigate }: Props) {
|
||||
const currentIndex = allAssets.findIndex((a) => a.id === asset.id);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (currentIndex > 0) onNavigate(allAssets[currentIndex - 1]);
|
||||
}, [currentIndex, allAssets, onNavigate]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (currentIndex < allAssets.length - 1) onNavigate(allAssets[currentIndex + 1]);
|
||||
}, [currentIndex, allAssets, onNavigate]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') goPrev();
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
}, [onClose, goPrev, goNext]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < allAssets.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||||
@@ -9,11 +41,29 @@ export function AssetPreview({ asset, onClose }: { asset: Asset; onClose: () =>
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 rounded-full bg-zinc-800 hover:bg-zinc-700 text-white"
|
||||
className="absolute top-4 right-4 p-2 rounded-full bg-zinc-800 hover:bg-zinc-700 text-white z-10"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{hasPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
className="absolute left-4 p-2 rounded-full bg-zinc-800/80 hover:bg-zinc-700 text-white z-10"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
)}
|
||||
{hasNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
||||
className="absolute right-4 p-2 rounded-full bg-zinc-800/80 hover:bg-zinc-700 text-white z-10"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
|
||||
{asset.type === 'image' ? (
|
||||
<img
|
||||
@@ -30,6 +80,13 @@ export function AssetPreview({ asset, onClose }: { asset: Asset; onClose: () =>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Counter */}
|
||||
{allAssets.length > 1 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-xs text-zinc-400 bg-zinc-900/80 px-3 py-1 rounded-full">
|
||||
{currentIndex + 1} / {allAssets.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); 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"
|
||||
>
|
||||
<Trash2 size={10} className="text-red-500" />
|
||||
|
||||
Reference in New Issue
Block a user