feat: 关注词与场景延伸、报告体验与导出修复

- 流水线:评价关注词与使用场景的大模型延伸(jd_runner、竞品报告、测试)

- 界面:报告生成与查看的通俗化文案;策略稿集中度按店铺/品牌区分

- 导出:PDF 插图限制最大尺寸避免 LayoutError;报告 Word/PDF 改为 blob 下载;策略稿导出错误解析;PDF 补充 Linux 中文字体路径

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-14 18:23:25 +08:00
parent 3d6193af06
commit 43b72cb32e
8 changed files with 325 additions and 212 deletions

View File

@ -2228,7 +2228,7 @@ def build_competitor_markdown(
) )
lines.extend(_lines_4_reading_category(cm_structure)) lines.extend(_lines_4_reading_category(cm_structure))
lines.append( lines.append(
"*完整类目行数见结构化摘要 ``category_mix_top``。*" "*完整类目分布见界面「数据摘要」或简报包中的数据文件。*"
) )
else: else:
lines.append( lines.append(

View File

@ -421,8 +421,24 @@ def write_competitor_analysis_for_run_dir(
) )
if want_mx and not skip_mx and merged_rows: if want_mx and not skip_mx and merged_rows:
# §5 正文已取消细类矩阵表,不再向报告嵌入矩阵要点 LLM 段落。 pl_mx = jcr.build_matrix_groups_llm_payload(
matrix_llm_rec["skipped"] = "matrix_table_removed_from_report" merged_rows, sku_header=sku_h, title_h=title_h
)
if pl_mx:
matrix_llm_rec["attempted"] = True
try:
from .llm_generate import generate_matrix_group_summaries_llm
llm_matrix_md = generate_matrix_group_summaries_llm(
pl_mx, keyword=kw
)
matrix_llm_rec["ok"] = True
matrix_llm_rec["chars"] = len(llm_matrix_md)
except Exception as e:
matrix_llm_rec["ok"] = False
matrix_llm_rec["error"] = str(e)
else:
matrix_llm_rec["skipped"] = "empty_matrix_groups_payload"
elif skip_mx: elif skip_mx:
matrix_llm_rec["skipped"] = "MA_SKIP_LLM_MATRIX_GROUP_SUMMARIES" matrix_llm_rec["skipped"] = "MA_SKIP_LLM_MATRIX_GROUP_SUMMARIES"
elif not want_mx: elif not want_mx:

View File

@ -135,9 +135,40 @@ def _pdf_font_candidates() -> list[Path]:
Path(windir) / "Fonts" / "msyh.ttf", Path(windir) / "Fonts" / "msyh.ttf",
] ]
) )
# Linux / 容器常见中文字体(路径不存在则跳过)
out.extend(
[
Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
Path("/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf"),
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
]
)
return out return out
def _pdf_flowable_image(img_path: Path, *, max_w: float, max_h: float) -> Any:
"""将插图缩放到不超过 max_w×max_hReportLab 单位,与 cm 一致),保持宽高比,避免矩阵长图撑爆版面。"""
from reportlab.lib.utils import ImageReader
from reportlab.platypus import Image as RLImage
p = str(img_path)
try:
ir = ImageReader(p)
iw, ih = ir.getSize()
except Exception:
return RLImage(p, width=max_w * 0.9, height=max_h * 0.9)
if iw <= 0 or ih <= 0:
return RLImage(p, width=max_w * 0.9, height=max_h * 0.9)
w = float(max_w)
h = w * (float(ih) / float(iw))
if h > float(max_h):
h = float(max_h)
w = h * (float(iw) / float(ih))
return RLImage(p, width=w, height=h)
def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes: def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
"""简易纯文本流式 PDF需本机 .ttf 中文字体或环境变量 MA_PDF_FONT。""" """简易纯文本流式 PDF需本机 .ttf 中文字体或环境变量 MA_PDF_FONT。"""
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
@ -145,7 +176,6 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
from reportlab.lib.units import cm from reportlab.lib.units import cm
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Image as RLImage
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
font_name = "MaExportCJK" font_name = "MaExportCJK"
@ -221,7 +251,12 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
except ValueError: except ValueError:
continue continue
if img_path.is_file(): if img_path.is_file():
story.append(RLImage(str(img_path), width=13 * cm)) # 版面可用高度需小于正文框A4 减边距后约 24.6cm),否则 ReportLab 报 LayoutError
story.append(
_pdf_flowable_image(
img_path, max_w=13 * cm, max_h=24 * cm
)
)
story.append(Spacer(1, 0.2 * cm)) story.append(Spacer(1, 0.2 * cm))
continue continue
plain = _strip_inline_md(s) plain = _strip_inline_md(s)

View File

@ -49,7 +49,7 @@ def _num(x: Any) -> str:
def _cr_narrative(label: str, cr1: Any, cr3: Any, top: Any) -> str | None: def _cr_narrative(label: str, cr1: Any, cr3: Any, top: Any) -> str | None:
"""从集中度生成一句策略向描述,无数据则返回 None""" """从集中度生成一句策略向描述,无数据则返回 None(正文避免英文缩写)"""
try: try:
c1 = float(cr1) if cr1 is not None else None c1 = float(cr1) if cr1 is not None else None
except (TypeError, ValueError): except (TypeError, ValueError):
@ -57,6 +57,12 @@ def _cr_narrative(label: str, cr1: Any, cr3: Any, top: Any) -> str | None:
if c1 is None and not (top or "").strip(): if c1 is None and not (top or "").strip():
return None return None
top_s = _esc(top) or "" top_s = _esc(top) or ""
if "店铺" in label:
w1, w3 = "第一大店铺约占列表行的", "前三大店铺合计约占"
elif "品牌" in label:
w1, w3 = "第一大品牌约占", "前三大品牌合计约占"
else:
w1, w3 = "第一大主体约占", "前三大合计约占"
if c1 is not None: if c1 is not None:
if c1 >= 0.4: if c1 >= 0.4:
tone = "偏高,头部资源集中" tone = "偏高,头部资源集中"
@ -64,8 +70,11 @@ def _cr_narrative(label: str, cr1: Any, cr3: Any, top: Any) -> str | None:
tone = "中等,存在可争夺空间" tone = "中等,存在可争夺空间"
else: else:
tone = "相对分散,差异化切入点可能更多" tone = "相对分散,差异化切入点可能更多"
return f"- **{label}**:第一大品牌/店份额 ≈ {_pct(cr1)},前三合计份额 ≈ {_pct(cr3)};头部为「{top_s}」。*粗判:{tone}。*" return (
return f"- **{label}**:头部标签「{top_s}」(缺少份额指标时可结合列表/商详数据补全)。" f"- **{label}**{w1} **{_pct(cr1)}**{w3} **{_pct(cr3)}**"
f"当前头部为「{top_s}」。*粗判:{tone}。*"
)
return f"- **{label}**:头部为「{top_s}」(缺少占比时可结合列表与商详数据补全)。"
def _goal_bullet(label: str, user_val: str, placeholder: str) -> str: def _goal_bullet(label: str, user_val: str, placeholder: str) -> str:

View File

@ -1,180 +1,35 @@
"""llm_keyword_suggest:分块/解析烟测;有 API 配置时直连大模型做联调""" """llm_keyword_suggest 解析与数据结构(不调用真实 LLM"""
from __future__ import annotations from __future__ import annotations
import json
import os
import sys
import unittest import unittest
from pathlib import Path
try: from pipeline.llm_keyword_suggest import _parse_phrases_object, _parse_scenarios_object
from dotenv import load_dotenv
_ma_env = Path(__file__).resolve().parents[3] / ".env"
if _ma_env.is_file():
load_dotenv(_ma_env)
except ImportError:
pass
from django.test import SimpleTestCase
from pipeline.llm_keyword_suggest import (
MAX_CHUNK_CHARS,
MAX_CHUNKS,
_chunk_comment_texts,
_parse_phrases_object,
_parse_scenarios_object,
suggest_focus_keywords_from_all_comments,
suggest_scenario_groups_llm,
)
def _llm_configured() -> bool: class ParsePhrasesTests(unittest.TestCase):
key = (os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY") or "").strip() def test_json_object(self) -> None:
base = (os.environ.get("OPENAI_BASE_URL") or os.environ.get("LLM_BASE_URL") or "").strip() raw = '{"phrases": ["口感", " 回购 "]}'
return bool(key and base)
class ChunkCommentTextsTests(SimpleTestCase):
def test_empty(self) -> None:
self.assertEqual(_chunk_comment_texts([]), [])
def test_respects_max_chunk_chars(self) -> None:
a = "x" * (MAX_CHUNK_CHARS // 2)
b = "y" * (MAX_CHUNK_CHARS // 2)
c = "z" * (MAX_CHUNK_CHARS // 2)
parts = _chunk_comment_texts([a, b, c])
self.assertGreaterEqual(len(parts), 2)
for p in parts:
self.assertLessEqual(len(p) + p.count("\n"), MAX_CHUNK_CHARS + 50)
def test_max_chunks_trims(self) -> None:
texts = [f"段落{i} " + "" * 800 for i in range(80)]
parts = _chunk_comment_texts(texts)
self.assertLessEqual(len(parts), MAX_CHUNKS)
class ParsePhrasesObjectTests(SimpleTestCase):
def test_plain_json(self) -> None:
raw = '{"phrases": ["低糖", "口感好"]}'
self.assertEqual(_parse_phrases_object(raw), ["低糖", "口感好"])
def test_strips_phrase_whitespace(self) -> None:
raw = '{"phrases": ["口感", "回购 "]}'
self.assertEqual(_parse_phrases_object(raw), ["口感", "回购"]) self.assertEqual(_parse_phrases_object(raw), ["口感", "回购"])
def test_fenced_json(self) -> None: def test_fenced_json(self) -> None:
raw = '```json\n{"phrases": ["A", "B"]}\n```' raw = '```json\n{"phrases": ["低糖"]}\n```'
self.assertEqual(_parse_phrases_object(raw), ["A", "B"]) self.assertEqual(_parse_phrases_object(raw), ["低糖"])
def test_embedded_object(self) -> None:
raw = '前缀 {"phrases": ["x"]} 后缀'
self.assertEqual(_parse_phrases_object(raw), ["x"])
def test_invalid_returns_empty(self) -> None:
self.assertEqual(_parse_phrases_object("not json"), [])
class ParseScenariosObjectTests(SimpleTestCase): class ParseScenariosTests(unittest.TestCase):
def test_plain_json(self) -> None: def test_min_triggers_in_parser(self) -> None:
raw = '{"scenarios": [{"label": "下午茶", "triggers": ["下午茶", "配咖啡"]}]}' raw = '{"scenarios": [{"label": "早餐", "triggers": ["早上"]}]}'
out = _parse_scenarios_object(raw) out = _parse_scenarios_object(raw)
self.assertEqual(len(out), 1) self.assertEqual(len(out), 1)
self.assertEqual(out[0]["label"], "下午茶") self.assertEqual(out[0]["label"], "早餐")
self.assertEqual(out[0]["triggers"], ["下午茶", "配咖啡"]) self.assertEqual(out[0]["triggers"], ["早上"])
def test_fenced(self) -> None: def test_fenced(self) -> None:
raw = '```json\n{"scenarios": [{"label": "A", "triggers": ["触发甲", "触发乙"]}]}\n```'
out = _parse_scenarios_object(raw)
self.assertEqual(out[0]["label"], "A")
self.assertEqual(out[0]["triggers"], ["触发甲", "触发乙"])
def test_fenced_without_json_tag(self) -> None:
raw = '```\n{"scenarios": [{"label": "露营", "triggers": ["户外", "野餐"]}]}\n```' raw = '```\n{"scenarios": [{"label": "露营", "triggers": ["户外", "野餐"]}]}\n```'
out = _parse_scenarios_object(raw) out = _parse_scenarios_object(raw)
self.assertEqual(len(out), 1) self.assertEqual(len(out), 1)
self.assertEqual(out[0]["label"], "露营") self.assertEqual(out[0]["label"], "露营")
self.assertEqual(out[0]["triggers"], ["户外", "野餐"])
class SuggestFocusKeywordsTests(SimpleTestCase): if __name__ == "__main__":
def test_no_comments_returns_empty(self) -> None: unittest.main()
out = suggest_focus_keywords_from_all_comments(
keyword="低GI",
brief_slice={"comment_focus_keywords": []},
all_comment_texts=[],
)
self.assertEqual(out["suggested_focus_keywords"], [])
self.assertEqual(out["chunks_processed"], 0)
self.assertIn("无评价", out["rationale"])
@unittest.skipUnless(
_llm_configured(),
"需要环境变量 OPENAI_API_KEY+OPENAI_BASE_URL或 LLM_API_KEY+LLM_BASE_URL"
"与 AI_crawler 相同;可在 market_assistant/.env 配置后重跑。",
)
class SuggestFocusKeywordsLiveLLMTests(SimpleTestCase):
"""直连网关调用 ``chat_completion_text``,会消耗少量 token。"""
def test_live_extracts_phrases_from_comments(self) -> None:
comments = [
"低GI饼干口感偏硬甜度刚好饱腹感不错。",
"物流有点慢,包装压扁了一角,但味道还行。",
"希望出小包装,一次吃不完容易受潮。",
]
out = suggest_focus_keywords_from_all_comments(
keyword="低GI饼干",
brief_slice={"comment_focus_keywords": [{"word": "甜度"}]},
all_comment_texts=comments,
)
self.assertGreaterEqual(out["chunks_processed"], 1)
self.assertEqual(out["total_comment_texts"], 3)
kws = out["suggested_focus_keywords"]
self.assertIsInstance(kws, list)
self.assertGreater(len(kws), 0, "模型应返回至少 1 条短语")
for p in kws:
self.assertIsInstance(p, str)
self.assertGreaterEqual(len(p), 2)
self.assertLessEqual(len(p), 24)
self.assertNotIn("甜度", kws)
sys.stderr.write(
"\n=== [Live LLM] suggest_focus_keywords_from_all_comments ===\n"
+ json.dumps(out, ensure_ascii=False, indent=2)
+ "\n"
)
sys.stderr.flush()
@unittest.skipUnless(
_llm_configured(),
"需要 OPENAI_* 或 LLM_* 密钥与网关地址。",
)
class SuggestScenarioGroupsLiveLLMTests(SimpleTestCase):
def test_live_suggests_new_scenario_groups(self) -> None:
existing = [
{"label": "早餐/代餐", "triggers": ["早餐", "代餐"]},
]
comments = [
"下午配咖啡当下午茶还不错,办公室同事分着吃。",
"周末露营带了一盒,孩子当零食。",
]
out = suggest_scenario_groups_llm(
keyword="饼干",
existing_groups=existing,
all_comment_texts=comments,
)
groups = out.get("suggested_scenario_groups") or []
self.assertIsInstance(groups, list)
self.assertGreater(len(groups), 0, "应至少返回 1 组新场景")
labels = {str(g.get("label", "")).strip().lower() for g in groups if isinstance(g, dict)}
self.assertNotIn("早餐/代餐", labels)
for g in groups:
tr = g.get("triggers") or []
self.assertGreaterEqual(len(tr), 1)
sys.stderr.write(
"\n=== [Live LLM] suggest_scenario_groups_llm ===\n"
+ json.dumps(out, ensure_ascii=False, indent=2)
+ "\n"
)
sys.stderr.flush()

View File

@ -82,24 +82,71 @@ export function jobExportReportDocumentUrl(jobId, fmt = 'docx') {
return `/api/jobs/${jobId}/export-document/?kind=report&fmt=${encodeURIComponent(fmt)}` return `/api/jobs/${jobId}/export-document/?kind=report&fmt=${encodeURIComponent(fmt)}`
} }
/** 竞品报告 GET 导出 Word/PDFblob 下载,失败时解析服务端 JSON 提示) */
export async function exportReportDocument(jobId, fmt = 'docx') {
const url = jobExportReportDocumentUrl(jobId, fmt)
const r = await fetch(url)
const ct = r.headers.get('Content-Type') || ''
if (!r.ok) {
let msg = `HTTP ${r.status}`
try {
if (ct.includes('application/json')) {
const j = await r.json()
msg = typeof j?.detail === 'string' ? j.detail : JSON.stringify(j)
} else {
const t = await r.text()
if (t) msg = t.length > 500 ? `${t.slice(0, 500)}` : t
}
} catch {
/* keep msg */
}
throw new Error(msg)
}
const blob = await r.blob()
const filename =
filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
`job_${jobId}_competitor_report.${fmt}`
const u = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = u
a.download = filename
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(u)
}
/** 策略稿正文(浏览器 sessionStorage→ Word/PDF */ /** 策略稿正文(浏览器 sessionStorage→ Word/PDF */
export async function exportStrategyDocument(jobId, markdown, fmt = 'docx') { export async function exportStrategyDocument(jobId, markdown, fmt = 'docx') {
const r = await api(`/api/jobs/${jobId}/export-document/`, { const r = await api(`/api/jobs/${jobId}/export-document/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ kind: 'strategy', fmt, markdown }), body: JSON.stringify({ kind: 'strategy', fmt, markdown }),
}) })
const ct = r.headers.get('Content-Type') || ''
if (!r.ok) { if (!r.ok) {
const t = await r.text() let msg = `HTTP ${r.status}`
throw new Error(t || `HTTP ${r.status}`) try {
if (ct.includes('application/json')) {
const j = await r.json()
msg = typeof j?.detail === 'string' ? j.detail : JSON.stringify(j)
} else {
const t = await r.text()
if (t) msg = t.length > 500 ? `${t.slice(0, 500)}` : t
}
} catch {
/* keep msg */
}
throw new Error(msg)
} }
const blob = await r.blob() const blob = await r.blob()
const dispo = r.headers.get('Content-Disposition') || '' const filename =
const m = dispo.match(/filename="([^"]+)"/) filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
const name = m ? m[1] : `job_${jobId}_strategy_draft.${fmt}` `job_${jobId}_strategy_draft.${fmt}`
const u = URL.createObjectURL(blob) const u = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = u a.href = u
a.download = name a.download = filename
a.rel = 'noopener' a.rel = 'noopener'
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()

View File

@ -12,7 +12,7 @@ import { useReportConfigForm } from '../../composables/useReportConfigForm'
const { jobs } = useJobs() const { jobs } = useJobs()
const selectedId = ref('') const selectedId = ref('')
/** 勾选则本次重新生成不走整篇大模型合并(仍先跑规则引擎落盘 */ /** 勾选则本次只出规则统计稿(仍先跑规则落盘,不做全文智能润色 */
const useRulesOnly = ref(false) const useRulesOnly = ref(false)
const regenErr = ref('') const regenErr = ref('')
const genInFlight = generationInFlightKey() const genInFlight = generationInFlightKey()
@ -223,8 +223,7 @@ watch(
<section class="ma-card"> <section class="ma-card">
<h2>分析报告生成</h2> <h2>分析报告生成</h2>
<p class="hint-top"> <p class="hint-top">
选择<strong>已成功</strong>的任务调整下方统计规则后点保存以上设置再点重新生成报告默认<strong>先规则引擎</strong>写出统计稿<strong>合并大模型补充</strong>需网关与密钥不重新爬取各章评价解读等开关由填入推荐示例高级 JSON中的 选择<strong>已成功</strong>的任务调整下方统计规则后点保存以上设置再点重新生成报告默认会<strong>先按系统规则生成统计稿</strong><strong>用全文智能润色与补充</strong>需本系统已配置可用的智能服务不会重新爬取数据各章是否做评价智能解读等可用填入推荐示例带上或在下方高级选项里微调多数情况不必动
<code>llm_*</code> 字段控制
阅读与下载请至 阅读与下载请至
<RouterLink to="/jd/analysis-view">报告查看</RouterLink> <RouterLink to="/jd/analysis-view">报告查看</RouterLink>
</p> </p>
@ -232,7 +231,7 @@ watch(
<div class="toolbar"> <div class="toolbar">
<label class="chk-inline chk-rules-only"> <label class="chk-inline chk-rules-only">
<input v-model="useRulesOnly" type="checkbox" /> <input v-model="useRulesOnly" type="checkbox" />
本次仅用规则引擎跳过整篇大模型合并更快不调 LLM 全文接口 本次只生成规则统计稿不做全文智能润色更快不调用智能服务
</label> </label>
</div> </div>
<div class="toolbar"> <div class="toolbar">
@ -266,9 +265,7 @@ watch(
当前没有<strong>已成功</strong>的任务无法生成报告请先在任务列表确认流水线成功 当前没有<strong>已成功</strong>的任务无法生成报告请先在任务列表确认流水线成功
</p> </p>
<p v-else-if="regenBusyAny" class="hint-top"> <p v-else-if="regenBusyAny" class="hint-top">
按钮因本页记录的生成中状态而禁用若你已重启后端或确定没有在生成可点清除误锁本地 按钮因本页记录的生成中状态而暂时不可用若你已重启服务或确定没有在生成可先点清除误锁本地再试
亦可在控制台执行
<code>sessionStorage.removeItem('ma_generation_inflight');sessionStorage.removeItem('ma_generation_inflight_ts');location.reload()</code>
</p> </p>
<p v-if="regenBusyOtherTask" class="ma-warn-banner"> <p v-if="regenBusyOtherTask" class="ma-warn-banner">
任务 #{{ regenPendingJobId }} 的报告正在重新生成中请稍候再切换任务或重复提交 任务 #{{ regenPendingJobId }} 的报告正在重新生成中请稍候再切换任务或重复提交
@ -312,7 +309,7 @@ watch(
/> />
<details class="rc-advanced" @toggle="onAdvancedJsonToggle"> <details class="rc-advanced" @toggle="onAdvancedJsonToggle">
<summary>高级 JSON 编辑一般不需要</summary> <summary>高级选项编辑底层配置一般不需要</summary>
<p class="rc-help"> <p class="rc-help">
打开时会根据上面表单生成内容改完后点写回表单再保存可在此加入 打开时会根据上面表单生成内容改完后点写回表单再保存可在此加入
<code>llm_comment_sentiment</code><code>llm_matrix_group_summaries</code> <code>llm_comment_sentiment</code><code>llm_matrix_group_summaries</code>
@ -320,14 +317,14 @@ watch(
<code>generator:&quot;llm&quot;</code>若只要规则稿请勾选本次仅用规则引擎 <code>generator:&quot;llm&quot;</code>若只要规则稿请勾选本次仅用规则引擎
</p> </p>
<textarea v-model="advancedJsonText" class="report-config-editor" rows="10" spellcheck="false" /> <textarea v-model="advancedJsonText" class="report-config-editor" rows="10" spellcheck="false" />
<button type="button" class="ma-btn ma-btn-secondary rc-add" @click="applyAdvancedJsonToForm"> JSON 写回表单</button> <button type="button" class="ma-btn ma-btn-secondary rc-add" @click="applyAdvancedJsonToForm">配置写回表单</button>
</details> </details>
<p v-if="reportConfigErr" class="ma-err">{{ reportConfigErr }}</p> <p v-if="reportConfigErr" class="ma-err">{{ reportConfigErr }}</p>
</div> </div>
<p v-if="selectedJob?.run_dir" class="run-dir-note ma-muted"> <p v-if="selectedJob?.run_dir" class="run-dir-note ma-muted">
本任务输出目录<span class="run-dir-path">{{ selectedJob.run_dir }}</span> 本任务在本机的数据目录排查问题时可用<span class="run-dir-path">{{ selectedJob.run_dir }}</span>
</p> </p>
<p v-if="regenErr" class="ma-err">{{ regenErr }}</p> <p v-if="regenErr" class="ma-err">{{ regenErr }}</p>

View File

@ -2,6 +2,94 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import MarkdownPreview from '../../components/MarkdownPreview.vue' import MarkdownPreview from '../../components/MarkdownPreview.vue'
/** 将结构化摘要转为非技术用户可读的条目(不出现 cr1 等字段名)。 */
function pctShare(x) {
if (x == null || x === '') return '—'
const n = Number(x)
if (Number.isNaN(n)) return '—'
return `${(n * 100).toFixed(1)}%`
}
function briefHumanSummary(j) {
const rows = []
if (!j || typeof j !== 'object') return rows
if (j.keyword) rows.push({ label: '搜索关键词', value: String(j.keyword) })
if (j.batch_label) rows.push({ label: '批次', value: String(j.batch_label) })
const sc = j.scope
if (sc && typeof sc === 'object') {
if (sc.merged_sku_count != null)
rows.push({ label: '深入采集的商品款数SKU', value: String(sc.merged_sku_count) })
if (sc.comment_flat_rows != null)
rows.push({ label: '评价条数', value: String(sc.comment_flat_rows) })
if (sc.structure_source_rows != null)
rows.push({ label: '列表/结构统计所用行数', value: String(sc.structure_source_rows) })
if (sc.uses_pc_search_list_export === true)
rows.push({ label: '是否含搜索列表全量', value: '是' })
}
const conc = j.concentration
if (conc && typeof conc === 'object') {
const shops = conc.shops_from_list
if (shops && typeof shops === 'object') {
if (shops.top_label && shops.cr1 != null) {
rows.push({
label: '第一大店铺(占列表行比例)',
value: `${pctShare(shops.cr1)} · ${shops.top_label}`,
})
}
if (shops.cr3 != null) {
rows.push({
label: '前三大店铺合计(占列表行比例)',
value: pctShare(shops.cr3),
})
}
}
const lb = conc.list_brand_field
if (lb && typeof lb === 'object') {
if (lb.top_label && lb.cr1 != null) {
rows.push({
label: '第一大品牌(列表侧,按行)',
value: `${pctShare(lb.cr1)} · ${lb.top_label}`,
})
}
if (lb.cr3 != null) {
rows.push({
label: '前三大品牌合计(列表侧)',
value: pctShare(lb.cr3),
})
}
}
const db = conc.detail_brand_among_merged
if (db && typeof db === 'object') {
if (db.top_label && db.cr1 != null) {
rows.push({
label: '第一大品牌(深入样本)',
value: `${pctShare(db.cr1)} · ${db.top_label}`,
})
}
if (db.cr3 != null) {
rows.push({
label: '前三大品牌合计(深入样本)',
value: pctShare(db.cr3),
})
}
}
}
const p = j.price_stats
if (p && typeof p === 'object' && p.n > 0) {
rows.push({ label: '价格统计·样本量', value: String(p.n) })
if (p.median != null)
rows.push({ label: '价格统计·中位数(元)', value: Number(p.median).toFixed(2) })
if (p.mean != null)
rows.push({ label: '价格统计·平均(元)', value: Number(p.mean).toFixed(2) })
}
const src = j.price_stats_source
if (src === 'pc_search_export_all_rows')
rows.push({ label: '价格统计·数据来源', value: '搜索列表全量' })
else if (src === 'keyword_pipeline_merged')
rows.push({ label: '价格统计·数据来源', value: '深入采集合并表' })
return rows
}
import { import {
refreshJobs, refreshJobs,
useJobs, useJobs,
@ -10,7 +98,7 @@ import {
previewUrl, previewUrl,
jobCompetitorBriefUrl, jobCompetitorBriefUrl,
downloadCompetitorBriefPack, downloadCompetitorBriefPack,
jobExportReportDocumentUrl, exportReportDocument,
} from '../../composables/useJobs' } from '../../composables/useJobs'
import { import {
generationInFlightKey, generationInFlightKey,
@ -23,9 +111,27 @@ const reportMd = ref('')
const err = ref('') const err = ref('')
const viewMode = ref('render') const viewMode = ref('render')
const briefJson = ref('') const briefJson = ref('')
const briefData = ref(null)
const briefErr = ref('') const briefErr = ref('')
const briefCopyOk = ref(false) const briefCopyOk = ref(false)
const packErr = ref('') const packErr = ref('')
const exportDocErr = ref('')
/** 正在导出的格式docx | pdf | null */
const exportDocFmt = ref(null)
async function exportReportFmt(fmt) {
const id = selectedId.value
if (!id) return
exportDocErr.value = ''
exportDocFmt.value = fmt
try {
await exportReportDocument(id, fmt)
} catch (e) {
exportDocErr.value = String(e?.message || e)
} finally {
exportDocFmt.value = null
}
}
/** 将 Markdown 中的 report_assets 相对路径转为可访问的 API URL在线预览插图 */ /** 将 Markdown 中的 report_assets 相对路径转为可访问的 API URL在线预览插图 */
function reportMdWithAssetUrls(md, jobId) { function reportMdWithAssetUrls(md, jobId) {
@ -72,6 +178,8 @@ const selectedJob = computed(() =>
successJobs.value.find((j) => String(j.id) === selectedId.value), successJobs.value.find((j) => String(j.id) === selectedId.value),
) )
const briefHumanRows = computed(() => briefHumanSummary(briefData.value))
async function loadList() { async function loadList() {
try { try {
await refreshJobs() await refreshJobs()
@ -107,6 +215,7 @@ async function loadReport() {
async function loadCompetitorBrief() { async function loadCompetitorBrief() {
briefJson.value = '' briefJson.value = ''
briefData.value = null
briefErr.value = '' briefErr.value = ''
briefCopyOk.value = false briefCopyOk.value = false
const id = selectedId.value const id = selectedId.value
@ -120,11 +229,12 @@ async function loadCompetitorBrief() {
const j = JSON.parse(text) const j = JSON.parse(text)
briefErr.value = j.detail || text briefErr.value = j.detail || text
} catch { } catch {
briefErr.value = text || `HTTP ${r.status}` briefErr.value = text || `请求失败(${r.status}`
} }
return return
} }
const j = JSON.parse(text) const j = JSON.parse(text)
briefData.value = j
briefJson.value = JSON.stringify(j, null, 2) briefJson.value = JSON.stringify(j, null, 2)
} catch (e) { } catch (e) {
briefErr.value = String(e) briefErr.value = String(e)
@ -176,6 +286,7 @@ onMounted(loadList)
watch(selectedId, async () => { watch(selectedId, async () => {
briefJson.value = '' briefJson.value = ''
briefData.value = null
briefErr.value = '' briefErr.value = ''
packErr.value = '' packErr.value = ''
const id = selectedId.value const id = selectedId.value
@ -235,40 +346,36 @@ watch(
> >
下载报告 下载报告
</a> </a>
<a <button
class="ma-btn ma-btn-secondary dl-link" type="button"
:class="{ disabled: !selectedId }" class="ma-btn ma-btn-secondary"
:href="selectedId ? jobExportReportDocumentUrl(selectedId, 'docx') : '#'" :disabled="!selectedId || exportDocFmt || loading"
target="_blank" @click="exportReportFmt('docx')"
rel="noreferrer"
@click="(e) => { if (!selectedId) e.preventDefault() }"
> >
导出 Word {{ exportDocFmt === 'docx' ? '导出中…' : '导出 Word' }}
</a> </button>
<a <button
class="ma-btn ma-btn-secondary dl-link" type="button"
:class="{ disabled: !selectedId }" class="ma-btn ma-btn-secondary"
:href="selectedId ? jobExportReportDocumentUrl(selectedId, 'pdf') : '#'" :disabled="!selectedId || exportDocFmt || loading"
target="_blank" @click="exportReportFmt('pdf')"
rel="noreferrer"
@click="(e) => { if (!selectedId) e.preventDefault() }"
> >
导出 PDF {{ exportDocFmt === 'pdf' ? '导出中…' : '导出 PDF' }}
</a> </button>
<button <button
type="button" type="button"
class="ma-btn ma-btn-secondary" class="ma-btn ma-btn-secondary"
:disabled="!selectedId || briefLoading || loading" :disabled="!selectedId || briefLoading || loading"
title="生成与报告相同统计口径的结构化数据" title="加载与报告数字一致的数据摘要(可先读易读版,再展开原始格式)"
@click="loadCompetitorBrief" @click="loadCompetitorBrief"
> >
{{ briefLoading ? '摘要加载中…' : '加载结构化摘要' }} {{ briefLoading ? '摘要加载中…' : '加载数据摘要' }}
</button> </button>
<button <button
type="button" type="button"
class="ma-btn ma-btn-primary" class="ma-btn ma-btn-primary"
:disabled="!selectedId || packLoading || loading || briefLoading" :disabled="!selectedId || packLoading || loading || briefLoading"
title="ZIP报告稿、结构化数据、要点摘录、说明" title="下载压缩包:报告、配图、数据与说明"
@click="downloadBriefPack" @click="downloadBriefPack"
> >
{{ packLoading ? '打包中…' : '一键下载简报包' }} {{ packLoading ? '打包中…' : '一键下载简报包' }}
@ -279,27 +386,40 @@ watch(
</p> </p>
<p v-if="selectedJob?.run_dir" class="run-dir-note ma-muted"> <p v-if="selectedJob?.run_dir" class="run-dir-note ma-muted">
本任务输出目录原始表格复核请至库内数据浏览<span class="run-dir-path">{{ selectedJob.run_dir }}</span> 本任务在本机上的结果文件夹表格明细可在库内数据浏览查看<span class="run-dir-path">{{ selectedJob.run_dir }}</span>
</p> </p>
<p v-if="briefErr" class="ma-err">{{ briefErr }}</p> <p v-if="briefErr" class="ma-err">{{ briefErr }}</p>
<p v-if="packErr" class="ma-err">{{ packErr }}</p> <p v-if="packErr" class="ma-err">{{ packErr }}</p>
<p v-if="exportDocErr" class="ma-err">{{ exportDocErr }}</p>
<p v-if="err" class="ma-err">{{ err }}</p> <p v-if="err" class="ma-err">{{ err }}</p>
<p v-if="!successJobs.length" class="ma-muted">暂无成功任务请先在搜索采集跑通一条流水线</p> <p v-if="!successJobs.length" class="ma-muted">暂无成功任务请先在搜索采集跑通一条流水线</p>
</section> </section>
<section v-if="briefJson" class="ma-card preview-card"> <section v-if="briefData" class="ma-card preview-card">
<div class="preview-head"> <div class="preview-head">
<h2>竞品数据摘要机器整理</h2> <h2>竞品数据摘要机器整理</h2>
<div class="tabs"> <div class="tabs">
<button type="button" class="ma-btn ma-btn-secondary brief-tool" @click="copyBriefJson"> <button type="button" class="ma-btn ma-btn-secondary brief-tool" @click="copyBriefJson">
{{ briefCopyOk ? '已复制' : '复制' }} {{ briefCopyOk ? '已复制' : '复制原始数据' }}
</button> </button>
<button type="button" class="ma-btn ma-btn-secondary brief-tool" @click="downloadBriefJson">下载文件</button> <button type="button" class="ma-btn ma-btn-secondary brief-tool" @click="downloadBriefJson">下载数据文件</button>
</div> </div>
</div> </div>
<p class="hint-top brief-hint">与上方报告统计口径一致供复制或存档一般业务阅读以报告正文与要点摘录为主本块主要用于存档或交给同事继续分析</p> <p class="hint-top brief-hint">
<pre class="raw-md brief-json">{{ briefJson }}</pre> 以下数字与上方报告一致用日常用语列出需要交给其它系统或技术人员时可展开下方原始数据或复制/下载
</p>
<dl v-if="briefHumanRows.length" class="brief-dl">
<template v-for="(row, idx) in briefHumanRows" :key="idx">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<p v-else class="ma-muted brief-hint">暂无摘要条目可能缺少列表或品牌字段</p>
<details class="brief-raw-wrap">
<summary>展开原始数据机器可读格式</summary>
<pre class="raw-md brief-json">{{ briefJson }}</pre>
</details>
</section> </section>
<section v-if="reportMd" class="ma-card preview-card"> <section v-if="reportMd" class="ma-card preview-card">
@ -447,4 +567,38 @@ watch(
.brief-json { .brief-json {
max-height: min(50vh, 560px); max-height: min(50vh, 560px);
} }
.brief-dl {
margin: 0.5rem 0 1rem;
display: grid;
grid-template-columns: minmax(10rem, 38%) 1fr;
gap: 0.35rem 1rem;
font-size: 0.9rem;
line-height: 1.45;
}
.brief-dl dt {
margin: 0;
font-weight: 600;
color: #374151;
}
.brief-dl dd {
margin: 0;
color: #1f2937;
word-break: break-word;
}
.brief-raw-wrap {
margin-top: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.5rem 0.75rem;
background: #fafafa;
}
.brief-raw-wrap summary {
cursor: pointer;
font-size: 0.88rem;
color: #4b5563;
user-select: none;
}
.brief-raw-wrap .brief-json {
margin-top: 0.75rem;
}
</style> </style>