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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user