前端文案挑选页按 A/B/C 三套分组展示
- 后端 _fmt_text 返回 strategy 字段,SSE 与 hydrate 两条路径贯通 - 新建 TextStrategyGroup 组件 + groupByStrategy(A/B/C/未分套排序,旧 NULL 数据归未分套) - text 页平铺改分组渲染,选中态/改稿回调透传不变 - 复用 ImageStrategyGroup 的 STRATEGY_LABEL,单向依赖无环 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@ 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 { TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
||||
import { TextStrategyGroup, groupByStrategy } from '@/components/tasks/TextStrategyGroup';
|
||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
|
||||
@@ -73,20 +74,23 @@ export default function TextSelectionPage() {
|
||||
</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}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */}
|
||||
{showSkeleton ? (
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <TextCandidateCardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : (
|
||||
groupByStrategy(textCandidates).map(([strategy, list]) => (
|
||||
<TextStrategyGroup
|
||||
key={strategy}
|
||||
strategy={strategy}
|
||||
candidates={list}
|
||||
selectedTextIds={selectedTextIds}
|
||||
onToggle={toggleTextSelection}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
{textCandidates.length > 0 && (
|
||||
|
||||
62
frontend/src/components/tasks/TextStrategyGroup.tsx
Normal file
62
frontend/src/components/tasks/TextStrategyGroup.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
/**
|
||||
* TextStrategyGroup — 文案按"套"(A/B/C)分组展示
|
||||
* A8(倩倩姐2026-06-18拍板"按三套分组展示"):让运营一眼看出三套不同角度,
|
||||
* 与图片侧 ImageStrategyGroup 同轴(套1痛点/套2场景/套3成分背书),复用 STRATEGY_LABEL。
|
||||
* 无 strategy 的旧数据归入"未分套"组,向后兼容不丢卡。
|
||||
*/
|
||||
import { TextCandidate } from '@/types/index';
|
||||
import { TextCandidateCard } from './TextCandidateCard';
|
||||
import { STRATEGY_LABEL } from './ImageStrategyGroup';
|
||||
|
||||
interface TextStrategyGroupProps {
|
||||
strategy: string; // 'A'|'B'|'C'|'_'(未分套)
|
||||
candidates: TextCandidate[];
|
||||
selectedTextIds: number[];
|
||||
onToggle: (id: number) => void;
|
||||
onSaveEdit?: (id: number, content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TextStrategyGroup({
|
||||
strategy, candidates, selectedTextIds, onToggle, onSaveEdit,
|
||||
}: TextStrategyGroupProps) {
|
||||
const label = strategy === '_' ? '未分套' : (STRATEGY_LABEL[strategy] || `套${strategy}`);
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<h3 className="mb-3 flex items-center text-sm font-semibold text-text-primary">
|
||||
{label}
|
||||
<span className="ml-2 text-xs font-normal text-text-tertiary">
|
||||
{candidates.length} 条
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{candidates.map((c) => (
|
||||
<TextCandidateCard
|
||||
key={c.candidate_id}
|
||||
candidate={c}
|
||||
selected={selectedTextIds.includes(c.candidate_id)}
|
||||
onToggle={onToggle}
|
||||
onSaveEdit={onSaveEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// 把平铺候选按 strategy 分组,返回有序 [strategy, candidates][](A/B/C 固定序,未分套垫底)。
|
||||
// 仅含真实存在的套(不硬塞空套),避免某套还没生成出来时显示空壳。
|
||||
export function groupByStrategy(
|
||||
candidates: TextCandidate[],
|
||||
): Array<[string, TextCandidate[]]> {
|
||||
const order = ['A', 'B', 'C', '_'];
|
||||
const buckets = new Map<string, TextCandidate[]>();
|
||||
for (const c of candidates) {
|
||||
const key = c.strategy ?? '_';
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key)!.push(c);
|
||||
}
|
||||
return order
|
||||
.filter((k) => buckets.has(k))
|
||||
.map((k) => [k, buckets.get(k) ?? []] as [string, TextCandidate[]]);
|
||||
}
|
||||
@@ -81,6 +81,7 @@ function handleEvent(
|
||||
const tc: TextCandidate = {
|
||||
candidate_id: event.data.candidate_id,
|
||||
angle_label: event.data.angle_label,
|
||||
strategy: event.data.strategy ?? null,
|
||||
content: event.data.content,
|
||||
source: 'ai',
|
||||
score: { title: 0, emotion: 0, selling: 0, keyword: 0, compliance: 0, total: totalScore },
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface TextScore {
|
||||
export interface TextCandidate {
|
||||
candidate_id: number;
|
||||
angle_label: string;
|
||||
strategy?: 'A' | 'B' | 'C' | null; // 三套正交叙事套(A痛点/B场景/C成分),前端按套分组;旧数据为空
|
||||
content: string;
|
||||
source: 'ai' | 'import';
|
||||
score: TextScore;
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface TextCandidateEvent extends SseBaseEvent {
|
||||
data: {
|
||||
candidate_id: number;
|
||||
angle_label: string;
|
||||
strategy?: 'A' | 'B' | 'C' | null; // 后端 pipeline_io 推送的正交叙事套标记
|
||||
content: string;
|
||||
score: number; // SSE 推总分整数(0-100),完整五维分在 GET /tasks/{id} 返回
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user