From cb041e8d3a8cdd9305f3c4150e602597fbfbc14c Mon Sep 17 00:00:00 2001 From: yangqianqian <5845211314@qq.com> Date: Mon, 22 Jun 2026 10:29:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=96=87=E6=A1=88=E6=8C=91?= =?UTF-8?q?=E9=80=89=E9=A1=B5=E6=8C=89=20A/B/C=20=E4=B8=89=E5=A5=97?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 _fmt_text 返回 strategy 字段,SSE 与 hydrate 两条路径贯通 - 新建 TextStrategyGroup 组件 + groupByStrategy(A/B/C/未分套排序,旧 NULL 数据归未分套) - text 页平铺改分组渲染,选中态/改稿回调透传不变 - 复用 ImageStrategyGroup 的 STRATEGY_LABEL,单向依赖无环 Co-Authored-By: Claude Opus 4.8 --- backend/app/api/v1/tasks.py | 1 + frontend/src/app/tasks/[id]/text/page.tsx | 34 +++++----- .../components/tasks/TextStrategyGroup.tsx | 62 +++++++++++++++++++ frontend/src/hooks/useSse.ts | 1 + frontend/src/types/dto.tasks.ts | 1 + frontend/src/types/sse.ts | 1 + 6 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/tasks/TextStrategyGroup.tsx diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py index 5ac6f26..4e170ff 100644 --- a/backend/app/api/v1/tasks.py +++ b/backend/app/api/v1/tasks.py @@ -121,6 +121,7 @@ def _fmt_text(tc: TextCandidate) -> dict: return { "candidate_id": tc.id, "angle_label": tc.angle_label, + "strategy": tc.strategy, # A/B/C 正交叙事套(前端按套分组展示) "title": parsed.get("title", ""), "content": parsed.get("content", ""), # 纯正文,前端直接展示 "tags": parsed.get("tags", []), diff --git a/frontend/src/app/tasks/[id]/text/page.tsx b/frontend/src/app/tasks/[id]/text/page.tsx index 6266a38..d45d31e 100644 --- a/frontend/src/app/tasks/[id]/text/page.tsx +++ b/frontend/src/app/tasks/[id]/text/page.tsx @@ -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() { )} - {/* 文案卡网格(谁好谁先冒) */} -
- {showSkeleton - ? Array.from({ length: 4 }).map((_, i) => ) - : textCandidates.map((c) => ( - - ))} -
+ {/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */} + {showSkeleton ? ( +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ ) : ( + groupByStrategy(textCandidates).map(([strategy, list]) => ( + + )) + )} {/* 底部操作栏 */} {textCandidates.length > 0 && ( diff --git a/frontend/src/components/tasks/TextStrategyGroup.tsx b/frontend/src/components/tasks/TextStrategyGroup.tsx new file mode 100644 index 0000000..854eb15 --- /dev/null +++ b/frontend/src/components/tasks/TextStrategyGroup.tsx @@ -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; +} + +export function TextStrategyGroup({ + strategy, candidates, selectedTextIds, onToggle, onSaveEdit, +}: TextStrategyGroupProps) { + const label = strategy === '_' ? '未分套' : (STRATEGY_LABEL[strategy] || `套${strategy}`); + return ( +
+

+ {label} + + {candidates.length} 条 + +

+
+ {candidates.map((c) => ( + + ))} +
+
+ ); +} + +// 把平铺候选按 strategy 分组,返回有序 [strategy, candidates][](A/B/C 固定序,未分套垫底)。 +// 仅含真实存在的套(不硬塞空套),避免某套还没生成出来时显示空壳。 +export function groupByStrategy( + candidates: TextCandidate[], +): Array<[string, TextCandidate[]]> { + const order = ['A', 'B', 'C', '_']; + const buckets = new Map(); + 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[]]); +} diff --git a/frontend/src/hooks/useSse.ts b/frontend/src/hooks/useSse.ts index 563c25d..25455e6 100644 --- a/frontend/src/hooks/useSse.ts +++ b/frontend/src/hooks/useSse.ts @@ -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 }, diff --git a/frontend/src/types/dto.tasks.ts b/frontend/src/types/dto.tasks.ts index 6fc050b..11425d1 100644 --- a/frontend/src/types/dto.tasks.ts +++ b/frontend/src/types/dto.tasks.ts @@ -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; diff --git a/frontend/src/types/sse.ts b/frontend/src/types/sse.ts index fe0a12b..019f48c 100644 --- a/frontend/src/types/sse.ts +++ b/frontend/src/types/sse.ts @@ -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} 返回 };