Files
beige/frontend/src/app/tasks/[id]/images/page.tsx
yangqianqian 25f86b2f4a 存量前端:生图分组/灯箱/重生控件/导入文案/驳回横幅+SSE+总核销表
- ImageStrategyGroup/ImageLightbox/RegenControls/ImportTextModal/
  RejectReasonBanner 新建组件
- tasks images/text/new + history + 配置/布局/SSE hooks 存量改动
- 总核销表进度更新

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:17:53 +08:00

139 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}