You've already forked Nano-Banana-AI-Image-Editor
修复内存泄漏问题,优化Blob URL清理机制
This commit is contained in:
48
package-lock.json
generated
48
package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "vite-react-typescript-starter",
|
||||
"name": "ano-banana-ai-image-editor",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vite-react-typescript-starter",
|
||||
"name": "ano-banana-ai-image-editor",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.16.0",
|
||||
@@ -21,6 +21,7 @@
|
||||
"konva": "^9.3.22",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.10.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-konva": "^18.2.10",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -359,6 +360,12 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -2915,6 +2922,22 @@
|
||||
"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": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
@@ -5267,6 +5290,27 @@
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"konva": "^9.3.22",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.10.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-konva": "^18.2.10",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
@@ -6,6 +6,8 @@ import { cn } from '../utils/cn';
|
||||
import { ImagePreviewModal } from './ImagePreviewModal';
|
||||
import * as indexedDBService from '../services/indexedDBService';
|
||||
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 = () => {
|
||||
const {
|
||||
@@ -42,9 +44,24 @@ export const HistoryPanel: React.FC = () => {
|
||||
const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener();
|
||||
|
||||
// 筛选和搜索状态
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [startDate, setStartDate] = 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 [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);
|
||||
@@ -117,10 +134,12 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 筛选记录的函数
|
||||
const filterRecords = (records: any[], isGeneration: boolean) => {
|
||||
return records.filter(record => {
|
||||
// 日期筛选
|
||||
// 日期筛选 - 修复日期比较逻辑
|
||||
const recordDate = new Date(record.timestamp);
|
||||
if (startDate && recordDate < new Date(startDate)) return false;
|
||||
if (endDate && recordDate > new Date(endDate)) return false;
|
||||
const recordDateStr = recordDate.toISOString().split('T')[0]; // 获取日期部分 YYYY-MM-DD
|
||||
|
||||
if (startDate && recordDateStr < startDate) return false;
|
||||
if (endDate && recordDateStr > endDate) return false;
|
||||
|
||||
// 搜索词筛选
|
||||
if (searchTerm) {
|
||||
@@ -246,20 +265,185 @@ export const HistoryPanel: React.FC = () => {
|
||||
{/* 筛选和搜索控件 */}
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="flex space-x-1">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
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"
|
||||
placeholder="开始日期"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
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"
|
||||
placeholder="结束日期"
|
||||
<div className="flex-1 relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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 ${
|
||||
showDatePicker ? 'ring-2 ring-yellow-400 border-yellow-400' : ''
|
||||
}`}
|
||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||
>
|
||||
<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">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<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 className="flex">
|
||||
<input
|
||||
@@ -274,9 +458,16 @@ export const HistoryPanel: React.FC = () => {
|
||||
size="sm"
|
||||
className="text-xs p-1.5 rounded-l-none h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
|
||||
onClick={() => {
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
setStartDate(todayStr);
|
||||
setEndDate(todayStr);
|
||||
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">
|
||||
{/* 显示生成记录 */}
|
||||
{[...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
|
||||
key={generation.id}
|
||||
className={cn(
|
||||
@@ -339,7 +540,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
setHoveredImage({
|
||||
url: imageUrl,
|
||||
title: `生成记录 G${index + 1}`,
|
||||
title: `生成记录 G${globalIndex + 1}`,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
size: size
|
||||
@@ -393,7 +594,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 即使图像加载失败,也显示预览
|
||||
setHoveredImage({
|
||||
url: imageUrl,
|
||||
title: `生成记录 G${index + 1}`,
|
||||
title: `生成记录 G${globalIndex + 1}`,
|
||||
width: 0,
|
||||
height: 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">
|
||||
G{index + 1}
|
||||
G{globalIndex + 1}
|
||||
</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
|
||||
key={edit.id}
|
||||
className={cn(
|
||||
@@ -551,7 +767,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
setHoveredImage({
|
||||
url: imageUrl,
|
||||
title: `编辑记录 E${index + 1}`,
|
||||
title: `编辑记录 E${globalIndex + 1}`,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
size: size
|
||||
@@ -598,7 +814,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 即使图像加载失败,也显示预览
|
||||
setHoveredImage({
|
||||
url: imageUrl,
|
||||
title: `编辑记录 E${index + 1}`,
|
||||
title: `编辑记录 E${globalIndex + 1}`,
|
||||
width: 0,
|
||||
height: 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">
|
||||
E{index + 1}
|
||||
E{globalIndex + 1}
|
||||
</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">
|
||||
|
||||
@@ -38,9 +38,18 @@ export const ImageCanvas: React.FC = () => {
|
||||
|
||||
// 加载图像并在 canvasImage 变化时自动适应
|
||||
useEffect(() => {
|
||||
let img: HTMLImageElement | null = null;
|
||||
|
||||
if (canvasImage) {
|
||||
const img = new window.Image();
|
||||
img = new window.Image();
|
||||
let isCancelled = false;
|
||||
|
||||
img.onload = () => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImage(img);
|
||||
|
||||
// 每次有新图像时都自动适应画布,而不仅仅是在初始状态下
|
||||
@@ -59,10 +68,26 @@ export const ImageCanvas: React.FC = () => {
|
||||
// 居中图像
|
||||
setCanvasPan({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
if (!isCancelled) {
|
||||
console.error('图像加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
img.src = canvasImage;
|
||||
} else {
|
||||
setImage(null);
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
// 取消图像加载
|
||||
if (img) {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
}
|
||||
};
|
||||
}, [canvasImage, stageSize, setCanvasZoom, setCanvasPan]);
|
||||
|
||||
// 处理舞台大小调整
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as indexedDBService from '../services/indexedDBService';
|
||||
|
||||
export const useIndexedDBListener = () => {
|
||||
@@ -6,44 +6,73 @@ export const useIndexedDBListener = () => {
|
||||
const [edits, setEdits] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const loadRecords = async () => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const allGenerations = await indexedDBService.getAllGenerations();
|
||||
const allEdits = await indexedDBService.getAllEdits();
|
||||
if (isMountedRef.current) {
|
||||
setGenerations(allGenerations);
|
||||
setEdits(allEdits);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('从IndexedDB加载记录失败:', err);
|
||||
if (isMountedRef.current) {
|
||||
setError('加载历史记录失败');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 标记组件已挂载
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 初始化数据库并加载记录
|
||||
const initAndLoad = async () => {
|
||||
try {
|
||||
await indexedDBService.initDB();
|
||||
if (isMountedRef.current) {
|
||||
await loadRecords();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('初始化IndexedDB失败:', err);
|
||||
if (isMountedRef.current) {
|
||||
setError('初始化数据库失败');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initAndLoad();
|
||||
|
||||
// 设置定时器定期检查新记录
|
||||
const interval = setInterval(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (isMountedRef.current) {
|
||||
loadRecords();
|
||||
}
|
||||
}, 3000); // 每3秒检查一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
// 清理函数
|
||||
return () => {
|
||||
// 标记组件已卸载
|
||||
isMountedRef.current = false;
|
||||
|
||||
// 清除定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = () => {
|
||||
|
||||
@@ -62,6 +62,24 @@ export class GeminiService {
|
||||
if (candidate.finishReason === 'IMAGE_SAFETY') {
|
||||
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[] = []
|
||||
@@ -132,6 +150,24 @@ export class GeminiService {
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
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[] = []
|
||||
@@ -196,6 +232,24 @@ export class GeminiService {
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
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
|
||||
|
||||
@@ -7,6 +7,49 @@ const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload'
|
||||
// 创建一个Map来缓存已上传的图像
|
||||
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编码的图像数据
|
||||
@@ -35,8 +78,15 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
|
||||
const imageHash = getImageHash(base64Data)
|
||||
|
||||
if (!skipCache && uploadCache.has(imageHash)) {
|
||||
const cachedResult = uploadCache.get(imageHash)!;
|
||||
// 检查缓存是否过期
|
||||
if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) {
|
||||
console.log('从缓存中获取上传结果')
|
||||
return uploadCache.get(imageHash)!
|
||||
return cachedResult;
|
||||
} else {
|
||||
// 缓存过期,删除它
|
||||
uploadCache.delete(imageHash);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -73,6 +123,12 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
|
||||
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
|
||||
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
|
||||
|
||||
// 清理过期缓存
|
||||
cleanupExpiredCache();
|
||||
|
||||
// 维护缓存大小
|
||||
maintainCacheSize();
|
||||
|
||||
// 将上传结果存储到缓存中
|
||||
const uploadResult = { success: true, url: fullUrl, error: undefined }
|
||||
uploadCache.set(imageHash, {
|
||||
@@ -88,6 +144,12 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
|
||||
console.error('上传图像时出错:', error)
|
||||
const errorResult = { success: false, url: undefined, error: error instanceof Error ? error.message : String(error) }
|
||||
|
||||
// 清理过期缓存
|
||||
cleanupExpiredCache();
|
||||
|
||||
// 维护缓存大小(即使是失败的结果也缓存,但时间较短)
|
||||
maintainCacheSize();
|
||||
|
||||
// 将失败的上传结果也存储到缓存中(可选)
|
||||
uploadCache.set(imageHash, {
|
||||
...errorResult,
|
||||
@@ -152,4 +214,5 @@ export const uploadImages = async (base64Images: string[], accessToken: string,
|
||||
*/
|
||||
export const clearUploadCache = (): void => {
|
||||
uploadCache.clear()
|
||||
console.log('上传缓存已清除')
|
||||
}
|
||||
|
||||
@@ -123,6 +123,10 @@ interface AppState {
|
||||
addBlob: (blob: Blob) => string;
|
||||
getBlob: (url: string) => Blob | undefined;
|
||||
cleanupOldHistory: () => void;
|
||||
|
||||
// Blob URL清理操作
|
||||
revokeBlobUrls: (urls: string[]) => void;
|
||||
cleanupAllBlobUrls: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
@@ -314,6 +318,36 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
// 清理旧记录以保持在限制内(现在限制为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);
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(1000).catch(err => {
|
||||
@@ -409,6 +443,34 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
// 清理旧记录以保持在限制内(现在限制为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);
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(1000).catch(err => {
|
||||
@@ -437,16 +499,56 @@ export const useAppStore = create<AppState>()(
|
||||
const generations = [...state.currentProject.generations];
|
||||
const edits = [...state.currentProject.edits];
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
|
||||
// 如果生成记录超过1000条,只保留最新的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);
|
||||
}
|
||||
|
||||
// 如果编辑记录超过1000条,只保留最新的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);
|
||||
}
|
||||
|
||||
// 释放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中的旧记录
|
||||
indexedDBService.cleanupOldRecords(1000).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
@@ -460,6 +562,27 @@ export const useAppStore = create<AppState>()(
|
||||
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() };
|
||||
})
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -7,4 +7,11 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user