存量前端:生图分组/灯箱/重生控件/导入文案/驳回横幅+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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||
import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
||||
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';
|
||||
@@ -17,7 +18,7 @@ export default function TextSelectionPage() {
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
|
||||
const { textCandidates, textDone, textTotal, flywheelInjected } = useGenerationStore();
|
||||
const { textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent } = useGenerationStore();
|
||||
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
|
||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||
|
||||
@@ -25,6 +26,15 @@ export default function TextSelectionPage() {
|
||||
if (taskId) setCurrentTask(taskId, 'generating');
|
||||
}, [taskId]);
|
||||
|
||||
async function handleSaveEdit(candidateId: number, content: string) {
|
||||
if (!taskId) return;
|
||||
// D1 改稿=飞轮最强信号 text_edit,后端回写 + record_signal
|
||||
await api.post(`/api/v1/tasks/${taskId}/text-candidates/${candidateId}/edit`, {
|
||||
candidate_id: candidateId, content,
|
||||
});
|
||||
patchTextCandidateContent(candidateId, content);
|
||||
}
|
||||
|
||||
async function handleProceedToImages() {
|
||||
if (!taskId || selectedTextIds.length === 0) return;
|
||||
for (const candidateId of selectedTextIds) {
|
||||
@@ -39,6 +49,7 @@ export default function TextSelectionPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={2} />
|
||||
<RejectReasonBanner taskId={taskId} />
|
||||
{flywheelInjected && (
|
||||
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
|
||||
)}
|
||||
@@ -72,6 +83,7 @@ export default function TextSelectionPage() {
|
||||
candidate={c}
|
||||
selected={selectedTextIds.includes(c.candidate_id)}
|
||||
onToggle={toggleTextSelection}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FlywheelBanner } from '@/components/FlywheelBanner';
|
||||
import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel';
|
||||
import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
|
||||
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
|
||||
import { ImportTextModal } from '@/components/tasks/ImportTextModal';
|
||||
import { api } from '@/lib/api';
|
||||
import { Product, CreateTaskRequest } from '@/types/index';
|
||||
import { PaginatedResponse } from '@/types/index';
|
||||
@@ -32,6 +33,7 @@ export default function NewTaskPage() {
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => { loadProducts(); }, []);
|
||||
|
||||
@@ -45,17 +47,22 @@ export default function NewTaskPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') {
|
||||
e.preventDefault();
|
||||
if (!selectedProduct) { setError('请选择产品'); return; }
|
||||
if (!form.theme.trim()) { setError('请填写今天主题'); return; }
|
||||
function validate(): boolean {
|
||||
if (!selectedProduct) { setError('请选择产品'); return false; }
|
||||
if (!form.theme.trim()) { setError('请填写今天主题'); return false; }
|
||||
// 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传
|
||||
if (form.need_product_image && !selectedProduct.image_path) {
|
||||
setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') {
|
||||
e.preventDefault();
|
||||
if (!validate() || !selectedProduct) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track };
|
||||
const data = await api.post<{ id: number }>('/api/v1/tasks', req);
|
||||
@@ -68,6 +75,43 @@ export default function NewTaskPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 轨B:先校验产品/主题,通过后开弹窗让用户贴文案
|
||||
function openImport(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (validate()) setImportOpen(true);
|
||||
}
|
||||
|
||||
// 弹窗确认:建 import 任务 → 逐条导入候选池 → 跳 text 页
|
||||
async function handleImportConfirm(contents: string[]) {
|
||||
if (!selectedProduct) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track: 'import' };
|
||||
const data = await api.post<{ id: number }>('/api/v1/tasks', req);
|
||||
// 任务已建。逐条导入;某条失败也不丢任务——跳进已建任务的 text 页,
|
||||
// 半成品文案在那里可见可续,避免任务 ID 丢失变成找不回的孤儿任务。
|
||||
let imported = 0;
|
||||
try {
|
||||
for (const content of contents) {
|
||||
await api.post(`/api/v1/tasks/${data.id}/import-text`, { content });
|
||||
imported += 1;
|
||||
}
|
||||
} catch (impErr) {
|
||||
const ae = impErr as ApiError;
|
||||
setError(`已导入 ${imported}/${contents.length} 条后中断(${ae?.message || '导入失败'}),已为你保留任务 #${data.id},可在文案页补齐。`);
|
||||
}
|
||||
setImportOpen(false);
|
||||
router.push(`/tasks/${data.id}/text`);
|
||||
} catch (err) {
|
||||
// 建任务本身失败(还没有任务 ID)
|
||||
const apiErr = err as ApiError;
|
||||
setError(apiErr?.message || getErrorAction(apiErr?.code).message);
|
||||
setImportOpen(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={1} />
|
||||
@@ -86,7 +130,7 @@ export default function NewTaskPage() {
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
onSubmitAi={(e) => handleSubmit(e, 'ai')}
|
||||
onSubmitImport={(e) => handleSubmit(e, 'import')}
|
||||
onSubmitImport={openImport}
|
||||
/>
|
||||
<div className="mt-8"><RecentTasksBar /></div>
|
||||
</div>
|
||||
@@ -94,6 +138,13 @@ export default function NewTaskPage() {
|
||||
{/* 右侧配置预览面板 */}
|
||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
|
||||
</div>
|
||||
|
||||
<ImportTextModal
|
||||
open={importOpen}
|
||||
submitting={submitting}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onConfirm={handleImportConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user