From 6470d0d56cca16a840fb83e2d1cb469dc22bb452 Mon Sep 17 00:00:00 2001 From: hub-gif <2487812171@qq.com> Date: Wed, 22 Apr 2026 15:35:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(marketing):=20=E8=90=A5=E9=94=80=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=91=BD=E5=90=8D=E3=80=81=E6=96=87=E7=94=9F=E5=9B=BE?= =?UTF-8?q?/=E8=A7=86=E9=A2=91=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=B8=8E?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E8=A7=84=E8=8C=83=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两步 LLM 增加可复制文生图/文生视频提示词并强化卖点可视化;normalize_detail_page_pack 补全键;产品用语由商详包改为营销内容;前端导出 Markdown 与策略预览区展示一致;营销包落盘模块入库。 Made-with: Cursor --- .../pipeline/llm/generate_marketing_detail.py | 87 ++++++++-- .../reporting/marketing_pack_persist.py | 23 +++ backend/pipeline/serializers.py | 2 +- .../tests/test_marketing_detail_pack.py | 41 ++++- backend/pipeline/views/job_report_views.py | 23 ++- frontend/src/composables/useJobs.js | 73 ++------ frontend/src/lib/marketingPackMarkdown.js | 150 +++++++++++++++++ frontend/src/views/jd/JdStrategyView.vue | 157 +++++++++++++++++- 8 files changed, 475 insertions(+), 81 deletions(-) create mode 100644 backend/pipeline/reporting/marketing_pack_persist.py create mode 100644 frontend/src/lib/marketingPackMarkdown.js diff --git a/backend/pipeline/llm/generate_marketing_detail.py b/backend/pipeline/llm/generate_marketing_detail.py index ca8e9b8..851664f 100644 --- a/backend/pipeline/llm/generate_marketing_detail.py +++ b/backend/pipeline/llm/generate_marketing_detail.py @@ -1,4 +1,4 @@ -"""策略稿 → 核心信息卡 → 商详文案包(两步 LLM,JSON 输出)。""" +"""策略稿 → 核心信息卡 → 营销内容多触点文案(两步 LLM,JSON 输出)。""" 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:**卖的是什么**(必填,建议 25~80 字)。写清**品类 + 主推形态/规格或适用场景**,让读者**不读策略稿**也能回答「你们在卖哪种货」。**仅可**综合策略稿、`strategy_decisions`(尤其 **pillar_product**、battlefield_one_line、audience_segment、marketing_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_titles、listing_subtitle、detail_headline、selling_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** 中提炼 **1~3 条可画出来的卖点**,写入 ``text_to_image_prompt_main``;**场景图** ``text_to_image_prompt_scene`` 非空时须保留**至少 1 处**同款质地或品类辨识(非空场景图时)。 + - **禁止**整段只有「白底」「居中」「电商主图」「健康食品」等空壳,而**不出现具体货态**(形态、切片、包装类型、手持/摆放方式至少择一)。 + - **口感/质地类**(如松软、酥脆、绵密、有嚼劲):**必须**写成**可见结构**,不能只写一次形容词了事。例:**松软**→「吐司切片横截面气孔细腻、边缘微翘显蓬松」「轻按后缓慢回弹」「手撕开可见柔软内里」;**酥脆**→「饼干断面层次清晰、碎屑自然」。信息卡未提质地则**不写**,勿编造。 + - **配料/品类视觉**(如全麦):可写「麸皮颗粒隐约可见」「浅褐全麦外皮」等,**禁止**疗效字幕、血糖仪、前后对比治病画面。 **JSON 键(须全部出现)**: -- listing_titles:字符串数组,4~6 条商品短标题备选(每条约 30 字内) +- listing_titles:字符串数组,4~6 条商品短标题备选(每条约 30 字内;**每条须含可识别品类或品名线索**,禁止 6 条全是「安心之选」类) - listing_subtitle:一条列表副文案(约 60 字内) -- detail_headline:商详首屏下 lead,1~2 句 +- detail_headline:商品详情页首屏下 lead,1~2 句(**首句须点明卖的是什么货**,再写价值) - selling_bullets:字符串数组,5~8 条卖点(每条约 40 字内) - spec_sidebar_lines:字符串数组,0~3 条参数区旁短句(可空数组) - faq:对象数组,每项含 question、answer 字符串,3~5 组;答句不得超出信息卡承诺 +- traceability_note:**依据与边界**(必填,2~4 句)。用业务可读中文说明:本包与信息卡中**哪些承诺方向一致**、**哪些表述须业务或法务核对**、**输入未体现的不得对外宣称**;**禁止**新数字、新功效、新认证。 +- main_image_three_points:字符串数组,**恰好 3 条**,主图/首图用超短句(每条建议 6~14 字);须与 **what_we_sell** 品类一致,可来自卖点压缩,禁止空泛口号 +- live_or_short_hook:一条直播或短视频开场钩句(≤40 字);同一事实约束 +- customer_service_opening:一条客服首句/欢迎语建议(≤50 字);同一事实约束 +- text_to_image_prompt_main:字符串,**主图/首图**文生图提示词(建议 **100~260** 字)。**必须**依次包含:① **具体货态**(与 **what_we_sell** 一致的品类+形态,如全麦吐司切片摞放、独立小包饼干);② **至少一条质地/卖点的视觉化描写**(与信息卡一致,参见上文「松软→截面/按压/手撕」等);③ **构图与背景**(如白底居中、轻微投影);④ **光影**(柔和棚拍、写实);⑤ **规避**(无 Logo、无疗效字、无竞品名)。**英文模型**可关键风格词括注英文。 +- text_to_image_prompt_scene:字符串,**场景/生活方式**备选图(建议 **80~200** 字):早餐桌、手持、厨房台面等;**须含**与主图**同一品类**的清晰货态,并**至少一处**质地或食用情境(如蒸汽、刀切截面、蘸牛奶)。与主图完全重复则宁可缩短但保留情境差分。无合适场景时 ``""``。 +- text_to_video_prompt:字符串,文生视频提示词(建议 **100~260** 字),竖屏 9:16、**5~15 秒**。**须**含 **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( diff --git a/backend/pipeline/reporting/marketing_pack_persist.py b/backend/pipeline/reporting/marketing_pack_persist.py new file mode 100644 index 0000000..f68fc5d --- /dev/null +++ b/backend/pipeline/reporting/marketing_pack_persist.py @@ -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_version、job_id、core_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 diff --git a/backend/pipeline/serializers.py b/backend/pipeline/serializers.py index 5c09fae..52f2117 100644 --- a/backend/pipeline/serializers.py +++ b/backend/pipeline/serializers.py @@ -476,7 +476,7 @@ class StrategyDraftRequestSerializer(serializers.Serializer): class MarketingDetailPackRequestSerializer(serializers.Serializer): - """策略稿正文 → 核心信息卡 → 商详包(两步 LLM)。""" + """策略稿正文 → 核心信息卡 → 营销内容(多触点文案,两步 LLM)。""" strategy_markdown = serializers.CharField( required=True, diff --git a/backend/pipeline/tests/test_marketing_detail_pack.py b/backend/pipeline/tests/test_marketing_detail_pack.py index 1431533..563aed4 100644 --- a/backend/pipeline/tests/test_marketing_detail_pack.py +++ b/backend/pipeline/tests/test_marketing_detail_pack.py @@ -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:16,5秒:从俯拍早餐桌缓慢推近至饼干包装,无对白,干净色调,无疗效字幕", } 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) diff --git a/backend/pipeline/views/job_report_views.py b/backend/pipeline/views/job_report_views.py index 2009f7e..8dd19f1 100644 --- a/backend/pipeline/views/job_report_views.py +++ b/backend/pipeline/views/job_report_views.py @@ -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) diff --git a/frontend/src/composables/useJobs.js b/frontend/src/composables/useJobs.js index 4e69a39..d1bf6c4 100644 --- a/frontend/src/composables/useJobs.js +++ b/frontend/src/composables/useJobs.js @@ -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, diff --git a/frontend/src/lib/marketingPackMarkdown.js b/frontend/src/lib/marketingPackMarkdown.js new file mode 100644 index 0000000..b93dc18 --- /dev/null +++ b/frontend/src/lib/marketingPackMarkdown.js @@ -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} 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' +} diff --git a/frontend/src/views/jd/JdStrategyView.vue b/frontend/src/views/jd/JdStrategyView.vue index 9426488..c03745d 100644 --- a/frontend/src/views/jd/JdStrategyView.vue +++ b/frontend/src/views/jd/JdStrategyView.vue @@ -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) => {

策略稿预览

- 选择在策略生成页已生成过的任务查看文稿(保存在本机浏览器 localStorage,同域名下可跨标签查看)。生成/导出/商详包等耗时操作状态在全局任务锁中同步,跨标签页可看到进行中。需要改决策请回到 + 选择在策略生成页已生成过的任务查看文稿(保存在本机浏览器 localStorage,同域名下可跨标签查看)。生成/导出/营销内容等耗时操作状态在全局任务锁中同步,跨标签页可看到进行中。需要改决策请回到 策略生成 重新提交。分析数据见 报告查看。 @@ -280,7 +365,7 @@ watch(successJobs, (list) => { :disabled="!draftMd || !selectedId || marketingBusy" @click="generateMarketingDetailPack" > - {{ marketingBusy ? '商详包生成中…' : '生成商详包' }} + {{ marketingBusy ? '营销内容生成中…' : '生成营销内容' }} @@ -290,19 +375,58 @@ watch(successJobs, (list) => {

{{ exportErr }}

{{ marketingErr }}

+

{{ marketingExportErr }}

-

商详包

+

营销内容

{{ marketingResult.generated_at }} · {{ marketingResult.source }}

+

+ 服务端会将本包写入任务目录 + marketing/marketing_detail_pack_v1.json(与批次一并归档;目录不可写时仅内存结果)。 +

+
+ + + +
核心信息卡
{{ JSON.stringify(marketingResult.core_info_card, null, 2) }}
-
- 商详字段 +
+ 列表与详情页主文案 +
{{ JSON.stringify(marketingPackDetailList, null, 2) }}
+
+
+ 详情页包字段
{{ JSON.stringify(marketingResult.detail_page_pack, null, 2) }}
+
+ 依据、主图、文生图/文生视频提示词、钩句与客服 +
{{ JSON.stringify(marketingPackTouchBlock, null, 2) }}
+

任务目录:{{ selectedJob.run_dir }} @@ -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; }