前端文案挑选页按 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:
yangqianqian
2026-06-22 10:29:35 +08:00
parent e095b1162e
commit cb041e8d3a
6 changed files with 85 additions and 15 deletions

View File

@@ -121,6 +121,7 @@ def _fmt_text(tc: TextCandidate) -> dict:
return { return {
"candidate_id": tc.id, "candidate_id": tc.id,
"angle_label": tc.angle_label, "angle_label": tc.angle_label,
"strategy": tc.strategy, # A/B/C 正交叙事套(前端按套分组展示)
"title": parsed.get("title", ""), "title": parsed.get("title", ""),
"content": parsed.get("content", ""), # 纯正文,前端直接展示 "content": parsed.get("content", ""), # 纯正文,前端直接展示
"tags": parsed.get("tags", []), "tags": parsed.get("tags", []),

View File

@@ -4,7 +4,8 @@ import { useEffect } 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 { 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 { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner'; import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner'; import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
@@ -73,20 +74,23 @@ export default function TextSelectionPage() {
</div> </div>
)} )}
{/* 文案卡网格(谁好谁先冒) */} {/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */}
<div className="grid grid-cols-2 xl:grid-cols-3 gap-4"> {showSkeleton ? (
{showSkeleton <div className="grid grid-cols-2 xl:grid-cols-3 gap-4">
? Array.from({ length: 4 }).map((_, i) => <TextCandidateCardSkeleton key={i} />) {Array.from({ length: 4 }).map((_, i) => <TextCandidateCardSkeleton key={i} />)}
: textCandidates.map((c) => ( </div>
<TextCandidateCard ) : (
key={c.candidate_id} groupByStrategy(textCandidates).map(([strategy, list]) => (
candidate={c} <TextStrategyGroup
selected={selectedTextIds.includes(c.candidate_id)} key={strategy}
onToggle={toggleTextSelection} strategy={strategy}
onSaveEdit={handleSaveEdit} candidates={list}
/> selectedTextIds={selectedTextIds}
))} onToggle={toggleTextSelection}
</div> onSaveEdit={handleSaveEdit}
/>
))
)}
{/* 底部操作栏 */} {/* 底部操作栏 */}
{textCandidates.length > 0 && ( {textCandidates.length > 0 && (

View 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[]]);
}

View File

@@ -81,6 +81,7 @@ function handleEvent(
const tc: TextCandidate = { const tc: TextCandidate = {
candidate_id: event.data.candidate_id, candidate_id: event.data.candidate_id,
angle_label: event.data.angle_label, angle_label: event.data.angle_label,
strategy: event.data.strategy ?? null,
content: event.data.content, content: event.data.content,
source: 'ai', source: 'ai',
score: { title: 0, emotion: 0, selling: 0, keyword: 0, compliance: 0, total: totalScore }, score: { title: 0, emotion: 0, selling: 0, keyword: 0, compliance: 0, total: totalScore },

View File

@@ -53,6 +53,7 @@ export interface TextScore {
export interface TextCandidate { export interface TextCandidate {
candidate_id: number; candidate_id: number;
angle_label: string; angle_label: string;
strategy?: 'A' | 'B' | 'C' | null; // 三套正交叙事套(A痛点/B场景/C成分),前端按套分组;旧数据为空
content: string; content: string;
source: 'ai' | 'import'; source: 'ai' | 'import';
score: TextScore; score: TextScore;

View File

@@ -23,6 +23,7 @@ export interface TextCandidateEvent extends SseBaseEvent {
data: { data: {
candidate_id: number; candidate_id: number;
angle_label: string; angle_label: string;
strategy?: 'A' | 'B' | 'C' | null; // 后端 pipeline_io 推送的正交叙事套标记
content: string; content: string;
score: number; // SSE 推总分整数0-100完整五维分在 GET /tasks/{id} 返回 score: number; // SSE 推总分整数0-100完整五维分在 GET /tasks/{id} 返回
}; };