- ImageStrategyGroup/ImageLightbox/RegenControls/ImportTextModal/ RejectReasonBanner 新建组件 - tasks images/text/new + history + 配置/布局/SSE hooks 存量改动 - 总核销表进度更新 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
5.8 KiB
TypeScript
139 lines
5.8 KiB
TypeScript
'use client';
|
||
// 屏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() {
|
||
const params = useParams<{ id: string }>();
|
||
const taskId = params?.id ? Number(params.id) : null;
|
||
const router = useRouter();
|
||
|
||
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 = 整体重生(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) {
|
||
await api.post(`/api/v1/tasks/${taskId}/image-candidates/select`, { candidate_id: candidateId });
|
||
}
|
||
router.push(`/tasks/${taskId}/confirm`);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<ProgressBar currentStep={3} />
|
||
<RejectReasonBanner taskId={taskId} />
|
||
{flywheelInjected && (
|
||
<FlywheelBannerFromSse
|
||
recentPreference={flywheelInjected.recentPreference}
|
||
rejectReasons={flywheelInjected.rejectReasons}
|
||
/>
|
||
)}
|
||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||
<div className="p-6 flex-1 overflow-auto">
|
||
<FatalErrorBanner />
|
||
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
|
||
{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={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
|
||
⟳ 重试{ROLE_LABEL[b.batch] || b.batch}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{imageCandidates.length > 0 && (
|
||
<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>
|
||
);
|
||
}
|