diff --git a/package-lock.json b/package-lock.json index 32b397e..8a70cdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 89bfc62..59e7977 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index 52b7c2e..d3e4ca1 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -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(''); - const [endDate, setEndDate] = useState(''); + const [startDate, setStartDate] = useState(() => { + const today = new Date(); + return today.toISOString().split('T')[0]; // 默认为今天 + }); + const [endDate, setEndDate] = useState(() => { + const today = new Date(); + return today.toISOString().split('T')[0]; // 默认为今天 + }); const [searchTerm, setSearchTerm] = useState(''); + 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 = () => { {/* 筛选和搜索控件 */}
- 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="开始日期" - /> - 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="结束日期" - /> +
+ + + {showDatePicker && ( +
+ + { + 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} + /> +
+ +
+
+ )} +
{ 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,52 +491,156 @@ export const HistoryPanel: React.FC = () => { ) : (
{/* 显示生成记录 */} - {[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((generation, index) => ( -
{ - selectGeneration(generation.id); - // 设置画布图像为第一个输出资产 - if (generation.outputAssets && generation.outputAssets.length > 0) { - const asset = generation.outputAssets[0]; - if (asset.url) { - setCanvasImage(asset.url); - } - } - }} - onMouseEnter={(e) => { - // 优先使用上传后的远程链接,如果没有则使用原始链接 - let imageUrl = getUploadedImageUrl(generation, 0); - if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) { - imageUrl = generation.outputAssets[0].url; - } - if (imageUrl) { - // 创建图像对象以获取尺寸 - const img = new Image(); - img.onload = () => { - // 计算文件大小(仅对base64数据) - let size = 0; - if (imageUrl.startsWith('data:')) { - // 估算base64数据大小 - const base64Data = imageUrl.split(',')[1]; - size = Math.round((base64Data.length * 3) / 4); + {(() => { + 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 ( +
{ + selectGeneration(generation.id); + // 设置画布图像为第一个输出资产 + if (generation.outputAssets && generation.outputAssets.length > 0) { + const asset = generation.outputAssets[0]; + if (asset.url) { + setCanvasImage(asset.url); + } } - - setHoveredImage({ - url: imageUrl, - title: `生成记录 G${index + 1}`, - width: img.width, - height: img.height, - size: size - }); + }} + onMouseEnter={(e) => { + // 优先使用上传后的远程链接,如果没有则使用原始链接 + let imageUrl = getUploadedImageUrl(generation, 0); + if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) { + imageUrl = generation.outputAssets[0].url; + } + if (imageUrl) { + // 创建图像对象以获取尺寸 + const img = new Image(); + img.onload = () => { + // 计算文件大小(仅对base64数据) + let size = 0; + if (imageUrl.startsWith('data:')) { + // 估算base64数据大小 + const base64Data = imageUrl.split(',')[1]; + size = Math.round((base64Data.length * 3) / 4); + } - // 计算预览位置,确保不超出屏幕边界 + setHoveredImage({ + url: imageUrl, + title: `生成记录 G${globalIndex + 1}`, + width: img.width, + height: img.height, + size: size + }); + + // 计算预览位置,确保不超出屏幕边界 + const previewWidth = 500; + const previewHeight = 500; + const offsetX = 10; + const offsetY = 10; + + + // 获取HistoryPanel的位置 + const historyPanel = document.querySelector('.w-72.bg-white.p-4'); + const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 }; + + // 计算相对于HistoryPanel的位置 + let x = e.clientX - panelRect.left + offsetX; + let y = e.clientY - panelRect.top + offsetY; + + + + // 确保预览窗口不会超出右边界 + if (x + previewWidth > window.innerWidth) { + x = window.innerWidth - previewWidth - 10; + } + + // 确保预览窗口不会超出下边界 + if (y + previewHeight > window.innerHeight) { + y = window.innerHeight - previewHeight - 10; + } + + // 确保预览窗口不会超出左边界 + if (x < 0) { + x = 10; + } + + // 确保预览窗口不会超出上边界 + if (y < 0) { + y = 10; + } + + // 添加额外的安全边界检查 + x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); + y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); + + setPreviewPosition({x, y}); + }; + img.onerror = (error) => { + console.error('图像加载失败:', error); + // 即使图像加载失败,也显示预览 + setHoveredImage({ + url: imageUrl, + title: `生成记录 G${globalIndex + 1}`, + width: 0, + height: 0, + size: 0 + }); + + // 计算预览位置 + const previewWidth = 500; + const previewHeight = 500; + const offsetX = 10; + const offsetY = 10; + + + let x = e.clientX + offsetX; + let y = e.clientY + offsetY; + + // 确保预览窗口不会超出右边界 + if (x + previewWidth > window.innerWidth) { + x = window.innerWidth - previewWidth - 10; + } + + // 确保预览窗口不会超出下边界 + if (y + previewHeight > window.innerHeight) { + y = window.innerHeight - previewHeight - 10; + } + + // 确保预览窗口不会超出左边界 + if (x < 0) { + x = 10; + } + + // 确保预览窗口不会超出上边界 + if (y < 0) { + y = 10; + } + + // 添加额外的安全边界检查 + x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); + y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); + + setPreviewPosition({x, y}); + }; + img.src = imageUrl; + } + }} + onMouseMove={(e) => { + // 调整预览位置以避免被遮挡 const previewWidth = 500; const previewHeight = 500; const offsetX = 10; @@ -360,16 +655,14 @@ export const HistoryPanel: React.FC = () => { let x = e.clientX - panelRect.left + offsetX; let y = e.clientY - panelRect.top + offsetY; - - // 确保预览窗口不会超出右边界 - if (x + previewWidth > window.innerWidth) { - x = window.innerWidth - previewWidth - 10; + if (x + previewWidth > (historyPanel ? historyPanel.clientWidth : window.innerWidth)) { + x = (historyPanel ? historyPanel.clientWidth : window.innerWidth) - previewWidth - 10; } // 确保预览窗口不会超出下边界 - if (y + previewHeight > window.innerHeight) { - y = window.innerHeight - previewHeight - 10; + if (y + previewHeight > (historyPanel ? historyPanel.clientHeight : window.innerHeight)) { + y = (historyPanel ? historyPanel.clientHeight : window.innerHeight) - previewHeight - 10; } // 确保预览窗口不会超出左边界 @@ -383,337 +676,299 @@ export const HistoryPanel: React.FC = () => { } // 添加额外的安全边界检查 - x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); - y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); + const maxWidth = historyPanel ? historyPanel.clientWidth : window.innerWidth; + const maxHeight = historyPanel ? historyPanel.clientHeight : window.innerHeight; + x = Math.max(10, Math.min(x, maxWidth - previewWidth - 10)); + y = Math.max(10, Math.min(y, maxHeight - previewHeight - 10)); setPreviewPosition({x, y}); - }; - img.onerror = (error) => { - console.error('图像加载失败:', error); - // 即使图像加载失败,也显示预览 - setHoveredImage({ - url: imageUrl, - title: `生成记录 G${index + 1}`, - width: 0, - height: 0, - size: 0 - }); + }} + onMouseLeave={() => { + setHoveredImage(null); + }} + > + {(() => { + // 优先使用上传后的远程链接,如果没有则使用原始链接 + const imageUrl = getUploadedImageUrl(generation, 0) || + (generation.outputAssets && generation.outputAssets.length > 0 ? generation.outputAssets[0].url : null); - // 计算预览位置 - const previewWidth = 500; - const previewHeight = 500; - const offsetX = 10; - const offsetY = 10; - - - let x = e.clientX + offsetX; - let y = e.clientY + offsetY; - - // 确保预览窗口不会超出右边界 - if (x + previewWidth > window.innerWidth) { - x = window.innerWidth - previewWidth - 10; + if (imageUrl) { + return 生成的变体; + } else { + return ( +
+ +
+ ); } - - // 确保预览窗口不会超出下边界 - if (y + previewHeight > window.innerHeight) { - y = window.innerHeight - previewHeight - 10; - } - - // 确保预览窗口不会超出左边界 - if (x < 0) { - x = 10; - } - - // 确保预览窗口不会超出上边界 - if (y < 0) { - y = 10; - } - - // 添加额外的安全边界检查 - x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); - y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); - - setPreviewPosition({x, y}); - }; - img.src = imageUrl; - } - }} - onMouseMove={(e) => { - // 调整预览位置以避免被遮挡 - const previewWidth = 500; - const previewHeight = 500; - const offsetX = 10; - const offsetY = 10; - - - // 获取HistoryPanel的位置 - const historyPanel = document.querySelector('.w-72.bg-white.p-4'); - const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 }; - - // 计算相对于HistoryPanel的位置 - let x = e.clientX - panelRect.left + offsetX; - let y = e.clientY - panelRect.top + offsetY; - - // 确保预览窗口不会超出右边界 - if (x + previewWidth > (historyPanel ? historyPanel.clientWidth : window.innerWidth)) { - x = (historyPanel ? historyPanel.clientWidth : window.innerWidth) - previewWidth - 10; - } - - // 确保预览窗口不会超出下边界 - if (y + previewHeight > (historyPanel ? historyPanel.clientHeight : window.innerHeight)) { - y = (historyPanel ? historyPanel.clientHeight : window.innerHeight) - previewHeight - 10; - } - - // 确保预览窗口不会超出左边界 - if (x < 0) { - x = 10; - } - - // 确保预览窗口不会超出上边界 - if (y < 0) { - y = 10; - } - - // 添加额外的安全边界检查 - const maxWidth = historyPanel ? historyPanel.clientWidth : window.innerWidth; - const maxHeight = historyPanel ? historyPanel.clientHeight : window.innerHeight; - x = Math.max(10, Math.min(x, maxWidth - previewWidth - 10)); - y = Math.max(10, Math.min(y, maxHeight - previewHeight - 10)); - - setPreviewPosition({x, y}); - }} - onMouseLeave={() => { - setHoveredImage(null); - }} - > - {(() => { - // 优先使用上传后的远程链接,如果没有则使用原始链接 - const imageUrl = getUploadedImageUrl(generation, 0) || - (generation.outputAssets && generation.outputAssets.length > 0 ? generation.outputAssets[0].url : null); - - if (imageUrl) { - return 生成的变体; - } else { - return ( -
- -
- ); - } - })()} - - {/* 变体编号 */} -
- G{index + 1} -
-
- ))} + })()} + + {/* 变体编号 */} +
+ G{globalIndex + 1} +
+
+ ); + }); + })()} {/* 显示编辑记录 */} - {[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((edit, index) => ( -
{ - selectEdit(edit.id); - selectGeneration(null); - // 设置画布图像为第一个输出资产 - if (edit.outputAssets && edit.outputAssets.length > 0) { - const asset = edit.outputAssets[0]; - if (asset.url) { - setCanvasImage(asset.url); - } - } - }} - onMouseEnter={(e) => { - // 优先使用上传后的远程链接,如果没有则使用原始链接 - let imageUrl = getUploadedImageUrl(edit, 0); - if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) { - imageUrl = edit.outputAssets[0].url; - } - - if (imageUrl) { - // 创建图像对象以获取尺寸 - const img = new Image(); - img.onload = () => { - // 计算文件大小(仅对base64数据) - let size = 0; - if (imageUrl.startsWith('data:')) { - // 估算base64数据大小 - const base64Data = imageUrl.split(',')[1]; - size = Math.round((base64Data.length * 3) / 4); - } - - setHoveredImage({ - url: imageUrl, - title: `编辑记录 E${index + 1}`, - width: img.width, - height: img.height, - size: size - }); - - // 计算预览位置,确保不超出屏幕边界 - const previewWidth = 500; - const previewHeight = 500; - const offsetX = 10; - const offsetY = 10; - - - let x = e.clientX + offsetX; - let y = e.clientY + offsetY; - - // 确保预览窗口不会超出右边界 - if (x + previewWidth > window.innerWidth) { - x = window.innerWidth - previewWidth - 10; - } - - // 确保预览窗口不会超出下边界 - if (y + previewHeight > window.innerHeight) { - y = window.innerHeight - previewHeight - 10; - } - - // 确保预览窗口不会超出左边界 - if (x < 0) { - x = 10; - } - - // 确保预览窗口不会超出上边界 - if (y < 0) { - y = 10; - } - - // 添加额外的安全边界检查 - x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); - y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); - - setPreviewPosition({x, y}); - }; - img.onerror = (error) => { - console.error('图像加载失败:', error); - // 即使图像加载失败,也显示预览 - setHoveredImage({ - url: imageUrl, - title: `编辑记录 E${index + 1}`, - width: 0, - height: 0, - size: 0 - }); - - // 计算预览位置 - const previewWidth = 500; - const previewHeight = 500; - const offsetX = 10; - const offsetY = 10; - - // 获取HistoryPanel的位置信息 - const historyPanel = e.currentTarget.closest('.w-72'); - const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 }; - - // 计算相对于整个视窗的位置 - let x = e.clientX + offsetX; - let y = e.clientY + offsetY; - - // 确保预览窗口不会超出右边界 - if (x + previewWidth > window.innerWidth) { - x = window.innerWidth - previewWidth - 10; - } - - // 确保预览窗口不会超出下边界 - if (y + previewHeight > window.innerHeight) { - y = window.innerHeight - previewHeight - 10; - } - - // 确保预览窗口不会超出左边界 - if (x < 0) { - x = 10; - } - - // 确保预览窗口不会超出上边界 - if (y < 0) { - y = 10; - } - - // 添加额外的安全边界检查 - x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); - y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); - - setPreviewPosition({x, y}); - }; - img.src = imageUrl; - } - }} - onMouseMove={(e) => { - // 调整预览位置以避免被遮挡 - const previewWidth = 500; - const previewHeight = 500; - const offsetX = 10; - const offsetY = 10; - - let x = e.clientX + offsetX; - let y = e.clientY + offsetY; - - // 确保预览窗口不会超出右边界 - if (x + previewWidth > window.innerWidth) { - x = window.innerWidth - previewWidth - 10; - } - - // 确保预览窗口不会超出下边界 - if (y + previewHeight > window.innerHeight) { - y = window.innerHeight - previewHeight - 10; - } - - // 确保预览窗口不会超出左边界 - if (x < 0) { - x = 10; - } - - // 确保预览窗口不会超出上边界 - if (y < 0) { - y = 10; - } - - // 添加额外的安全边界检查 - x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); - y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); - - setPreviewPosition({x, y}); - }} - onMouseLeave={() => { - setHoveredImage(null); - }} - > - {(() => { - // 优先使用上传后的远程链接,如果没有则使用原始链接 - const imageUrl = getUploadedImageUrl(edit, 0) || - (edit.outputAssets && edit.outputAssets.length > 0 ? edit.outputAssets[0].url : null); - - if (imageUrl) { - return 编辑的变体; - } else { - return ( -
- -
- ); - } - })()} + {(() => { + 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; - {/* 编辑标签 */} -
- E{index + 1} -
-
- ))} + return ( +
{ + selectEdit(edit.id); + selectGeneration(null); + // 设置画布图像为第一个输出资产 + if (edit.outputAssets && edit.outputAssets.length > 0) { + const asset = edit.outputAssets[0]; + if (asset.url) { + setCanvasImage(asset.url); + } + } + }} + onMouseEnter={(e) => { + // 优先使用上传后的远程链接,如果没有则使用原始链接 + let imageUrl = getUploadedImageUrl(edit, 0); + if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) { + imageUrl = edit.outputAssets[0].url; + } + + if (imageUrl) { + // 创建图像对象以获取尺寸 + const img = new Image(); + img.onload = () => { + // 计算文件大小(仅对base64数据) + let size = 0; + if (imageUrl.startsWith('data:')) { + // 估算base64数据大小 + const base64Data = imageUrl.split(',')[1]; + size = Math.round((base64Data.length * 3) / 4); + } + + setHoveredImage({ + url: imageUrl, + title: `编辑记录 E${globalIndex + 1}`, + width: img.width, + height: img.height, + size: size + }); + + // 计算预览位置,确保不超出屏幕边界 + const previewWidth = 500; + const previewHeight = 500; + const offsetX = 10; + const offsetY = 10; + + + let x = e.clientX + offsetX; + let y = e.clientY + offsetY; + + // 确保预览窗口不会超出右边界 + if (x + previewWidth > window.innerWidth) { + x = window.innerWidth - previewWidth - 10; + } + + // 确保预览窗口不会超出下边界 + if (y + previewHeight > window.innerHeight) { + y = window.innerHeight - previewHeight - 10; + } + + // 确保预览窗口不会超出左边界 + if (x < 0) { + x = 10; + } + + // 确保预览窗口不会超出上边界 + if (y < 0) { + y = 10; + } + + // 添加额外的安全边界检查 + x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); + y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); + + setPreviewPosition({x, y}); + }; + img.onerror = (error) => { + console.error('图像加载失败:', error); + // 即使图像加载失败,也显示预览 + setHoveredImage({ + url: imageUrl, + title: `编辑记录 E${globalIndex + 1}`, + width: 0, + height: 0, + size: 0 + }); + + // 计算预览位置 + const previewWidth = 500; + const previewHeight = 500; + const offsetX = 10; + const offsetY = 10; + + // 获取HistoryPanel的位置信息 + const historyPanel = e.currentTarget.closest('.w-72'); + const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 }; + + // 计算相对于整个视窗的位置 + let x = e.clientX + offsetX; + let y = e.clientY + offsetY; + + // 确保预览窗口不会超出右边界 + if (x + previewWidth > window.innerWidth) { + x = window.innerWidth - previewWidth - 10; + } + + // 确保预览窗口不会超出下边界 + if (y + previewHeight > window.innerHeight) { + y = window.innerHeight - previewHeight - 10; + } + + // 确保预览窗口不会超出左边界 + if (x < 0) { + x = 10; + } + + // 确保预览窗口不会超出上边界 + if (y < 0) { + y = 10; + } + + // 添加额外的安全边界检查 + x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); + y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); + + setPreviewPosition({x, y}); + }; + img.src = imageUrl; + } + }} + onMouseMove={(e) => { + // 调整预览位置以避免被遮挡 + const previewWidth = 500; + const previewHeight = 500; + const offsetX = 10; + const offsetY = 10; + + let x = e.clientX + offsetX; + let y = e.clientY + offsetY; + + // 确保预览窗口不会超出右边界 + if (x + previewWidth > window.innerWidth) { + x = window.innerWidth - previewWidth - 10; + } + + // 确保预览窗口不会超出下边界 + if (y + previewHeight > window.innerHeight) { + y = window.innerHeight - previewHeight - 10; + } + + // 确保预览窗口不会超出左边界 + if (x < 0) { + x = 10; + } + + // 确保预览窗口不会超出上边界 + if (y < 0) { + y = 10; + } + + // 添加额外的安全边界检查 + x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10)); + y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10)); + + setPreviewPosition({x, y}); + }} + onMouseLeave={() => { + setHoveredImage(null); + }} + > + {(() => { + // 优先使用上传后的远程链接,如果没有则使用原始链接 + const imageUrl = getUploadedImageUrl(edit, 0) || + (edit.outputAssets && edit.outputAssets.length > 0 ? edit.outputAssets[0].url : null); + + if (imageUrl) { + return 编辑的变体; + } else { + return ( +
+ +
+ ); + } + })()} + + {/* 编辑标签 */} +
+ E{globalIndex + 1} +
+
+ ); + }); + })()}
)}
- + {/* 分页控件 */} + {(() => { + const totalItems = filteredGenerations.length + filteredEdits.length; + const totalPages = Math.ceil(totalItems / itemsPerPage); + + // 只在有多页时显示分页控件 + if (totalPages > 1) { + return ( +
+ + +
+ 第 {currentPage} 页,共 {totalPages} 页 +
+ + +
+ ); + } + + return null; + })()} {/* 生成详情 */}
diff --git a/src/components/ImageCanvas.tsx b/src/components/ImageCanvas.tsx index cdc87a5..f9766ec 100644 --- a/src/components/ImageCanvas.tsx +++ b/src/components/ImageCanvas.tsx @@ -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]); // 处理舞台大小调整 diff --git a/src/hooks/useIndexedDBListener.ts b/src/hooks/useIndexedDBListener.ts index 2ddb65a..2c4ed07 100644 --- a/src/hooks/useIndexedDBListener.ts +++ b/src/hooks/useIndexedDBListener.ts @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const intervalRef = useRef(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(); - setGenerations(allGenerations); - setEdits(allEdits); - setError(null); + if (isMountedRef.current) { + setGenerations(allGenerations); + setEdits(allEdits); + setError(null); + } } catch (err) { console.error('从IndexedDB加载记录失败:', err); - setError('加载历史记录失败'); + if (isMountedRef.current) { + setError('加载历史记录失败'); + } } finally { - setLoading(false); + if (isMountedRef.current) { + setLoading(false); + } } }; useEffect(() => { + // 标记组件已挂载 + isMountedRef.current = true; + // 初始化数据库并加载记录 const initAndLoad = async () => { try { await indexedDBService.initDB(); - await loadRecords(); + if (isMountedRef.current) { + await loadRecords(); + } } catch (err) { console.error('初始化IndexedDB失败:', err); - setError('初始化数据库失败'); - setLoading(false); + if (isMountedRef.current) { + setError('初始化数据库失败'); + setLoading(false); + } } }; initAndLoad(); // 设置定时器定期检查新记录 - const interval = setInterval(() => { - loadRecords(); + 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 = () => { diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index 36e7379..c8c4a96 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -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 diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index c947f18..ce16a62 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -7,6 +7,49 @@ const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload' // 创建一个Map来缓存已上传的图像 const uploadCache = new Map() +// 缓存配置 +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)) { - console.log('从缓存中获取上传结果') - return uploadCache.get(imageHash)! + const cachedResult = uploadCache.get(imageHash)!; + // 检查缓存是否过期 + if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) { + console.log('从缓存中获取上传结果') + 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('上传缓存已清除') } diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index e1ac43a..b833160 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -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()( @@ -314,6 +318,36 @@ export const useAppStore = create()( // 清理旧记录以保持在限制内(现在限制为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()( // 清理旧记录以保持在限制内(现在限制为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()( 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()( 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() }; }) }), { diff --git a/vite.config.ts b/vite.config.ts index 147380a..e89bd8e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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', + }, + }, });