YuSaZh commited on
Commit
5dfd431
·
1 Parent(s): 5112a33

Refine image conversation record controls

Browse files
web/src/app/image/components/image-results.tsx CHANGED
@@ -18,7 +18,9 @@ type ImageResultsProps = {
18
  selectedConversation: ImageConversation | null;
19
  onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
20
  onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void;
21
- onDeleteTurn: (conversationId: string, turnId: string) => void;
 
 
22
  onRegenerateTurn: (conversationId: string, turnId: string) => void | Promise<void>;
23
  onRetryImage: (conversationId: string, turnId: string, imageId: string) => void | Promise<void>;
24
  formatConversationTime: (value: string) => string;
@@ -35,7 +37,9 @@ export function ImageResults({
35
  selectedConversation,
36
  onOpenLightbox,
37
  onContinueEdit,
38
- onDeleteTurn,
 
 
39
  onRegenerateTurn,
40
  onRetryImage,
41
  formatConversationTime,
@@ -100,118 +104,60 @@ export function ImageResults({
100
 
101
  return (
102
  <div key={turn.id} className="flex flex-col gap-3 sm:gap-4">
103
- <div className="flex justify-end">
104
- <div className="max-w-[90%] px-1 py-1 text-[14px] leading-6 text-stone-900 sm:max-w-[82%] sm:text-[15px] sm:leading-7">
105
- <div className="mb-1.5 flex flex-wrap justify-end gap-2 text-[11px] text-stone-400 sm:mb-2">
106
- <span>第 {turnIndex + 1} 轮</span>
107
- <span>
108
- {turn.mode === "edit" ? "编辑图" : "文生图"}
109
- </span>
110
- <span>{getTurnStatusLabel(turn.status)}</span>
111
- <span>{formatConversationTime(turn.createdAt)}</span>
112
- </div>
113
- <div className="text-right">{turn.prompt}</div>
114
- <div className="mt-2 flex flex-wrap justify-end gap-1.5">
115
- <button
116
- type="button"
117
- onClick={() => void onRegenerateTurn(selectedConversation.id, turn.id)}
118
- className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 text-[11px] font-medium text-stone-600 transition hover:bg-stone-200 hover:text-stone-900"
119
- >
120
- <RotateCcw className="size-3" />
121
- 全部重新生成
122
- </button>
123
- <button
124
- type="button"
125
- onClick={() => onDeleteTurn(selectedConversation.id, turn.id)}
126
- className="inline-flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-medium text-rose-600 transition hover:bg-rose-100"
127
- >
128
- <Trash2 className="size-3" />
129
- 删除本轮
130
- </button>
131
- </div>
132
- </div>
133
- </div>
134
-
135
- <div className="flex justify-start">
136
- <div className="w-full p-1">
137
- {turn.referenceImages.length > 0 ? (
138
- <div className="mb-4 flex flex-col items-end">
139
- <div className="mb-3 text-xs font-medium text-stone-500">本轮参考图</div>
140
- <div className="flex flex-wrap justify-end gap-3">
141
- {turn.referenceImages.map((image, index) => (
142
- <div key={`${turn.id}-${image.name}-${index}`} className="flex flex-col items-end gap-2">
143
- <button
144
- type="button"
145
- onClick={() => onOpenLightbox(referenceLightboxImages, index)}
146
- className="group relative h-24 w-24 overflow-hidden border border-stone-200/80 bg-stone-100/60 text-left transition hover:border-stone-300"
147
- aria-label={`预览参考图 ${image.name || index + 1}`}
148
- >
149
- <img
150
- src={image.dataUrl}
151
- alt={image.name || `参考图 ${index + 1}`}
152
- className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
153
- />
154
- </button>
155
- <Button
156
- variant="outline"
157
- size="sm"
158
- className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
159
- onClick={() => onContinueEdit(selectedConversation.id, image)}
160
- >
161
- <Sparkles className="size-4" />
162
- 加入编辑
163
- </Button>
164
- </div>
165
- ))}
166
- </div>
167
  </div>
168
- ) : null}
169
-
170
- <div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px] text-stone-500 sm:mb-4 sm:gap-2 sm:text-xs">
171
- <span className="rounded-full bg-stone-100 px-3 py-1">{turn.count} 张</span>
172
- <span className="rounded-full bg-stone-100 px-3 py-1">{getTurnStatusLabel(turn.status)}</span>
173
- {turn.status === "queued" ? (
174
- <span className="rounded-full bg-amber-50 px-3 py-1 text-amber-700">等待当前对话中的前序任务完成</span>
175
- ) : null}
176
  </div>
 
 
177
 
178
- <div className="grid grid-cols-3 gap-2 sm:columns-2 sm:block sm:gap-4 sm:space-y-4 xl:columns-3">
179
- {turn.images.map((image, index) => {
180
- const imageSrc = image.status === "success" ? getStoredImageSrc(image) : "";
181
- if (image.status === "success" && imageSrc) {
182
- const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
183
- const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
184
- const dimensions = imageDimensions[image.id];
185
- const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
186
-
187
- return (
188
- <div
189
- key={image.id}
190
- className="break-inside-avoid overflow-hidden rounded-xl bg-white sm:rounded-none"
191
- >
192
- <button
193
- type="button"
194
- onClick={() => onOpenLightbox(successfulTurnImages, currentIndex)}
195
- className="group block aspect-square w-full cursor-zoom-in overflow-hidden sm:aspect-auto"
196
- >
197
- <img
198
- src={imageSrc}
199
- alt={`Generated result ${index + 1}`}
200
- className="block h-full w-full object-cover transition duration-200 group-hover:brightness-90 sm:h-auto sm:object-contain"
201
- onLoad={(event) => {
202
- updateImageDimensions(
203
- image.id,
204
- event.currentTarget.naturalWidth,
205
- event.currentTarget.naturalHeight,
206
- );
207
- }}
208
- />
209
- </button>
210
- <div className="hidden items-center justify-between gap-2 px-3 py-3 sm:flex">
211
- <div className="min-w-0 text-xs text-stone-500">
212
- <span>结果 {index + 1}</span>
213
- {imageMeta ? <span className="ml-2 text-stone-400">{imageMeta}</span> : null}
214
- </div>
215
  <Button
216
  variant="outline"
217
  size="sm"
@@ -222,74 +168,154 @@ export function ImageResults({
222
  加入编辑
223
  </Button>
224
  </div>
225
- </div>
226
- );
227
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
- if (image.status === "error") {
230
  return (
231
  <div
232
  key={image.id}
233
  className={cn(
234
- "break-inside-avoid overflow-hidden rounded-xl border border-rose-200 bg-rose-50 sm:rounded-none",
235
- "aspect-square",
236
- turn.size === "1:1" && "sm:aspect-square",
237
- turn.size === "16:9" && "sm:aspect-video",
238
- turn.size === "9:16" && "sm:aspect-[9/16]",
239
- turn.size === "4:3" && "sm:aspect-[4/3]",
240
- turn.size === "3:4" && "sm:aspect-[3/4]",
241
- !["1:1", "16:9", "9:16", "4:3", "3:4"].includes(turn.size) && "sm:aspect-square",
242
  )}
243
  >
244
- <div className="flex h-full min-h-16 flex-col items-center justify-center gap-1.5 px-2 py-2 text-center text-[11px] leading-4 text-rose-600 sm:gap-3 sm:px-6 sm:py-8 sm:text-sm sm:leading-6">
245
- <span className="line-clamp-2 sm:line-clamp-none">{image.error || "生成失败"}</span>
246
- <button
247
- type="button"
248
- onClick={() => void onRetryImage(selectedConversation.id, turn.id, image.id)}
249
- className="rounded-full bg-white px-2 py-1 text-[10px] font-medium text-rose-600 shadow-sm transition hover:bg-rose-100 sm:px-3 sm:text-xs"
250
- >
251
- 重新生成这一张
252
- </button>
253
  </div>
254
  </div>
255
  );
256
- }
 
257
 
258
- return (
259
- <div
260
- key={image.id}
261
- className={cn(
262
- "break-inside-avoid overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 sm:rounded-none",
263
- turn.size === "1:1" && "aspect-square",
264
- turn.size === "16:9" && "aspect-video",
265
- turn.size === "9:16" && "aspect-[9/16]",
266
- turn.size === "4:3" && "aspect-[4/3]",
267
- turn.size === "3:4" && "aspect-[3/4]",
268
- !["1:1", "16:9", "9:16", "4:3", "3:4"].includes(turn.size) && "aspect-square",
269
- )}
270
- >
271
- <div className="flex h-full flex-col items-center justify-center gap-1.5 px-2 py-3 text-center text-stone-500 sm:gap-3 sm:px-6 sm:py-8">
272
- <div className="rounded-full bg-white p-2 shadow-sm sm:p-3">
273
- {turn.status === "queued" ? (
274
- <Clock3 className="size-4 sm:size-5" />
275
- ) : (
276
- <LoaderCircle className="size-4 animate-spin sm:size-5" />
277
- )}
278
- </div>
279
- <p className="text-[10px] leading-4 sm:text-sm">{turn.status === "queued" ? "排队中" : "处理中"}</p>
280
- </div>
281
- </div>
282
- );
283
- })}
284
- </div>
285
 
286
- {turn.status === "error" && turn.error ? (
287
- <div className="mt-4 border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
288
- {turn.error}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  </div>
290
- ) : null}
291
  </div>
292
- </div>
293
  </div>
294
  );
295
  })}
 
18
  selectedConversation: ImageConversation | null;
19
  onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
20
  onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void;
21
+ onDeletePrompt: (conversationId: string, turnId: string) => void;
22
+ onDeleteResults: (conversationId: string, turnId: string) => void;
23
+ onReuseTurnConfig: (conversationId: string, turnId: string) => void | Promise<void>;
24
  onRegenerateTurn: (conversationId: string, turnId: string) => void | Promise<void>;
25
  onRetryImage: (conversationId: string, turnId: string, imageId: string) => void | Promise<void>;
26
  formatConversationTime: (value: string) => string;
 
37
  selectedConversation,
38
  onOpenLightbox,
39
  onContinueEdit,
40
+ onDeletePrompt,
41
+ onDeleteResults,
42
+ onReuseTurnConfig,
43
  onRegenerateTurn,
44
  onRetryImage,
45
  formatConversationTime,
 
104
 
105
  return (
106
  <div key={turn.id} className="flex flex-col gap-3 sm:gap-4">
107
+ {!turn.promptDeleted ? (
108
+ <div className="flex justify-end">
109
+ <div className="max-w-[90%] px-1 py-1 text-[14px] leading-6 text-stone-900 sm:max-w-[82%] sm:text-[15px] sm:leading-7">
110
+ <div className="mb-1.5 flex flex-wrap justify-end gap-2 text-[11px] text-stone-400 sm:mb-2">
111
+ <span>第 {turnIndex + 1} 轮</span>
112
+ <span>
113
+ {turn.mode === "edit" ? "编辑图" : "文生图"}
114
+ </span>
115
+ <span>{getTurnStatusLabel(turn.status)}</span>
116
+ <span>{formatConversationTime(turn.createdAt)}</span>
117
+ </div>
118
+ <div className="text-right">{turn.prompt}</div>
119
+ <div className="mt-2 flex flex-wrap justify-end gap-1.5">
120
+ <button
121
+ type="button"
122
+ onClick={() => void onReuseTurnConfig(selectedConversation.id, turn.id)}
123
+ className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 text-[11px] font-medium text-stone-600 transition hover:bg-stone-200 hover:text-stone-900"
124
+ >
125
+ 复用配置
126
+ </button>
127
+ <button
128
+ type="button"
129
+ onClick={() => onDeletePrompt(selectedConversation.id, turn.id)}
130
+ className="inline-flex size-6 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500"
131
+ aria-label="删除提示词记录"
132
+ >
133
+ <Trash2 className="size-3" />
134
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </div>
 
 
 
 
 
 
 
 
136
  </div>
137
+ </div>
138
+ ) : null}
139
 
140
+ {!turn.resultsDeleted ? (
141
+ <div className="flex justify-start">
142
+ <div className="w-full p-1">
143
+ {turn.referenceImages.length > 0 ? (
144
+ <div className="mb-4 flex flex-col items-end">
145
+ <div className="mb-3 text-xs font-medium text-stone-500">本轮参考图</div>
146
+ <div className="flex flex-wrap justify-end gap-3">
147
+ {turn.referenceImages.map((image, index) => (
148
+ <div key={`${turn.id}-${image.name}-${index}`} className="flex flex-col items-end gap-2">
149
+ <button
150
+ type="button"
151
+ onClick={() => onOpenLightbox(referenceLightboxImages, index)}
152
+ className="group relative h-24 w-24 overflow-hidden border border-stone-200/80 bg-stone-100/60 text-left transition hover:border-stone-300"
153
+ aria-label={`预览参考图 ${image.name || index + 1}`}
154
+ >
155
+ <img
156
+ src={image.dataUrl}
157
+ alt={image.name || `参考图 ${index + 1}`}
158
+ className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
159
+ />
160
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  <Button
162
  variant="outline"
163
  size="sm"
 
168
  加入编辑
169
  </Button>
170
  </div>
171
+ ))}
172
+ </div>
173
+ </div>
174
+ ) : null}
175
+
176
+ <div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px] text-stone-500 sm:mb-4 sm:gap-2 sm:text-xs">
177
+ <span className="rounded-full bg-stone-100 px-3 py-1">{turn.count} 张</span>
178
+ <span className="rounded-full bg-stone-100 px-3 py-1">{getTurnStatusLabel(turn.status)}</span>
179
+ {turn.status === "queued" ? (
180
+ <span className="rounded-full bg-amber-50 px-3 py-1 text-amber-700">等待当前对话中的前序任务完成</span>
181
+ ) : null}
182
+ </div>
183
+
184
+ <div className="grid grid-cols-3 gap-2 sm:block sm:columns-2 sm:gap-4 sm:space-y-4 xl:columns-3">
185
+ {turn.images.map((image, index) => {
186
+ const imageSrc = image.status === "success" ? getStoredImageSrc(image) : "";
187
+ if (image.status === "success" && imageSrc) {
188
+ const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
189
+ const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
190
+ const dimensions = imageDimensions[image.id];
191
+ const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
192
+
193
+ return (
194
+ <div
195
+ key={image.id}
196
+ className="break-inside-avoid overflow-hidden rounded-xl sm:rounded-none"
197
+ >
198
+ <button
199
+ type="button"
200
+ onClick={() => onOpenLightbox(successfulTurnImages, currentIndex)}
201
+ className="group block aspect-square w-full cursor-zoom-in overflow-hidden sm:aspect-auto"
202
+ >
203
+ <img
204
+ src={imageSrc}
205
+ alt={`Generated result ${index + 1}`}
206
+ className="block h-full w-full object-cover transition duration-200 group-hover:brightness-90 sm:h-auto sm:object-contain"
207
+ onLoad={(event) => {
208
+ updateImageDimensions(
209
+ image.id,
210
+ event.currentTarget.naturalWidth,
211
+ event.currentTarget.naturalHeight,
212
+ );
213
+ }}
214
+ />
215
+ </button>
216
+ <div className="hidden items-center justify-between gap-2 px-3 py-3 sm:flex">
217
+ <div className="min-w-0 text-xs text-stone-500">
218
+ <span>结果 {index + 1}</span>
219
+ {imageMeta ? <span className="ml-2 text-stone-400">{imageMeta}</span> : null}
220
+ </div>
221
+ <Button
222
+ variant="outline"
223
+ size="sm"
224
+ className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
225
+ onClick={() => onContinueEdit(selectedConversation.id, image)}
226
+ >
227
+ <Sparkles className="size-4" />
228
+ 加入编辑
229
+ </Button>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+
235
+ if (image.status === "error") {
236
+ return (
237
+ <div
238
+ key={image.id}
239
+ className={cn(
240
+ "break-inside-avoid overflow-hidden rounded-xl border border-rose-200 bg-rose-50 sm:rounded-none",
241
+ "aspect-square",
242
+ turn.size === "1:1" && "sm:aspect-square",
243
+ turn.size === "16:9" && "sm:aspect-video",
244
+ turn.size === "9:16" && "sm:aspect-[9/16]",
245
+ turn.size === "4:3" && "sm:aspect-[4/3]",
246
+ turn.size === "3:4" && "sm:aspect-[3/4]",
247
+ !["1:1", "16:9", "9:16", "4:3", "3:4"].includes(turn.size) && "sm:aspect-square",
248
+ )}
249
+ >
250
+ <div className="flex h-full min-h-16 flex-col items-center justify-center gap-1.5 px-2 py-2 text-center text-[11px] leading-4 text-rose-600 sm:gap-3 sm:px-6 sm:py-8 sm:text-sm sm:leading-6">
251
+ <span className="line-clamp-2 sm:line-clamp-none">{image.error || "生成失败"}</span>
252
+ <button
253
+ type="button"
254
+ onClick={() => void onRetryImage(selectedConversation.id, turn.id, image.id)}
255
+ className="rounded-full bg-white px-2 py-1 text-[10px] font-medium text-rose-600 shadow-sm transition hover:bg-rose-100 sm:px-3 sm:text-xs"
256
+ >
257
+ 重新生成这一张
258
+ </button>
259
+ </div>
260
+ </div>
261
+ );
262
+ }
263
 
 
264
  return (
265
  <div
266
  key={image.id}
267
  className={cn(
268
+ "break-inside-avoid overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 sm:rounded-none",
269
+ turn.size === "1:1" && "aspect-square",
270
+ turn.size === "16:9" && "aspect-video",
271
+ turn.size === "9:16" && "aspect-[9/16]",
272
+ turn.size === "4:3" && "aspect-[4/3]",
273
+ turn.size === "3:4" && "aspect-[3/4]",
274
+ !["1:1", "16:9", "9:16", "4:3", "3:4"].includes(turn.size) && "aspect-square",
 
275
  )}
276
  >
277
+ <div className="flex h-full flex-col items-center justify-center gap-1.5 px-2 py-3 text-center text-stone-500 sm:gap-3 sm:px-6 sm:py-8">
278
+ <div className="rounded-full bg-white p-2 shadow-sm sm:p-3">
279
+ {turn.status === "queued" ? (
280
+ <Clock3 className="size-4 sm:size-5" />
281
+ ) : (
282
+ <LoaderCircle className="size-4 animate-spin sm:size-5" />
283
+ )}
284
+ </div>
285
+ <p className="text-[10px] leading-4 sm:text-sm">{turn.status === "queued" ? "排队中" : "处理中"}</p>
286
  </div>
287
  </div>
288
  );
289
+ })}
290
+ </div>
291
 
292
+ {turn.status === "error" && turn.error ? (
293
+ <div className="mt-4 border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
294
+ {turn.error}
295
+ </div>
296
+ ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
+ <div className="mt-3 flex items-center gap-1.5 text-[11px] sm:mt-4">
299
+ <button
300
+ type="button"
301
+ onClick={() => void onRegenerateTurn(selectedConversation.id, turn.id)}
302
+ className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 font-medium text-stone-500 transition hover:bg-stone-200 hover:text-stone-900"
303
+ >
304
+ <RotateCcw className="size-3" />
305
+ 全部重新生成
306
+ </button>
307
+ <button
308
+ type="button"
309
+ onClick={() => onDeleteResults(selectedConversation.id, turn.id)}
310
+ className="inline-flex size-6 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500"
311
+ aria-label="删除生成结果"
312
+ >
313
+ <Trash2 className="size-3" />
314
+ </button>
315
  </div>
316
+ </div>
317
  </div>
318
+ ) : null}
319
  </div>
320
  );
321
  })}
web/src/app/image/page.tsx CHANGED
@@ -222,7 +222,9 @@ async function syncConversationImageTasks(items: ImageConversation[]) {
222
  new Set(
223
  items.flatMap((conversation) =>
224
  conversation.turns.flatMap((turn) =>
225
- turn.images.flatMap((image) => (image.status === "loading" && image.taskId ? [image.taskId] : [])),
 
 
226
  ),
227
  ),
228
  ),
@@ -355,7 +357,11 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
355
  const [lightboxOpen, setLightboxOpen] = useState(false);
356
  const [lightboxIndex, setLightboxIndex] = useState(0);
357
  const [deleteConfirm, setDeleteConfirm] = useState<
358
- { type: "one"; id: string } | { type: "turn"; conversationId: string; turnId: string } | { type: "all" } | null
 
 
 
 
359
  >(null);
360
 
361
  const parsedCount = useMemo(() => Number(clampImageCount(imageCount)), [imageCount]);
@@ -372,15 +378,25 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
372
  [conversations],
373
  );
374
  const deleteConfirmTitle =
375
- deleteConfirm?.type === "all" ? "清空历史记录" : deleteConfirm?.type === "turn" ? "删除本轮记录" : deleteConfirm?.type === "one" ? "删除对话" : "";
 
 
 
 
 
 
 
 
376
  const deleteConfirmDescription =
377
  deleteConfirm?.type === "all"
378
  ? "确认删除全部图片历史记录吗?删除后无法恢复。"
379
- : deleteConfirm?.type === "turn"
380
- ? "确认删除这轮提示词和生成记录吗?删除后无法恢复。"
381
- : deleteConfirm?.type === "one"
382
- ? "确认删除这条图片对话吗?删除后无法恢复。"
383
- : "";
 
 
384
 
385
  useEffect(() => {
386
  conversationsRef.current = conversations;
@@ -574,13 +590,35 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
574
  }
575
  };
576
 
577
- const handleDeleteTurn = async (conversationId: string, turnId: string) => {
578
  const conversation = conversationsRef.current.find((item) => item.id === conversationId);
579
  if (!conversation) {
580
  return;
581
  }
582
 
583
- const turns = conversation.turns.filter((turn) => turn.id !== turnId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  if (turns.length === 0) {
585
  await handleDeleteConversation(conversationId);
586
  return;
@@ -613,8 +651,12 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
613
  setDeleteConfirm({ type: "one", id });
614
  };
615
 
616
- const openDeleteTurnConfirm = (conversationId: string, turnId: string) => {
617
- setDeleteConfirm({ type: "turn", conversationId, turnId });
 
 
 
 
618
  };
619
 
620
  const openClearHistoryConfirm = () => {
@@ -632,8 +674,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
632
  await handleClearHistory();
633
  return;
634
  }
635
- if (target.type === "turn") {
636
- await handleDeleteTurn(target.conversationId, target.turnId);
637
  return;
638
  }
639
  await handleDeleteConversation(target.id);
@@ -715,6 +757,28 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
715
  [],
716
  );
717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  const openLightbox = useCallback((images: ImageLightboxItem[], index: number) => {
719
  if (images.length === 0) {
720
  return;
@@ -981,6 +1045,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
981
  !activeConversationQueueIds.has(conversation.id) &&
982
  conversation.turns.some(
983
  (turn) =>
 
984
  (turn.status === "queued" || turn.status === "generating") &&
985
  turn.images.some((image) => image.status === "loading"),
986
  )
@@ -1129,7 +1194,9 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1129
  selectedConversation={selectedConversation}
1130
  onOpenLightbox={openLightbox}
1131
  onContinueEdit={handleContinueEdit}
1132
- onDeleteTurn={openDeleteTurnConfirm}
 
 
1133
  onRegenerateTurn={handleRegenerateTurn}
1134
  onRetryImage={handleRetryImage}
1135
  formatConversationTime={formatConversationTime}
 
222
  new Set(
223
  items.flatMap((conversation) =>
224
  conversation.turns.flatMap((turn) =>
225
+ turn.resultsDeleted
226
+ ? []
227
+ : turn.images.flatMap((image) => (image.status === "loading" && image.taskId ? [image.taskId] : [])),
228
  ),
229
  ),
230
  ),
 
357
  const [lightboxOpen, setLightboxOpen] = useState(false);
358
  const [lightboxIndex, setLightboxIndex] = useState(0);
359
  const [deleteConfirm, setDeleteConfirm] = useState<
360
+ | { type: "one"; id: string }
361
+ | { type: "prompt"; conversationId: string; turnId: string }
362
+ | { type: "results"; conversationId: string; turnId: string }
363
+ | { type: "all" }
364
+ | null
365
  >(null);
366
 
367
  const parsedCount = useMemo(() => Number(clampImageCount(imageCount)), [imageCount]);
 
378
  [conversations],
379
  );
380
  const deleteConfirmTitle =
381
+ deleteConfirm?.type === "all"
382
+ ? "清空历史记录"
383
+ : deleteConfirm?.type === "prompt"
384
+ ? "删除提示词记录"
385
+ : deleteConfirm?.type === "results"
386
+ ? "删除生成结果"
387
+ : deleteConfirm?.type === "one"
388
+ ? "删除对话"
389
+ : "";
390
  const deleteConfirmDescription =
391
  deleteConfirm?.type === "all"
392
  ? "确认删除全部图片历史记录吗?删除后无法恢复。"
393
+ : deleteConfirm?.type === "prompt"
394
+ ? "确认删除这条提示词记录吗?对应生成结果会保留。"
395
+ : deleteConfirm?.type === "results"
396
+ ? "确认删除这条生成结果吗?对应提示词记录会保留。"
397
+ : deleteConfirm?.type === "one"
398
+ ? "确认删除这条图片对话吗?删除后无法恢复。"
399
+ : "";
400
 
401
  useEffect(() => {
402
  conversationsRef.current = conversations;
 
590
  }
591
  };
592
 
593
+ const handleDeleteTurnPart = async (conversationId: string, turnId: string, part: "prompt" | "results") => {
594
  const conversation = conversationsRef.current.find((item) => item.id === conversationId);
595
  if (!conversation) {
596
  return;
597
  }
598
 
599
+ const turns = conversation.turns
600
+ .map((turn) => {
601
+ if (turn.id !== turnId) {
602
+ return turn;
603
+ }
604
+ const nextTurn = {
605
+ ...turn,
606
+ promptDeleted: part === "prompt" ? true : turn.promptDeleted,
607
+ resultsDeleted: part === "results" ? true : turn.resultsDeleted,
608
+ status: part === "results" && turn.status === "generating" ? "error" as const : turn.status,
609
+ images:
610
+ part === "results"
611
+ ? turn.images.map((image) =>
612
+ image.status === "loading"
613
+ ? { ...image, status: "error" as const, error: "生成结果已删除" }
614
+ : image,
615
+ )
616
+ : turn.images,
617
+ };
618
+ return nextTurn.promptDeleted && nextTurn.resultsDeleted ? null : nextTurn;
619
+ })
620
+ .filter((turn): turn is ImageTurn => Boolean(turn));
621
+
622
  if (turns.length === 0) {
623
  await handleDeleteConversation(conversationId);
624
  return;
 
651
  setDeleteConfirm({ type: "one", id });
652
  };
653
 
654
+ const openDeletePromptConfirm = (conversationId: string, turnId: string) => {
655
+ setDeleteConfirm({ type: "prompt", conversationId, turnId });
656
+ };
657
+
658
+ const openDeleteResultsConfirm = (conversationId: string, turnId: string) => {
659
+ setDeleteConfirm({ type: "results", conversationId, turnId });
660
  };
661
 
662
  const openClearHistoryConfirm = () => {
 
674
  await handleClearHistory();
675
  return;
676
  }
677
+ if (target.type === "prompt" || target.type === "results") {
678
+ await handleDeleteTurnPart(target.conversationId, target.turnId, target.type);
679
  return;
680
  }
681
  await handleDeleteConversation(target.id);
 
757
  [],
758
  );
759
 
760
+ const handleReuseTurnConfig = useCallback(async (conversationId: string, turnId: string) => {
761
+ const conversation = conversationsRef.current.find((item) => item.id === conversationId);
762
+ const turn = conversation?.turns.find((item) => item.id === turnId);
763
+ if (!conversation || !turn) {
764
+ return;
765
+ }
766
+
767
+ setSelectedConversationId(conversationId);
768
+ setImagePrompt(turn.prompt);
769
+ setImageCount(String(Math.max(1, turn.count || turn.images.length || 1)));
770
+ setImageSize(turn.size);
771
+ setReferenceImages(turn.referenceImages);
772
+ setReferenceImageFiles(
773
+ turn.referenceImages.map((image) => dataUrlToFile(image.dataUrl, image.name, image.type)),
774
+ );
775
+ if (fileInputRef.current) {
776
+ fileInputRef.current.value = "";
777
+ }
778
+ textareaRef.current?.focus();
779
+ toast.success("已复用这条提示词配置");
780
+ }, []);
781
+
782
  const openLightbox = useCallback((images: ImageLightboxItem[], index: number) => {
783
  if (images.length === 0) {
784
  return;
 
1045
  !activeConversationQueueIds.has(conversation.id) &&
1046
  conversation.turns.some(
1047
  (turn) =>
1048
+ !turn.resultsDeleted &&
1049
  (turn.status === "queued" || turn.status === "generating") &&
1050
  turn.images.some((image) => image.status === "loading"),
1051
  )
 
1194
  selectedConversation={selectedConversation}
1195
  onOpenLightbox={openLightbox}
1196
  onContinueEdit={handleContinueEdit}
1197
+ onDeletePrompt={openDeletePromptConfirm}
1198
+ onDeleteResults={openDeleteResultsConfirm}
1199
+ onReuseTurnConfig={handleReuseTurnConfig}
1200
  onRegenerateTurn={handleRegenerateTurn}
1201
  onRetryImage={handleRetryImage}
1202
  formatConversationTime={formatConversationTime}
web/src/store/image-conversations.ts CHANGED
@@ -36,6 +36,8 @@ export type ImageTurn = {
36
  createdAt: string;
37
  status: ImageTurnStatus;
38
  error?: string;
 
 
39
  };
40
 
41
  export type ImageConversation = {
@@ -144,6 +146,8 @@ function normalizeTurn(turn: ImageTurn & Record<string, unknown>): ImageTurn {
144
  ? turn.status
145
  : derivedStatus,
146
  error: typeof turn.error === "string" ? turn.error : undefined,
 
 
147
  };
148
  }
149
 
@@ -265,6 +269,9 @@ export function getImageConversationStats(conversation: ImageConversation | null
265
 
266
  return conversation.turns.reduce(
267
  (acc, turn) => {
 
 
 
268
  if (turn.status === "queued") {
269
  acc.queued += 1;
270
  } else if (turn.status === "generating") {
 
36
  createdAt: string;
37
  status: ImageTurnStatus;
38
  error?: string;
39
+ promptDeleted?: boolean;
40
+ resultsDeleted?: boolean;
41
  };
42
 
43
  export type ImageConversation = {
 
146
  ? turn.status
147
  : derivedStatus,
148
  error: typeof turn.error === "string" ? turn.error : undefined,
149
+ promptDeleted: turn.promptDeleted === true,
150
+ resultsDeleted: turn.resultsDeleted === true,
151
  };
152
  }
153
 
 
269
 
270
  return conversation.turns.reduce(
271
  (acc, turn) => {
272
+ if (turn.resultsDeleted) {
273
+ return acc;
274
+ }
275
  if (turn.status === "queued") {
276
  acc.queued += 1;
277
  } else if (turn.status === "generating") {