feat(assets): 优化资产预览性能并添加资源管理器连接状态提示

- 使用缩略图替代原图展示,通过 sharp 库生成缓存缩略图
- 优化资产分组逻辑,避免不必要的重计算
- 添加 WebSocket 连接状态提示到输入框
- 使用 `useCallback` 和 `useRef` 优化组件渲染性能
- 添加 AbortController 支持请求取消,防止内存泄漏
- 添加 `disconnected` 事件处理,自动重置会话状态
This commit is contained in:
2026-05-08 02:47:23 +08:00
parent a92379e20e
commit 7440ade66d
13 changed files with 680 additions and 76 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { RefreshCw, FolderOpen, Image as ImageIcon, Film } from 'lucide-react';
import { useAssets } from '@/hooks/useAssets';
import { useAccounts } from '@/hooks/useAccounts';
@@ -16,6 +16,13 @@ interface ManifestGroup {
latest_at: string;
}
interface GroupEntry {
manifestPath: string;
assets: Asset[];
title: string;
subtitle: string;
}
type TypeTab = 'all' | 'image' | 'video';
function AssetGroupSkeleton() {
@@ -34,18 +41,28 @@ function AssetGroupSkeleton() {
);
}
function getGroupName(manifestPath: string) {
const parts = manifestPath.replace(/\\/g, '/').split('/');
return parts.length >= 2 ? parts[parts.length - 2] : manifestPath;
}
export function AssetGallery() {
const [accountFilter, setAccountFilter] = useState('');
const [typeTab, setTypeTab] = useState<TypeTab>('all');
const [manifests, setManifests] = useState<ManifestGroup[]>([]);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const { accounts } = useAccounts();
const { assets, loading, remove } = useAssets({
const { assets, loading, refresh, remove } = useAssets({
accountId: accountFilter || undefined,
});
useEffect(() => {
fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {});
const controller = new AbortController();
fetch('/api/assets/manifests', { signal: controller.signal })
.then((r) => r.json())
.then(setManifests)
.catch(() => {});
return () => controller.abort();
}, []);
const filtered = useMemo(() => {
@@ -53,49 +70,55 @@ export function AssetGallery() {
return assets.filter((a) => a.type === typeTab);
}, [assets, typeTab]);
const grouped = useMemo(() => {
const imageCount = useMemo(() => assets.filter((a) => a.type === 'image').length, [assets]);
const videoCount = useMemo(() => assets.filter((a) => a.type === 'video').length, [assets]);
const groupEntries = useMemo(() => {
const map = new Map<string, Asset[]>();
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;
}, [filtered]);
const imageCount = assets.filter((a) => a.type === 'image').length;
const videoCount = assets.filter((a) => a.type === 'video').length;
const entries: GroupEntry[] = [];
for (const [manifestPath, groupAssets] of map) {
// Filter by account
if (accountFilter && manifestPath !== 'ungrouped') {
const m = manifests.find((x) => x.manifest_path === manifestPath);
if (m?.account_id !== accountFilter) continue;
}
const handleScan = async () => {
const title = getGroupName(manifestPath);
const m = manifests.find((x) => x.manifest_path === manifestPath);
const parts: string[] = [];
if (m?.image_count) parts.push(`${m.image_count}`);
if (m?.video_count) parts.push(`${m.video_count} 视频`);
const acc = accounts.find((a) => a.id === m?.account_id);
if (acc) parts.push(acc.name);
entries.push({ manifestPath, assets: groupAssets, title, subtitle: parts.join(' · ') });
}
return entries;
}, [filtered, manifests, accounts, accountFilter]);
const handleScan = useCallback(async () => {
await fetch('/api/assets/scan', { method: 'POST' });
window.location.reload();
};
const controller = new AbortController();
fetch('/api/assets/manifests', { signal: controller.signal })
.then((r) => r.json())
.then(setManifests)
.catch(() => {});
refresh();
}, [refresh]);
const getGroupName = (manifestPath: string) => {
const parts = manifestPath.replace(/\\/g, '/').split('/');
const dir = parts.length >= 2 ? parts[parts.length - 2] : manifestPath;
return dir;
};
const handlePreview = useCallback((asset: Asset) => setPreviewAsset(asset), []);
const handleClosePreview = useCallback(() => setPreviewAsset(null), []);
const getGroupSubtitle = (manifestPath: string) => {
const m = manifests.find((m) => m.manifest_path === manifestPath);
if (!m) return '';
const parts: string[] = [];
if (m.image_count) parts.push(`${m.image_count}`);
if (m.video_count) parts.push(`${m.video_count} 视频`);
const acc = accounts.find((a) => a.id === m.account_id);
if (acc) parts.push(acc.name);
return parts.join(' · ');
};
const filteredGroups = useMemo(() => {
if (!accountFilter) return [...grouped.entries()];
return [...grouped.entries()].filter(([key]) => {
if (key === 'ungrouped') return true;
const m = manifests.find((m) => m.manifest_path === key);
return m?.account_id === accountFilter;
});
}, [grouped, manifests, accountFilter]);
const previewIndex = useMemo(() => {
if (!previewAsset) return -1;
return filtered.findIndex((a) => a.id === previewAsset.id);
}, [previewAsset, filtered]);
const tabs: { key: TypeTab; label: string; icon: typeof ImageIcon; count: number }[] = [
{ key: 'all', label: '全部', icon: FolderOpen, count: assets.length },
@@ -151,7 +174,7 @@ export function AssetGallery() {
<div>
{Array.from({ length: 3 }).map((_, i) => <AssetGroupSkeleton key={i} />)}
</div>
) : filteredGroups.length === 0 ? (
) : groupEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center mt-16 text-zinc-300">
<div className="w-16 h-16 rounded-2xl bg-zinc-50 flex items-center justify-center mb-4">
<FolderOpen size={24} strokeWidth={1.5} className="text-zinc-300" />
@@ -160,20 +183,28 @@ export function AssetGallery() {
<p className="text-xs mt-1 text-zinc-300"> output </p>
</div>
) : (
filteredGroups.map(([manifestPath, groupAssets]) => (
groupEntries.map((entry) => (
<AssetProjectGroup
key={manifestPath}
title={getGroupName(manifestPath)}
subtitle={getGroupSubtitle(manifestPath)}
assets={groupAssets}
onPreview={setPreviewAsset}
key={entry.manifestPath}
title={entry.title}
subtitle={entry.subtitle}
assets={entry.assets}
onPreview={handlePreview}
onDelete={remove}
/>
))
)}
</div>
{previewAsset && <AssetPreview asset={previewAsset} allAssets={filtered} onClose={() => setPreviewAsset(null)} onNavigate={setPreviewAsset} />}
{previewAsset && (
<AssetPreview
asset={previewAsset}
allAssets={filtered}
currentIndex={previewIndex}
onClose={handleClosePreview}
onNavigate={handlePreview}
/>
)}
</div>
);
}

View File

@@ -1,23 +1,25 @@
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useRef } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
import type { Asset } from '@/types';
interface Props {
asset: Asset;
allAssets: Asset[];
currentIndex: number;
onClose: () => void;
onNavigate: (asset: Asset) => void;
}
export function AssetPreview({ asset, allAssets, onClose, onNavigate }: Props) {
const currentIndex = allAssets.findIndex((a) => a.id === asset.id);
export function AssetPreview({ asset, allAssets, currentIndex, onClose, onNavigate }: Props) {
const lengthRef = useRef(allAssets.length);
lengthRef.current = allAssets.length;
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]);
if (currentIndex < lengthRef.current - 1) onNavigate(allAssets[currentIndex + 1]);
}, [currentIndex, allAssets, onNavigate]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {

View File

@@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import { Trash2, Film, Image as ImageIcon } from 'lucide-react';
import type { Asset } from '@/types';
@@ -9,9 +10,9 @@ interface Props {
onDelete: (id: string) => void;
}
export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete }: Props) {
const images = assets.filter((a) => a.type === 'image');
const videos = assets.filter((a) => a.type === 'video');
export const AssetProjectGroup = memo(function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete }: Props) {
const images = useMemo(() => assets.filter((a) => a.type === 'image'), [assets]);
const videos = useMemo(() => assets.filter((a) => a.type === 'video'), [assets]);
return (
<div className="mb-6">
@@ -43,7 +44,7 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
>
{asset.type === 'image' ? (
<img
src={`/api/assets/file?path=${encodeURIComponent(asset.file_path)}`}
src={`/api/assets/thumb?path=${encodeURIComponent(asset.file_path)}&size=200`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
@@ -75,4 +76,4 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
</div>
</div>
);
}
});

View File

@@ -15,7 +15,7 @@ export interface ImageAttachment {
preview: string;
}
export function ChatInput({ onSend, disabled }: { onSend: (content: string, images?: Array<{ data: string; mimeType: string }>) => void; disabled?: boolean }) {
export function ChatInput({ onSend, disabled, connecting }: { onSend: (content: string, images?: Array<{ data: string; mimeType: string }>) => void; disabled?: boolean; connecting?: boolean }) {
const [input, setInput] = useState('');
const [showCmds, setShowCmds] = useState(false);
const [cmdIdx, setCmdIdx] = useState(0);
@@ -186,7 +186,7 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string, imag
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={disabled ? '等待回复中...' : '输入消息...'}
placeholder={disabled ? (connecting ? '连接中...' : '等待回复中...') : '输入消息...'}
className="flex-1 bg-transparent text-[15px] resize-none outline-none placeholder:text-zinc-300 text-zinc-800 min-h-[40px] max-h-[140px] py-2 leading-relaxed"
disabled={disabled}
style={{ height: 'auto' }}

View File

@@ -150,7 +150,7 @@ export function ChatView() {
<h2 className="text-lg font-semibold text-zinc-800"></h2>
<p className="text-sm text-zinc-400 mt-1"></p>
</div>
<ChatInput onSend={handleSend} disabled={thinking || creatingRef.current} />
<ChatInput onSend={handleSend} disabled={thinking || !connected || creatingRef.current} connecting={!connected} />
{accounts.length > 0 && (
<div className="flex items-center gap-2 mt-3 justify-center">
<span className="text-xs text-zinc-400">:</span>
@@ -275,7 +275,7 @@ export function ChatView() {
)}
{/* Input */}
<ChatInput onSend={handleSend} disabled={thinking} />
<ChatInput onSend={handleSend} disabled={thinking || !connected} connecting={!connected} />
</div>
</div>
</div>

View File

@@ -1,19 +1,28 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { api } from '@/lib/api';
import type { Asset } from '@/types';
export function useAssets(params?: { accountId?: string; type?: string }) {
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const accountId = params?.accountId;
const type = params?.type;
const refresh = useCallback(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
api.listAssets(params).then(setAssets).finally(() => setLoading(false));
}, [params?.accountId, params?.type]);
api.listAssets({ accountId, type }, controller.signal)
.then(setAssets)
.catch((err) => { if (err.name !== 'AbortError') throw err; })
.finally(() => { if (controller === abortRef.current) setLoading(false); });
}, [accountId, type]);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => { refresh(); return () => { abortRef.current?.abort(); }; }, [refresh]);
const remove = (id: string) => api.deleteAsset(id).then(refresh);
const remove = useCallback((id: string) => api.deleteAsset(id).then(refresh), [refresh]);
return { assets, loading, refresh, remove };
}

View File

@@ -24,6 +24,10 @@ export function useChat(conversationId: string | null) {
useEffect(() => {
const onConnected = () => setConnected(true);
const onDisconnected = () => {
setConnected(false);
activeConvRef.current = null;
};
const onHistory = (data: Record<string, unknown>) => {
setMessages((data.messages as Message[]) || []);
};
@@ -96,6 +100,7 @@ export function useChat(conversationId: string | null) {
};
chatSocket.on('connected', onConnected);
chatSocket.on('disconnected', onDisconnected);
chatSocket.on('history', onHistory);
chatSocket.on('message', onMessage);
chatSocket.on('status', onStatus);
@@ -112,6 +117,7 @@ export function useChat(conversationId: string | null) {
return () => {
chatSocket.off('connected', onConnected);
chatSocket.off('disconnected', onDisconnected);
chatSocket.off('history', onHistory);
chatSocket.off('message', onMessage);
chatSocket.off('status', onStatus);

View File

@@ -40,11 +40,11 @@ export const api = {
request<void>(`/pipeline/messages/${id}`, { method: 'DELETE' }),
// Assets
listAssets: (params?: { accountId?: string; type?: string }) => {
listAssets: (params?: { accountId?: string; type?: string }, signal?: AbortSignal) => {
const qs = new URLSearchParams();
if (params?.accountId) qs.set('accountId', params.accountId);
if (params?.type) qs.set('type', params.type);
return request<Asset[]>(`/assets?${qs}`);
return request<Asset[]>(`/assets?${qs}`, { signal });
},
deleteAsset: (id: string) => request<void>(`/assets/${id}`, { method: 'DELETE' }),

View File

@@ -16,6 +16,7 @@ class ChatSocket {
try { const { type, data } = JSON.parse(event.data); this.emit(type, data); } catch {}
};
this.ws.onclose = () => {
this.emit('disconnected', {});
if (!this.intentionallyClosed) {
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
}