feat(assets): 优化资产预览性能并添加资源管理器连接状态提示
- 使用缩略图替代原图展示,通过 sharp 库生成缓存缩略图 - 优化资产分组逻辑,避免不必要的重计算 - 添加 WebSocket 连接状态提示到输入框 - 使用 `useCallback` 和 `useRef` 优化组件渲染性能 - 添加 AbortController 支持请求取消,防止内存泄漏 - 添加 `disconnected` 事件处理,自动重置会话状态
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user