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} 返回
};