上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段); 修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品 - dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开; settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch - 新增用户管理(users API/admin service/改密页) + alembic 022/023/024 - 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example - A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,21 +3,82 @@
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar';
|
||||
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
|
||||
import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages';
|
||||
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
|
||||
import { TextLightbox } from '@/components/tasks/TextLightbox';
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { useState } from 'react';
|
||||
import { TextCandidate, ImageCandidate } from '@/types/index';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function ConfirmPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
const { selectedTextIds, selectedImageIds } = useTaskStore();
|
||||
const { selectedTextIds, selectedImageIds, setSelections } = useTaskStore();
|
||||
// imageCandidates 平铺(契约 image_candidate 事件无 batch 字段)
|
||||
const { textCandidates, imageCandidates } = useGenerationStore();
|
||||
const { textCandidates, imageCandidates, hydrateFromTask } = useGenerationStore();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [hydrating, setHydrating] = useState(false);
|
||||
const [hydrateError, setHydrateError] = useState(false); // C1: 拉取失败时给重试入口
|
||||
const [submitError, setSubmitError] = useState(''); // 提审被后端拒绝时显式提示,不再吞错
|
||||
// 飞轮节点反馈:提交审核/打回后短暂绿芽提示
|
||||
const [flywheelMsg, setFlywheelMsg] = useState<string | null>(null);
|
||||
// 失败任务查看不能空白(#11):记录任务状态+失败原因,failed 时显眼提示
|
||||
const [taskMeta, setTaskMeta] = useState<{ status: string; failReason: string } | null>(null);
|
||||
// 已通过/已打回 = 只读查看态(倩倩姐2026-06-26):不提交审核,文案点看全文、图点放大
|
||||
const isReadonly = taskMeta?.status === 'approved' || taskMeta?.status === 'rejected';
|
||||
const [zoomImage, setZoomImage] = useState<ImageCandidate | null>(null);
|
||||
const [zoomText, setZoomText] = useState<TextCandidate | null>(null);
|
||||
|
||||
// store 无持久化:从历史归档「查看」直达本页时 store 是空的 → 「暂无选中文案」。
|
||||
// 挂载时拉 GET /tasks/{id}:候选为空则回填+据 is_selected 还原已选;
|
||||
// 同时记录任务状态/失败原因,失败任务不再空白展示(#11)。
|
||||
const loadTask = (id: number) => {
|
||||
// 跨任务切换修复(倩倩姐2026-06-26报bug):SPA 路由切到不同任务卡片时,
|
||||
// 上个任务数据仍在 store → storeEmpty=false → 旧逻辑直接 return 显示上一个任务,
|
||||
// 手动刷新才好(刷新=重挂载清空 store)。这里按 currentTaskId 判定是否换了任务,
|
||||
// 换了就先清两个 store,让本次走"空 store 全量回填"路径,根治"显示上个任务/暂无选中"。
|
||||
const ts = useTaskStore.getState();
|
||||
if (ts.currentTaskId !== id) {
|
||||
useGenerationStore.getState().reset();
|
||||
ts.reset();
|
||||
ts.setCurrentTask(id, 'pending_selection');
|
||||
}
|
||||
const storeEmpty =
|
||||
useGenerationStore.getState().textCandidates.length === 0 &&
|
||||
useGenerationStore.getState().imageCandidates.length === 0;
|
||||
if (storeEmpty) setHydrating(true);
|
||||
setHydrateError(false);
|
||||
api.get<{
|
||||
status: string; reject_reason?: string | null;
|
||||
text_candidates: TextCandidate[]; image_candidates: ImageCandidate[];
|
||||
}>(`/api/v1/tasks/${id}`)
|
||||
.then((data) => {
|
||||
setTaskMeta({ status: data.status, failReason: data.reject_reason || '' });
|
||||
if (!storeEmpty) return;
|
||||
const texts = data.text_candidates || [];
|
||||
const images = data.image_candidates || [];
|
||||
hydrateFromTask({ textCandidates: texts, imageCandidates: images });
|
||||
// hydrateFromTask 内已据 is_selected 写 taskStore;但 storeEmpty 路径
|
||||
// generationStore 可能比 taskStore setSelections 先跑,兜底再调一次
|
||||
setSelections(
|
||||
texts.filter((c) => c.is_selected).map((c) => c.candidate_id),
|
||||
images.filter((c) => c.is_selected).map((c) => c.candidate_id),
|
||||
);
|
||||
})
|
||||
.catch(() => { setHydrateError(true); }) // C1: 不再静默,给重试入口
|
||||
.finally(() => setHydrating(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
loadTask(taskId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]);
|
||||
|
||||
const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id));
|
||||
const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id));
|
||||
@@ -25,9 +86,17 @@ export default function ConfirmPage() {
|
||||
async function handleSubmitReview() {
|
||||
if (!taskId) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/submit-review`);
|
||||
router.push('/review');
|
||||
setFlywheelMsg('🌿 已学习你的选择,下次更懂你');
|
||||
setTimeout(() => {
|
||||
setFlywheelMsg(null);
|
||||
router.push('/review');
|
||||
}, 2500);
|
||||
} catch (e) {
|
||||
// 后端校验失败(如未选满)以前被 finally 吞掉 → 用户看着像"点了没反应"
|
||||
setSubmitError(e instanceof Error ? e.message : '提交审核失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -38,7 +107,11 @@ export default function ConfirmPage() {
|
||||
setRegenerating(true);
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
|
||||
router.push(`/tasks/${taskId}/text`);
|
||||
setFlywheelMsg('🌿 已记录你的顾虑,下次会避开');
|
||||
setTimeout(() => {
|
||||
setFlywheelMsg(null);
|
||||
router.push(`/tasks/${taskId}/text`);
|
||||
}, 2500);
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
@@ -46,27 +119,66 @@ export default function ConfirmPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={4} />
|
||||
{/* 已通过/已打回=只读查看,不显示步骤进度条(倩倩姐2026-06-26) */}
|
||||
{!isReadonly && <ProgressBar currentStep={4} />}
|
||||
|
||||
<div className="p-6 flex-1 overflow-auto max-w-4xl">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6">确认预览</h2>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-text-primary">
|
||||
{isReadonly ? '查看笔记' : '确认预览'}
|
||||
</h2>
|
||||
{taskId && <DeleteTaskButton taskId={taskId} />}
|
||||
</div>
|
||||
|
||||
{/* C1: 拉取失败给重新加载入口,避免永久灰死 */}
|
||||
{hydrateError && (
|
||||
<div className="mb-6 rounded-lg bg-yellow-50 border border-yellow-200 p-4" role="alert">
|
||||
<p className="text-sm text-yellow-700">加载选中内容失败。</p>
|
||||
<button
|
||||
onClick={() => taskId && loadTask(taskId)}
|
||||
className="mt-2 text-sm bg-brand-orange text-white rounded-lg px-4 py-1.5 hover:bg-orange-600"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{taskMeta?.status === 'failed' && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4" role="alert">
|
||||
<p className="text-sm font-semibold text-red-600 mb-1">⚠ 本任务生成失败</p>
|
||||
<p className="text-sm text-red-600 whitespace-pre-wrap">
|
||||
{taskMeta.failReason || '生成过程中出错,未产出可用内容。可点下方「重新生成」重试。'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="mt-3 text-sm bg-brand-orange text-white rounded-lg px-4 py-2 hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{regenerating ? '重新生成中…' : '重新生成'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 已选文案 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-secondary mb-3">已选文案</h3>
|
||||
{selectedTexts.length === 0 ? (
|
||||
{hydrating ? (
|
||||
<p className="text-text-tertiary text-sm">加载中…</p>
|
||||
) : selectedTexts.length === 0 ? (
|
||||
<p className="text-text-tertiary text-sm">暂无选中文案</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedTexts.map((c) => (
|
||||
<div key={c.candidate_id}
|
||||
className="border border-border-default rounded-xl p-4 bg-surface-primary">
|
||||
onClick={isReadonly ? () => setZoomText(c) : undefined}
|
||||
className={`border border-border-default rounded-xl p-4 bg-surface-primary ${isReadonly ? 'cursor-pointer hover:border-brand-orange' : ''}`}>
|
||||
<span className="text-xs text-brand-orange font-medium">{c.angle_label}</span>
|
||||
<p className="text-sm text-text-primary mt-1 whitespace-pre-wrap line-clamp-6">
|
||||
<p className={`text-sm text-text-primary mt-1 whitespace-pre-wrap ${isReadonly ? '' : 'line-clamp-6'}`}>
|
||||
{c.content}
|
||||
</p>
|
||||
<span className="text-xs text-text-tertiary mt-1 block">总分 {c.score.total}</span>
|
||||
<span className="text-xs text-text-tertiary mt-1 block">
|
||||
总分 {c.score.total}{isReadonly ? ' · 点击看全文' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -74,18 +186,55 @@ export default function ConfirmPage() {
|
||||
</div>
|
||||
|
||||
{/* 已选图 */}
|
||||
<ConfirmPreviewImages images={selectedImages} />
|
||||
<ConfirmPreviewImages images={selectedImages} onZoom={isReadonly ? setZoomImage : undefined} />
|
||||
</div>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<ConfirmActionBar
|
||||
submitting={submitting}
|
||||
regenerating={regenerating}
|
||||
canSubmit={selectedTextIds.length > 0}
|
||||
onSubmit={handleSubmitReview}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
{/* 提审被拒:显式红框,不再静默吞错 */}
|
||||
{submitError && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3" role="alert">
|
||||
<p className="text-sm text-red-600">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 飞轮节点反馈:提交成功后短暂绿芽提示 */}
|
||||
{flywheelMsg && (
|
||||
<div role="status" aria-live="polite"
|
||||
className="mb-4 rounded-lg bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark">
|
||||
{flywheelMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作栏:只读态(已通过/已打回)不提交审核,只给只读说明;否则正常确认操作 */}
|
||||
{isReadonly ? (
|
||||
<div className="mt-6 rounded-lg bg-gray-50 border border-border-default px-4 py-3 text-sm text-text-secondary">
|
||||
{taskMeta?.status === 'approved'
|
||||
? '本笔记已通过审核,当前为查看模式。点击文案看全文、点击图片放大查看。'
|
||||
: '本笔记已被打回,当前为查看模式。点击文案看全文、点击图片放大查看。'}
|
||||
</div>
|
||||
) : (
|
||||
<ConfirmActionBar
|
||||
submitting={submitting}
|
||||
regenerating={regenerating}
|
||||
hydrating={hydrating}
|
||||
canSubmit={selectedTextIds.length > 0 && selectedImageIds.length > 0}
|
||||
onSubmit={handleSubmitReview}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 只读查看:文案全文 / 图片放大 */}
|
||||
<TextLightbox
|
||||
text={zoomText?.content ?? null}
|
||||
label={zoomText?.angle_label}
|
||||
score={zoomText?.score.total}
|
||||
onClose={() => setZoomText(null)}
|
||||
/>
|
||||
<ImageLightbox
|
||||
url={zoomImage?.url ?? null}
|
||||
caption={zoomImage?.role}
|
||||
onClose={() => setZoomImage(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
// 屏4 挑图:0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, 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 { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
|
||||
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 { StageProgress } from '@/components/tasks/StageProgress';
|
||||
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
|
||||
import { useSse } from '@/hooks/useSse';
|
||||
@@ -24,11 +26,14 @@ export default function ImageSelectionPage() {
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
|
||||
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
|
||||
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected, removeImageCandidate } = 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 [regenPending, setRegenPending] = useState<Record<string, { label: string; minId: number }>>({});
|
||||
const [regenNotice, setRegenNotice] = useState<string | null>(null);
|
||||
const [justSavedFlywheel, setJustSavedFlywheel] = useState(false);
|
||||
|
||||
const pendingCount = imageTotal - imageDone;
|
||||
const allDone = imageTotal > 0 && pendingCount === 0;
|
||||
@@ -45,6 +50,29 @@ export default function ImageSelectionPage() {
|
||||
const groups: Array<['A' | 'B' | 'C', ImageCandidate[]]> = (['A', 'B', 'C'] as const).map(
|
||||
(s) => [s, latestByRole(imageCandidates.filter((c) => c.strategy === s))]
|
||||
);
|
||||
const regeneratingKeys = useMemo(() => new Set(Object.keys(regenPending)), [regenPending]);
|
||||
|
||||
useEffect(() => {
|
||||
setRegenPending((pending) => {
|
||||
let changed = false;
|
||||
const next = { ...pending };
|
||||
for (const [key, meta] of Object.entries(pending)) {
|
||||
const [strategy, role] = key.split(':');
|
||||
const finished = imageCandidates.some((c) =>
|
||||
c.strategy === strategy &&
|
||||
(role === '*' || c.role === role) &&
|
||||
c.candidate_id > meta.minId &&
|
||||
c.is_regen
|
||||
);
|
||||
if (finished) {
|
||||
delete next[key];
|
||||
changed = true;
|
||||
setRegenNotice(`${meta.label} 已重生完成,已展示最新图片`);
|
||||
}
|
||||
}
|
||||
return changed ? next : pending;
|
||||
});
|
||||
}, [imageCandidates]);
|
||||
|
||||
async function handleRetry(_batchRole: string) {
|
||||
// 一期 regenerate = 整体重生(R2 已扩展按角色单批重生,见 handleRegen)
|
||||
@@ -55,10 +83,23 @@ export default function ImageSelectionPage() {
|
||||
// R2 单张/单套重生 + 可选人工提示词
|
||||
async function handleRegen(target: RegenTarget, customPrompt: string) {
|
||||
if (!taskId) return;
|
||||
const key = `${target.strategy}:${target.role ?? '*'}`;
|
||||
if (regenPending[key]) return;
|
||||
const minId = imageCandidates.reduce((max, c) => Math.max(max, c.candidate_id), 0);
|
||||
setRegenPending((prev) => ({ ...prev, [key]: { label: target.label, minId } }));
|
||||
setRegenNotice(`${target.label} 已提交重生,生成完成后会自动替换为最新图片`);
|
||||
await api.post(`/api/v1/tasks/${taskId}/regenerate`, {
|
||||
strategy: target.strategy,
|
||||
role: target.role,
|
||||
custom_prompt: customPrompt || undefined,
|
||||
}).catch((err) => {
|
||||
setRegenPending((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setRegenNotice(err?.message || '重生提交失败,请稍后重试');
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +107,19 @@ export default function ImageSelectionPage() {
|
||||
setRegenTarget({ strategy: strategy as 'A' | 'B' | 'C', role, label });
|
||||
}
|
||||
|
||||
async function handleDeleteImage(candidateId: number) {
|
||||
if (!taskId) return;
|
||||
await api.delete(`/api/v1/tasks/${taskId}/image-candidates/${candidateId}`);
|
||||
removeImageCandidate(candidateId);
|
||||
}
|
||||
|
||||
// 选图即时飞轮反馈:点选后绿芽提示4秒
|
||||
function handleToggleImage(candidateId: number) {
|
||||
toggleImageSelection(candidateId);
|
||||
setJustSavedFlywheel(true);
|
||||
setTimeout(() => setJustSavedFlywheel(false), 4000);
|
||||
}
|
||||
|
||||
async function handleProceedToConfirm() {
|
||||
if (!taskId || selectedImageIds.length === 0) return;
|
||||
for (const candidateId of selectedImageIds) {
|
||||
@@ -82,11 +136,36 @@ export default function ImageSelectionPage() {
|
||||
<FlywheelBannerFromSse
|
||||
recentPreference={flywheelInjected.recentPreference}
|
||||
rejectReasons={flywheelInjected.rejectReasons}
|
||||
signalCount={flywheelInjected.signalCount}
|
||||
/>
|
||||
)}
|
||||
{justSavedFlywheel && (
|
||||
<p role="status" aria-live="polite"
|
||||
className="px-4 py-2 text-sm text-brand-green-dark bg-brand-green-light border border-brand-green/30">
|
||||
🌿 已记录你的选择偏好
|
||||
</p>
|
||||
)}
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
<div className="p-6 flex-1 overflow-auto">
|
||||
{taskId && (
|
||||
<div className="mb-4 flex justify-end">
|
||||
<DeleteTaskButton taskId={taskId} />
|
||||
</div>
|
||||
)}
|
||||
<FatalErrorBanner />
|
||||
{regenNotice && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700">
|
||||
<span>{regenNotice}</span>
|
||||
<button
|
||||
onClick={() => setRegenNotice(null)}
|
||||
className="ml-4 text-xs text-amber-700 underline"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* #4 阶段进度:配图全程阶段(分析→文案→配图→完成) */}
|
||||
{!allDone && <StageProgress scope="image" />}
|
||||
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
|
||||
{imageCandidates.length === 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
|
||||
@@ -100,9 +179,11 @@ export default function ImageSelectionPage() {
|
||||
strategy={s}
|
||||
candidates={list}
|
||||
selectedImageIds={selectedImageIds}
|
||||
onToggle={toggleImageSelection}
|
||||
onToggle={handleToggleImage}
|
||||
onZoom={setZoom}
|
||||
onRegen={openRegen}
|
||||
regeneratingKeys={regeneratingKeys}
|
||||
onDelete={handleDeleteImage}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
@@ -110,13 +191,20 @@ export default function ImageSelectionPage() {
|
||||
{/* 失败批次提示 */}
|
||||
{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>
|
||||
))}
|
||||
<p className="font-medium">有 {failedBatches.length} 批没出来。</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{failedBatches.map((b) => (
|
||||
<li key={b.batch} className="flex flex-wrap items-center gap-2">
|
||||
<span>{ROLE_LABEL[b.batch] || b.batch}:{b.reason || '中转站暂时无响应'}</span>
|
||||
{b.retryable && (
|
||||
<button onClick={() => handleRetry(b.batch)}
|
||||
className="underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{imageCandidates.length > 0 && (
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
'use client';
|
||||
// 屏3 挑文案:SSE实时+五维分+多选+飞轮隐形
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
|
||||
import { FlywheelBanner, FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||
import { TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
||||
import { TextStrategyGroup, groupByStrategy } from '@/components/tasks/TextStrategyGroup';
|
||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||
import { StageProgress } from '@/components/tasks/StageProgress';
|
||||
import { TextOutcomeBanner } from '@/components/tasks/TextOutcomeBanner';
|
||||
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 { usePreferenceStore } from '@/stores/preferenceStore';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function TextSelectionPage() {
|
||||
@@ -19,14 +23,32 @@ export default function TextSelectionPage() {
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
|
||||
const { textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent } = useGenerationStore();
|
||||
const { stage, textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent, removeTextCandidate } = useGenerationStore();
|
||||
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
|
||||
const { context, fetchContext } = usePreferenceStore();
|
||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||
const [proceedError, setProceedError] = useState('');
|
||||
const [track, setTrack] = useState<'ai' | 'import' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) setCurrentTask(taskId, 'generating');
|
||||
}, [taskId]);
|
||||
|
||||
// 历史回填:无 SSE 活跃飞轮数据时,拉 task 维度 context 兜底展示
|
||||
useEffect(() => {
|
||||
if (!taskId || flywheelInjected) return;
|
||||
fetchContext(taskId);
|
||||
}, [taskId, flywheelInjected]);
|
||||
|
||||
// 拉取 track:导入轨「去生图」要先触发生图(reuse_text)再跳页,AI轨直接跳页。
|
||||
// track=null 表示未拉到,按钮禁用,避免竞态误把导入轨当 AI 轨跳过生图触发。
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
api.get<{ track?: 'ai' | 'import' }>(`/api/v1/tasks/${taskId}`)
|
||||
.then((t) => setTrack(t.track === 'import' ? 'import' : 'ai'))
|
||||
.catch(() => setTrack('ai'));
|
||||
}, [taskId]);
|
||||
|
||||
async function handleSaveEdit(candidateId: number, content: string) {
|
||||
if (!taskId) return;
|
||||
// D1 改稿=飞轮最强信号 text_edit,后端回写 + record_signal
|
||||
@@ -36,43 +58,80 @@ export default function TextSelectionPage() {
|
||||
patchTextCandidateContent(candidateId, content);
|
||||
}
|
||||
|
||||
async function handleDeleteText(candidateId: number) {
|
||||
if (!taskId) return;
|
||||
await api.delete(`/api/v1/tasks/${taskId}/text-candidates/${candidateId}`);
|
||||
removeTextCandidate(candidateId);
|
||||
}
|
||||
|
||||
async function handleProceedToImages() {
|
||||
if (!taskId || selectedTextIds.length === 0) return;
|
||||
setProceedError('');
|
||||
const failed: number[] = [];
|
||||
for (const candidateId of selectedTextIds) {
|
||||
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
|
||||
} catch {
|
||||
// 不再静默吞错:残留/失效id会被后端拒(404),要让用户知道而非"以为都生效了"
|
||||
failed.push(candidateId);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
setProceedError(`有 ${failed.length} 条文案选中失败(可能已失效),请重新选择后再试。`);
|
||||
return;
|
||||
}
|
||||
// 导入轨:选完文案点「去生图」→ 触发只生图(复用导入文案),再跳生图页看进度。
|
||||
// AI轨:生图已在建任务时随文案一并跑完,直接跳页即可。
|
||||
if (track === 'import') {
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/generate-images`, {});
|
||||
} catch {
|
||||
setProceedError('触发生图失败,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push(`/tasks/${taskId}/images`);
|
||||
}
|
||||
|
||||
const isGenerating = textDone < textTotal || textTotal === 0;
|
||||
// task_done(stage=done)/failed 后生成已结束——绝不再显示"生成中"骨架屏装在跑,
|
||||
// 否则就是图17那种"完成了却永远转圈/0条还显完成"。
|
||||
const finished = stage === 'done' || stage === 'failed';
|
||||
const isGenerating = !finished && (textDone < textTotal || textTotal === 0);
|
||||
const showSkeleton = textCandidates.length === 0 && isGenerating;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={2} />
|
||||
<RejectReasonBanner taskId={taskId} />
|
||||
{flywheelInjected && (
|
||||
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
|
||||
)}
|
||||
{flywheelInjected ? (
|
||||
<FlywheelBannerFromSse
|
||||
recentPreference={flywheelInjected.recentPreference}
|
||||
rejectReasons={flywheelInjected.rejectReasons}
|
||||
signalCount={flywheelInjected.signalCount}
|
||||
/>
|
||||
) : context ? (
|
||||
<FlywheelBanner context={context} />
|
||||
) : null}
|
||||
|
||||
<div className="p-6 flex-1 overflow-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-text-primary">
|
||||
挑文案{textTotal > 0 && <span className="text-sm font-normal text-text-secondary ml-2">已生成 {textDone}/{textTotal} 条</span>}
|
||||
</h2>
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
<div className="flex items-center gap-3">
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
{taskId && <DeleteTaskButton taskId={taskId} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */}
|
||||
<FatalErrorBanner />
|
||||
|
||||
{/* SSE 进度提示(生成中且无内容时) */}
|
||||
{showSkeleton && (
|
||||
<div className="mb-4 bg-brand-cream border border-brand-orange/20 rounded-xl px-4 py-3 text-sm text-text-secondary"
|
||||
role="status" aria-live="polite">
|
||||
正在生成文案… 预计 15-30 秒,好了会更新,可先去忙别的
|
||||
</div>
|
||||
)}
|
||||
{/* B1 文案产出结果提示:完成但没出文案/补充中/评分不可用,明确告知真实原因 */}
|
||||
<TextOutcomeBanner />
|
||||
|
||||
{/* #3 阶段进度:替代静态"预计15-30秒",展示 分析竞品→生成文案 实时阶段 */}
|
||||
{isGenerating && <StageProgress scope="text" />}
|
||||
|
||||
{/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */}
|
||||
{showSkeleton ? (
|
||||
@@ -88,6 +147,7 @@ export default function TextSelectionPage() {
|
||||
selectedTextIds={selectedTextIds}
|
||||
onToggle={toggleTextSelection}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onDelete={handleDeleteText}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -97,10 +157,11 @@ export default function TextSelectionPage() {
|
||||
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
|
||||
<span className="text-sm text-text-secondary">
|
||||
已选 <strong className="text-text-primary">{selectedTextIds.length}</strong> 条
|
||||
{proceedError && <span className="ml-3 text-red-600">{proceedError}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleProceedToImages}
|
||||
disabled={selectedTextIds.length === 0}
|
||||
disabled={selectedTextIds.length === 0 || track === null}
|
||||
aria-label={`用选中的 ${selectedTextIds.length} 条文案去生图`}
|
||||
className="bg-brand-orange text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ApiError } from '@/types/index';
|
||||
|
||||
export default function NewTaskPage() {
|
||||
const router = useRouter();
|
||||
const { context } = usePreferenceStore();
|
||||
const { context, fetchContextByProduct } = usePreferenceStore();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
|
||||
@@ -35,6 +35,7 @@ export default function NewTaskPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState(false); // 提升:供右侧「编辑配置」(运营/组长)触发就地编辑
|
||||
|
||||
useEffect(() => { loadProducts(); }, []);
|
||||
|
||||
@@ -47,6 +48,12 @@ export default function NewTaskPage() {
|
||||
.catch(() => setBenchmarks([]));
|
||||
}, [selectedProduct]);
|
||||
|
||||
// 选品变化:拉该产品飞轮偏好上下文,驱动顶部 FlywheelBanner
|
||||
useEffect(() => {
|
||||
if (!selectedProduct) return;
|
||||
fetchContextByProduct(selectedProduct.id);
|
||||
}, [selectedProduct?.id]);
|
||||
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
|
||||
@@ -57,6 +64,12 @@ export default function NewTaskPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// #1 内联建品:把新产品并入列表顶部并选中(QuickProductCreate 已含上传产品图)
|
||||
function handleProductCreated(p: Product) {
|
||||
setProducts((list) => [p, ...list.filter((x) => x.id !== p.id)]);
|
||||
setSelectedProduct(p);
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
if (!selectedProduct) { setError('请选择产品'); return false; }
|
||||
if (!form.theme.trim()) { setError('请填写今天主题'); return false; }
|
||||
@@ -135,6 +148,9 @@ export default function NewTaskPage() {
|
||||
products={products}
|
||||
selectedProduct={selectedProduct}
|
||||
onSelectProduct={setSelectedProduct}
|
||||
onProductCreated={handleProductCreated}
|
||||
editingProduct={editingProduct}
|
||||
setEditingProduct={setEditingProduct}
|
||||
benchmarks={benchmarks}
|
||||
form={form}
|
||||
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
|
||||
@@ -147,7 +163,7 @@ export default function NewTaskPage() {
|
||||
</div>
|
||||
|
||||
{/* 右侧配置预览面板 */}
|
||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
|
||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} onEditInline={() => setEditingProduct(true)} />}
|
||||
</div>
|
||||
|
||||
<ImportTextModal
|
||||
|
||||
Reference in New Issue
Block a user