存量前端:生图分组/灯箱/重生控件/导入文案/驳回横幅+SSE+总核销表

- ImageStrategyGroup/ImageLightbox/RegenControls/ImportTextModal/
  RejectReasonBanner 新建组件
- tasks images/text/new + history + 配置/布局/SSE hooks 存量改动
- 总核销表进度更新

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 11:17:53 +08:00
parent d85dcd401b
commit 25f86b2f4a
20 changed files with 741 additions and 60 deletions

View File

@@ -1,16 +1,22 @@
'use client';
// 屏4 挑图0/N异步电影 + 平铺选图 + 单批重试(一期=整体重生) + 能离开
// 屏4 挑图0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开
import { useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
import { ImageStrategyGroup, ROLE_LABEL } from '@/components/tasks/ImageStrategyGroup';
import { RegenControls, RegenTarget } from '@/components/tasks/RegenControls';
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
import { useSse } from '@/hooks/useSse';
import { useGenerationStore } from '@/stores/generationStore';
import { useTaskStore } from '@/stores/taskStore';
import { ImageCandidate } from '@/types/index';
import { api } from '@/lib/api';
export default function ImageSelectionPage() {
@@ -21,17 +27,45 @@ export default function ImageSelectionPage() {
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
const { selectedImageIds, toggleImageSelection } = useTaskStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId);
const [zoom, setZoom] = useState<ImageCandidate | null>(null);
const [regenTarget, setRegenTarget] = useState<RegenTarget | null>(null);
const pendingCount = imageTotal - imageDone;
const allDone = imageTotal > 0 && pendingCount === 0;
// 按套(A/B/C)分组展示R2 同套同角色去重只留最新一张candidate_id 最大=最新重生)
function latestByRole(list: ImageCandidate[]): ImageCandidate[] {
const byRole = new Map<string, ImageCandidate>();
for (const c of list) {
const prev = byRole.get(c.role);
if (!prev || c.candidate_id > prev.candidate_id) byRole.set(c.role, c);
}
return Array.from(byRole.values());
}
const groups: Array<['A' | 'B' | 'C', ImageCandidate[]]> = (['A', 'B', 'C'] as const).map(
(s) => [s, latestByRole(imageCandidates.filter((c) => c.strategy === s))]
);
async function handleRetry(_batchRole: string) {
// 一期 regenerate = 整体重生,契约无 batch 字段,不发 body
// 二期按批重生时再扩展_batchRole 目前仅作入参占位UI 提示用)
// 一期 regenerate = 整体重生R2 已扩展按角色单批重生,见 handleRegen
if (!taskId) return;
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
}
// R2 单张/单套重生 + 可选人工提示词
async function handleRegen(target: RegenTarget, customPrompt: string) {
if (!taskId) return;
await api.post(`/api/v1/tasks/${taskId}/regenerate`, {
strategy: target.strategy,
role: target.role,
custom_prompt: customPrompt || undefined,
});
}
function openRegen(strategy: string, role: string | undefined, label: string) {
setRegenTarget({ strategy: strategy as 'A' | 'B' | 'C', role, label });
}
async function handleProceedToConfirm() {
if (!taskId || selectedImageIds.length === 0) return;
for (const candidateId of selectedImageIds) {
@@ -43,6 +77,7 @@ export default function ImageSelectionPage() {
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={3} />
<RejectReasonBanner taskId={taskId} />
{flywheelInjected && (
<FlywheelBannerFromSse
recentPreference={flywheelInjected.recentPreference}
@@ -53,29 +88,34 @@ export default function ImageSelectionPage() {
<div className="p-6 flex-1 overflow-auto">
<FatalErrorBanner />
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
{/* 图片网格(谁好谁先冒) */}
<div className="grid grid-cols-3 xl:grid-cols-5 gap-3">
{imageCandidates.length === 0
? Array.from({ length: 5 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)
: imageCandidates.map((c) => (
<div key={c.candidate_id}
onClick={() => toggleImageSelection(c.candidate_id)}
className={`relative rounded-xl overflow-hidden border cursor-pointer transition-all
${selectedImageIds.includes(c.candidate_id) ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default hover:border-brand-orange/50'}`}
aria-label={`图片 ${c.role}${selectedImageIds.includes(c.candidate_id) ? '已选中' : '未选中'}`}
>
<img src={c.url} alt={c.role} className="w-full aspect-square object-cover" />
<p className="text-xs text-center py-1 text-text-secondary">{c.role}</p>
</div>
))}
</div>
{imageCandidates.length === 0 ? (
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)}
</div>
) : (
groups.map(([s, list]) =>
list.length > 0 ? (
<ImageStrategyGroup
key={s}
strategy={s}
candidates={list}
selectedImageIds={selectedImageIds}
onToggle={toggleImageSelection}
onZoom={setZoom}
onRegen={openRegen}
/>
) : null
)
)}
{/* 失败批次提示 */}
{failedBatches.length > 0 && allDone && (
<div role="alert" className="mt-4 bg-red-50 border border-status-error rounded-xl px-4 py-3 text-sm text-status-error">
{failedBatches.length}
{failedBatches.filter((b) => b.retryable).map((b) => (
<button key={b.batch} onClick={() => handleRetry(b.batch)}
className="ml-2 underline" aria-label={`重试${b.batch}`}> {b.batch}</button>
className="ml-2 underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
{ROLE_LABEL[b.batch] || b.batch}
</button>
))}
</div>
)}
@@ -83,6 +123,16 @@ export default function ImageSelectionPage() {
<ImageActionBar selectedCount={selectedImageIds.length} onProceed={handleProceedToConfirm} />
)}
</div>
<ImageLightbox
url={zoom?.url ?? null}
caption={zoom ? (ROLE_LABEL[zoom.role] || zoom.role) : undefined}
onClose={() => setZoom(null)}
/>
<RegenControls
target={regenTarget}
onClose={() => setRegenTarget(null)}
onSubmit={handleRegen}
/>
</div>
);
}