上线版: 产品表单统一+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:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -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报bugSPA 路由切到不同任务卡片时,
// 上个任务数据仍在 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>
);
}

View File

@@ -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 && (

View File

@@ -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"
>

View File

@@ -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