前端文案挑选页按 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:
@@ -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", []),
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
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 = {
|
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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} 返回
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user