baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
'use client';
// 屏4.5 确认预览:提交审核 / 重新生成(飞轮-1
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar';
import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages';
import { useTaskStore } from '@/stores/taskStore';
import { useGenerationStore } from '@/stores/generationStore';
import { api } from '@/lib/api';
import { useState } 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();
// imageCandidates 平铺(契约 image_candidate 事件无 batch 字段)
const { textCandidates, imageCandidates } = useGenerationStore();
const [submitting, setSubmitting] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id));
const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id));
async function handleSubmitReview() {
if (!taskId) return;
setSubmitting(true);
try {
await api.post(`/api/v1/tasks/${taskId}/submit-review`);
router.push('/review');
} finally {
setSubmitting(false);
}
}
async function handleRegenerate() {
if (!taskId) return;
setRegenerating(true);
try {
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
router.push(`/tasks/${taskId}/text`);
} finally {
setRegenerating(false);
}
}
return (
<div className="flex flex-col h-full">
<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="grid grid-cols-2 gap-6">
{/* 已选文案 */}
<div>
<h3 className="text-sm font-semibold text-text-secondary mb-3"></h3>
{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">
<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">
{c.content}
</p>
<span className="text-xs text-text-tertiary mt-1 block"> {c.score.total}</span>
</div>
))}
</div>
)}
</div>
{/* 已选图 */}
<ConfirmPreviewImages images={selectedImages} />
</div>
{/* 操作栏 */}
<ConfirmActionBar
submitting={submitting}
regenerating={regenerating}
canSubmit={selectedTextIds.length > 0}
onSubmit={handleSubmitReview}
onRegenerate={handleRegenerate}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
// 屏4 挑图0/N异步电影 + 平铺选图 + 单批重试(一期=整体重生) + 能离开
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { useSse } from '@/hooks/useSse';
import { useGenerationStore } from '@/stores/generationStore';
import { useTaskStore } from '@/stores/taskStore';
import { api } from '@/lib/api';
export default function ImageSelectionPage() {
const params = useParams<{ id: string }>();
const taskId = params?.id ? Number(params.id) : null;
const router = useRouter();
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
const { selectedImageIds, toggleImageSelection } = useTaskStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId);
const pendingCount = imageTotal - imageDone;
const allDone = imageTotal > 0 && pendingCount === 0;
async function handleRetry(_batchRole: string) {
// 一期 regenerate = 整体重生,契约无 batch 字段,不发 body
// 二期按批重生时再扩展_batchRole 目前仅作入参占位UI 提示用)
if (!taskId) return;
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
}
async function handleProceedToConfirm() {
if (!taskId || selectedImageIds.length === 0) return;
for (const candidateId of selectedImageIds) {
await api.post(`/api/v1/tasks/${taskId}/image-candidates/select`, { candidate_id: candidateId });
}
router.push(`/tasks/${taskId}/confirm`);
}
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={3} />
{flywheelInjected && (
<FlywheelBannerFromSse
recentPreference={flywheelInjected.recentPreference}
rejectReasons={flywheelInjected.rejectReasons}
/>
)}
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
<div className="p-6 flex-1 overflow-auto">
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
{/* 图片网格(谁好谁先冒) */}
<div className="grid grid-cols-3 xl:grid-cols-5 gap-3">
{imageCandidates.length === 0
? Array.from({ length: 5 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)
: imageCandidates.map((c) => (
<div key={c.candidate_id}
onClick={() => toggleImageSelection(c.candidate_id)}
className={`relative rounded-xl overflow-hidden border cursor-pointer transition-all
${selectedImageIds.includes(c.candidate_id) ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default hover:border-brand-orange/50'}`}
aria-label={`图片 ${c.role}${selectedImageIds.includes(c.candidate_id) ? '已选中' : '未选中'}`}
>
<img src={c.url} alt={c.role} className="w-full aspect-square object-cover" />
<p className="text-xs text-center py-1 text-text-secondary">{c.role}</p>
</div>
))}
</div>
{/* 失败批次提示 */}
{failedBatches.length > 0 && allDone && (
<div role="alert" className="mt-4 bg-red-50 border border-status-error rounded-xl px-4 py-3 text-sm text-status-error">
{failedBatches.length}
{failedBatches.filter((b) => b.retryable).map((b) => (
<button key={b.batch} onClick={() => handleRetry(b.batch)}
className="ml-2 underline" aria-label={`重试第${b.batch}`}> {b.batch}</button>
))}
</div>
)}
{imageCandidates.length > 0 && (
<ImageActionBar selectedCount={selectedImageIds.length} onProceed={handleProceedToConfirm} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
// 屏3 挑文案SSE实时+五维分+多选+飞轮隐形
import { useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { useSse } from '@/hooks/useSse';
import { useGenerationStore } from '@/stores/generationStore';
import { useTaskStore } from '@/stores/taskStore';
import { api } from '@/lib/api';
export default function TextSelectionPage() {
const params = useParams<{ id: string }>();
const taskId = params?.id ? Number(params.id) : null;
const router = useRouter();
const { textCandidates, textDone, textTotal, flywheelInjected } = useGenerationStore();
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId);
useEffect(() => {
if (taskId) setCurrentTask(taskId, 'generating');
}, [taskId]);
async function handleProceedToImages() {
if (!taskId || selectedTextIds.length === 0) return;
for (const candidateId of selectedTextIds) {
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
}
router.push(`/tasks/${taskId}/images`);
}
const isGenerating = textDone < textTotal || textTotal === 0;
const showSkeleton = textCandidates.length === 0 && isGenerating;
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={2} />
{flywheelInjected && (
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
)}
<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>
{/* 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>
)}
{/* 文案卡网格(谁好谁先冒) */}
<div className="grid grid-cols-2 xl:grid-cols-3 gap-4">
{showSkeleton
? Array.from({ length: 4 }).map((_, i) => <TextCandidateCardSkeleton key={i} />)
: textCandidates.map((c) => (
<TextCandidateCard
key={c.candidate_id}
candidate={c}
selected={selectedTextIds.includes(c.candidate_id)}
onToggle={toggleTextSelection}
/>
))}
</div>
{/* 底部操作栏 */}
{textCandidates.length > 0 && (
<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>
</span>
<button
onClick={handleProceedToImages}
disabled={selectedTextIds.length === 0}
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"
>
{selectedTextIds.length}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
/**
* 屏2 开新任务(熟手 /tasks/new
* 使用 NewTaskForm + ConfigPreviewPanel + RecentTasksBar + FlywheelBanner
*/
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBanner } from '@/components/FlywheelBanner';
import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel';
import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
import { NewTaskForm } from '@/components/tasks/NewTaskForm';
import { api } from '@/lib/api';
import { Product, CreateTaskRequest } from '@/types/index';
import { PaginatedResponse } from '@/types/index';
import { usePreferenceStore } from '@/stores/preferenceStore';
import { getErrorAction } from '@/types/errors';
import { ApiError } from '@/types/index';
export default function NewTaskPage() {
const router = useRouter();
const { context } = usePreferenceStore();
const [products, setProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [form, setForm] = useState<Omit<CreateTaskRequest, 'product_id'>>({
benchmark_ids: [],
theme: '',
text_count: 8,
image_count: 6,
track: 'ai',
need_product_image: true,
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => { loadProducts(); }, []);
async function loadProducts() {
try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
setProducts(data.items);
if (data.items.length > 0) setSelectedProduct(data.items[0]);
} catch {
// ignore
}
}
async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') {
e.preventDefault();
if (!selectedProduct) { setError('请选择产品'); return; }
if (!form.theme.trim()) { setError('请填写今天主题'); return; }
// 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传
if (form.need_product_image && !selectedProduct.image_path) {
setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。');
return;
}
setSubmitting(true);
setError('');
try {
const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track };
const data = await api.post<{ id: number }>('/api/v1/tasks', req);
router.push(`/tasks/${data.id}/text`);
} catch (err) {
const apiErr = err as ApiError;
setError(apiErr?.message || getErrorAction(apiErr?.code).message);
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col h-full">
<ProgressBar currentStep={1} />
{context && <FlywheelBanner context={context} />}
<div className="flex flex-1 overflow-hidden">
{/* 主表单区 */}
<div className="flex-1 p-6 overflow-auto">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2>
<NewTaskForm
products={products}
selectedProduct={selectedProduct}
onSelectProduct={setSelectedProduct}
form={form}
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
submitting={submitting}
error={error}
onSubmitAi={(e) => handleSubmit(e, 'ai')}
onSubmitImport={(e) => handleSubmit(e, 'import')}
/>
<div className="mt-8"><RecentTasksBar /></div>
</div>
{/* 右侧配置预览面板 */}
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
</div>
</div>
);
}