fix: show gallery reference images

This commit is contained in:
FengLee
2026-06-06 22:11:36 +08:00
parent e059d445e2
commit fd18f2de68
5 changed files with 98 additions and 4 deletions

View File

@@ -27,6 +27,7 @@ function createGalleryRow(overrides = {}) {
params: {
creationMode: 'text2img',
referenceImages: ['/api/local-storage/reference.webp', ''],
referenceImageThumbnails: ['/api/local-storage/thumbnails/reference.webp', ''],
},
user_id: '22222222-2222-2222-2222-222222222222',
nickname: 'login-name',
@@ -79,6 +80,7 @@ await runTest('maps reference images without blank entries', () => {
assert.deepEqual(work.referenceImages, ['/api/local-storage/reference.webp']);
assert.equal(work.referenceImage, '/api/local-storage/reference.webp');
assert.deepEqual(work.referenceImageThumbnails, ['/api/local-storage/thumbnails/reference.webp']);
});
await runTest('allows stale gallery cache rows for instant first paint', () => {

View File

@@ -74,6 +74,9 @@ await runTest('gallery publish persists reference images as stable local-storage
await runTest('gallery detail shows reference images but does not expose reference downloads', () => {
const source = read('src/app/gallery/page.tsx');
assert.match(source, /getWorkReferenceImages/);
assert.match(source, /getWorkReferenceImageThumbnails/);
assert.match(source, /ReferencePreviewImage/);
assert.match(source, /thumbnailSrc=\{selectedReferenceImageThumbnails\[index\]\}/);
assert.match(source, /参考图/);
assert.match(source, /referencePreviewSrc/);
assert.match(source, /disableContextMenu/);
@@ -81,6 +84,13 @@ await runTest('gallery detail shows reference images but does not expose referen
assert.doesNotMatch(source, /handleDownload\([^)]*reference/i);
});
await runTest('gallery api merges reference metadata from duplicate result rows', () => {
const route = read('src/app/api/gallery/route.ts');
assert.match(route, /mergeGalleryRowMetadata/);
assert.match(route, /dedupeGalleryRowsByResultUrl/);
assert.match(route, /referenceImageThumbnails/);
});
await runTest('inspiration reuse preserves original reference images when available', () => {
const reuseSource = read('src/lib/creation-reuse.ts');
assert.match(reuseSource, /explicitReferences/);

View File

@@ -12,6 +12,47 @@ import { MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH, toPublicGalleryWork } from '@/lib
const galleryThumbnailQueue = new Map<string, Record<string, unknown>>();
let galleryThumbnailProcessing = false;
function hasGalleryReferenceMetadata(params: Record<string, unknown>) {
return typeof params.referenceImage === 'string' && params.referenceImage.trim().length > 0
|| (Array.isArray(params.referenceImages) && params.referenceImages.length > 0);
}
function mergeGalleryRowMetadata(target: Record<string, unknown>, source: Record<string, unknown>) {
const targetParams = (target.params || {}) as Record<string, unknown>;
const sourceParams = (source.params || {}) as Record<string, unknown>;
if (!target.thumbnail_url && source.thumbnail_url) target.thumbnail_url = source.thumbnail_url;
if (!target.width && source.width) target.width = source.width;
if (!target.height && source.height) target.height = source.height;
if (!hasGalleryReferenceMetadata(targetParams) && hasGalleryReferenceMetadata(sourceParams)) {
target.params = {
...targetParams,
referenceImage: sourceParams.referenceImage,
referenceImages: sourceParams.referenceImages,
referenceImageThumbnails: sourceParams.referenceImageThumbnails,
refImageCount: sourceParams.refImageCount,
};
}
}
function dedupeGalleryRowsByResultUrl(rows: Record<string, unknown>[], metadataRows: Record<string, unknown>[] = []) {
const byUrl = new Map<string, Record<string, unknown>[]>();
for (const row of [...rows, ...metadataRows]) {
if (typeof row.result_url !== 'string' || !row.result_url.trim()) continue;
const group = byUrl.get(row.result_url) || [];
group.push(row);
byUrl.set(row.result_url, group);
}
return rows.map(row => {
if (typeof row.result_url !== 'string' || !row.result_url.trim()) return row;
const group = byUrl.get(row.result_url) || [];
for (const candidate of group) {
if (candidate.user_id && row.user_id && candidate.user_id !== row.user_id) continue;
if (candidate !== row) mergeGalleryRowMetadata(row, candidate);
}
return row;
});
}
async function ensureGalleryThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
const type = String(row.type || '');
if (typeof row.result_url !== 'string') return row;
@@ -161,7 +202,22 @@ export async function GET(request: NextRequest) {
);
for (const row of result.rows || []) scheduleGalleryThumbnail(row);
const rows = result.rows || [];
const resultRows = result.rows || [];
const resultUrls = [...new Set(resultRows
.map((row: Record<string, unknown>) => typeof row.result_url === 'string' ? row.result_url.trim() : '')
.filter(Boolean))];
let metadataRows: Record<string, unknown>[] = [];
if (resultUrls.length > 0) {
const metadataResult = await client.query(
`SELECT id, result_url, thumbnail_url, width, height, user_id, params
FROM works
WHERE status = $1
AND result_url = ANY($2::text[])`,
['completed', resultUrls],
);
metadataRows = metadataResult.rows || [];
}
const rows = dedupeGalleryRowsByResultUrl(resultRows, metadataRows);
const works = rows.map((row: Record<string, unknown>) => toPublicGalleryWork(row));
const total = parseInt(countResult.rows[0]?.total || '0', 10);

View File

@@ -28,6 +28,7 @@ import { copyTextToClipboard, downloadFile, getImageDownloadExtension, triggerDo
import { useAuth } from '@/lib/auth-store';
import { FullscreenPreview } from '@/components/fullscreen-preview';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
import { ReferencePreviewImage } from '@/components/reference-preview-image';
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
import { buildCreationReuseDraft, type CreationReuseTarget, writeCreationReuseDraft } from '@/lib/creation-reuse';
import { GALLERY_CACHE_MAX_AGE_MS, isGalleryCacheEntryUsable } from '@/lib/gallery-cache-policy';
@@ -43,8 +44,8 @@ const CATEGORIES = [
];
const GALLERY_PAGE_SIZE = 18;
const GALLERY_CACHE_KEY = 'miaojing:gallery:v3';
const GALLERY_CACHE_VERSION = 3;
const GALLERY_CACHE_KEY = 'miaojing:gallery:v4';
const GALLERY_CACHE_VERSION = 4;
const GALLERY_CACHE_MAX_ENTRIES = 12;
const GALLERY_CACHE_MAX_WORKS_PER_ENTRY = 72;
@@ -65,6 +66,7 @@ interface GalleryWork {
params: Record<string, unknown>;
referenceImage?: string | null;
referenceImages?: string[];
referenceImageThumbnails?: string[];
publisherId: string;
publisherNickname: string;
publisherAvatarUrl?: string | null;
@@ -316,6 +318,14 @@ function getWorkReferenceImages(work: GalleryWork): string[] {
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
}
function getWorkReferenceImageThumbnails(work: GalleryWork): string[] {
const fromArray = Array.isArray(work.referenceImageThumbnails) ? work.referenceImageThumbnails : [];
const fromParams = Array.isArray(work.params?.referenceImageThumbnails)
? (work.params.referenceImageThumbnails as unknown[]).filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: [];
return [...new Set([...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
}
async function copyGalleryText(text: string, successMessage: string) {
const copyResult = await copyTextToClipboard(text);
if (copyResult === 'copied') {
@@ -856,6 +866,10 @@ export default function GalleryPage() {
() => selectedWork ? getWorkReferenceImages(selectedWork) : [],
[selectedWork],
);
const selectedReferenceImageThumbnails = useMemo(
() => selectedWork ? getWorkReferenceImageThumbnails(selectedWork) : [],
[selectedWork],
);
const toggleLike = (id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
@@ -1380,7 +1394,8 @@ export default function GalleryPage() {
<div className="grid max-h-[240px] grid-cols-2 gap-2 overflow-y-auto pr-1">
{selectedReferenceImages.map((url, index) => (
<div key={`${url}-${index}`} className={`${detailGlassInner} group relative overflow-hidden`}>
<img
<ReferencePreviewImage
thumbnailSrc={selectedReferenceImageThumbnails[index]}
src={url}
alt={`参考图 ${index + 1}`}
className="aspect-square w-full cursor-zoom-in object-cover"

View File

@@ -16,6 +16,7 @@ export interface PublicGalleryWork {
params: Record<string, unknown>;
referenceImage?: string;
referenceImages: string[];
referenceImageThumbnails: string[];
publisherId: unknown;
publisherNickname: string;
publisherAvatarUrl: string | null;
@@ -50,9 +51,18 @@ export function getGalleryReferenceImages(params: Record<string, unknown>) {
return { referenceImage, referenceImages };
}
export function getGalleryReferenceImageThumbnails(params: Record<string, unknown>) {
return Array.isArray(params.referenceImageThumbnails)
? params.referenceImageThumbnails
.map(normalizeString)
.filter((item): item is string => item.length > 0 && !item.startsWith('data:') && !item.startsWith('['))
: [];
}
export function toPublicGalleryWork(row: Record<string, unknown>): PublicGalleryWork {
const workParams = asRecord(row.params);
const references = getGalleryReferenceImages(workParams);
const referenceImageThumbnails = getGalleryReferenceImageThumbnails(workParams);
const emailPrefix = normalizeString(row.email).split('@')[0];
return {
@@ -71,6 +81,7 @@ export function toPublicGalleryWork(row: Record<string, unknown>): PublicGallery
params: workParams,
referenceImage: references.referenceImage,
referenceImages: references.referenceImages,
referenceImageThumbnails,
publisherId: row.user_id,
publisherNickname: normalizeString(row.display_nickname) || normalizeString(row.nickname) || emailPrefix || '匿名用户',
publisherAvatarUrl: getPublicGalleryAvatarUrl(row.avatar_url),