feat(web): 添加全局 Toast 通知系统和资产预览导航功能

- 新增 ToastProvider 和 useToast hook,支持全局成功/错误/信息提示
- 资产预览增加左右导航按钮、键盘快捷键(方向键)和计数器显示
- 资产库增加图片/视频类型筛选标签页及计数
- 对话列表增加最近对话展示、搜索优化和删除确认
- 消息增加删除确认对话框
- 优化聊天自动滚动行为,仅在用户未手动滚动时跟随新内容
- 新增删除消息 API 端点
- 优化消息历史清理逻辑,过滤错误消息和孤儿 tool 消息
- 添加自定义滚动条样式
- 优化账户参考图显示逻辑,支持本地文件显示
- 修复对话创建流程,直接导航到新创建的对话
This commit is contained in:
2026-05-08 00:23:36 +08:00
parent 088bdb9a8e
commit 66d170066a
23 changed files with 801 additions and 374 deletions

View File

@@ -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>
);
}