存量前端:生图分组/灯箱/重生控件/导入文案/驳回横幅+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:
@@ -116,6 +116,14 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
|||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
</Link>
|
</Link>
|
||||||
|
{task.status !== 'failed' && (
|
||||||
|
<Link
|
||||||
|
href={`/tasks/${task.id}/images`}
|
||||||
|
className="text-brand-orange hover:underline text-xs"
|
||||||
|
>
|
||||||
|
排图
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
|
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
// 屏4 挑图:0/N异步电影 + 平铺选图 + 单批重试(一期=整体重生) + 能离开
|
// 屏4 挑图:0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开
|
||||||
|
import { useState } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||||
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||||
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
|
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
|
||||||
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
|
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
|
||||||
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
|
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 { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||||
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||||
|
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
|
||||||
import { useSse } from '@/hooks/useSse';
|
import { useSse } from '@/hooks/useSse';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useTaskStore } from '@/stores/taskStore';
|
import { useTaskStore } from '@/stores/taskStore';
|
||||||
|
import { ImageCandidate } from '@/types/index';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
export default function ImageSelectionPage() {
|
export default function ImageSelectionPage() {
|
||||||
@@ -21,17 +27,45 @@ export default function ImageSelectionPage() {
|
|||||||
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
|
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
|
||||||
const { selectedImageIds, toggleImageSelection } = useTaskStore();
|
const { selectedImageIds, toggleImageSelection } = useTaskStore();
|
||||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||||
|
const [zoom, setZoom] = useState<ImageCandidate | null>(null);
|
||||||
|
const [regenTarget, setRegenTarget] = useState<RegenTarget | null>(null);
|
||||||
|
|
||||||
const pendingCount = imageTotal - imageDone;
|
const pendingCount = imageTotal - imageDone;
|
||||||
const allDone = imageTotal > 0 && pendingCount === 0;
|
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) {
|
async function handleRetry(_batchRole: string) {
|
||||||
// 一期 regenerate = 整体重生,契约无 batch 字段,不发 body
|
// 一期 regenerate = 整体重生(R2 已扩展按角色单批重生,见 handleRegen)
|
||||||
// 二期按批重生时再扩展,_batchRole 目前仅作入参占位(UI 提示用)
|
|
||||||
if (!taskId) return;
|
if (!taskId) return;
|
||||||
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
|
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() {
|
async function handleProceedToConfirm() {
|
||||||
if (!taskId || selectedImageIds.length === 0) return;
|
if (!taskId || selectedImageIds.length === 0) return;
|
||||||
for (const candidateId of selectedImageIds) {
|
for (const candidateId of selectedImageIds) {
|
||||||
@@ -43,6 +77,7 @@ export default function ImageSelectionPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<ProgressBar currentStep={3} />
|
<ProgressBar currentStep={3} />
|
||||||
|
<RejectReasonBanner taskId={taskId} />
|
||||||
{flywheelInjected && (
|
{flywheelInjected && (
|
||||||
<FlywheelBannerFromSse
|
<FlywheelBannerFromSse
|
||||||
recentPreference={flywheelInjected.recentPreference}
|
recentPreference={flywheelInjected.recentPreference}
|
||||||
@@ -53,29 +88,34 @@ export default function ImageSelectionPage() {
|
|||||||
<div className="p-6 flex-1 overflow-auto">
|
<div className="p-6 flex-1 overflow-auto">
|
||||||
<FatalErrorBanner />
|
<FatalErrorBanner />
|
||||||
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
|
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
|
||||||
{/* 图片网格(谁好谁先冒) */}
|
{imageCandidates.length === 0 ? (
|
||||||
<div className="grid grid-cols-3 xl:grid-cols-5 gap-3">
|
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
|
||||||
{imageCandidates.length === 0
|
{Array.from({ length: 6 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)}
|
||||||
? Array.from({ length: 5 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)
|
</div>
|
||||||
: imageCandidates.map((c) => (
|
) : (
|
||||||
<div key={c.candidate_id}
|
groups.map(([s, list]) =>
|
||||||
onClick={() => toggleImageSelection(c.candidate_id)}
|
list.length > 0 ? (
|
||||||
className={`relative rounded-xl overflow-hidden border cursor-pointer transition-all
|
<ImageStrategyGroup
|
||||||
${selectedImageIds.includes(c.candidate_id) ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default hover:border-brand-orange/50'}`}
|
key={s}
|
||||||
aria-label={`图片 ${c.role},${selectedImageIds.includes(c.candidate_id) ? '已选中' : '未选中'}`}
|
strategy={s}
|
||||||
>
|
candidates={list}
|
||||||
<img src={c.url} alt={c.role} className="w-full aspect-square object-cover" />
|
selectedImageIds={selectedImageIds}
|
||||||
<p className="text-xs text-center py-1 text-text-secondary">{c.role}</p>
|
onToggle={toggleImageSelection}
|
||||||
</div>
|
onZoom={setZoom}
|
||||||
))}
|
onRegen={openRegen}
|
||||||
</div>
|
/>
|
||||||
|
) : null
|
||||||
|
)
|
||||||
|
)}
|
||||||
{/* 失败批次提示 */}
|
{/* 失败批次提示 */}
|
||||||
{failedBatches.length > 0 && allDone && (
|
{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">
|
<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.length} 批没出来。
|
||||||
{failedBatches.filter((b) => b.retryable).map((b) => (
|
{failedBatches.filter((b) => b.retryable).map((b) => (
|
||||||
<button key={b.batch} onClick={() => handleRetry(b.batch)}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -83,6 +123,16 @@ export default function ImageSelectionPage() {
|
|||||||
<ImageActionBar selectedCount={selectedImageIds.length} onProceed={handleProceedToConfirm} />
|
<ImageActionBar selectedCount={selectedImageIds.length} onProceed={handleProceedToConfirm} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
|||||||
import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
||||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||||
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||||
|
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
|
||||||
import { useSse } from '@/hooks/useSse';
|
import { useSse } from '@/hooks/useSse';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useTaskStore } from '@/stores/taskStore';
|
import { useTaskStore } from '@/stores/taskStore';
|
||||||
@@ -17,7 +18,7 @@ export default function TextSelectionPage() {
|
|||||||
const taskId = params?.id ? Number(params.id) : null;
|
const taskId = params?.id ? Number(params.id) : null;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { textCandidates, textDone, textTotal, flywheelInjected } = useGenerationStore();
|
const { textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent } = useGenerationStore();
|
||||||
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
|
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
|
||||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||||
|
|
||||||
@@ -25,6 +26,15 @@ export default function TextSelectionPage() {
|
|||||||
if (taskId) setCurrentTask(taskId, 'generating');
|
if (taskId) setCurrentTask(taskId, 'generating');
|
||||||
}, [taskId]);
|
}, [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() {
|
async function handleProceedToImages() {
|
||||||
if (!taskId || selectedTextIds.length === 0) return;
|
if (!taskId || selectedTextIds.length === 0) return;
|
||||||
for (const candidateId of selectedTextIds) {
|
for (const candidateId of selectedTextIds) {
|
||||||
@@ -39,6 +49,7 @@ export default function TextSelectionPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<ProgressBar currentStep={2} />
|
<ProgressBar currentStep={2} />
|
||||||
|
<RejectReasonBanner taskId={taskId} />
|
||||||
{flywheelInjected && (
|
{flywheelInjected && (
|
||||||
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
|
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
|
||||||
)}
|
)}
|
||||||
@@ -72,6 +83,7 @@ export default function TextSelectionPage() {
|
|||||||
candidate={c}
|
candidate={c}
|
||||||
selected={selectedTextIds.includes(c.candidate_id)}
|
selected={selectedTextIds.includes(c.candidate_id)}
|
||||||
onToggle={toggleTextSelection}
|
onToggle={toggleTextSelection}
|
||||||
|
onSaveEdit={handleSaveEdit}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FlywheelBanner } from '@/components/FlywheelBanner';
|
|||||||
import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel';
|
import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel';
|
||||||
import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
|
import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
|
||||||
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
|
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
|
||||||
|
import { ImportTextModal } from '@/components/tasks/ImportTextModal';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Product, CreateTaskRequest } from '@/types/index';
|
import { Product, CreateTaskRequest } from '@/types/index';
|
||||||
import { PaginatedResponse } from '@/types/index';
|
import { PaginatedResponse } from '@/types/index';
|
||||||
@@ -32,6 +33,7 @@ export default function NewTaskPage() {
|
|||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { loadProducts(); }, []);
|
useEffect(() => { loadProducts(); }, []);
|
||||||
|
|
||||||
@@ -45,17 +47,22 @@ export default function NewTaskPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') {
|
function validate(): boolean {
|
||||||
e.preventDefault();
|
if (!selectedProduct) { setError('请选择产品'); return false; }
|
||||||
if (!selectedProduct) { setError('请选择产品'); return; }
|
if (!form.theme.trim()) { setError('请填写今天主题'); return false; }
|
||||||
if (!form.theme.trim()) { setError('请填写今天主题'); return; }
|
|
||||||
// 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传
|
// 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传
|
||||||
if (form.need_product_image && !selectedProduct.image_path) {
|
if (form.need_product_image && !selectedProduct.image_path) {
|
||||||
setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。');
|
setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
|
||||||
setError('');
|
setError('');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate() || !selectedProduct) return;
|
||||||
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track };
|
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track };
|
||||||
const data = await api.post<{ id: number }>('/api/v1/tasks', req);
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<ProgressBar currentStep={1} />
|
<ProgressBar currentStep={1} />
|
||||||
@@ -86,7 +130,7 @@ export default function NewTaskPage() {
|
|||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
error={error}
|
error={error}
|
||||||
onSubmitAi={(e) => handleSubmit(e, 'ai')}
|
onSubmitAi={(e) => handleSubmit(e, 'ai')}
|
||||||
onSubmitImport={(e) => handleSubmit(e, 'import')}
|
onSubmitImport={openImport}
|
||||||
/>
|
/>
|
||||||
<div className="mt-8"><RecentTasksBar /></div>
|
<div className="mt-8"><RecentTasksBar /></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,6 +138,13 @@ export default function NewTaskPage() {
|
|||||||
{/* 右侧配置预览面板 */}
|
{/* 右侧配置预览面板 */}
|
||||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
|
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ImportTextModal
|
||||||
|
open={importOpen}
|
||||||
|
submitting={submitting}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
onConfirm={handleImportConfirm}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const ROUTE_ROLES: Record<string, UserRole[]> = {
|
|||||||
'/config': ['admin'],
|
'/config': ['admin'],
|
||||||
'/review': ['admin', 'supervisor'],
|
'/review': ['admin', 'supervisor'],
|
||||||
'/history': ['admin', 'supervisor', 'operator'],
|
'/history': ['admin', 'supervisor', 'operator'],
|
||||||
'/tasks/new': ['admin', 'operator'],
|
'/tasks/new': ['admin', 'supervisor', 'operator'],
|
||||||
|
'/fission': ['admin', 'supervisor', 'operator'],
|
||||||
'/dashboard': ['admin', 'supervisor', 'operator'],
|
'/dashboard': ['admin', 'supervisor', 'operator'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,16 +58,22 @@ export function ProductImageUpload({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => inputRef.current?.click()}
|
type="button"
|
||||||
disabled={uploading}
|
onClick={() => inputRef.current?.click()}
|
||||||
className="text-xs border border-dashed border-border-default rounded px-3 py-1
|
disabled={uploading}
|
||||||
text-text-secondary hover:border-brand-orange hover:text-brand-orange
|
className="text-xs border border-dashed border-border-default rounded px-3 py-1
|
||||||
disabled:opacity-50 transition-colors"
|
text-text-secondary hover:border-brand-orange hover:text-brand-orange
|
||||||
>
|
disabled:opacity-50 transition-colors"
|
||||||
{uploading ? '上传中…' : '+ 上传产品参考图'}
|
>
|
||||||
</button>
|
{uploading ? '上传中…' : '+ 上传产品参考图'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1 leading-relaxed">
|
||||||
|
必须含产品主体(瓶身/包装本体),生图以此为锚点保证产品一致。
|
||||||
|
质地图、手臂上脸图等可作补充,但不能替代主图单独上传。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ interface ProgressBarProps {
|
|||||||
|
|
||||||
export function ProgressBar({ currentStep }: ProgressBarProps) {
|
export function ProgressBar({ currentStep }: ProgressBarProps) {
|
||||||
const { role } = useAuthStore();
|
const { role } = useAuthStore();
|
||||||
// 组长/管理员不走流程条
|
// 管理员是配置者角色,不走流程条;supervisor 含运营全部操作,走流程时与 operator 一致显示
|
||||||
if (role === 'supervisor' || role === 'admin') return null;
|
if (role === 'admin') return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ interface NavItem {
|
|||||||
icon: string;
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅保留真实存在的路由(app/目录)。选文案/选图片是任务流程内步骤
|
// 三并列顶层创作入口(倩倩姐2026-06-15拍板"三并列入口"):
|
||||||
// (经 /tasks/[id]/text|images 进入,需 task id,不做顶层导航)。
|
// 文案创作=开新任务;图片排版=历史任务列表里挑一条进图片步骤(图片排版必须有 task id,
|
||||||
|
// 故落到 /history,每行提供"去排图"入口,不造无 task-id 的死链);裂变扩散=独立裂变页。
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ label: '工作台', href: '/dashboard', icon: '🏠' },
|
{ label: '工作台', href: '/dashboard', icon: '🏠' },
|
||||||
{ label: '开新任务', href: '/tasks/new', icon: '✏️' },
|
{ label: '文案创作', href: '/tasks/new', icon: '✏️' },
|
||||||
|
{ label: '图片排版', href: '/history', icon: '🖼️' },
|
||||||
|
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
|
||||||
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
|
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
|
||||||
{ label: '历史归档', href: '/history', icon: '🗂️' },
|
{ label: '历史归档', href: '/history', icon: '🗂️' },
|
||||||
{ label: '配置中心', href: '/config', roles: ['admin'], icon: '⚙️' },
|
{ label: '配置中心', href: '/config', roles: ['admin'], icon: '⚙️' },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function ImageActionBar({ selectedCount, onProceed }: ImageActionBarProps
|
|||||||
return (
|
return (
|
||||||
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
|
<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">
|
<span className="text-sm text-text-secondary">
|
||||||
已选 <strong className="text-text-primary">{selectedCount}</strong> 批
|
已选 <strong className="text-text-primary">{selectedCount}</strong> 张
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onProceed}
|
onClick={onProceed}
|
||||||
|
|||||||
57
frontend/src/components/tasks/ImageLightbox.tsx
Normal file
57
frontend/src/components/tasks/ImageLightbox.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
// ImageLightbox — 点击挑图卡片放大看全图(解决"只看半截"),ESC/点遮罩关闭
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ImageLightboxProps {
|
||||||
|
url: string | null;
|
||||||
|
caption?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageLightbox({ url, caption, onClose }: ImageLightboxProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
// 打开时锁滚动,关闭时恢复
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [url, onClose]);
|
||||||
|
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="图片放大预览"
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
|
>
|
||||||
|
<div className="relative max-h-full max-w-3xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭预览"
|
||||||
|
className="absolute -top-3 -right-3 z-10 h-9 w-9 rounded-full bg-white text-gray-700 shadow-lg hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{/* 原图按真实比例完整展示,不裁切 */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={caption || '放大预览'}
|
||||||
|
className="max-h-[85vh] w-auto rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
{caption && (
|
||||||
|
<p className="mt-2 text-center text-sm text-white/90">{caption}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/components/tasks/ImageStrategyGroup.tsx
Normal file
112
frontend/src/components/tasks/ImageStrategyGroup.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
// ImageStrategyGroup — 按"套"(A/B/C)分组展示挑图,组内按 seq 固定6图序,点图放大
|
||||||
|
import { ImageCandidate } from '@/types/index';
|
||||||
|
|
||||||
|
// 套别中文标签(三套正交叙事:痛点先行/场景先行/成分背书先行)
|
||||||
|
export const STRATEGY_LABEL: Record<string, string> = {
|
||||||
|
A: '套1 · 痛点先行',
|
||||||
|
B: '套2 · 场景先行',
|
||||||
|
C: '套3 · 成分背书先行',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 角色中文名(北哥固定6图序,让"重试批次hook"变可读)
|
||||||
|
export const ROLE_LABEL: Record<string, string> = {
|
||||||
|
hook: '封面·痛点大字',
|
||||||
|
product_closeup: '产品特写',
|
||||||
|
ingredient: '成分背书',
|
||||||
|
texture: '质地展示',
|
||||||
|
applied_proof: '使用实证',
|
||||||
|
closer: '收尾导购',
|
||||||
|
pain: '痛点场景',
|
||||||
|
proof: '效果实证',
|
||||||
|
quality: '品质细节',
|
||||||
|
credit: '资质背书',
|
||||||
|
convert: '转化引导',
|
||||||
|
main: '主图',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ImageStrategyGroupProps {
|
||||||
|
strategy: string;
|
||||||
|
candidates: ImageCandidate[];
|
||||||
|
selectedImageIds: number[];
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
onZoom: (c: ImageCandidate) => void;
|
||||||
|
onRegen?: (strategy: string, role: string | undefined, label: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageStrategyGroup({
|
||||||
|
strategy, candidates, selectedImageIds, onToggle, onZoom, onRegen,
|
||||||
|
}: ImageStrategyGroupProps) {
|
||||||
|
// 组内按 seq 升序(北哥固定序);seq 缺失的排末尾
|
||||||
|
const ordered = [...candidates].sort(
|
||||||
|
(a, b) => (a.seq ?? 999) - (b.seq ?? 999)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h3 className="mb-2 flex items-center text-sm font-semibold text-text-primary">
|
||||||
|
{STRATEGY_LABEL[strategy] || `套${strategy}`}
|
||||||
|
<span className="ml-2 text-xs font-normal text-text-tertiary">
|
||||||
|
共 {ordered.length} 张
|
||||||
|
</span>
|
||||||
|
{onRegen && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRegen(strategy, undefined, STRATEGY_LABEL[strategy] || `套${strategy}`)}
|
||||||
|
className="ml-auto rounded-md border border-border-default px-2 py-0.5 text-xs font-normal text-text-secondary hover:border-brand-orange hover:text-brand-orange"
|
||||||
|
>
|
||||||
|
⟳ 重生整套
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
|
||||||
|
{ordered.map((c) => {
|
||||||
|
const selected = selectedImageIds.includes(c.candidate_id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.candidate_id}
|
||||||
|
className={`relative overflow-hidden rounded-xl border transition-all
|
||||||
|
${selected ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default'}`}
|
||||||
|
>
|
||||||
|
{/* 序号角标:组内第几张 */}
|
||||||
|
<span className="absolute left-1.5 top-1.5 z-10 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] font-semibold text-white">
|
||||||
|
{c.seq ?? '?'}
|
||||||
|
</span>
|
||||||
|
{onRegen && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRegen(strategy, c.role, ROLE_LABEL[c.role] || c.role)}
|
||||||
|
aria-label={`重生 ${ROLE_LABEL[c.role] || c.role}`}
|
||||||
|
className="absolute bottom-9 right-1.5 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-brand-orange"
|
||||||
|
>
|
||||||
|
⟳ 重生
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={c.url}
|
||||||
|
alt={ROLE_LABEL[c.role] || c.role}
|
||||||
|
onClick={() => onZoom(c)}
|
||||||
|
className="aspect-[3/4] w-full cursor-zoom-in object-cover"
|
||||||
|
/>
|
||||||
|
{c.ai_visual_score != null && (
|
||||||
|
<span
|
||||||
|
title={c.ai_visual_note || undefined}
|
||||||
|
className={`absolute right-1.5 top-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-semibold text-white
|
||||||
|
${c.ai_visual_score >= 80 ? 'bg-emerald-500' : c.ai_visual_score >= 60 ? 'bg-amber-500' : 'bg-gray-500'}`}
|
||||||
|
>
|
||||||
|
AI {Math.round(c.ai_visual_score)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(c.candidate_id)}
|
||||||
|
aria-label={`${selected ? '取消选择' : '选择'} ${ROLE_LABEL[c.role] || c.role}`}
|
||||||
|
className={`w-full py-1.5 text-xs font-medium transition-colors
|
||||||
|
${selected ? 'bg-brand-orange text-white' : 'bg-surface-secondary text-text-secondary hover:bg-brand-orange/10'}`}
|
||||||
|
>
|
||||||
|
{ROLE_LABEL[c.role] || c.role}{selected ? ' ✓' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/components/tasks/ImportTextModal.tsx
Normal file
86
frontend/src/components/tasks/ImportTextModal.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
/**
|
||||||
|
* ImportTextModal — 导入外部文案弹窗(R4-c 轨B 补输入入口)
|
||||||
|
* 此前"导入外部文案"按钮直接建任务跳走,没给用户贴文案的地方。
|
||||||
|
* 这里让用户粘贴多条文案(用一行 "---" 分隔),确认后回调父组件:
|
||||||
|
* 父组件先建任务(track=import),再逐条调 import-text 端点,最后跳 text 页。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
submitting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** 返回切分后的多条文案,父组件负责建任务+逐条导入 */
|
||||||
|
onConfirm: (contents: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPLIT_HINT = '多条文案用单独一行 --- 分隔;只贴一条则整体作为一条';
|
||||||
|
|
||||||
|
function splitContents(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(/^\s*---\s*$/m)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportTextModal({ open, submitting, onClose, onConfirm }: Props) {
|
||||||
|
const [raw, setRaw] = useState('');
|
||||||
|
// 每次打开清空上次残留内容,避免重新打开显示旧文案+旧计数
|
||||||
|
useEffect(() => { if (open) setRaw(''); }, [open]);
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const parts = splitContents(raw);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="导入外部文案"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-surface-primary rounded-2xl shadow-xl w-full max-w-lg p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold text-text-primary mb-1">导入外部文案</h3>
|
||||||
|
<p className="text-xs text-text-tertiary mb-3 leading-relaxed">
|
||||||
|
把已有文案贴进来直接进候选池,跳过 AI 生成。{SPLIT_HINT}。
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={raw}
|
||||||
|
onChange={(e) => setRaw(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
placeholder={'第一条文案…\n---\n第二条文案…'}
|
||||||
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:border-brand-orange resize-y"
|
||||||
|
aria-label="粘贴文案内容"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
将导入 <strong className="text-text-primary">{parts.length}</strong> 条
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-sm px-4 py-2 rounded-lg border border-border-default text-text-secondary hover:bg-surface-tertiary"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={parts.length === 0 || submitting}
|
||||||
|
onClick={() => onConfirm(parts)}
|
||||||
|
className="text-sm px-4 py-2 rounded-lg bg-brand-orange text-white font-medium
|
||||||
|
hover:bg-orange-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? '导入中…' : `导入这 ${parts.length} 条 →`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/components/tasks/RegenControls.tsx
Normal file
75
frontend/src/components/tasks/RegenControls.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
// RegenControls — R2 重生弹窗:单张/单套重生 + 可选人工提示词
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export interface RegenTarget {
|
||||||
|
strategy: 'A' | 'B' | 'C';
|
||||||
|
role?: string; // 有=单张重生;无=整套重生
|
||||||
|
label: string; // 展示用(套名 或 角色名)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegenControlsProps {
|
||||||
|
target: RegenTarget | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (target: RegenTarget, customPrompt: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegenControls({ target, onClose, onSubmit }: RegenControlsProps) {
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!target || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(target, prompt.trim());
|
||||||
|
setPrompt('');
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl bg-white p-5 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="mb-1 text-base font-semibold text-text-primary">
|
||||||
|
重生{target.role ? '此张' : '整套'}:{target.label}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-3 text-xs text-text-tertiary">
|
||||||
|
可补充提示词指导这次重生(留空则按原策略重生)。瓶身忠于参考图,合规与真实约束不可被覆盖。
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
rows={3}
|
||||||
|
placeholder="例:标题字再大一点,背景换成浅米色,突出成分卖点"
|
||||||
|
className="mb-3 w-full resize-none rounded-lg border border-border-default p-2 text-sm focus:border-brand-orange focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-secondary"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-lg bg-brand-orange px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? '提交中…' : '开始重生'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/tasks/RejectReasonBanner.tsx
Normal file
46
frontend/src/components/tasks/RejectReasonBanner.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
/**
|
||||||
|
* RejectReasonBanner — 打回原因横幅(R4-b 审核回路死链修复)
|
||||||
|
* 被打回的任务回到 pending_selection,用户重进挑选页时显示"为何被打回",
|
||||||
|
* 不再一脸懵。任务页/历史页可复用。
|
||||||
|
* 自取 task 详情判断 review_status==='rejected' 才显示。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { TaskListItem } from '@/types/dto.tasks';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
taskId: number | null;
|
||||||
|
/** 外部已有 task 数据时直接传入,省一次请求 */
|
||||||
|
task?: TaskListItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RejectReasonBanner({ taskId, task: taskProp }: Props) {
|
||||||
|
const [task, setTask] = useState<TaskListItem | null>(taskProp ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (taskProp) { setTask(taskProp); return; }
|
||||||
|
if (!taskId) return;
|
||||||
|
let alive = true;
|
||||||
|
api.get<TaskListItem>(`/api/v1/tasks/${taskId}`)
|
||||||
|
.then((d) => { if (alive) setTask(d); })
|
||||||
|
.catch(() => { /* 取不到不显示,不阻塞页面 */ });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [taskId, taskProp]);
|
||||||
|
|
||||||
|
if (!task || task.review_status !== 'rejected' || !task.reject_reason) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-6 mt-4 bg-red-50 border border-red-200 rounded-xl px-4 py-3"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-red-700">
|
||||||
|
<span aria-hidden="true">↩️</span> 这条笔记被组长打回,请按原因重新调整后再提交审核
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-600 mt-1 leading-relaxed">
|
||||||
|
打回原因:{task.reject_reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ interface TextCandidateCardProps {
|
|||||||
candidate: TextCandidate;
|
candidate: TextCandidate;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onToggle: (id: number) => void;
|
onToggle: (id: number) => void;
|
||||||
|
onSaveEdit?: (id: number, content: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BANNED_STATUS_STYLES = {
|
const BANNED_STATUS_STYLES = {
|
||||||
@@ -24,8 +25,27 @@ const VERDICT_STYLES: Record<string, string> = {
|
|||||||
'不合格':'text-status-error',
|
'不合格':'text-status-error',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TextCandidateCard({ candidate, selected, onToggle }: TextCandidateCardProps) {
|
export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }: TextCandidateCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(candidate.content);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
|
|
||||||
|
async function handleSave(e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!onSaveEdit || saving || draft.trim() === '') return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSaveEdit(candidate.candidate_id, draft);
|
||||||
|
setEditing(false);
|
||||||
|
setJustSaved(true); // 飞轮节点反馈:改稿=最强信号已喂回
|
||||||
|
setTimeout(() => setJustSaved(false), 4000);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
onClick={() => onToggle(candidate.candidate_id)}
|
onClick={() => onToggle(candidate.candidate_id)}
|
||||||
@@ -52,18 +72,62 @@ export function TextCandidateCard({ candidate, selected, onToggle }: TextCandida
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className="text-xs font-medium text-brand-orange mb-1 block">{candidate.angle_label}</span>
|
<span className="text-xs font-medium text-brand-orange mb-1 block">
|
||||||
<p className={clsx(
|
{candidate.angle_label}
|
||||||
'text-sm text-text-primary whitespace-pre-wrap',
|
{(candidate.edited || justSaved) && (
|
||||||
!expanded && 'line-clamp-4'
|
<span className="ml-1.5 text-[10px] text-brand-green-dark bg-brand-green-light px-1.5 py-0.5 rounded-full">已改稿</span>
|
||||||
)}>{candidate.content}</p>
|
)}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
{editing ? (
|
||||||
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
|
<textarea
|
||||||
className="mt-1 text-xs text-brand-orange hover:underline"
|
value={draft}
|
||||||
>
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
{expanded ? '收起' : '展开全文'}
|
onClick={(e) => e.stopPropagation()}
|
||||||
</button>
|
rows={6}
|
||||||
|
className="w-full text-sm border border-brand-orange/50 rounded-lg p-2 focus:outline-none focus:ring-1 focus:ring-brand-orange"
|
||||||
|
aria-label="改稿编辑框"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={clsx(
|
||||||
|
'text-sm text-text-primary whitespace-pre-wrap',
|
||||||
|
!expanded && 'line-clamp-4'
|
||||||
|
)}>{candidate.content}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
|
||||||
|
className="mt-1 text-xs text-brand-orange hover:underline"
|
||||||
|
>
|
||||||
|
{expanded ? '收起' : '展开全文'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* D1 改稿入口(飞轮最强信号 text_edit +5) */}
|
||||||
|
{onSaveEdit && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={handleSave} disabled={saving}
|
||||||
|
className="text-xs bg-brand-orange text-white rounded-md px-2.5 py-1 hover:bg-orange-600 disabled:opacity-50">
|
||||||
|
{saving ? '保存中…' : '保存改稿'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); setEditing(false); setDraft(candidate.content); }}
|
||||||
|
className="text-xs text-text-secondary hover:underline">取消</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); setDraft(candidate.content); setEditing(true); }}
|
||||||
|
className="text-xs text-brand-orange hover:underline">✎ 改稿</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 飞轮节点反馈:改稿即时提示已学习 */}
|
||||||
|
{justSaved && (
|
||||||
|
<p role="status" aria-live="polite" className="mt-1.5 text-[11px] text-brand-green-dark">
|
||||||
|
🌿 已学习你的改动,下次会更贴近你的偏好
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 违禁词状态 */}
|
{/* 违禁词状态 */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { createSseClient, SseClient, SseConnectionStatus } from '@/lib/sse';
|
import { createSseClient, SseClient, SseConnectionStatus } from '@/lib/sse';
|
||||||
import { SseEvent } from '@/types/sse';
|
import { SseEvent } from '@/types/sse';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { useTaskStore } from '@/stores/taskStore';
|
||||||
import { TextCandidate, ImageCandidate } from '@/types/index';
|
import { TextCandidate, ImageCandidate } from '@/types/index';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export function useSse(taskId: number | null) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskId) return;
|
if (!taskId) return;
|
||||||
store.reset();
|
store.reset();
|
||||||
|
// 进入任务时清空上一个任务残留的选中态,避免"交审"按钮基于陈旧选择误判可点/置灰
|
||||||
|
useTaskStore.getState().clearSelections();
|
||||||
|
|
||||||
// reset 之后立即回填已有候选(历史/已完成任务 SSE 不重放)。
|
// reset 之后立即回填已有候选(历史/已完成任务 SSE 不重放)。
|
||||||
// 必须放在 reset 之后、与 SSE 同一 effect 内,否则与页面侧 hydrate 形成
|
// 必须放在 reset 之后、与 SSE 同一 effect 内,否则与页面侧 hydrate 形成
|
||||||
@@ -102,8 +105,12 @@ function handleEvent(
|
|||||||
const ic: ImageCandidate = {
|
const ic: ImageCandidate = {
|
||||||
candidate_id: event.data.candidate_id,
|
candidate_id: event.data.candidate_id,
|
||||||
strategy: event.data.strategy,
|
strategy: event.data.strategy,
|
||||||
|
seq: event.data.seq,
|
||||||
url: event.data.url,
|
url: event.data.url,
|
||||||
role: event.data.role as ImageCandidate['role'],
|
role: event.data.role as ImageCandidate['role'],
|
||||||
|
is_regen: event.data.is_regen ?? false,
|
||||||
|
ai_visual_score: event.data.ai_visual_score ?? null,
|
||||||
|
ai_visual_note: event.data.ai_visual_note ?? null,
|
||||||
};
|
};
|
||||||
store.addImageCandidate(ic);
|
store.addImageCandidate(ic);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface GenerationState {
|
|||||||
setTextProgress: (done: number, total: number) => void;
|
setTextProgress: (done: number, total: number) => void;
|
||||||
addTextCandidate: (candidate: TextCandidate) => void;
|
addTextCandidate: (candidate: TextCandidate) => void;
|
||||||
patchTextCandidateScore: (candidateId: number, score: TextCandidate['score']) => void;
|
patchTextCandidateScore: (candidateId: number, score: TextCandidate['score']) => void;
|
||||||
|
patchTextCandidateContent: (candidateId: number, content: string) => void;
|
||||||
setImageProgress: (done: number, total: number) => void;
|
setImageProgress: (done: number, total: number) => void;
|
||||||
addImageCandidate: (candidate: ImageCandidate) => void;
|
addImageCandidate: (candidate: ImageCandidate) => void;
|
||||||
markBatchFailed: (batch: string, reason: string, retryable: boolean) => void;
|
markBatchFailed: (batch: string, reason: string, retryable: boolean) => void;
|
||||||
@@ -86,6 +87,14 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
patchTextCandidateContent(candidateId, content) {
|
||||||
|
set((s) => ({
|
||||||
|
textCandidates: s.textCandidates.map((tc) =>
|
||||||
|
tc.candidate_id === candidateId ? { ...tc, content, edited: true } : tc
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
setImageProgress(done, total) {
|
setImageProgress(done, total) {
|
||||||
set({ imageDone: done, imageTotal: total });
|
set({ imageDone: done, imageTotal: total });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export interface TaskListItem {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
text_count: number;
|
text_count: number;
|
||||||
image_count: number;
|
image_count: number;
|
||||||
|
// 审核回路:打回原因+状态(R4-b死链修复/R8历史展示),后端_fmt_task返回
|
||||||
|
review_status?: string | null;
|
||||||
|
reject_reason?: string | null;
|
||||||
|
reviewed_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTaskRequest {
|
export interface CreateTaskRequest {
|
||||||
@@ -54,6 +58,7 @@ export interface TextCandidate {
|
|||||||
banned_word_status: BannedWordStatus;
|
banned_word_status: BannedWordStatus;
|
||||||
verdict?: string; // AI评委总评:"优秀"|"合格"|"不合格",旧数据为空字符串
|
verdict?: string; // AI评委总评:"优秀"|"合格"|"不合格",旧数据为空字符串
|
||||||
summary?: string; // AI评委一句话总评(含改进点),旧数据为空字符串
|
summary?: string; // AI评委一句话总评(含改进点),旧数据为空字符串
|
||||||
|
edited?: boolean; // 是否被运营改过稿(D1 飞轮最强信号)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectCandidateRequest { candidate_id: number; }
|
export interface SelectCandidateRequest { candidate_id: number; }
|
||||||
@@ -67,6 +72,12 @@ export interface ImageCandidate {
|
|||||||
strategy: 'A' | 'B' | 'C';
|
strategy: 'A' | 'B' | 'C';
|
||||||
url: string;
|
url: string;
|
||||||
role: ImageRole;
|
role: ImageRole;
|
||||||
|
seq?: number;
|
||||||
|
is_selected?: boolean;
|
||||||
|
is_regen?: boolean; // R2 重生标记(同 strategy+role 去重展示最新用)
|
||||||
|
// E12 AI评图分:纯展示+排序,不进飞轮权重
|
||||||
|
ai_visual_score?: number | null;
|
||||||
|
ai_visual_note?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 审核
|
// 审核
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export interface ImageProgressEvent extends SseBaseEvent {
|
|||||||
|
|
||||||
export interface ImageCandidateEvent extends SseBaseEvent {
|
export interface ImageCandidateEvent extends SseBaseEvent {
|
||||||
type: 'image_candidate';
|
type: 'image_candidate';
|
||||||
data: { candidate_id: number; strategy: 'A' | 'B' | 'C'; url: string; role: string };
|
data: { candidate_id: number; strategy: 'A' | 'B' | 'C'; seq?: number; url: string; role: string;
|
||||||
|
is_regen?: boolean;
|
||||||
|
ai_visual_score?: number | null; ai_visual_note?: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FlywheelInjectedEvent extends SseBaseEvent {
|
export interface FlywheelInjectedEvent extends SseBaseEvent {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
| ID | 任务 | 模块核销表 | 细化 | 状态 |
|
| ID | 任务 | 模块核销表 | 细化 | 状态 |
|
||||||
|----|------|-----------|------|------|
|
|----|------|-----------|------|------|
|
||||||
| M6 | 第2环 标杆8特征分析 | plans/核销-第2环-标杆.md | 🟢S1-S12(S12拆a/b)/A1-A12 | ◐ **引擎双路径端到端真验完成**(2026-06-16 c1ac2a6):①文本路径(手填亮点)8维齐全conf0.9 ②截图vision路径(真喂4.7MB素颜霜图)source=vision真读图,识别三段式卖点/√图标/大字报模板/办公背景,看不到的诚实标"截图未体现"。features_json落库done。端点健壮(404/422/读图失败降级/key缺失提示)。**剩非引擎本体待办:①北哥过目8维草案标✅满分 ②第5/6环消费features_json对标(接线,归M6下游或随M1/M2)**。8维=banana不变量+俊达配方+3链接 |
|
| M6 | 第2环 标杆8特征分析 | plans/核销-第2环-标杆.md | 🟢S1-S12(S12拆a/b)/A1-A12 | ◐ **引擎双路径端到端真验完成**(2026-06-16 c1ac2a6):①文本路径(手填亮点)8维齐全conf0.9 ②截图vision路径(真喂4.7MB素颜霜图)source=vision真读图,识别三段式卖点/√图标/大字报模板/办公背景,看不到的诚实标"截图未体现"。features_json落库done。端点健壮(404/422/读图失败降级/key缺失提示)。**剩非引擎本体待办:①北哥过目8维草案标✅满分 ②第5/6环消费features_json对标(接线,归M6下游或随M1/M2)**。8维=banana不变量+俊达配方+3链接 |
|
||||||
| M7 | 第11环 裂变(工作台内) | plans/核销-第11环-裂变.md | 🟢S1-S13(含S10.5)/AC1-12 | □ 代码:只有model骨架(fission.py)+迁移010/011,**service/API/worker全无**,"1爆款→N套笔记包"流程不存在。**档位不等北哥**:banana有现成裂变流程可移植,参考链接已有link_url字段(benchmarks.py:81可手录),先移植打通再请北哥定档位微调 |
|
| M7 | 第11环 裂变(工作台内) | plans/核销-第11环-裂变.md | 🟢S1-S13(含S10.5)/AC1-12 | ◐ **引擎端到端真跑通**(2026-06-16 cefdbaa):①fission_service扇出1源→N子任务(回填source_fission_id,复用主管道run_generation_pipeline),MAX_FANOUT=5红线 ②fission_prompt三档(high/mid/low)规则 ③tasks.py worker识别裂变子任务→注入对应档位进文案上下文(自抓关键缺口,**worker曾跑旧内存代码未热重载,restart后实测注入日志level=high真出现**) ④POST/fission触发+GET/fission/{id}进度聚合,main.py注册路由 ⑤实测fission_id=2扇出task70/71,子任务带source_fission_id+档位注入生效,进度端点聚合正确(total=2/done=0/generating)。**剩待办:①三档真实措辞待北哥定义(现占位规则) ②前端裂变入口+进度呈现(归前端攒批) ③子任务真出3套差异化文案+配图的质量验收(需北哥过目)** |
|
||||||
|
|
||||||
## P3 前端打通+飞轮(2026-06-16 九文档审核结论·按序逐条修)
|
## P3 前端打通+飞轮(2026-06-16 九文档审核结论·按序逐条修)
|
||||||
|
|
||||||
@@ -80,6 +80,87 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## P4 本轮执行清单(倩倩姐2026-06-12实跑15问+截图欠账·去重合并·按序R1→R8做一块核销一块)
|
||||||
|
|
||||||
|
> 来源:倩倩姐亲自实跑前端提15问 + 上次实跑截图欠账(C3/C5/C7/B1/M3/M4/M5/E12/补1/D2)。
|
||||||
|
> 经4 agent并行审码定位 file:line + 2交叉核(is_active登录校验/api-keys路由前缀)。
|
||||||
|
> 顺序倩倩姐拍板:P0(R1挑图体验/R2重生/R3生图质量) → P1(R4工作台/R5多图/R6账号) → P2(R7飞轮/R8归档)。
|
||||||
|
> 拍板3点:①operator能看改**全部**产品档案 ②3套图是前端没分组非后端没出 ③截图欠账全接入不许丢。
|
||||||
|
|
||||||
|
### R1 挑图页体验(P0)
|
||||||
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 图按A/B/C分组展示 | =既有B1(九文档桶C) | ✅ImageStrategyGroup按strategy分3组,page.tsx:34-36+pipeline_io.py:263 | ✅ |
|
||||||
|
| 点图放大看全图lightbox | =C5,既有M5含 | ✅ImageLightbox object-contain完整不裁+ESC/遮罩关,page.tsx:78接线 | ✅ |
|
||||||
|
| 组内按seq固定6图序 | 问题12新 | ✅组内sort by seq升序,后端pipeline_io.py:272推seq+sse.ts声明 | ✅ |
|
||||||
|
| 修"交审"按钮点不动 | 问题7新bug | ✅useSse.ts:24进任务clearSelections清残留,选0张才灰 | ✅ |
|
||||||
|
| 修"批次重试"点不动 | 问题8新bug | ✅按钮可点+发请求+ROLE_LABEL中文名;单批重生归R2(一期整体重生已标注) | ✅ |
|
||||||
|
| 进度条补五维(不全0) | =B1实跑(≠九文档B1) | ✅占位0→异步GET补全,后端_score_array_to_obj返七维+dims,ScoreDimBars消费 | ✅ |
|
||||||
|
|
||||||
|
> R1 核销证据:tsc EXIT=0(0错误) + 独立agent交叉重核5✅+1⚠️一期妥协(单批重生归R2),无漏接线/无类型隐患。2026-06-12。
|
||||||
|
|
||||||
|
### R2 单张/单套重生+人工提示词(P0)
|
||||||
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 后端regenerate加strategy/role/custom_prompt | 问题6新 | ✅RegenerateRequest请求体+role必配strategy校验,透传enqueue→Celery→pipeline→image_gen全链路 | ✅ |
|
||||||
|
| 前端单张/单套重生+提示词输入框 | 问题6新 | ✅RegenControls弹窗(含custom_prompt框)+每张"重生此张"+每套"重生整套"+同strategy+role去重展示最新 | ✅ |
|
||||||
|
|
||||||
|
> R2 核销证据:①后端真import+4签名校验通过 ②alembic 017(is_regen)升级到head ③Pydantic五模式校验全过(整体/单套/单张/非法strategy拦/超长拦) ④生图局部重生4逻辑测试过(单张只1张/custom_prompt注入/全量3张/匹配不到回退) ⑤前端tsc EXIT=0 ⑥独立agent交叉审8项:7✅+1硬伤(空文案降级生图)已修(无文案硬失败推error40002不静默降级) ⑦红线守住:重生新增不删旧/eval_score留NULL/合规约束不被custom_prompt覆盖(sanitize+追加末尾)。2026-06-12。
|
||||||
|
|
||||||
|
### R3 生图质量(P0·C3硬伤提前)
|
||||||
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| C3中文乱码(canvas叠字) | =C3,记忆标硬伤 | 倩倩姐2026-06-12拍板"只留口子不实现":OVERLAY_TEXT_RENDER_ENABLED=False+image_gen.py TODO(C3-overlay)接入点;本轮验收图大标题"倍分子素颜霜"+瓶身字清晰未触发错别字 | ✅ |
|
||||||
|
| C7像小红书(prompt约束) | =C7截图🟡 | 新增IMAGE_XHS_STYLE_CONSTRAINTS(反电商/素人感)append到base_prompt;真实验收:参考图笔电背景→生成图换梳妆台镜子生活场景,带标题/卖点/#标签/emoji,明显去电商化 | ✅ |
|
||||||
|
| 补1上传主图必含瓶身校验 | =既有补1 | 后端2层硬拦截已就位(task_service.py:57建任务校验+pipeline_io.py:199worker兜底);前端ProductSubComponents.tsx加引导"必须含产品主体,质地图不能替代主图"(倩倩姐定调:质地图也算产品但主体必传) | ✅ |
|
||||||
|
| C1产品一致(走真图) | =既有C1 | 真实端到端:产品5真图(白罐金边盖)→生成图瓶身忠于参考图原样,印"倍分子焕颜沁透水润 素颜霜 SKIN CREAM"未改包装/未混入他品;apiports503自动回落codeproxy+gpt-5.5强档兜底 | ✅ |
|
||||||
|
|
||||||
|
> R3 核销证据(2026-06-17真实端到端):worker容器内 r3_verify.py 用产品5真图+真key+真模型生 product_closeup 特写图,83秒,1.9MB,AI分82。独立Explore agent交叉审4处改动无硬伤、R1/R2回归安全。
|
||||||
|
> 备忘(非R3,记R5收紧):ALLOW_TEXT_ONLY_IMAGE默认true第三层放水,与前端引导文案语义不一致;当前前两层防线已拦截"入镜却纯文生图",R5多图时一并收紧默认值。
|
||||||
|
> 待优化(非阻塞):①产品5未录brand_keyword(品牌词客户填,本次验证未带词) ②生成图出现"核心买点。"模板占位词漏到图上=文案字段没填实,记文案优化。
|
||||||
|
|
||||||
|
### R4 三功能工作台(P1·形态级)
|
||||||
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 文案/图片排版/裂变三独立入口+可连贯 | 问题3新形态 | Sidebar线性流水线,裂变前端零入口 | 🟢 倩倩姐2026-06-15拍板"三并列入口";Sidebar.tsx改三并列(文案创作/tasks/new·图片排版/history·裂变扩散/fission);裂变扩散=独立裂变页(倩倩姐拍板"新建独立裂变页",app/fission/page.tsx+FissionLauncher+FissionProgress);tsc零错 |
|
||||||
|
| 图片排版可脱离文案独立用 | 问题3新 | images页强依赖text页带入 | 🟢 图片排版入口落/history每行加"排图"链→/tasks/{id}/images(图片步骤需task id,从历史任一非failed任务直接进排图,不强依赖刚走完text);components.tsx已加 |
|
||||||
|
| supervisor含运营全部操作 | 问题9新 | AuthGuard:18把/tasks/new挡住supervisor | 🟢 AuthGuard:18已加supervisor;ProgressBar:25只排除admin;后端POST/tasks用require_write_permission不卡角色(前轮R4-a改) |
|
||||||
|
| 导入文案补粘贴/上传入口 | 问题11新 | tasks/new:48直跳过输入,/import-text没调 | 🟢 新建ImportTextModal(粘贴多条用"---"切分);new页"导入外部文案"→先校验→开弹窗→建import任务→逐条POST/api/v1/tasks/{id}/import-text→跳text页;半成品态保留task_id跳text可续;tsc零错;端点路径核对/api/v1/tasks/{id}/import-text真实存在 |
|
||||||
|
| 第8环审核回路(死链/打回) | =既有M3 | M3未开工 | 🟢 M3前后端已建成(前轮核实非未开工);_fmt_task补review_status/reject_reason/reviewed_at修死链;RejectReasonBanner组件text/images页呈现打回原因 |
|
||||||
|
|
||||||
|
### R5 产品多图(P1)
|
||||||
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 产品图多张(DB+上传端点+前端multiple) | 问题2/14新 | image_path单值字符串,无multiple,覆盖式 | □ |
|
||||||
|
| 生图按场景选用对应图 | 问题2新 | pipeline_io只读单条image_path | □ |
|
||||||
|
| operator选品/快速建品/产品入镜 | 问题1新 | POST/products限admin,operator不能建 | □ |
|
||||||
|
|
||||||
|
### R6 账号体系+Key+权限(P1)
|
||||||
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 后端users.py增/删/列用户(删=软删is_active) | 问题15新 | 无users CRUD,is_active登录已校验:37 | □ |
|
||||||
|
| 前端配置台账号管理Tab | 问题15新 | 无账号管理入口 | □ |
|
||||||
|
| Key绑定账号显式展示 | 问题15新 | UserApiKey已按user隔离,只缺前端展示 | □ |
|
||||||
|
| operator配置编辑权(放开全部产品) | 问题13a新 | /config硬限admin,products CRUD require_admin | □ |
|
||||||
|
| 保存按钮补错误提示(治点不动) | 问题13b新 | ProductForm校验静默return无提示 | □ |
|
||||||
|
|
||||||
|
### R7 飞轮闭环+可视化(P2)
|
||||||
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 飞轮片段注入生图链路(补断点1) | 新 | run_image_generation没传flywheel_fragment | □ |
|
||||||
|
| 选图信号带strategy进飞轮(补断点2) | 新 | image_select的angle_label=None空转 | □ |
|
||||||
|
| 统一前端查询与注入的计数口径 | 新 | service用次数/aggregator用权重不一致 | □ |
|
||||||
|
| AI评图分仅展示+生成筛选 | =既有E12 | 守eval_score NULL红线 | □ |
|
||||||
|
| 飞轮可视化轻量节点反馈 | =既有FV/D2 | 让客户感到越用越好 | □ |
|
||||||
|
|
||||||
|
### R8 归档(P2)
|
||||||
|
| 子项 | 去重映射 | 根因证据 | 状态 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 历史打回显示原因 | =问题10,既有M4沾边 | _fmt_task()没返reject_reason,前端没读 | □ |
|
||||||
|
| 日期/品类筛选+导出 | =既有M4 | M4未开工 | □ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔴 跨模块迁移版本号统一(防三环撞008车)
|
## 🔴 跨模块迁移版本号统一(防三环撞008车)
|
||||||
008=brand_keyword / 009=benchmark_analyze_fields(含features_json) / 010=fission_tasks表 / 011=task_benchmark_fission_fields / 012=product_target_audience / 013=text_candidate edited+signal_type ENUM补text_edit(D1已落) / **014=generation_tasks.status ENUM补failed(B6已落)**。下一个可用=015。
|
008=brand_keyword / 009=benchmark_analyze_fields(含features_json) / 010=fission_tasks表 / 011=task_benchmark_fission_fields / 012=product_target_audience / 013=text_candidate edited+signal_type ENUM补text_edit(D1已落) / **014=generation_tasks.status ENUM补failed(B6已落)**。下一个可用=015。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user