feat(marketing): 营销内容命名、文生图/视频提示词与结果规范化

两步 LLM 增加可复制文生图/文生视频提示词并强化卖点可视化;normalize_detail_page_pack 补全键;产品用语由商详包改为营销内容;前端导出 Markdown 与策略预览区展示一致;营销包落盘模块入库。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-22 15:35:35 +08:00
parent 5df82f7d80
commit 6470d0d56c
8 changed files with 475 additions and 81 deletions

View File

@ -1,4 +1,4 @@
"""策略稿 → 核心信息卡 → 商详文案包(两步 LLMJSON 输出)。"""
"""策略稿 → 核心信息卡 → 营销内容多触点文案(两步 LLMJSON 输出)。"""
from __future__ import annotations
import json
@ -8,14 +8,16 @@ from .llm_client import call_llm
_MAX_STRATEGY_CHARS = 28_000
CORE_CARD_SYSTEM = """你是电商商详与营销文案顾问。根据用户提供的「策略稿全文」与结构化决策、业务备注,输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏,不要前后说明文字)。
CORE_CARD_SYSTEM = """你是电商营销内容顾问。根据用户提供的「策略稿全文」与结构化决策、业务备注,输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏,不要前后说明文字)。
**硬性**
- 事实数字功效检测结论销量评价原文**仅可**来自输入**禁止**编造未出现的品牌名数据用户说引语
- 食品/健康相关**禁止**治疗承诺与夸大疗效无依据写输入未体现待法务确认
- 句子短可落地兼顾**购买者决策**与商详写手可用性
- 句子短可落地兼顾**购买者决策****列表/商详/主图等多触点**上架可用性
- **读者第一眼须知道在卖什么**禁止通篇只有价值感信任体验**不出现可识别的品类/形态**如饼干燕麦奶粉饮料等若输入未给出具体 SKU 仍须写清**类目 + 形态/规格层级** GI 方向早餐饼干待业务定款不得用优质好物健康之选**无品类**的句子糊弄本条
**JSON 须全部出现值为字符串无内容用空串**
- what_we_sell**卖的是什么**必填建议 2580 写清**品类 + 主推形态/规格或适用场景**让读者**不读策略稿**也能回答你们在卖哪种货**仅可**综合策略稿`strategy_decisions`尤其 **pillar_product**battlefield_one_lineaudience_segmentmarketing_strategy`business_notes` `keyword` 监测语境中已出现的信息 `pillar_product` 非空须与之**不矛盾**无具体商品名时须明确写待业务补充主推 SKU/品名并保留类目词可与关键词监测范围对读
- one_liner_value一句话价值主张买家能得到什么
- buyer_job_to_be_done购买者的任务或情境一句
- key_pain_or_desire核心痛点或欲望与策略一致
@ -27,22 +29,80 @@ CORE_CARD_SYSTEM = """你是电商商详与营销文案顾问。根据用户提
- open_points_for_business待业务补充无则空串
"""
DETAIL_PACK_SYSTEM = """你是京东商详向文案手。输入为已定稿的「核心信息卡」JSON 与关键词。请输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏)。
DETAIL_PACK_SYSTEM = """你是京东场景营销内容写手。输入为已定稿的「核心信息卡」JSON 与关键词。请输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏)。
**硬性**
- **仅可**依据核心信息卡展开**禁止**新增数字功效认证评价引语竞品具体名除非信息卡里已有
- 购买者视角短句禁止输出 JSON 键名英文给最终读者值全部为中文商详可用文案
- 购买者视角短句禁止输出 JSON 键名英文给最终读者值全部为中文**多触点上架**可用文案
- 不要泄露核心信息卡策略稿等内部词
- **每条 listing_titleslisting_subtitledetail_headlineselling_bullets 的前两条**均须让读者能识别**在卖什么品类/什么货**须与信息卡 **what_we_sell** 一致可缩写但**禁止**偷换品类或只剩空洞形容词若信息卡 `what_we_sell` 已写品类文案中**至少一处**直接出现该类目词或同义可识别表述
- **文生图/文生视频提示词**须为**可直接复制**到常见文生图文生视频模型的**中文**描述**仅可**依据信息卡已有事实与品类**禁止**在提示词里写策略稿信息卡JSON等元话语**禁止**要求生成未授权的具体品牌 Logo真实包装上的可辨认商标带疗效承诺的贴片字
- **文生图须有货有卖点画面**硬性
- 从信息卡 **what_we_sell****one_liner_value****key_pain_or_desire****why_this_product****differentiation_vs_alternatives****price_value_framing** 中提炼 **13 条可画出来的卖点**写入 ``text_to_image_prompt_main``**场景图** ``text_to_image_prompt_scene`` 非空时须保留**至少 1 **同款质地或品类辨识非空场景图时
- **禁止**整段只有白底居中电商主图健康食品等空壳**不出现具体货态**形态切片包装类型手持/摆放方式至少择一
- **口感/质地类**如松软酥脆绵密有嚼劲**必须**写成**可见结构**不能只写一次形容词了事**松软**吐司切片横截面气孔细腻边缘微翘显蓬松轻按后缓慢回弹手撕开可见柔软内里**酥脆**饼干断面层次清晰碎屑自然信息卡未提质地则**不写**勿编造
- **配料/品类视觉**如全麦可写麸皮颗粒隐约可见浅褐全麦外皮**禁止**疗效字幕血糖仪前后对比治病画面
**JSON 须全部出现**
- listing_titles字符串数组46 条商品短标题备选每条约 30 字内
- listing_titles字符串数组46 条商品短标题备选每条约 30 字内**每条须含可识别品类或品名线索**禁止 6 条全是安心之选
- listing_subtitle一条列表副文案 60 字内
- detail_headline商详首屏下 lead12
- detail_headline情页首屏下 lead12 **首句须点明卖的是什么货**再写价值
- selling_bullets字符串数组58 条卖点每条约 40 字内
- spec_sidebar_lines字符串数组03 条参数区旁短句可空数组
- faq对象数组每项含 questionanswer 字符串35 答句不得超出信息卡承诺
- traceability_note**依据与边界**必填24 用业务可读中文说明本包与信息卡中**哪些承诺方向一致****哪些表述须业务或法务核对****输入未体现的不得对外宣称****禁止**新数字新功效新认证
- main_image_three_points字符串数组**恰好 3 **主图/首图用超短句每条建议 614 须与 **what_we_sell** 品类一致可来自卖点压缩禁止空泛口号
- live_or_short_hook一条直播或短视频开场钩句40 同一事实约束
- customer_service_opening一条客服首句/欢迎语建议50 同一事实约束
- text_to_image_prompt_main字符串**主图/首图**文生图提示词建议 **100260** **必须**依次包含 **具体货态** **what_we_sell** 一致的品类+形态如全麦吐司切片摞放独立小包饼干 **至少一条质地/卖点的视觉化描写**与信息卡一致参见上文松软截面/按压/手撕 **构图与背景**如白底居中轻微投影 **光影**柔和棚拍写实 **规避** Logo无疗效字无竞品名**英文模型**可关键风格词括注英文
- text_to_image_prompt_scene字符串**场景/生活方式**备选图建议 **80200** 早餐桌手持厨房台面等**须含**与主图**同一品类**的清晰货态**至少一处**质地或食用情境如蒸汽刀切截面蘸牛奶与主图完全重复则宁可缩短但保留情境差分无合适场景时 ``""``
- text_to_video_prompt字符串文生视频提示词建议 **100260** 竖屏 9:16**515 ****** **1 个能体现质地或卖点的镜头**如慢镜撕开吐司见柔软内里刀切截面特写轻捏回弹与信息卡卖点一致另写开场与转场推近/平移**禁止**疗效字幕未授权标识无对白一句中性口播
"""
# 第二步 JSON 完整键表;模型漏键或旧落盘缺字段时由 ``normalize_detail_page_pack`` 补齐。
_DETAIL_PAGE_PACK_DEFAULTS: dict[str, Any] = {
"listing_titles": [],
"listing_subtitle": "",
"detail_headline": "",
"selling_bullets": [],
"spec_sidebar_lines": [],
"faq": [],
"traceability_note": "",
"main_image_three_points": [],
"live_or_short_hook": "",
"customer_service_opening": "",
"text_to_image_prompt_main": "",
"text_to_image_prompt_scene": "",
"text_to_video_prompt": "",
}
_DETAIL_PAGE_PACK_LIST_KEYS: frozenset[str] = frozenset(
{
"listing_titles",
"selling_bullets",
"spec_sidebar_lines",
"main_image_three_points",
"faq",
}
)
def normalize_detail_page_pack(data: dict[str, Any]) -> dict[str, Any]:
"""保证 ``detail_page_pack`` 含全部约定键,避免模型漏输出或旧 JSON 缺字段。"""
out: dict[str, Any] = dict(data)
for key in _DETAIL_PAGE_PACK_DEFAULTS:
v = out.get(key)
if key in _DETAIL_PAGE_PACK_LIST_KEYS:
if isinstance(v, list):
continue
out[key] = []
continue
if v is None:
out[key] = ""
elif not isinstance(v, str):
out[key] = str(v)
return out
def _truncate_strategy(md: str) -> tuple[str, bool]:
t = (md or "").strip()
@ -101,11 +161,18 @@ def generate_detail_page_pack(
"keyword": keyword,
"core_info_card": core_info_card,
}
user = "请根据以下 JSON 输出商详包(仅 JSON 对象):\n" + json.dumps(
payload, ensure_ascii=False
user = (
"请根据以下 JSON 输出营销内容多触点文案(**仅**一段 JSON 对象)。\n"
"**必填键名(缺一不可,勿省略)**listing_titles, listing_subtitle, detail_headline, "
"selling_bullets, spec_sidebar_lines, faq, traceability_note, main_image_three_points, "
"live_or_short_hook, customer_service_opening, text_to_image_prompt_main, "
"text_to_image_prompt_scene, text_to_video_prompt。\n"
"**文生图/视频**:须让「货」和卖点**看得见**(如松软→截面气孔、手撕/按压回弹);禁止整段只有白底健康食品而无具体形态与质地描写。\n"
"输入数据:\n"
+ json.dumps(payload, ensure_ascii=False)
)
raw = call_llm(DETAIL_PACK_SYSTEM, user)
return _parse_llm_json(raw)
return normalize_detail_page_pack(_parse_llm_json(raw))
def generate_marketing_detail_pack(

View File

@ -0,0 +1,23 @@
"""营销内容包等产物落盘(任务 run_dir便于归档与审计"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
def persist_marketing_detail_pack_v1(run_dir: str | None, payload: dict[str, Any]) -> Path | None:
"""
写入 ``{run_dir}/marketing/marketing_detail_pack_v1.json``
``payload`` 建议与 API 响应体一致 schema_versionjob_idcore_info_card
"""
if not run_dir or not str(run_dir).strip():
return None
root = Path(run_dir)
if not root.is_dir():
return None
out_dir = root / "marketing"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / "marketing_detail_pack_v1.json"
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path

View File

@ -476,7 +476,7 @@ class StrategyDraftRequestSerializer(serializers.Serializer):
class MarketingDetailPackRequestSerializer(serializers.Serializer):
"""策略稿正文 → 核心信息卡 → 商详包(两步 LLM"""
"""策略稿正文 → 核心信息卡 → 营销内容(多触点文案,两步 LLM"""
strategy_markdown = serializers.CharField(
required=True,

View File

@ -1,7 +1,8 @@
"""商详包:两步 JSON LLM 与解析。"""
"""营销内容包:两步 JSON LLM 与解析。"""
from __future__ import annotations
import json
import tempfile
from unittest.mock import patch
from django.test import SimpleTestCase
@ -9,10 +10,27 @@ from django.test import SimpleTestCase
from pipeline.llm.generate_marketing_detail import (
_parse_llm_json,
generate_marketing_detail_pack,
normalize_detail_page_pack,
)
from pipeline.reporting.marketing_pack_persist import persist_marketing_detail_pack_v1
class MarketingDetailPackTests(SimpleTestCase):
def test_normalize_fills_missing_keys_including_aigc(self) -> None:
"""旧结果或模型漏键时补齐文生图/文生视频等字段。"""
slim = {
"traceability_note": "x",
"main_image_three_points": ["a", "b", "c"],
"live_or_short_hook": "h",
"customer_service_opening": "c",
}
n = normalize_detail_page_pack(slim)
self.assertEqual(n["text_to_image_prompt_main"], "")
self.assertEqual(n["text_to_image_prompt_scene"], "")
self.assertEqual(n["text_to_video_prompt"], "")
self.assertEqual(n["listing_titles"], [])
self.assertEqual(n["traceability_note"], "x")
def test_parse_llm_json_strips_wrapped(self) -> None:
raw = '前缀 {"a": 1, "b": "x"} 后缀'
d = _parse_llm_json(raw)
@ -24,6 +42,7 @@ class MarketingDetailPackTests(SimpleTestCase):
def test_generate_marketing_detail_pack_two_calls(self) -> None:
core = {
"what_we_sell": "低GI方向早餐饼干监测关键词语境待业务定主推款",
"one_liner_value": "低负担早餐选择",
"buyer_job_to_be_done": "控糖加餐",
"key_pain_or_desire": "怕升糖",
@ -41,6 +60,13 @@ class MarketingDetailPackTests(SimpleTestCase):
"selling_bullets": ["卖点1"],
"spec_sidebar_lines": [],
"faq": [{"question": "是否低GI", "answer": "以包装与检测为准。"}],
"traceability_note": "与策略方向一致;具体宣称以包装为准。",
"main_image_three_points": ["低GI早餐", "独立小包", "配料可核对"],
"live_or_short_hook": "控糖早餐怎么选?先看配料表。",
"customer_service_opening": "您好,这款饼干适合关注血糖管理的早餐场景,需要我帮您对比规格吗?",
"text_to_image_prompt_main": "电商主图白底居中摆放一盘低GI早餐饼干柔和棚拍光写实无品牌logo健康清爽风格",
"text_to_image_prompt_scene": "早餐餐桌场景,牛奶与饼干搭配,自然窗光,生活感,无文字贴片",
"text_to_video_prompt": "竖屏9:165秒从俯拍早餐桌缓慢推近至饼干包装无对白干净色调无疗效字幕",
}
with patch(
"pipeline.llm.generate_marketing_detail.call_llm",
@ -54,3 +80,16 @@ class MarketingDetailPackTests(SimpleTestCase):
)
self.assertEqual(out["core_info_card"]["one_liner_value"], "低负担早餐选择")
self.assertEqual(out["detail_page_pack"]["listing_titles"][0], "标题A")
self.assertIn("竖屏", out["detail_page_pack"]["text_to_video_prompt"])
def test_persist_marketing_detail_pack_v1_writes_file(self) -> None:
with tempfile.TemporaryDirectory() as td:
payload = {"schema_version": 1, "job_id": 1, "x": "y"}
path = persist_marketing_detail_pack_v1(td, payload)
self.assertIsNotNone(path)
assert path is not None
self.assertTrue(path.is_file())
self.assertEqual(path.parent.name, "marketing")
self.assertIn("marketing_detail_pack_v1.json", path.name)
loaded = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(loaded["job_id"], 1)

View File

@ -28,6 +28,7 @@ from ..reporting.brief_strategy_scope import (
list_matrix_groups_for_api,
resolve_strategy_matrix_group_index,
)
from ..reporting.marketing_pack_persist import persist_marketing_detail_pack_v1
from ..reporting.md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes
from ..reporting.report_matrix_group_evidence import (
load_report_matrix_group_evidence_markdown,
@ -266,7 +267,7 @@ class JobStrategyDraftView(APIView):
@method_decorator(csrf_exempt, name="dispatch")
class JobMarketingDetailPackView(APIView):
"""
根据浏览器会话中的策略稿 Markdown核心信息卡再派生**商详包**JSON
根据浏览器会话中的策略稿 Markdown核心信息卡再派生**营销内容**多触点文案 JSON
两步均走 ``call_llm``事实约束见提示词
"""
@ -281,7 +282,7 @@ class JobMarketingDetailPackView(APIView):
raise Http404()
if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip():
return Response(
{"detail": "仅可对已成功且含 run_dir 的任务生成商详包"},
{"detail": "仅可对已成功且含 run_dir 的任务生成营销内容"},
status=status.HTTP_400_BAD_REQUEST,
)
ser = MarketingDetailPackRequestSerializer(data=request.data or {})
@ -314,6 +315,10 @@ class JobMarketingDetailPackView(APIView):
"source": "llm_marketing_detail_pack_v1",
**inner,
}
try:
persist_marketing_detail_pack_v1(job.run_dir, body)
except OSError:
pass
return Response(body)
@ -323,7 +328,7 @@ class JobExportDocumentView(APIView):
Markdown 导出为 Word.docx或简易 PDF
- GET``kind=report``读取 ``run_dir/competitor_analysis.md``若文件缺失但已有合并表
则先按任务配置调用 ``regenerate_competitor_report`` 再导出报告生成规则版一致
- POST``kind=strategy``请求体 JSON 字段 ``markdown`` 为策略稿正文与前端 sessionStorage 一致
- POST``kind=strategy``请求体 ``markdown`` 为策略稿正文``kind=marketing_detail`` 为营销内容等派生稿同一套转版逻辑下载文件名不同
PDF 依赖本机中文字体或环境变量 ``MA_PDF_FONT`` 指向 .ttf
"""
@ -408,9 +413,9 @@ class JobExportDocumentView(APIView):
kind = (body.get("kind") or "strategy").strip().lower()
fmt = (body.get("fmt") or "docx").strip().lower()
md = (body.get("markdown") or "").strip()
if kind != "strategy":
if kind not in ("strategy", "marketing_detail"):
return Response(
{"detail": "POST 仅支持 kind=strategy"},
{"detail": "POST 的 kind 须为 strategy 或 marketing_detail"},
status=status.HTTP_400_BAD_REQUEST,
)
if not md:
@ -424,14 +429,18 @@ class JobExportDocumentView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
try:
if kind == "marketing_detail":
base = f"job_{pk}_marketing_detail_pack"
else:
base = f"job_{pk}_strategy_draft"
if fmt == "docx":
data = markdown_to_docx_bytes(md)
ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
fn = f"job_{pk}_strategy_draft.docx"
fn = f"{base}.docx"
else:
data = markdown_to_pdf_bytes(md)
ct = "application/pdf"
fn = f"job_{pk}_strategy_draft.pdf"
fn = f"{base}.pdf"
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
resp = HttpResponse(data, content_type=ct)

View File

@ -1,44 +1,5 @@
import { ref, watch } from 'vue'
const jobs = ref([])
/** 终态 */
const TERMINAL_JOB_STATUSES = new Set(['success', 'failed', 'cancelled'])
function isActiveJobStatus(status) {
return status === 'pending' || status === 'running'
}
/** 单一定时器轮询列表(避免 N 个任务 → N 路 GET /api/jobs/:id/ */
let jobsListPollTimer = null
function stopJobsListPoll() {
if (jobsListPollTimer != null) {
clearInterval(jobsListPollTimer)
jobsListPollTimer = null
}
}
async function fetchJobsListQuietly() {
try {
const r = await api('/api/jobs/')
if (r.ok) {
jobs.value = await r.json()
}
} catch {
/* 忽略网络错误,下一轮再试 */
}
}
function syncJobsListPoll() {
const hasActive = jobs.value.some((j) => isActiveJobStatus(j.status))
if (!hasActive) {
stopJobsListPoll()
return
}
if (jobsListPollTimer != null) return
jobsListPollTimer = setInterval(fetchJobsListQuietly, 3000)
}
import { storeToRefs } from 'pinia'
import { useJobStore } from '../stores/jobs'
export function api(path, opts = {}) {
return fetch(path, {
@ -48,9 +9,7 @@ export function api(path, opts = {}) {
}
export async function refreshJobs() {
const r = await api('/api/jobs/')
if (!r.ok) throw new Error(await r.text())
jobs.value = await r.json()
return useJobStore().refreshJobs()
}
export function jobCancelUrl(jobId) {
@ -117,11 +76,14 @@ export async function exportReportDocument(jobId, fmt = 'docx') {
URL.revokeObjectURL(u)
}
/** 策略稿正文(浏览器 sessionStorage→ Word/PDF */
export async function exportStrategyDocument(jobId, markdown, fmt = 'docx') {
/**
* 策略稿或营销内容 Markdown Word/PDF
* @param {'strategy' | 'marketing_detail'} [kind]
*/
export async function exportStrategyDocument(jobId, markdown, fmt = 'docx', kind = 'strategy') {
const r = await api(`/api/jobs/${jobId}/export-document/`, {
method: 'POST',
body: JSON.stringify({ kind: 'strategy', fmt, markdown }),
body: JSON.stringify({ kind, fmt, markdown }),
})
const ct = r.headers.get('Content-Type') || ''
if (!r.ok) {
@ -140,9 +102,12 @@ export async function exportStrategyDocument(jobId, markdown, fmt = 'docx') {
throw new Error(msg)
}
const blob = await r.blob()
const fallback =
kind === 'marketing_detail'
? `job_${jobId}_marketing_detail_pack.${fmt}`
: `job_${jobId}_strategy_draft.${fmt}`
const filename =
filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
`job_${jobId}_strategy_draft.${fmt}`
filenameFromContentDisposition(r.headers.get('Content-Disposition')) || fallback
const u = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = u
@ -282,14 +247,6 @@ export async function downloadJobDatasetExport(jobId, kind, exportFmt) {
URL.revokeObjectURL(u)
}
watch(
jobs,
() => {
syncJobsListPoll()
},
{ deep: true },
)
export function jobConfigHint(j) {
const parts = []
if (j.page_start != null || j.page_to != null) {
@ -311,6 +268,8 @@ export function jobConfigHint(j) {
}
export function useJobs() {
const store = useJobStore()
const { jobs } = storeToRefs(store)
return {
jobs,
refreshJobs,

View File

@ -0,0 +1,150 @@
/**
* 将营销内容 API 返回的 JSON 转为可导出 Word/PDF Markdown中文小标题
*/
const CORE_LABELS = {
what_we_sell: '卖的是什么(本品)',
one_liner_value: '一句话价值主张',
buyer_job_to_be_done: '购买者任务与情境',
key_pain_or_desire: '核心痛点或欲望',
why_this_product: '为何要选这一款',
proof_or_trust_angle: '信任或证明角度',
differentiation_vs_alternatives: '与替代方案的差异',
price_value_framing: '价位与价值感表述',
compliance_taboos: '表述禁区摘要',
open_points_for_business: '待业务补充',
}
function escLine(s) {
if (s == null || s === '') return '—'
return String(s).replace(/\r\n/g, '\n').trim() || '—'
}
function pushCoreCard(lines, card) {
if (!card || typeof card !== 'object') {
lines.push('(无核心信息卡数据)')
lines.push('')
return
}
for (const [en, zh] of Object.entries(CORE_LABELS)) {
lines.push(`### ${zh}`)
lines.push('')
lines.push(escLine(card[en]))
lines.push('')
}
}
function pushBullets(lines, title, arr) {
lines.push(`### ${title}`)
lines.push('')
if (!Array.isArray(arr) || !arr.length) {
lines.push('—')
lines.push('')
return
}
let any = false
for (const item of arr) {
const t = escLine(item)
if (t !== '—') {
lines.push(`- ${t}`)
any = true
}
}
if (!any) lines.push('—')
lines.push('')
}
function pushFaq(lines, faq) {
lines.push('### 买家问答')
lines.push('')
if (!Array.isArray(faq) || !faq.length) {
lines.push('—')
lines.push('')
return
}
let n = 0
for (const item of faq) {
if (!item || typeof item !== 'object') continue
const q = escLine(item.question)
const a = escLine(item.answer)
if (q === '—' && a === '—') continue
n += 1
lines.push(`#### 问 ${n}${q === '—' ? '(未提供)' : q}`)
lines.push('')
lines.push(a)
lines.push('')
}
if (n === 0) {
lines.push('—')
lines.push('')
}
}
function pushDetailPack(lines, pack) {
if (!pack || typeof pack !== 'object') {
lines.push('(无多触点营销文案数据)')
lines.push('')
return
}
lines.push('### 依据与边界')
lines.push('')
lines.push(escLine(pack.traceability_note))
lines.push('')
pushBullets(lines, '商品短标题备选', pack.listing_titles)
lines.push('### 列表副文案')
lines.push('')
lines.push(escLine(pack.listing_subtitle))
lines.push('')
lines.push('### 商品详情页首屏引导')
lines.push('')
lines.push(escLine(pack.detail_headline))
lines.push('')
pushBullets(lines, '卖点列表', pack.selling_bullets)
pushBullets(lines, '参数区旁短句', pack.spec_sidebar_lines)
pushFaq(lines, pack.faq)
pushBullets(lines, '主图三要点', pack.main_image_three_points)
lines.push('### 文生图提示词(主图)')
lines.push('')
lines.push(escLine(pack.text_to_image_prompt_main))
lines.push('')
lines.push('### 文生图提示词(场景/备选)')
lines.push('')
lines.push(escLine(pack.text_to_image_prompt_scene))
lines.push('')
lines.push('### 文生视频提示词(短视频)')
lines.push('')
lines.push(escLine(pack.text_to_video_prompt))
lines.push('')
lines.push('### 直播/短视频钩句')
lines.push('')
lines.push(escLine(pack.live_or_short_hook))
lines.push('')
lines.push('### 客服首句建议')
lines.push('')
lines.push(escLine(pack.customer_service_opening))
lines.push('')
}
/**
* @param {Record<string, unknown>} result marketing-detail-pack API JSON
* @returns {string}
*/
export function marketingPackResultToMarkdown(result) {
if (!result || typeof result !== 'object') return ''
const lines = []
const jobId = result.job_id ?? ''
const kw = result.keyword ?? ''
const genAt = result.generated_at ?? ''
const src = result.source ?? ''
lines.push('# 营销内容')
lines.push('')
lines.push(`> 任务 #${jobId} · 关键词:${kw} · ${genAt}${src ? ` · ${src}` : ''}`)
lines.push('')
lines.push('## 核心信息卡')
lines.push('')
pushCoreCard(lines, result.core_info_card)
lines.push('## 多触点文案(列表/详情页/主图等)')
lines.push('')
pushDetailPack(lines, result.detail_page_pack)
return lines.join('\n').trim() + '\n'
}

View File

@ -10,6 +10,7 @@ import {
} from '../../composables/useJobs'
import { generationInFlightKey, withGenerationInFlight } from '../../composables/useGenerationInFlight'
import { loadStrategyDraftRecord } from '../../lib/strategyDraftStorage'
import { marketingPackResultToMarkdown } from '../../lib/marketingPackMarkdown'
const route = useRoute()
const router = useRouter()
@ -23,6 +24,7 @@ const draftMeta = ref(null)
const viewMode = ref('render')
const exportErr = ref('')
const marketingErr = ref('')
const marketingExportErr = ref('')
const marketingResult = ref(null)
const exportBusy = computed(() => {
@ -37,6 +39,18 @@ const marketingBusy = computed(() => {
return genInFlight.value.includes(`marketing-detail-pack:${id}`)
})
const marketingExportBusy = computed(() => {
const id = selectedId.value
if (!id) return false
return genInFlight.value.some((k) => String(k).startsWith(`export-marketing-pack:${id}:`))
})
function isMarketingExporting(fmt) {
const id = selectedId.value
if (!id) return false
return genInFlight.value.includes(`export-marketing-pack:${id}:${fmt}`)
}
function payloadForMarketing(lastRequest) {
if (!lastRequest || typeof lastRequest !== 'object') {
return { business_notes: '', strategy_decisions: {} }
@ -61,6 +75,40 @@ const selectedJob = computed(() =>
successJobs.value.find((j) => String(j.id) === selectedId.value),
)
function pickDetailPackSubset(pack, keys) {
if (!pack || typeof pack !== 'object') return null
const o = {}
for (const k of keys) {
if (Object.prototype.hasOwnProperty.call(pack, k)) o[k] = pack[k]
}
return Object.keys(o).length ? o : null
}
/** 列表/详情页主文案(与「触点」分开展示) */
const marketingPackDetailList = computed(() =>
pickDetailPackSubset(marketingResult.value?.detail_page_pack, [
'listing_titles',
'listing_subtitle',
'detail_headline',
'selling_bullets',
'spec_sidebar_lines',
'faq',
]),
)
/** 依据、主图要点、文生图/文生视频提示词、短视频钩句、客服 */
const marketingPackTouchBlock = computed(() =>
pickDetailPackSubset(marketingResult.value?.detail_page_pack, [
'traceability_note',
'main_image_three_points',
'text_to_image_prompt_main',
'text_to_image_prompt_scene',
'text_to_video_prompt',
'live_or_short_hook',
'customer_service_opening',
]),
)
function loadDraft() {
const id = selectedId.value
if (!id) {
@ -109,9 +157,44 @@ function downloadDraftMd() {
URL.revokeObjectURL(u)
}
function downloadMarketingPackJson() {
if (!marketingResult.value || !selectedId.value) return
const blob = new Blob([JSON.stringify(marketingResult.value, null, 2)], {
type: 'application/json;charset=utf-8',
})
const u = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = u
a.download = `job_${selectedId.value}_marketing_detail_pack.json`
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(u)
}
async function exportMarketingPackFmt(fmt) {
if (!marketingResult.value || !selectedId.value) return
marketingExportErr.value = ''
const id = selectedId.value
const md = marketingPackResultToMarkdown(marketingResult.value)
if (!md.trim()) {
marketingExportErr.value = '无可导出的营销内容'
return
}
try {
await withGenerationInFlight(`export-marketing-pack:${id}:${fmt}`, async () => {
await exportStrategyDocument(id, md, fmt, 'marketing_detail')
})
} catch (e) {
marketingExportErr.value = String(e)
}
}
async function generateMarketingDetailPack() {
if (!draftMd.value || !selectedId.value) return
marketingErr.value = ''
marketingExportErr.value = ''
marketingResult.value = null
const id = selectedId.value
const { business_notes, strategy_decisions } = payloadForMarketing(
@ -211,6 +294,8 @@ watch(
)
watch(selectedId, (id) => {
marketingResult.value = null
marketingExportErr.value = ''
loadDraft()
const want = id ? String(id) : ''
if (String(route.query.job || '') !== want) {
@ -233,7 +318,7 @@ watch(successJobs, (list) => {
<section class="ma-card">
<h2>策略稿预览</h2>
<p class="hint-top">
选择在<strong>策略生成</strong>页已生成过的任务查看文稿保存在本机浏览器 <strong>localStorage</strong>同域名下可跨标签查看生成/导出/商详包等耗时操作状态在全局任务锁中同步跨标签页可看到进行中需要改决策请回到
选择在<strong>策略生成</strong>页已生成过的任务查看文稿保存在本机浏览器 <strong>localStorage</strong>同域名下可跨标签查看生成/导出/营销内容等耗时操作状态在全局任务锁中同步跨标签页可看到进行中需要改决策请回到
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
重新提交分析数据见
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>
@ -280,7 +365,7 @@ watch(successJobs, (list) => {
:disabled="!draftMd || !selectedId || marketingBusy"
@click="generateMarketingDetailPack"
>
{{ marketingBusy ? '商详包生成中…' : '生成商详包' }}
{{ marketingBusy ? '营销内容生成中…' : '生成营销内容' }}
</button>
</div>
@ -290,19 +375,58 @@ watch(successJobs, (list) => {
</p>
<p v-if="exportErr" class="ma-err">{{ exportErr }}</p>
<p v-if="marketingErr" class="ma-err">{{ marketingErr }}</p>
<p v-if="marketingExportErr" class="ma-err">{{ marketingExportErr }}</p>
<div v-if="marketingResult" class="marketing-pack-out">
<h3 class="marketing-pack-h">商详包</h3>
<h3 class="marketing-pack-h">营销内容</h3>
<p class="ma-muted marketing-pack-meta">
{{ marketingResult.generated_at }} · {{ marketingResult.source }}
</p>
<p class="ma-muted marketing-pack-disk">
服务端会将本包写入任务目录
<code>marketing/marketing_detail_pack_v1.json</code>与批次一并归档目录不可写时仅内存结果
</p>
<div class="toolbar marketing-pack-actions">
<button
type="button"
class="ma-btn ma-btn-secondary"
:disabled="!selectedId || marketingExportBusy || marketingBusy"
@click="downloadMarketingPackJson"
>
下载 JSON
</button>
<button
type="button"
class="ma-btn ma-btn-secondary"
:disabled="!selectedId || marketingExportBusy || marketingBusy"
@click="exportMarketingPackFmt('docx')"
>
{{ isMarketingExporting('docx') ? '导出中…' : '营销内容导出 Word' }}
</button>
<button
type="button"
class="ma-btn ma-btn-secondary"
:disabled="!selectedId || marketingExportBusy || marketingBusy"
@click="exportMarketingPackFmt('pdf')"
>
{{ isMarketingExporting('pdf') ? '导出中…' : '营销内容导出 PDF' }}
</button>
</div>
<details open class="marketing-details">
<summary>核心信息卡</summary>
<pre class="marketing-pre">{{ JSON.stringify(marketingResult.core_info_card, null, 2) }}</pre>
</details>
<details open class="marketing-details">
<summary>商详字段</summary>
<details v-if="marketingPackDetailList" open class="marketing-details">
<summary>列表与详情页主文案</summary>
<pre class="marketing-pre">{{ JSON.stringify(marketingPackDetailList, null, 2) }}</pre>
</details>
<details v-else-if="marketingResult.detail_page_pack" open class="marketing-details">
<summary>详情页包字段</summary>
<pre class="marketing-pre">{{ JSON.stringify(marketingResult.detail_page_pack, null, 2) }}</pre>
</details>
<details v-if="marketingPackTouchBlock" open class="marketing-details">
<summary>依据主图文生图/文生视频提示词钩句与客服</summary>
<pre class="marketing-pre">{{ JSON.stringify(marketingPackTouchBlock, null, 2) }}</pre>
</details>
</div>
<p v-if="selectedJob?.run_dir" class="run-dir-note ma-muted">
任务目录<span class="run-dir-path">{{ selectedJob.run_dir }}</span>
@ -461,6 +585,29 @@ watch(successJobs, (list) => {
margin: 0 0 0.75rem;
font-size: 0.8rem;
}
.marketing-pack-disk {
margin: 0 0 0.65rem;
font-size: 0.78rem;
line-height: 1.45;
max-width: 52rem;
}
.marketing-pack-disk code {
font-size: 0.85em;
background: #e2e8f0;
padding: 0.1em 0.35em;
border-radius: 4px;
}
.marketing-product-hint {
margin: 0.5rem 0 0;
font-size: 0.82rem;
line-height: 1.45;
max-width: 52rem;
}
.marketing-pack-actions {
margin: 0 0 0.75rem;
flex-wrap: wrap;
gap: 0.35rem;
}
.marketing-details {
margin-bottom: 0.65rem;
}