Skip to content

Commit dc43517

Browse files
authored
Merge pull request #52 from ShellMonster/fix/album-image-source-contract
fix: stabilize album image loading with explicit image source contract
2 parents c8acd41 + e6c354c commit dc43517

6 files changed

Lines changed: 161 additions & 79 deletions

File tree

backend/internal/api/folders.go

Lines changed: 123 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -60,35 +60,86 @@ func CreateFolderHandler(c *gin.Context) {
6060

6161
// FolderResponse 文件夹响应(包含图片数量)
6262
type FolderResponse struct {
63-
ID uint `json:"id"`
64-
Name string `json:"name"`
65-
Type string `json:"type"`
66-
Year int `json:"year"`
67-
Month int `json:"month"`
68-
CreatedAt string `json:"created_at"`
69-
UpdatedAt string `json:"updated_at"`
70-
ImageCount int64 `json:"image_count"`
71-
CoverImage string `json:"cover_image,omitempty"`
63+
ID uint `json:"id"`
64+
Name string `json:"name"`
65+
Type string `json:"type"`
66+
Year int `json:"year"`
67+
Month int `json:"month"`
68+
CreatedAt string `json:"created_at"`
69+
UpdatedAt string `json:"updated_at"`
70+
ImageCount int64 `json:"image_count"`
71+
CoverImage string `json:"cover_image,omitempty"`
72+
CoverImageSource *ImageSource `json:"cover_image_source,omitempty"`
7273
}
7374

74-
func toPublicImagePath(raw string) string {
75+
type ImageSource struct {
76+
Kind string `json:"kind"`
77+
Value string `json:"value"`
78+
}
79+
80+
const (
81+
IMAGE_SOURCE_KIND_HTTP_URL = "http_url"
82+
IMAGE_SOURCE_KIND_STORAGE_RELATIVE = "storage_relative"
83+
)
84+
85+
func normalizeStoragePath(raw string) string {
7586
trimmed := strings.TrimSpace(raw)
7687
if trimmed == "" {
7788
return ""
7889
}
7990

80-
lower := strings.ToLower(trimmed)
81-
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
82-
return trimmed
83-
}
84-
8591
normalized := strings.ReplaceAll(trimmed, "\\", "/")
8692
if idx := strings.Index(normalized, "/storage/"); idx >= 0 {
8793
return normalized[idx:]
8894
}
8995
if idx := strings.Index(normalized, "storage/"); idx >= 0 {
9096
return "/" + normalized[idx:]
9197
}
98+
return ""
99+
}
100+
101+
func buildImageSource(raw string) *ImageSource {
102+
trimmed := strings.TrimSpace(raw)
103+
if trimmed == "" {
104+
return nil
105+
}
106+
107+
lower := strings.ToLower(trimmed)
108+
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
109+
return &ImageSource{Kind: IMAGE_SOURCE_KIND_HTTP_URL, Value: trimmed}
110+
}
111+
112+
if storagePath := normalizeStoragePath(trimmed); storagePath != "" {
113+
return &ImageSource{Kind: IMAGE_SOURCE_KIND_STORAGE_RELATIVE, Value: storagePath}
114+
}
115+
116+
// 安全兜底:不向前端暴露无法识别的本地路径(尤其绝对路径)
117+
return nil
118+
}
119+
120+
func pickFirstImageSource(candidates ...string) *ImageSource {
121+
for _, candidate := range candidates {
122+
if source := buildImageSource(candidate); source != nil {
123+
return source
124+
}
125+
}
126+
return nil
127+
}
128+
129+
func toPublicImagePath(raw string) string {
130+
trimmed := strings.TrimSpace(raw)
131+
if trimmed == "" {
132+
return ""
133+
}
134+
135+
lower := strings.ToLower(trimmed)
136+
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
137+
return trimmed
138+
}
139+
140+
if storagePath := normalizeStoragePath(trimmed); storagePath != "" {
141+
return storagePath
142+
}
92143

93144
return ""
94145
}
@@ -158,20 +209,16 @@ func GetFoldersHandler(c *gin.Context) {
158209
log.Printf("[API] 查询文件夹封面候选失败: %v\n", err)
159210
}
160211

161-
pickCover := func(c folderCoverCandidate) string {
162-
for _, v := range []string{
163-
toPublicImagePath(c.ThumbnailPath),
164-
toPublicImagePath(c.LocalPath),
165-
strings.TrimSpace(c.ThumbnailURL),
166-
strings.TrimSpace(c.ImageURL),
167-
} {
168-
if strings.TrimSpace(v) != "" {
169-
return v
170-
}
171-
}
172-
return ""
212+
pickCoverSource := func(c folderCoverCandidate) *ImageSource {
213+
return pickFirstImageSource(
214+
c.ThumbnailPath,
215+
c.LocalPath,
216+
c.ThumbnailURL,
217+
c.ImageURL,
218+
)
173219
}
174220
coverMap := make(map[uint]string, len(folders))
221+
coverSourceMap := make(map[uint]*ImageSource, len(folders))
175222
for _, candidate := range coverCandidates {
176223
if candidate.FolderID == "" {
177224
continue
@@ -184,26 +231,28 @@ func GetFoldersHandler(c *gin.Context) {
184231
if _, exists := coverMap[fid]; exists {
185232
continue
186233
}
187-
cover := pickCover(candidate)
188-
if cover == "" {
234+
coverSource := pickCoverSource(candidate)
235+
if coverSource == nil {
189236
continue
190237
}
191-
coverMap[fid] = cover
238+
coverMap[fid] = coverSource.Value
239+
coverSourceMap[fid] = coverSource
192240
}
193241

194242
// 构建响应,包含图片数量
195243
responses := make([]FolderResponse, len(folders))
196244
for i, folder := range folders {
197245
responses[i] = FolderResponse{
198-
ID: folder.ID,
199-
Name: folder.Name,
200-
Type: folder.Type,
201-
Year: folder.Year,
202-
Month: folder.Month,
203-
CreatedAt: folder.CreatedAt.Format("2006-01-02 15:04:05"),
204-
UpdatedAt: folder.UpdatedAt.Format("2006-01-02 15:04:05"),
205-
ImageCount: countMap[folder.ID],
206-
CoverImage: coverMap[folder.ID],
246+
ID: folder.ID,
247+
Name: folder.Name,
248+
Type: folder.Type,
249+
Year: folder.Year,
250+
Month: folder.Month,
251+
CreatedAt: folder.CreatedAt.Format("2006-01-02 15:04:05"),
252+
UpdatedAt: folder.UpdatedAt.Format("2006-01-02 15:04:05"),
253+
ImageCount: countMap[folder.ID],
254+
CoverImage: coverMap[folder.ID],
255+
CoverImageSource: coverSourceMap[folder.ID],
207256
}
208257
}
209258

@@ -212,22 +261,24 @@ func GetFoldersHandler(c *gin.Context) {
212261
}
213262

214263
type FolderImageTaskResponse struct {
215-
TaskID string `json:"task_id"`
216-
Prompt string `json:"prompt"`
217-
ModelID string `json:"model_id,omitempty"`
218-
ProviderName string `json:"provider_name,omitempty"`
219-
LocalPath string `json:"local_path,omitempty"`
220-
ThumbnailPath string `json:"thumbnail_path,omitempty"`
221-
ImageURL string `json:"image_url,omitempty"`
222-
ThumbnailURL string `json:"thumbnail_url,omitempty"`
223-
Width int `json:"width,omitempty"`
224-
Height int `json:"height,omitempty"`
225-
CreatedAt string `json:"created_at"`
226-
UpdatedAt string `json:"updated_at,omitempty"`
227-
Status string `json:"status"`
228-
TotalCount int `json:"total_count,omitempty"`
229-
ErrorMessage string `json:"error_message,omitempty"`
230-
ConfigSnap string `json:"config_snapshot,omitempty"`
264+
TaskID string `json:"task_id"`
265+
Prompt string `json:"prompt"`
266+
ModelID string `json:"model_id,omitempty"`
267+
ProviderName string `json:"provider_name,omitempty"`
268+
LocalPath string `json:"local_path,omitempty"`
269+
ThumbnailPath string `json:"thumbnail_path,omitempty"`
270+
ImageURL string `json:"image_url,omitempty"`
271+
ThumbnailURL string `json:"thumbnail_url,omitempty"`
272+
ImageSource *ImageSource `json:"image_source,omitempty"`
273+
ThumbnailSource *ImageSource `json:"thumbnail_source,omitempty"`
274+
Width int `json:"width,omitempty"`
275+
Height int `json:"height,omitempty"`
276+
CreatedAt string `json:"created_at"`
277+
UpdatedAt string `json:"updated_at,omitempty"`
278+
Status string `json:"status"`
279+
TotalCount int `json:"total_count,omitempty"`
280+
ErrorMessage string `json:"error_message,omitempty"`
281+
ConfigSnap string `json:"config_snapshot,omitempty"`
231282
}
232283

233284
// GetFolderImagesHandler 获取指定文件夹下的图片列表(分页)
@@ -304,21 +355,23 @@ func GetFolderImagesHandler(c *gin.Context) {
304355
responses := make([]FolderImageTaskResponse, len(tasks))
305356
for i, task := range tasks {
306357
responses[i] = FolderImageTaskResponse{
307-
TaskID: task.TaskID,
308-
Prompt: task.Prompt,
309-
ModelID: task.ModelID,
310-
ProviderName: task.ProviderName,
311-
LocalPath: toPublicImagePath(task.LocalPath),
312-
ThumbnailPath: toPublicImagePath(task.ThumbnailPath),
313-
ImageURL: strings.TrimSpace(task.ImageURL),
314-
ThumbnailURL: strings.TrimSpace(task.ThumbnailURL),
315-
Width: task.Width,
316-
Height: task.Height,
317-
CreatedAt: task.CreatedAt.Format(time.RFC3339),
318-
Status: task.Status,
319-
TotalCount: task.TotalCount,
320-
ErrorMessage: task.ErrorMessage,
321-
ConfigSnap: task.ConfigSnapshot,
358+
TaskID: task.TaskID,
359+
Prompt: task.Prompt,
360+
ModelID: task.ModelID,
361+
ProviderName: task.ProviderName,
362+
LocalPath: toPublicImagePath(task.LocalPath),
363+
ThumbnailPath: toPublicImagePath(task.ThumbnailPath),
364+
ImageURL: strings.TrimSpace(task.ImageURL),
365+
ThumbnailURL: strings.TrimSpace(task.ThumbnailURL),
366+
ImageSource: pickFirstImageSource(task.LocalPath, task.ImageURL, task.ThumbnailPath, task.ThumbnailURL),
367+
ThumbnailSource: pickFirstImageSource(task.ThumbnailPath, task.LocalPath, task.ThumbnailURL, task.ImageURL),
368+
Width: task.Width,
369+
Height: task.Height,
370+
CreatedAt: task.CreatedAt.Format(time.RFC3339),
371+
Status: task.Status,
372+
TotalCount: task.TotalCount,
373+
ErrorMessage: task.ErrorMessage,
374+
ConfigSnap: task.ConfigSnapshot,
322375
}
323376
}
324377

desktop/src/components/HistoryPanel/AlbumView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { ImageCard } from './ImageCard';
88
import { ImagePreview } from '../GenerateArea/ImagePreview';
99
import { FlattenedImage } from './HistoryList';
1010
import { getFolders, getFolderImages, Folder } from '../../services/folderApi';
11-
import { getImageUrl } from '../../services/api';
11+
import { getImageUrlFromSource } from '../../services/api';
1212
import { toast } from '../../store/toastStore';
1313
import { mapBackendHistoryResponse } from '../../utils/mapping';
1414
import { formatAspectRatioLabel } from '../../utils/aspectRatio';
@@ -90,7 +90,7 @@ export const AlbumView = forwardRef<AlbumViewRef, {}>(function AlbumView(_props,
9090
const foldersWithCount: FolderWithCount[] = data.map((folder) => ({
9191
...folder,
9292
imageCount: folder.image_count ?? 0,
93-
coverImage: folder.cover_image ? getImageUrl(folder.cover_image) : undefined
93+
coverImage: getImageUrlFromSource(folder.cover_image_source, folder.cover_image || '') || undefined
9494
}));
9595
setFolders(foldersWithCount);
9696
} catch (error) {

desktop/src/services/api.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export interface ApiRequestConfig extends AxiosRequestConfig {
55
__returnResponse?: boolean;
66
}
77

8+
export interface ImageSource {
9+
kind?: 'http_url' | 'storage_relative' | string;
10+
value?: string;
11+
}
12+
813
// 根据 API 文档,后端地址默认为 http://127.0.0.1:8080
914
export let BASE_URL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8080/api/v1';
1015

@@ -239,7 +244,12 @@ export const getImageUrl = (path: string) => {
239244
| undefined;
240245

241246
const isWindowsAbsolutePath = (p: string) => /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('\\\\');
242-
const isPosixAbsolutePath = (p: string) => p.startsWith('/');
247+
const isStorageRelativePath = (p: string) => {
248+
const normalized = p.replace(/\\/g, '/');
249+
return normalized.startsWith('/storage/') || normalized.startsWith('storage/');
250+
};
251+
// 注意:/storage/... 是后端静态资源相对路径,不是磁盘绝对路径
252+
const isPosixAbsolutePath = (p: string) => p.startsWith('/') && !isStorageRelativePath(p);
243253
const looksLikeAbsolutePath = (p: string) => isPosixAbsolutePath(p) || isWindowsAbsolutePath(p);
244254

245255
const convertFileSrcSync: ((filePath: string) => string) | null = (() => {
@@ -305,6 +315,17 @@ export const getImageUrl = (path: string) => {
305315
return url;
306316
};
307317

318+
export const getImageUrlFromSource = (source?: ImageSource | null, fallbackPath: string = '') => {
319+
if (!source?.value) return getImageUrl(fallbackPath);
320+
const kind = (source.kind || '').trim();
321+
const value = source.value.trim();
322+
if (!value) return getImageUrl(fallbackPath);
323+
if (kind && kind !== 'http_url' && kind !== 'storage_relative') {
324+
return getImageUrl(fallbackPath);
325+
}
326+
return getImageUrl(value);
327+
};
328+
308329
// 获取图片下载 URL
309330
export const getImageDownloadUrl = (id: string) => {
310331
return `${BASE_URL}/images/${id}/download`;

desktop/src/services/folderApi.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import api from './api';
2-
import { BackendHistoryResponse } from '../types';
2+
import { BackendHistoryResponse, BackendImageSource } from '../types';
33

44
export interface Folder {
55
id: number;
@@ -11,6 +11,7 @@ export interface Folder {
1111
updated_at?: string;
1212
image_count?: number;
1313
cover_image?: string;
14+
cover_image_source?: BackendImageSource;
1415
}
1516

1617
export interface FolderImagesQuery {

desktop/src/types/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ export interface TemplateListResponse {
142142
items: TemplateItem[];
143143
}
144144

145+
export interface BackendImageSource {
146+
kind: 'http_url' | 'storage_relative' | string;
147+
value: string;
148+
}
149+
145150
// 后端 Task 模型(用于 API 响应)
146151
export interface BackendTask {
147152
task_id: string;
@@ -152,6 +157,8 @@ export interface BackendTask {
152157
thumbnail_path?: string;
153158
image_url?: string;
154159
thumbnail_url?: string;
160+
image_source?: BackendImageSource;
161+
thumbnail_source?: BackendImageSource;
155162
width?: number;
156163
height?: number;
157164
created_at: string;

desktop/src/utils/mapping.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { GenerationTask, GeneratedImage, BackendTask, BackendHistoryResponse } from '../types';
2-
import { getImageUrl } from '../services/api';
2+
import { getImageUrlFromSource } from '../services/api';
33

44
function sanitizeBackendErrorMessage(message?: string): string {
55
if (!message) return '';
@@ -15,8 +15,8 @@ function sanitizeBackendErrorMessage(message?: string): string {
1515
* 将后端 Task 模型映射为前端 GenerationTask 模型
1616
*/
1717
export const mapBackendTaskToFrontend = (task: BackendTask): GenerationTask => {
18-
const getFullUrl = (path: string | undefined) => {
19-
return getImageUrl(path || '');
18+
const getFullUrl = (path: string | undefined, source?: BackendTask['image_source']) => {
19+
return getImageUrlFromSource(source, path || '');
2020
};
2121

2222
const sanitizedErrorMessage = sanitizeBackendErrorMessage(task.error_message);
@@ -37,9 +37,9 @@ export const mapBackendTaskToFrontend = (task: BackendTask): GenerationTask => {
3737
errorMessage: sanitizedErrorMessage,
3838
status: task.status === 'completed' ? 'success' : (task.status === 'failed' ? 'failed' : 'pending'),
3939
// 弹窗预览使用原图
40-
url: getFullUrl(task.local_path || task.image_url || task.thumbnail_path || task.thumbnail_url),
40+
url: getFullUrl(task.local_path || task.image_url || task.thumbnail_path || task.thumbnail_url, task.image_source),
4141
// 卡片展示优先使用缩略图
42-
thumbnailUrl: getFullUrl(task.thumbnail_path || task.local_path || task.thumbnail_url || task.image_url)
42+
thumbnailUrl: getFullUrl(task.thumbnail_path || task.local_path || task.thumbnail_url || task.image_url, task.thumbnail_source || task.image_source)
4343
};
4444

4545
return {

0 commit comments

Comments
 (0)