修复内存泄漏问题,优化Blob URL清理机制

This commit is contained in:
2025-09-18 23:48:16 +08:00
parent a4583eb1f0
commit 803cc100be
9 changed files with 1009 additions and 408 deletions

48
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "vite-react-typescript-starter", "name": "ano-banana-ai-image-editor",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vite-react-typescript-starter", "name": "ano-banana-ai-image-editor",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@google/genai": "^1.16.0", "@google/genai": "^1.16.0",
@@ -21,6 +21,7 @@
"konva": "^9.3.22", "konva": "^9.3.22",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^9.10.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -359,6 +360,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -2915,6 +2922,22 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@@ -5267,6 +5290,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-day-picker": {
"version": "9.10.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.10.0.tgz",
"integrity": "sha512-tedecLSd+fpSN+J08601MaMsf122nxtqZXxB6lwX37qFoLtuPNuRJN8ylxFjLhyJS1kaLfAqL1GUkSLd2BMrpQ==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View File

@@ -28,6 +28,7 @@
"konva": "^9.3.22", "konva": "^9.3.22",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^9.10.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",

View File

@@ -6,6 +6,8 @@ import { cn } from '../utils/cn';
import { ImagePreviewModal } from './ImagePreviewModal'; import { ImagePreviewModal } from './ImagePreviewModal';
import * as indexedDBService from '../services/indexedDBService'; import * as indexedDBService from '../services/indexedDBService';
import { useIndexedDBListener } from '../hooks/useIndexedDBListener'; import { useIndexedDBListener } from '../hooks/useIndexedDBListener';
import { DayPicker } from 'react-day-picker';
import zhCN from 'react-day-picker/dist/locale/zh-CN';
export const HistoryPanel: React.FC = () => { export const HistoryPanel: React.FC = () => {
const { const {
@@ -42,9 +44,24 @@ export const HistoryPanel: React.FC = () => {
const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener(); const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener();
// 筛选和搜索状态 // 筛选和搜索状态
const [startDate, setStartDate] = useState<string>(''); const [startDate, setStartDate] = useState<string>(() => {
const [endDate, setEndDate] = useState<string>(''); const today = new Date();
return today.toISOString().split('T')[0]; // 默认为今天
});
const [endDate, setEndDate] = useState<string>(() => {
const today = new Date();
return today.toISOString().split('T')[0]; // 默认为今天
});
const [searchTerm, setSearchTerm] = useState<string>(''); const [searchTerm, setSearchTerm] = useState<string>('');
const [showDatePicker, setShowDatePicker] = useState(false); // 控制日期选择器的显示
const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined }>({
from: new Date(new Date().setHours(0, 0, 0, 0)),
to: new Date(new Date().setHours(0, 0, 0, 0))
});
// 分页状态
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 30; // 每页显示的项目数
// 悬浮预览状态 // 悬浮预览状态
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number, size?: number} | null>(null); const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number, size?: number} | null>(null);
@@ -117,10 +134,12 @@ export const HistoryPanel: React.FC = () => {
// 筛选记录的函数 // 筛选记录的函数
const filterRecords = (records: any[], isGeneration: boolean) => { const filterRecords = (records: any[], isGeneration: boolean) => {
return records.filter(record => { return records.filter(record => {
// 日期筛选 // 日期筛选 - 修复日期比较逻辑
const recordDate = new Date(record.timestamp); const recordDate = new Date(record.timestamp);
if (startDate && recordDate < new Date(startDate)) return false; const recordDateStr = recordDate.toISOString().split('T')[0]; // 获取日期部分 YYYY-MM-DD
if (endDate && recordDate > new Date(endDate)) return false;
if (startDate && recordDateStr < startDate) return false;
if (endDate && recordDateStr > endDate) return false;
// 搜索词筛选 // 搜索词筛选
if (searchTerm) { if (searchTerm) {
@@ -246,20 +265,185 @@ export const HistoryPanel: React.FC = () => {
{/* 筛选和搜索控件 */} {/* 筛选和搜索控件 */}
<div className="mb-3 space-y-2"> <div className="mb-3 space-y-2">
<div className="flex space-x-1"> <div className="flex space-x-1">
<input <div className="flex-1 relative">
type="date" <Button
value={startDate} variant="outline"
onChange={(e) => setStartDate(e.target.value)} size="sm"
className="flex-1 text-xs p-1.5 border border-gray-200 rounded-lg text-gray-600 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-yellow-400 card" className={`w-full text-xs p-1.5 border-gray-200 text-gray-600 hover:bg-gray-100 card justify-start hover:shadow-sm transition-all ${
placeholder="开始日期" showDatePicker ? 'ring-2 ring-yellow-400 border-yellow-400' : ''
/> }`}
<input onClick={() => setShowDatePicker(!showDatePicker)}
type="date" >
value={endDate} <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
onChange={(e) => setEndDate(e.target.value)} <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
className="flex-1 text-xs p-1.5 border border-gray-200 rounded-lg text-gray-600 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-yellow-400 card" <line x1="16" y1="2" x2="16" y2="6"></line>
placeholder="结束日期" <line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{dateRange.from && dateRange.to ? (
dateRange.from.getTime() === dateRange.to.getTime() ? (
`${dateRange.from.toLocaleDateString()}`
) : (
`${dateRange.from.toLocaleDateString()} - ${dateRange.to.toLocaleDateString()}`
)
) : (
"选择日期范围"
)}
</Button>
{showDatePicker && (
<div className="absolute top-full left-0 z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 mt-1 card">
<style>{`
.rdp {
--rdp-cell-size: 36px; /* 增加单元格大小 */
--rdp-accent-color: #FDE047; /* 使用项目中的香蕉黄 */
--rdp-background-color: #FDE047;
margin: 0;
font-family: 'Inter', sans-serif;
font-size: 0.875rem;
}
.rdp-caption {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
}
.rdp-caption_label {
font-weight: 600;
font-size: 0.875rem;
color: #212529; /* 使用项目中的文本主色 */
}
.rdp-nav {
display: flex;
gap: 0.25rem;
}
.rdp-nav_button {
width: 24px;
height: 24px;
border-radius: 0.5rem;
border: 1px solid #E9ECEF; /* 使用项目中的边框色 */
background-color: #FFFFFF; /* 使用项目中的背景色 */
color: #6C757D; /* 使用项目中的次级文本色 */
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.rdp-nav_button:hover {
background-color: #F8F9FA; /* 使用项目中的面板背景色 */
color: #212529; /* 使用项目中的文本主色 */
border-color: #DEE2E6; /* 使用项目中的悬停边框色 */
}
.rdp-head_cell {
font-size: 0.75rem;
font-weight: 500;
color: #6C757D; /* 使用项目中的次级文本色 */
text-transform: uppercase;
padding: 0.25rem 0; /* 增加表头单元格的垂直间距 */
}
.rdp-head_row {
border-bottom: 1px solid #E9ECEF; /* 添加表头下边框 */
margin-bottom: 0.5rem; /* 增加表头下边距 */
}
.rdp-day {
border-radius: 0.5rem;
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
margin: 2px; /* 增加每天之间的间距 */
}
.rdp .rdp-day_selected {
background-color: #FDE047; /* 使用项目中的香蕉黄 */
color: #212529; /* 使用项目中的文本主色 */
font-weight: 600;
margin: 2px; /* 保持选中日期的间距 */
box-shadow: 0 0 0 1px #FDE047, 0 0 0 3px rgba(253, 224, 71, 0.3); /* 添加外发光效果 */
}
.rdp .rdp-day_range_middle {
background-color: #FEF9C3; /* 使用项目中的香蕉黄浅色 */
color: #713F12; /* 使用项目中的香蕉黄深色 */
border-radius: 0;
margin: 2px; /* 保持范围中间日期的间距 */
box-shadow: inset 0 0 0 1px rgba(253, 224, 71, 0.5); /* 添加内阴影增强视觉效果 */
}
.rdp .rdp-day_range_start, .rdp .rdp-day_range_end {
background-color: #FDE047; /* 使用项目中的香蕉黄 */
color: #212529; /* 使用项目中的文本主色 */
font-weight: 600;
border-radius: 0.5rem;
margin: 2px; /* 保持范围端点日期的间距 */
box-shadow: 0 0 0 1px #FDE047, 0 0 0 3px rgba(253, 224, 71, 0.3); /* 添加外发光效果 */
}
.rdp .rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_range_middle):not(.rdp-day_range_start):not(.rdp-day_range_end) {
background-color: #F8F9FA; /* 使用项目中的面板背景色 */
color: #212529; /* 使用项目中的文本主色 */
}
.rdp .rdp-day_selected:hover {
background-color: #FDE047; /* 保持选中状态的背景色 */
}
.rdp .rdp-day_range_middle:hover {
background-color: #FEF9C3; /* 保持范围中间的背景色 */
}
.rdp .rdp-day_range_start:hover, .rdp .rdp-day_range_end:hover {
background-color: #FDE047; /* 保持范围端点的背景色 */
}
.rdp .rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_middle):not(.rdp-day_range_start):not(.rdp-day_range_end) {
background-color: #FFF9DB; /* 今天的背景色 */
color: #713F12; /* 今天的文本色 */
font-weight: 500;
position: relative;
}
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_middle):not(.rdp-day_range_start):not(.rdp-day_range_end)::after {
content: "";
position: absolute;
bottom: 2px;
right: 2px;
width: 4px;
height: 4px;
background-color: #713F12;
border-radius: 50%;
}
/* 添加月份间的分隔线 */
.rdp-months {
display: flex;
gap: 1rem;
}
.rdp-month {
padding: 0.5rem;
}
`}</style>
<DayPicker
mode="range"
selected={dateRange}
onSelect={(range) => {
if (range) {
setDateRange(range);
// 更新字符串格式的日期用于筛选
if (range.from) {
setStartDate(range.from.toISOString().split('T')[0]);
}
if (range.to) {
setEndDate(range.to.toISOString().split('T')[0]);
}
}
}}
numberOfMonths={2}
className="border-0"
locale={zhCN}
/> />
<div className="flex justify-end space-x-2 mt-2">
<Button
variant="outline"
size="sm"
className="text-xs p-1.5 h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => setShowDatePicker(false)}
>
</Button>
</div>
</div>
)}
</div>
</div> </div>
<div className="flex"> <div className="flex">
<input <input
@@ -274,9 +458,16 @@ export const HistoryPanel: React.FC = () => {
size="sm" size="sm"
className="text-xs p-1.5 rounded-l-none h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card" className="text-xs p-1.5 rounded-l-none h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => { onClick={() => {
setStartDate(''); const today = new Date(new Date().setHours(0, 0, 0, 0));
setEndDate(''); const todayStr = today.toISOString().split('T')[0];
setStartDate(todayStr);
setEndDate(todayStr);
setSearchTerm(''); setSearchTerm('');
// 重置日期范围
setDateRange({
from: today,
to: today
});
}} }}
> >
@@ -300,7 +491,17 @@ export const HistoryPanel: React.FC = () => {
) : ( ) : (
<div className="grid grid-cols-3 gap-1.5 max-h-72 relative overflow-y-scroll"> <div className="grid grid-cols-3 gap-1.5 max-h-72 relative overflow-y-scroll">
{/* 显示生成记录 */} {/* 显示生成记录 */}
{[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((generation, index) => ( {(() => {
const sortedGenerations = [...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedGenerations = sortedGenerations.slice(startIndex, endIndex);
return paginatedGenerations.map((generation, index) => {
// 计算全局索引用于显示编号
const globalIndex = startIndex + index;
return (
<div <div
key={generation.id} key={generation.id}
className={cn( className={cn(
@@ -339,7 +540,7 @@ export const HistoryPanel: React.FC = () => {
setHoveredImage({ setHoveredImage({
url: imageUrl, url: imageUrl,
title: `生成记录 G${index + 1}`, title: `生成记录 G${globalIndex + 1}`,
width: img.width, width: img.width,
height: img.height, height: img.height,
size: size size: size
@@ -393,7 +594,7 @@ export const HistoryPanel: React.FC = () => {
// 即使图像加载失败,也显示预览 // 即使图像加载失败,也显示预览
setHoveredImage({ setHoveredImage({
url: imageUrl, url: imageUrl,
title: `生成记录 G${index + 1}`, title: `生成记录 G${globalIndex + 1}`,
width: 0, width: 0,
height: 0, height: 0,
size: 0 size: 0
@@ -504,13 +705,28 @@ export const HistoryPanel: React.FC = () => {
{/* 变体编号 */} {/* 变体编号 */}
<div className="absolute top-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-white"> <div className="absolute top-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-white">
G{index + 1} G{globalIndex + 1}
</div> </div>
</div> </div>
))} );
});
})()}
{/* 显示编辑记录 */} {/* 显示编辑记录 */}
{[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((edit, index) => ( {(() => {
const sortedEdits = [...filteredEdits].sort((a, b) => b.timestamp - a.timestamp);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedEdits = sortedEdits.slice(startIndex, endIndex);
// 计算生成记录的数量,用于编辑记录的编号
const generationCount = [...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).length;
return paginatedEdits.map((edit, index) => {
// 计算全局索引用于显示编号
const globalIndex = startIndex + index;
return (
<div <div
key={edit.id} key={edit.id}
className={cn( className={cn(
@@ -551,7 +767,7 @@ export const HistoryPanel: React.FC = () => {
setHoveredImage({ setHoveredImage({
url: imageUrl, url: imageUrl,
title: `编辑记录 E${index + 1}`, title: `编辑记录 E${globalIndex + 1}`,
width: img.width, width: img.width,
height: img.height, height: img.height,
size: size size: size
@@ -598,7 +814,7 @@ export const HistoryPanel: React.FC = () => {
// 即使图像加载失败,也显示预览 // 即使图像加载失败,也显示预览
setHoveredImage({ setHoveredImage({
url: imageUrl, url: imageUrl,
title: `编辑记录 E${index + 1}`, title: `编辑记录 E${globalIndex + 1}`,
width: 0, width: 0,
height: 0, height: 0,
size: 0 size: 0
@@ -705,15 +921,54 @@ export const HistoryPanel: React.FC = () => {
{/* 编辑标签 */} {/* 编辑标签 */}
<div className="absolute top-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-white"> <div className="absolute top-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-white">
E{index + 1} E{globalIndex + 1}
</div> </div>
</div> </div>
))} );
});
})()}
</div> </div>
)} )}
</div> </div>
{/* 分页控件 */}
{(() => {
const totalItems = filteredGenerations.length + filteredEdits.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
// 只在有多页时显示分页控件
if (totalPages > 1) {
return (
<div className="flex items-center justify-between py-2 border-t border-gray-100">
<Button
variant="outline"
size="sm"
className="text-xs p-1.5 h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
</Button>
<div className="text-xs text-gray-500">
{currentPage} {totalPages}
</div>
<Button
variant="outline"
size="sm"
className="text-xs p-1.5 h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
);
}
return null;
})()}
{/* 生成详情 */} {/* 生成详情 */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">

View File

@@ -38,9 +38,18 @@ export const ImageCanvas: React.FC = () => {
// 加载图像并在 canvasImage 变化时自动适应 // 加载图像并在 canvasImage 变化时自动适应
useEffect(() => { useEffect(() => {
let img: HTMLImageElement | null = null;
if (canvasImage) { if (canvasImage) {
const img = new window.Image(); img = new window.Image();
let isCancelled = false;
img.onload = () => { img.onload = () => {
// 检查是否已取消
if (isCancelled) {
return;
}
setImage(img); setImage(img);
// 每次有新图像时都自动适应画布,而不仅仅是在初始状态下 // 每次有新图像时都自动适应画布,而不仅仅是在初始状态下
@@ -59,10 +68,26 @@ export const ImageCanvas: React.FC = () => {
// 居中图像 // 居中图像
setCanvasPan({ x: 0, y: 0 }); setCanvasPan({ x: 0, y: 0 });
}; };
img.onerror = () => {
if (!isCancelled) {
console.error('图像加载失败');
}
};
img.src = canvasImage; img.src = canvasImage;
} else { } else {
setImage(null); setImage(null);
} }
// 清理函数
return () => {
// 取消图像加载
if (img) {
img.onload = null;
img.onerror = null;
}
};
}, [canvasImage, stageSize, setCanvasZoom, setCanvasPan]); }, [canvasImage, stageSize, setCanvasZoom, setCanvasPan]);
// 处理舞台大小调整 // 处理舞台大小调整

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import * as indexedDBService from '../services/indexedDBService'; import * as indexedDBService from '../services/indexedDBService';
export const useIndexedDBListener = () => { export const useIndexedDBListener = () => {
@@ -6,44 +6,73 @@ export const useIndexedDBListener = () => {
const [edits, setEdits] = useState<any[]>([]); const [edits, setEdits] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const isMountedRef = useRef(true);
const loadRecords = async () => { const loadRecords = async () => {
if (!isMountedRef.current) return;
try { try {
setLoading(true); setLoading(true);
const allGenerations = await indexedDBService.getAllGenerations(); const allGenerations = await indexedDBService.getAllGenerations();
const allEdits = await indexedDBService.getAllEdits(); const allEdits = await indexedDBService.getAllEdits();
if (isMountedRef.current) {
setGenerations(allGenerations); setGenerations(allGenerations);
setEdits(allEdits); setEdits(allEdits);
setError(null); setError(null);
}
} catch (err) { } catch (err) {
console.error('从IndexedDB加载记录失败:', err); console.error('从IndexedDB加载记录失败:', err);
if (isMountedRef.current) {
setError('加载历史记录失败'); setError('加载历史记录失败');
}
} finally { } finally {
if (isMountedRef.current) {
setLoading(false); setLoading(false);
} }
}
}; };
useEffect(() => { useEffect(() => {
// 标记组件已挂载
isMountedRef.current = true;
// 初始化数据库并加载记录 // 初始化数据库并加载记录
const initAndLoad = async () => { const initAndLoad = async () => {
try { try {
await indexedDBService.initDB(); await indexedDBService.initDB();
if (isMountedRef.current) {
await loadRecords(); await loadRecords();
}
} catch (err) { } catch (err) {
console.error('初始化IndexedDB失败:', err); console.error('初始化IndexedDB失败:', err);
if (isMountedRef.current) {
setError('初始化数据库失败'); setError('初始化数据库失败');
setLoading(false); setLoading(false);
} }
}
}; };
initAndLoad(); initAndLoad();
// 设置定时器定期检查新记录 // 设置定时器定期检查新记录
const interval = setInterval(() => { intervalRef.current = setInterval(() => {
if (isMountedRef.current) {
loadRecords(); loadRecords();
}
}, 3000); // 每3秒检查一次 }, 3000); // 每3秒检查一次
return () => clearInterval(interval); // 清理函数
return () => {
// 标记组件已卸载
isMountedRef.current = false;
// 清除定时器
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, []); }, []);
const refresh = () => { const refresh = () => {

View File

@@ -62,6 +62,24 @@ export class GeminiService {
if (candidate.finishReason === 'IMAGE_SAFETY') { if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。') throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
} }
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据');
}
}
} }
const images: string[] = [] const images: string[] = []
@@ -132,6 +150,24 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') { if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。') throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
} }
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据');
}
}
} }
const images: string[] = [] const images: string[] = []
@@ -196,6 +232,24 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') { if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。') throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
} }
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据');
}
}
} }
const responseText = response.candidates[0].content.parts[0].text const responseText = response.candidates[0].content.parts[0].text

View File

@@ -7,6 +7,49 @@ const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload'
// 创建一个Map来缓存已上传的图像 // 创建一个Map来缓存已上传的图像
const uploadCache = new Map<string, UploadResult>() const uploadCache = new Map<string, UploadResult>()
// 缓存配置
const MAX_CACHE_SIZE = 100; // 最大缓存条目数
const CACHE_EXPIRY_TIME = 30 * 60 * 1000; // 缓存过期时间30分钟
/**
* 清理过期的缓存条目
*/
function cleanupExpiredCache(): void {
const now = Date.now();
let deletedCount = 0;
uploadCache.forEach((value, key) => {
if (now - value.timestamp > CACHE_EXPIRY_TIME) {
uploadCache.delete(key);
deletedCount++;
}
});
if (deletedCount > 0) {
console.log(`清除了 ${deletedCount} 个过期的缓存条目`);
}
}
/**
* 检查并维护缓存大小
*/
function maintainCacheSize(): void {
// 如果缓存大小超过限制,删除最旧的条目
if (uploadCache.size >= MAX_CACHE_SIZE) {
// 获取所有条目并按时间排序
const entries = Array.from(uploadCache.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
// 删除最旧的条目,直到缓存大小在限制内
const deleteCount = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.1)); // 删除10%的条目
for (let i = 0; i < deleteCount && uploadCache.size >= MAX_CACHE_SIZE; i++) {
uploadCache.delete(entries[i][0]);
}
console.log(`缓存已满,删除了 ${deleteCount} 个最旧的条目`);
}
}
/** /**
* 生成图像的唯一标识符 * 生成图像的唯一标识符
* @param base64Data - base64编码的图像数据 * @param base64Data - base64编码的图像数据
@@ -35,8 +78,15 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
const imageHash = getImageHash(base64Data) const imageHash = getImageHash(base64Data)
if (!skipCache && uploadCache.has(imageHash)) { if (!skipCache && uploadCache.has(imageHash)) {
const cachedResult = uploadCache.get(imageHash)!;
// 检查缓存是否过期
if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) {
console.log('从缓存中获取上传结果') console.log('从缓存中获取上传结果')
return uploadCache.get(imageHash)! return cachedResult;
} else {
// 缓存过期,删除它
uploadCache.delete(imageHash);
}
} }
try { try {
@@ -73,6 +123,12 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || '' const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
// 清理过期缓存
cleanupExpiredCache();
// 维护缓存大小
maintainCacheSize();
// 将上传结果存储到缓存中 // 将上传结果存储到缓存中
const uploadResult = { success: true, url: fullUrl, error: undefined } const uploadResult = { success: true, url: fullUrl, error: undefined }
uploadCache.set(imageHash, { uploadCache.set(imageHash, {
@@ -88,6 +144,12 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
console.error('上传图像时出错:', error) console.error('上传图像时出错:', error)
const errorResult = { success: false, url: undefined, error: error instanceof Error ? error.message : String(error) } const errorResult = { success: false, url: undefined, error: error instanceof Error ? error.message : String(error) }
// 清理过期缓存
cleanupExpiredCache();
// 维护缓存大小(即使是失败的结果也缓存,但时间较短)
maintainCacheSize();
// 将失败的上传结果也存储到缓存中(可选) // 将失败的上传结果也存储到缓存中(可选)
uploadCache.set(imageHash, { uploadCache.set(imageHash, {
...errorResult, ...errorResult,
@@ -152,4 +214,5 @@ export const uploadImages = async (base64Images: string[], accessToken: string,
*/ */
export const clearUploadCache = (): void => { export const clearUploadCache = (): void => {
uploadCache.clear() uploadCache.clear()
console.log('上传缓存已清除')
} }

View File

@@ -123,6 +123,10 @@ interface AppState {
addBlob: (blob: Blob) => string; addBlob: (blob: Blob) => string;
getBlob: (url: string) => Blob | undefined; getBlob: (url: string) => Blob | undefined;
cleanupOldHistory: () => void; cleanupOldHistory: () => void;
// Blob URL清理操作
revokeBlobUrls: (urls: string[]) => void;
cleanupAllBlobUrls: () => void;
} }
export const useAppStore = create<AppState>()( export const useAppStore = create<AppState>()(
@@ -314,6 +318,36 @@ export const useAppStore = create<AppState>()(
// 清理旧记录以保持在限制内现在限制为1000条 // 清理旧记录以保持在限制内现在限制为1000条
if (updatedProject.generations.length > 1000) { if (updatedProject.generations.length > 1000) {
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const generationsToRemove = updatedProject.generations.slice(0, updatedProject.generations.length - 1000);
generationsToRemove.forEach(gen => {
gen.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
gen.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 清理数组
updatedProject.generations.splice(0, updatedProject.generations.length - 1000); updatedProject.generations.splice(0, updatedProject.generations.length - 1000);
// 同时清理IndexedDB中的旧记录 // 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => { indexedDBService.cleanupOldRecords(1000).catch(err => {
@@ -409,6 +443,34 @@ export const useAppStore = create<AppState>()(
// 清理旧记录以保持在限制内现在限制为1000条 // 清理旧记录以保持在限制内现在限制为1000条
if (updatedProject.edits.length > 1000) { if (updatedProject.edits.length > 1000) {
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const editsToRemove = updatedProject.edits.slice(0, updatedProject.edits.length - 1000);
editsToRemove.forEach(edit => {
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
}
edit.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 清理数组
updatedProject.edits.splice(0, updatedProject.edits.length - 1000); updatedProject.edits.splice(0, updatedProject.edits.length - 1000);
// 同时清理IndexedDB中的旧记录 // 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => { indexedDBService.cleanupOldRecords(1000).catch(err => {
@@ -437,16 +499,56 @@ export const useAppStore = create<AppState>()(
const generations = [...state.currentProject.generations]; const generations = [...state.currentProject.generations];
const edits = [...state.currentProject.edits]; const edits = [...state.currentProject.edits];
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
// 如果生成记录超过1000条只保留最新的1000条 // 如果生成记录超过1000条只保留最新的1000条
if (generations.length > 1000) { if (generations.length > 1000) {
const generationsToRemove = generations.slice(0, generations.length - 1000);
generationsToRemove.forEach(gen => {
gen.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
gen.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
generations.splice(0, generations.length - 1000); generations.splice(0, generations.length - 1000);
} }
// 如果编辑记录超过1000条只保留最新的1000条 // 如果编辑记录超过1000条只保留最新的1000条
if (edits.length > 1000) { if (edits.length > 1000) {
const editsToRemove = edits.slice(0, edits.length - 1000);
editsToRemove.forEach(edit => {
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
}
edit.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
edits.splice(0, edits.length - 1000); edits.splice(0, edits.length - 1000);
} }
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 同时清理IndexedDB中的旧记录 // 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => { indexedDBService.cleanupOldRecords(1000).catch(err => {
console.error('清理IndexedDB旧记录失败:', err); console.error('清理IndexedDB旧记录失败:', err);
@@ -460,6 +562,27 @@ export const useAppStore = create<AppState>()(
updatedAt: Date.now() updatedAt: Date.now()
} }
}; };
}),
// 释放指定的Blob URLs
revokeBlobUrls: (urls: string[]) => set((state) => {
urls.forEach(url => {
if (state.blobStore.has(url)) {
URL.revokeObjectURL(url);
const newBlobStore = new Map(state.blobStore);
newBlobStore.delete(url);
state = { ...state, blobStore: newBlobStore };
}
});
return state;
}),
// 释放所有Blob URLs
cleanupAllBlobUrls: () => set((state) => {
state.blobStore.forEach((_, url) => {
URL.revokeObjectURL(url);
});
return { ...state, blobStore: new Map() };
}) })
}), }),
{ {

View File

@@ -7,4 +7,11 @@ export default defineConfig({
optimizeDeps: { optimizeDeps: {
exclude: ['lucide-react'], exclude: ['lucide-react'],
}, },
resolve: {
alias: {
'react-day-picker/dist/locale/zh-CN': 'date-fns/locale/zh-CN',
'react-day-picker/dist/locale': 'date-fns/locale',
'react-day-picker/locale': 'date-fns/locale',
},
},
}); });