|
|
|
|
@ -60,7 +60,7 @@ export async function generateSmartArtifact({ |
|
|
|
|
throw new Error("代码模型没有返回完整HTML") |
|
|
|
|
} |
|
|
|
|
if (isDataDumpArtifactHtml(html)) { |
|
|
|
|
throw new Error("专题页面仍为原始数据表,已切换报告模板") |
|
|
|
|
throw new Error("专题页面仍为原始数据表,已拒绝展示,请重新生成") |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return { |
|
|
|
|
@ -69,670 +69,6 @@ export async function generateSmartArtifact({ |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
export function buildLocalSmartArtifact({ |
|
|
|
|
question, |
|
|
|
|
answer = "", |
|
|
|
|
charts = [], |
|
|
|
|
datasets = [], |
|
|
|
|
context = null, |
|
|
|
|
reason = "", |
|
|
|
|
} = {}) { |
|
|
|
|
const data = JSON.parse(buildArtifactDataJson({ question, answer, charts, datasets, context })) |
|
|
|
|
const report = buildLocalReport(data, reason) |
|
|
|
|
|
|
|
|
|
return `<!doctype html>
|
|
|
|
|
<html lang="zh-CN"> |
|
|
|
|
<head> |
|
|
|
|
<meta charset="utf-8" /> |
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
|
|
|
<title>${escapeHtml(report.title)}</title> |
|
|
|
|
<style> |
|
|
|
|
* { box-sizing: border-box; } |
|
|
|
|
body { |
|
|
|
|
margin: 0; |
|
|
|
|
min-height: 100vh; |
|
|
|
|
color: #18313b; |
|
|
|
|
background: #f3f7fa; |
|
|
|
|
font-family: "Microsoft YaHei", Arial, sans-serif; |
|
|
|
|
} |
|
|
|
|
.page { |
|
|
|
|
min-height: 100vh; |
|
|
|
|
padding: 26px 30px 34px; |
|
|
|
|
background: |
|
|
|
|
linear-gradient(90deg, rgba(255,255,255,0.96), rgba(244,248,250,0.9)), |
|
|
|
|
radial-gradient(circle at 84% 16%, rgba(32, 124, 138, 0.12), transparent 34%), |
|
|
|
|
#f3f7fa; |
|
|
|
|
} |
|
|
|
|
.report-header { |
|
|
|
|
display: flex; |
|
|
|
|
align-items: flex-end; |
|
|
|
|
justify-content: space-between; |
|
|
|
|
gap: 18px; |
|
|
|
|
padding-bottom: 16px; |
|
|
|
|
margin-bottom: 22px; |
|
|
|
|
border-bottom: 2px solid #244956; |
|
|
|
|
} |
|
|
|
|
.kicker { |
|
|
|
|
margin: 0 0 6px; |
|
|
|
|
color: #307181; |
|
|
|
|
font-size: 13px; |
|
|
|
|
font-weight: 700; |
|
|
|
|
} |
|
|
|
|
h1 { |
|
|
|
|
margin: 0; |
|
|
|
|
color: #0f3340; |
|
|
|
|
font-size: 30px; |
|
|
|
|
line-height: 1.28; |
|
|
|
|
font-weight: 800; |
|
|
|
|
letter-spacing: 0; |
|
|
|
|
} |
|
|
|
|
.subtitle { |
|
|
|
|
margin: 8px 0 0; |
|
|
|
|
color: #54717b; |
|
|
|
|
font-size: 14px; |
|
|
|
|
} |
|
|
|
|
.source { |
|
|
|
|
flex: 0 0 auto; |
|
|
|
|
padding: 8px 12px; |
|
|
|
|
color: #5c747d; |
|
|
|
|
background: #e8eef1; |
|
|
|
|
border-radius: 999px; |
|
|
|
|
font-size: 12px; |
|
|
|
|
} |
|
|
|
|
.section-title { |
|
|
|
|
display: flex; |
|
|
|
|
align-items: center; |
|
|
|
|
gap: 8px; |
|
|
|
|
margin: 0 0 12px; |
|
|
|
|
color: #153946; |
|
|
|
|
font-size: 18px; |
|
|
|
|
font-weight: 800; |
|
|
|
|
} |
|
|
|
|
.section-title::before { |
|
|
|
|
content: ""; |
|
|
|
|
width: 9px; |
|
|
|
|
height: 9px; |
|
|
|
|
border-radius: 50%; |
|
|
|
|
background: #1b7f8d; |
|
|
|
|
box-shadow: 0 0 0 4px rgba(27, 127, 141, 0.12); |
|
|
|
|
} |
|
|
|
|
.grid { |
|
|
|
|
display: grid; |
|
|
|
|
grid-template-columns: repeat(12, minmax(0, 1fr)); |
|
|
|
|
gap: 14px; |
|
|
|
|
margin-bottom: 18px; |
|
|
|
|
} |
|
|
|
|
.card { |
|
|
|
|
min-width: 0; |
|
|
|
|
padding: 18px; |
|
|
|
|
border: 1px solid #e4ecef; |
|
|
|
|
border-radius: 6px; |
|
|
|
|
background: rgba(255,255,255,0.9); |
|
|
|
|
box-shadow: 0 8px 22px rgba(25, 64, 77, 0.08); |
|
|
|
|
} |
|
|
|
|
.span-12 { grid-column: span 12; } |
|
|
|
|
.span-7 { grid-column: span 7; } |
|
|
|
|
.span-5 { grid-column: span 5; } |
|
|
|
|
.span-6 { grid-column: span 6; } |
|
|
|
|
.summary { |
|
|
|
|
color: #294853; |
|
|
|
|
font-size: 14px; |
|
|
|
|
line-height: 1.9; |
|
|
|
|
} |
|
|
|
|
.summary p { |
|
|
|
|
margin: 0 0 8px; |
|
|
|
|
} |
|
|
|
|
.metric-grid { |
|
|
|
|
display: grid; |
|
|
|
|
grid-template-columns: repeat(4, minmax(0, 1fr)); |
|
|
|
|
gap: 12px; |
|
|
|
|
margin-bottom: 18px; |
|
|
|
|
} |
|
|
|
|
.metric-card { |
|
|
|
|
min-width: 0; |
|
|
|
|
padding: 18px 16px; |
|
|
|
|
border: 1px solid #e6edf0; |
|
|
|
|
border-radius: 6px; |
|
|
|
|
background: #fff; |
|
|
|
|
box-shadow: 0 8px 22px rgba(25, 64, 77, 0.08); |
|
|
|
|
} |
|
|
|
|
.metric-card span, |
|
|
|
|
.metric-card small { |
|
|
|
|
display: block; |
|
|
|
|
overflow: hidden; |
|
|
|
|
text-overflow: ellipsis; |
|
|
|
|
white-space: nowrap; |
|
|
|
|
color: #5f7780; |
|
|
|
|
font-size: 13px; |
|
|
|
|
} |
|
|
|
|
.metric-card strong { |
|
|
|
|
display: block; |
|
|
|
|
margin: 8px 0 4px; |
|
|
|
|
overflow: hidden; |
|
|
|
|
text-overflow: ellipsis; |
|
|
|
|
white-space: nowrap; |
|
|
|
|
color: #123542; |
|
|
|
|
font-size: 26px; |
|
|
|
|
line-height: 1.25; |
|
|
|
|
} |
|
|
|
|
.metric-card strong em { |
|
|
|
|
margin-left: 3px; |
|
|
|
|
color: #516c75; |
|
|
|
|
font-size: 13px; |
|
|
|
|
font-style: normal; |
|
|
|
|
font-weight: 700; |
|
|
|
|
} |
|
|
|
|
.bar-list { |
|
|
|
|
display: grid; |
|
|
|
|
gap: 10px; |
|
|
|
|
} |
|
|
|
|
.bar-row { |
|
|
|
|
display: grid; |
|
|
|
|
grid-template-columns: minmax(92px, 150px) 1fr minmax(86px, auto); |
|
|
|
|
align-items: center; |
|
|
|
|
gap: 12px; |
|
|
|
|
min-width: 0; |
|
|
|
|
color: #385964; |
|
|
|
|
font-size: 13px; |
|
|
|
|
} |
|
|
|
|
.bar-row span, |
|
|
|
|
.bar-row em { |
|
|
|
|
overflow: hidden; |
|
|
|
|
text-overflow: ellipsis; |
|
|
|
|
white-space: nowrap; |
|
|
|
|
font-style: normal; |
|
|
|
|
} |
|
|
|
|
.bar-track { |
|
|
|
|
height: 18px; |
|
|
|
|
overflow: hidden; |
|
|
|
|
border-radius: 4px; |
|
|
|
|
background: #e8eef1; |
|
|
|
|
} |
|
|
|
|
.bar-track i { |
|
|
|
|
display: block; |
|
|
|
|
height: 100%; |
|
|
|
|
border-radius: 4px; |
|
|
|
|
background: #1c7886; |
|
|
|
|
} |
|
|
|
|
.bar-note { |
|
|
|
|
margin: 12px 0 0; |
|
|
|
|
color: #647b84; |
|
|
|
|
font-size: 12px; |
|
|
|
|
text-align: right; |
|
|
|
|
} |
|
|
|
|
table { |
|
|
|
|
width: 100%; |
|
|
|
|
border-collapse: collapse; |
|
|
|
|
table-layout: fixed; |
|
|
|
|
font-size: 13px; |
|
|
|
|
} |
|
|
|
|
th, |
|
|
|
|
td { |
|
|
|
|
padding: 11px 10px; |
|
|
|
|
border-bottom: 1px solid #e5edf0; |
|
|
|
|
overflow: hidden; |
|
|
|
|
text-overflow: ellipsis; |
|
|
|
|
white-space: nowrap; |
|
|
|
|
text-align: left; |
|
|
|
|
} |
|
|
|
|
th { |
|
|
|
|
color: #315f6c; |
|
|
|
|
font-weight: 700; |
|
|
|
|
background: #edf4f6; |
|
|
|
|
} |
|
|
|
|
td { color: #375763; } |
|
|
|
|
.callout { |
|
|
|
|
border-left: 4px solid #e1a62c; |
|
|
|
|
background: #fff6dc; |
|
|
|
|
} |
|
|
|
|
.recommend { |
|
|
|
|
border-left: 4px solid #1b7f8d; |
|
|
|
|
background: #f1f8f9; |
|
|
|
|
} |
|
|
|
|
.list { |
|
|
|
|
margin: 0; |
|
|
|
|
padding-left: 18px; |
|
|
|
|
color: #355661; |
|
|
|
|
font-size: 14px; |
|
|
|
|
line-height: 1.8; |
|
|
|
|
} |
|
|
|
|
.list li + li { margin-top: 4px; } |
|
|
|
|
.footnote { |
|
|
|
|
margin-top: 16px; |
|
|
|
|
color: #778b92; |
|
|
|
|
font-size: 12px; |
|
|
|
|
line-height: 1.7; |
|
|
|
|
} |
|
|
|
|
@media (max-width: 1100px) { |
|
|
|
|
.report-header { display: block; } |
|
|
|
|
.source { display: inline-block; margin-top: 12px; } |
|
|
|
|
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
|
|
|
|
.span-7, |
|
|
|
|
.span-5, |
|
|
|
|
.span-6 { grid-column: span 12; } |
|
|
|
|
.page { padding: 18px; } |
|
|
|
|
} |
|
|
|
|
@media (max-width: 680px) { |
|
|
|
|
.metric-grid { grid-template-columns: 1fr; } |
|
|
|
|
h1 { font-size: 24px; } |
|
|
|
|
} |
|
|
|
|
</style> |
|
|
|
|
</head> |
|
|
|
|
<body> |
|
|
|
|
<main class="page"> |
|
|
|
|
<header class="report-header"> |
|
|
|
|
<div> |
|
|
|
|
<p class="kicker">智慧管理专题报告</p> |
|
|
|
|
<h1>${escapeHtml(report.title)}</h1> |
|
|
|
|
<p class="subtitle">${escapeHtml(report.subtitle)}</p> |
|
|
|
|
</div> |
|
|
|
|
<div class="source">${escapeHtml(report.sourceText)}</div> |
|
|
|
|
</header> |
|
|
|
|
|
|
|
|
|
${renderReportMetrics(report.metrics)} |
|
|
|
|
|
|
|
|
|
<section class="grid"> |
|
|
|
|
<article class="card span-12"> |
|
|
|
|
<h2 class="section-title">总体态势</h2> |
|
|
|
|
<div class="summary">${renderReportParagraphs(report.summary)}</div> |
|
|
|
|
</article> |
|
|
|
|
|
|
|
|
|
<article class="card span-7"> |
|
|
|
|
<h2 class="section-title">${escapeHtml(report.primaryChart.title)}</h2> |
|
|
|
|
${renderReportBars(report.primaryChart)} |
|
|
|
|
</article> |
|
|
|
|
|
|
|
|
|
<article class="card span-5"> |
|
|
|
|
<h2 class="section-title">${escapeHtml(report.table.title)}</h2> |
|
|
|
|
${renderReportTable(report.table)} |
|
|
|
|
</article> |
|
|
|
|
|
|
|
|
|
${report.secondaryChart ? `<article class="card span-12"><h2 class="section-title">${escapeHtml(report.secondaryChart.title)}</h2>${renderReportBars(report.secondaryChart)}</article>` : ""} |
|
|
|
|
|
|
|
|
|
<article class="card callout span-6"> |
|
|
|
|
<h2 class="section-title">需关注事项</h2> |
|
|
|
|
${renderReportList(report.attention)} |
|
|
|
|
</article> |
|
|
|
|
|
|
|
|
|
<article class="card recommend span-6"> |
|
|
|
|
<h2 class="section-title">工作建议</h2> |
|
|
|
|
${renderReportList(report.recommendations)} |
|
|
|
|
</article> |
|
|
|
|
</section> |
|
|
|
|
|
|
|
|
|
${report.footnotes.length ? `<div class="footnote">${report.footnotes.map((item) => `<div>${escapeHtml(item)}</div>`).join("")}</div>` : ""} |
|
|
|
|
</main> |
|
|
|
|
</body> |
|
|
|
|
</html>` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildLocalReport(data, reason) { |
|
|
|
|
const kind = getReportKind(data) |
|
|
|
|
if (kind === "balance") return buildBalanceReport(data, reason) |
|
|
|
|
if (kind === "yak") return buildYakReport(data, reason) |
|
|
|
|
return buildGenericReport(data, reason) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function getReportKind(data) { |
|
|
|
|
const question = String(data?.question || "") |
|
|
|
|
const intentText = normalizeList(data?.context?.intents) |
|
|
|
|
.map((item) => `${item?.key || ""}${item?.label || ""}`) |
|
|
|
|
.join(" ") |
|
|
|
|
const text = `${question} ${intentText}` |
|
|
|
|
if (/草畜|平衡|承载|载畜|超载|放牧压力/.test(text)) return "balance" |
|
|
|
|
if (/牦牛|存栏|畜群|牛群|出栏|交易|预测/.test(text)) return "yak" |
|
|
|
|
return "generic" |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildBalanceReport(data, reason) { |
|
|
|
|
const scope = data.context?.scope || "红原县" |
|
|
|
|
const county = firstRow(data, "balance.county") || firstRow(data, "balance.target") || findFirstRow(data, (row) => row?.type === "county") || {} |
|
|
|
|
const townRows = rowsOf(data, "balance.townRows").length |
|
|
|
|
? rowsOf(data, "balance.townRows") |
|
|
|
|
: findRows(data, (row) => row?.type === "town" && row?.overloadRate !== undefined) |
|
|
|
|
const chartRows = rowsOfFirstDataset(data, (dataset) => /草畜平衡|超载率/.test(dataset?.title || "") && normalizeList(dataset?.rows).some((row) => row?.value !== undefined)) |
|
|
|
|
const pressureRows = (townRows.length ? townRows : chartRows) |
|
|
|
|
.map((row) => ({ |
|
|
|
|
name: row.name || row.areaName || row.key, |
|
|
|
|
overloadRate: toLocalNumber(row.overloadRate ?? row.value), |
|
|
|
|
level: row.level || row.extra || "", |
|
|
|
|
livestockCapacity: toLocalNumber(row.livestockCapacity ?? row.yakNumber), |
|
|
|
|
bearingCapacity: toLocalNumber(row.bearingCapacity), |
|
|
|
|
})) |
|
|
|
|
.filter((row) => row.name && Number.isFinite(row.overloadRate)) |
|
|
|
|
.sort((a, b) => b.overloadRate - a.overloadRate) |
|
|
|
|
const topPressures = pressureRows.slice(0, 5) |
|
|
|
|
const countyRate = toLocalNumber(county.overloadRate) |
|
|
|
|
const livestock = toLocalNumber(county.livestockCapacity ?? county.yakNumber) |
|
|
|
|
const bearing = toLocalNumber(county.bearingCapacity) |
|
|
|
|
const area = toLocalNumber(county.areaRange) |
|
|
|
|
const level = county.level || (Number.isFinite(countyRate) ? "基本实现草畜平衡" : "待核算") |
|
|
|
|
const pressureNames = topPressures.map((row) => `${row.name}${formatRateSuffix(row.overloadRate)}`).join("、") |
|
|
|
|
const advice = splitAdvice(county.advice) |
|
|
|
|
|
|
|
|
|
return { |
|
|
|
|
title: "草畜平衡情况", |
|
|
|
|
subtitle: `${scope} · 草畜平衡专题`, |
|
|
|
|
sourceText: "数据来源:平台草畜平衡核算数据", |
|
|
|
|
metrics: [ |
|
|
|
|
metric("全县超载率", formatRate(countyRate), level), |
|
|
|
|
metric("实际载畜量", formatHead(livestock), "当前核算口径"), |
|
|
|
|
metric("理论承载力", formatHead(bearing), "按草地面积折算"), |
|
|
|
|
metric("草地面积", formatWanMu(area), "纳入平衡核算"), |
|
|
|
|
], |
|
|
|
|
summary: [ |
|
|
|
|
`${scope}当前草畜平衡总体处于“${level}”状态,超载率为${formatRate(countyRate)},实际载畜量${formatHead(livestock)},理论承载力${formatHead(bearing)}。`, |
|
|
|
|
pressureNames |
|
|
|
|
? `乡镇层面,放牧压力相对较高的区域集中在${pressureNames},这些区域应作为后续动态监测和调控的重点。` |
|
|
|
|
: "乡镇层面的草畜平衡压力整体可控,后续仍需结合季节牧草产量和牲畜流动持续校核。", |
|
|
|
|
], |
|
|
|
|
primaryChart: { |
|
|
|
|
title: "乡镇超载率对比", |
|
|
|
|
note: "按超载率由高到低展示前几项", |
|
|
|
|
rows: pressureRows.slice(0, 8).map((row) => ({ |
|
|
|
|
label: row.name, |
|
|
|
|
value: row.overloadRate, |
|
|
|
|
unit: "%", |
|
|
|
|
})), |
|
|
|
|
}, |
|
|
|
|
secondaryChart: buildCapacityGapChart(pressureRows), |
|
|
|
|
table: { |
|
|
|
|
title: "重点区域与平衡状态", |
|
|
|
|
columns: ["区域", "超载率", "实际载畜量", "理论承载力", "状态"], |
|
|
|
|
rows: pressureRows.slice(0, 6).map((row) => [ |
|
|
|
|
row.name, |
|
|
|
|
formatRate(row.overloadRate), |
|
|
|
|
formatHead(row.livestockCapacity), |
|
|
|
|
formatHead(row.bearingCapacity), |
|
|
|
|
row.level || "待跟踪", |
|
|
|
|
]), |
|
|
|
|
}, |
|
|
|
|
attention: [ |
|
|
|
|
topPressures.length ? `${topPressures[0].name}超载率最高,为${formatRate(topPressures[0].overloadRate)},建议优先复核载畜量和可利用草地面积。` : "暂无明显高压乡镇,需保持常态化监测。", |
|
|
|
|
"草畜平衡结果受季节牧草产量、牲畜出入栏、补饲能力影响,应定期更新核算口径。", |
|
|
|
|
"接近平衡上限的区域不宜继续扩大放牧规模,避免从基本平衡转为超载。", |
|
|
|
|
], |
|
|
|
|
recommendations: advice.length ? advice : [ |
|
|
|
|
"对高压力乡镇开展轮牧、休牧和补饲组合管理。", |
|
|
|
|
"将超载率、载畜量和草地面积纳入月度跟踪,及时发现压力变化。", |
|
|
|
|
"结合出栏计划和饲草供应能力,动态优化畜群规模。", |
|
|
|
|
], |
|
|
|
|
footnotes: buildReportFootnotes(data, reason), |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildYakReport(data, reason) { |
|
|
|
|
const scope = data.context?.scope || "红原县" |
|
|
|
|
const townRows = rowsOf(data, "yak.townDistribution") |
|
|
|
|
.map((row) => ({ |
|
|
|
|
name: row.name || row.areaName || row.key, |
|
|
|
|
count: toLocalNumber(row.count ?? row.value ?? row.total), |
|
|
|
|
})) |
|
|
|
|
.filter((row) => row.name && Number.isFinite(row.count)) |
|
|
|
|
.sort((a, b) => b.count - a.count) |
|
|
|
|
const total = townRows.reduce((sum, row) => sum + row.count, 0) |
|
|
|
|
const topRows = townRows.slice(0, 5) |
|
|
|
|
const topShare = total ? topRows.reduce((sum, row) => sum + row.count, 0) / total * 100 : NaN |
|
|
|
|
const structureRows = rowsOf(data, "yak.structure") |
|
|
|
|
.map((row) => ({ |
|
|
|
|
name: row.name || row.label || row.key, |
|
|
|
|
ratio: toLocalNumber(row.ratio ?? row.value), |
|
|
|
|
count: toLocalNumber(row.count), |
|
|
|
|
})) |
|
|
|
|
.filter((row) => row.name && (Number.isFinite(row.ratio) || Number.isFinite(row.count))) |
|
|
|
|
const forecastRows = rowsOf(data, "yak.forecast") |
|
|
|
|
const latestForecast = forecastRows[forecastRows.length - 1] || {} |
|
|
|
|
const latestStock = toLocalNumber(latestForecast.stock) |
|
|
|
|
const topTown = townRows[0] |
|
|
|
|
|
|
|
|
|
return { |
|
|
|
|
title: "牦牛分布情况", |
|
|
|
|
subtitle: `${scope} · 牦牛存栏专题`, |
|
|
|
|
sourceText: "数据来源:平台牦牛存栏与畜群结构数据", |
|
|
|
|
metrics: [ |
|
|
|
|
metric("牦牛存栏", formatHead(total), "乡镇汇总"), |
|
|
|
|
metric("最高乡镇", topTown ? topTown.name : "--", topTown ? formatHead(topTown.count) : ""), |
|
|
|
|
metric("覆盖乡镇", `${townRows.length || "--"}个`, "当前展示范围"), |
|
|
|
|
metric("预测存栏", formatHead(latestStock), latestForecast.label || "趋势预测"), |
|
|
|
|
], |
|
|
|
|
summary: [ |
|
|
|
|
`${scope}当前牦牛存栏约${formatHead(total)},主要分布在${topRows.map((row) => row.name).join("、") || "重点牧区"}等区域。`, |
|
|
|
|
Number.isFinite(topShare) |
|
|
|
|
? `前五个乡镇合计占全县存栏的${formatRate(topShare)},存栏分布具有较明显的重点区域特征。` |
|
|
|
|
: "当前已完成乡镇存栏数据整理,可用于后续联动草畜平衡、出栏计划和疫病防控管理。", |
|
|
|
|
], |
|
|
|
|
primaryChart: { |
|
|
|
|
title: "乡镇牦牛存栏分布", |
|
|
|
|
note: Number.isFinite(topShare) ? `前五项占比${formatRate(topShare)}` : "", |
|
|
|
|
rows: townRows.slice(0, 9).map((row) => ({ |
|
|
|
|
label: row.name, |
|
|
|
|
value: row.count, |
|
|
|
|
unit: "头", |
|
|
|
|
})), |
|
|
|
|
}, |
|
|
|
|
secondaryChart: structureRows.length ? { |
|
|
|
|
title: "畜群结构占比", |
|
|
|
|
rows: structureRows.slice(0, 8).map((row) => ({ |
|
|
|
|
label: row.name, |
|
|
|
|
value: Number.isFinite(row.ratio) ? row.ratio : row.count, |
|
|
|
|
unit: Number.isFinite(row.ratio) ? "%" : "头", |
|
|
|
|
})), |
|
|
|
|
} : null, |
|
|
|
|
table: { |
|
|
|
|
title: "重点乡镇存栏情况", |
|
|
|
|
columns: ["乡镇", "存栏量", "全县占比"], |
|
|
|
|
rows: townRows.slice(0, 6).map((row) => [ |
|
|
|
|
row.name, |
|
|
|
|
formatHead(row.count), |
|
|
|
|
total ? formatRate(row.count / total * 100) : "--", |
|
|
|
|
]), |
|
|
|
|
}, |
|
|
|
|
attention: [ |
|
|
|
|
topTown ? `${topTown.name}存栏量最高,约${formatHead(topTown.count)},建议同步关注草地承载和疫病防控压力。` : "暂无可排序的乡镇存栏数据。", |
|
|
|
|
"存栏密集区域需要与草畜平衡结果联动研判,避免单看数量忽略草场承载能力。", |
|
|
|
|
"预测存栏继续上行时,应提前校核出栏节奏、补饲能力和畜群结构。", |
|
|
|
|
], |
|
|
|
|
recommendations: [ |
|
|
|
|
"对高存栏乡镇建立动态监测清单,联动草地面积、出栏和补饲指标。", |
|
|
|
|
"围绕能繁母牛、犊牛和种公牛结构优化畜群质量,避免单纯追求数量增长。", |
|
|
|
|
"将存栏分布结果用于牧户服务、疫病防控和草畜平衡调控的优先级排序。", |
|
|
|
|
], |
|
|
|
|
footnotes: buildReportFootnotes(data, reason), |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildGenericReport(data, reason) { |
|
|
|
|
const scope = data.context?.scope || "红原县" |
|
|
|
|
const metrics = normalizeLocalMetrics(data.context?.insights) |
|
|
|
|
const metricRows = metrics.length ? metrics : deriveLocalMetrics(data.datasets) |
|
|
|
|
const firstChart = normalizeList(data.datasets) |
|
|
|
|
.map((dataset) => ({ |
|
|
|
|
title: dataset?.title || "指标对比", |
|
|
|
|
rows: normalizeList(dataset?.rows) |
|
|
|
|
.map((row, index) => ({ |
|
|
|
|
label: pickRowLabel(row, index), |
|
|
|
|
value: pickNumericValue(row), |
|
|
|
|
unit: row?.unit || dataset?.unit || "", |
|
|
|
|
})) |
|
|
|
|
.filter((row) => row.label && Number.isFinite(row.value)) |
|
|
|
|
.slice(0, 8), |
|
|
|
|
})) |
|
|
|
|
.find((chart) => chart.rows.length) |
|
|
|
|
return { |
|
|
|
|
title: buildLocalTitle(data.question), |
|
|
|
|
subtitle: `${scope} · 智慧管理专题`, |
|
|
|
|
sourceText: "数据来源:平台业务汇总数据", |
|
|
|
|
metrics: metricRows.slice(0, 4).map((item) => metric(item.label, item.value, item.note)), |
|
|
|
|
summary: [ |
|
|
|
|
`${scope}已完成本次问题相关业务数据整理,并形成专题分析结果。`, |
|
|
|
|
"报告优先呈现关键指标、主要分布和可执行建议,原始字段明细不作为页面主体展示。", |
|
|
|
|
], |
|
|
|
|
primaryChart: firstChart || { title: "核心指标对比", rows: [] }, |
|
|
|
|
secondaryChart: null, |
|
|
|
|
table: buildGenericReportTable(data), |
|
|
|
|
attention: ["需结合业务口径持续复核关键指标变化。", "若涉及乡镇、牧户或生态区域,应按重点对象继续下钻。"], |
|
|
|
|
recommendations: ["将本报告作为专题研判入口,后续结合业务部门口径补充结论。", "对指标波动较大的对象建立跟踪清单。"], |
|
|
|
|
footnotes: buildReportFootnotes(data, reason), |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildCapacityGapChart(rows) { |
|
|
|
|
const gapRows = rows |
|
|
|
|
.map((row) => ({ |
|
|
|
|
label: row.name, |
|
|
|
|
value: Number.isFinite(row.livestockCapacity) && Number.isFinite(row.bearingCapacity) |
|
|
|
|
? Math.max(0, row.livestockCapacity - row.bearingCapacity) |
|
|
|
|
: NaN, |
|
|
|
|
unit: "头", |
|
|
|
|
})) |
|
|
|
|
.filter((row) => row.label && Number.isFinite(row.value) && row.value > 0) |
|
|
|
|
.slice(0, 8) |
|
|
|
|
if (!gapRows.length) return null |
|
|
|
|
return { |
|
|
|
|
title: "超承载量估算", |
|
|
|
|
note: "仅展示实际载畜量高于理论承载力的区域", |
|
|
|
|
rows: gapRows, |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildGenericReportTable(data) { |
|
|
|
|
const rows = normalizeList(data.datasets) |
|
|
|
|
.flatMap((dataset) => normalizeList(dataset?.rows).map((row, index) => ({ |
|
|
|
|
target: pickRowLabel(row, index), |
|
|
|
|
value: pickNumericValue(row), |
|
|
|
|
unit: row?.unit || dataset?.unit || "", |
|
|
|
|
note: dataset?.title || "", |
|
|
|
|
}))) |
|
|
|
|
.filter((row) => row.target && Number.isFinite(row.value)) |
|
|
|
|
.slice(0, 6) |
|
|
|
|
return { |
|
|
|
|
title: "重点指标明细", |
|
|
|
|
columns: ["对象", "数值", "说明"], |
|
|
|
|
rows: rows.map((row) => [ |
|
|
|
|
row.target, |
|
|
|
|
`${formatLocalNumber(row.value)}${row.unit || ""}`, |
|
|
|
|
row.note, |
|
|
|
|
]), |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderReportMetrics(metrics) { |
|
|
|
|
const rows = normalizeList(metrics).filter(Boolean).slice(0, 4) |
|
|
|
|
if (!rows.length) return "" |
|
|
|
|
return `<section class="metric-grid">${rows.map((item) => ` |
|
|
|
|
<article class="metric-card"> |
|
|
|
|
<span>${escapeHtml(item.label)}</span> |
|
|
|
|
<strong>${escapeHtml(item.value)}</strong> |
|
|
|
|
<small>${escapeHtml(item.note || "")}</small> |
|
|
|
|
</article>`).join("")}</section>` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderReportParagraphs(paragraphs) { |
|
|
|
|
return normalizeList(paragraphs) |
|
|
|
|
.filter(Boolean) |
|
|
|
|
.map((item) => `<p>${escapeHtml(item)}</p>`) |
|
|
|
|
.join("") |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderReportBars(chart) { |
|
|
|
|
const rows = normalizeList(chart?.rows).filter((row) => row?.label && Number.isFinite(Number(row.value))).slice(0, 9) |
|
|
|
|
if (!rows.length) return '<div class="summary"><p>当前问题暂无可绘制的核心指标。</p></div>' |
|
|
|
|
const maxValue = Math.max(...rows.map((row) => Math.abs(Number(row.value))), 1) |
|
|
|
|
return `<div class="bar-list">
|
|
|
|
|
${rows.map((row) => { |
|
|
|
|
const value = Number(row.value) |
|
|
|
|
const width = Math.max(3, Math.round(Math.abs(value) / maxValue * 100)) |
|
|
|
|
return `<div class="bar-row">
|
|
|
|
|
<span title="${escapeHtml(row.label)}">${escapeHtml(row.label)}</span> |
|
|
|
|
<div class="bar-track"><i style="width:${width}%"></i></div> |
|
|
|
|
<em>${escapeHtml(formatLocalNumber(value))}${escapeHtml(row.unit || "")}</em> |
|
|
|
|
</div>` |
|
|
|
|
}).join("")} |
|
|
|
|
${chart?.note ? `<p class="bar-note">${escapeHtml(chart.note)}</p>` : ""} |
|
|
|
|
</div>` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderReportTable(table) { |
|
|
|
|
const rows = normalizeList(table?.rows).slice(0, 8) |
|
|
|
|
const columns = normalizeList(table?.columns) |
|
|
|
|
if (!rows.length || !columns.length) return '<div class="summary"><p>暂无可展示的重点明细。</p></div>' |
|
|
|
|
return `<table>
|
|
|
|
|
<thead><tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr></thead> |
|
|
|
|
<tbody> |
|
|
|
|
${rows.map((row) => `<tr>${normalizeList(row).map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`).join("")} |
|
|
|
|
</tbody> |
|
|
|
|
</table>` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderReportList(items) { |
|
|
|
|
const rows = normalizeList(items).filter(Boolean).slice(0, 6) |
|
|
|
|
if (!rows.length) return '<div class="summary"><p>暂无补充事项。</p></div>' |
|
|
|
|
return `<ul class="list">${rows.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function metric(label, value, note = "") { |
|
|
|
|
return { |
|
|
|
|
label: label || "指标", |
|
|
|
|
value: value === undefined || value === null || value === "" ? "--" : String(value), |
|
|
|
|
note: note || "", |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function rowsOf(data, id) { |
|
|
|
|
return normalizeList(normalizeList(data?.datasets).find((dataset) => dataset?.id === id)?.rows) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function firstRow(data, id) { |
|
|
|
|
return rowsOf(data, id).find(Boolean) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function findRows(data, predicate) { |
|
|
|
|
return normalizeList(data?.datasets) |
|
|
|
|
.flatMap((dataset) => normalizeList(dataset?.rows)) |
|
|
|
|
.filter((row) => row && typeof row === "object" && predicate(row)) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function findFirstRow(data, predicate) { |
|
|
|
|
return findRows(data, predicate).find(Boolean) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function rowsOfFirstDataset(data, predicate) { |
|
|
|
|
const dataset = normalizeList(data?.datasets).find(predicate) |
|
|
|
|
return normalizeList(dataset?.rows) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function splitAdvice(value) { |
|
|
|
|
return uniqueText(String(value || "").split(/[;;。.\n]+/)).slice(0, 5) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildReportFootnotes(data) { |
|
|
|
|
const notes = uniqueText([...(data.context?.notes || [])]) |
|
|
|
|
return notes.slice(0, 3) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function formatWanMu(value) { |
|
|
|
|
const number = Number(value) |
|
|
|
|
if (!Number.isFinite(number)) return "--" |
|
|
|
|
return `${formatLocalNumber(number / 10000)}万亩` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function formatHead(value) { |
|
|
|
|
const number = Number(value) |
|
|
|
|
if (!Number.isFinite(number)) return "--" |
|
|
|
|
return `${formatLocalNumber(number)}头` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function formatRate(value) { |
|
|
|
|
const number = Number(value) |
|
|
|
|
if (!Number.isFinite(number)) return "--" |
|
|
|
|
return `${formatLocalNumber(number)}%` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function formatRateSuffix(value) { |
|
|
|
|
const text = formatRate(value) |
|
|
|
|
return text === "--" ? "" : `(${text})` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildArtifactDataJson({ question, answer, charts, datasets, context }) { |
|
|
|
|
const payload = { |
|
|
|
|
question: String(question || "").trim(), |
|
|
|
|
@ -761,110 +97,6 @@ function buildArtifactDataJson({ question, answer, charts, datasets, context }) |
|
|
|
|
return serialized |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildLocalTitle(question) { |
|
|
|
|
const text = String(question || "").replace(/[??。!!]+$/g, "").trim() |
|
|
|
|
return text ? `${text}专题分析` : "智慧管理专题分析" |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function normalizeList(value) { |
|
|
|
|
return Array.isArray(value) ? value : [] |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function uniqueText(values) { |
|
|
|
|
const seen = new Set() |
|
|
|
|
return normalizeList(values) |
|
|
|
|
.map((item) => String(item || "").trim()) |
|
|
|
|
.filter((item) => { |
|
|
|
|
if (!item || seen.has(item)) return false |
|
|
|
|
seen.add(item) |
|
|
|
|
return true |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function normalizeLocalMetrics(insights) { |
|
|
|
|
return normalizeList(insights) |
|
|
|
|
.map((item) => ({ |
|
|
|
|
label: item?.label || item?.name || "指标", |
|
|
|
|
value: item?.value ?? "--", |
|
|
|
|
note: item?.note || item?.title || "", |
|
|
|
|
})) |
|
|
|
|
.filter((item) => item.label && item.value !== undefined && item.value !== null) |
|
|
|
|
.slice(0, 6) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function deriveLocalMetrics(datasets = []) { |
|
|
|
|
return normalizeList(datasets) |
|
|
|
|
.map((dataset) => { |
|
|
|
|
const row = normalizeList(dataset?.rows).find(Boolean) |
|
|
|
|
if (!row || typeof row !== "object") return null |
|
|
|
|
const value = pickNumericValue(row) |
|
|
|
|
if (!Number.isFinite(value)) return null |
|
|
|
|
return { |
|
|
|
|
label: pickRowLabel(row, 0), |
|
|
|
|
value: `${formatLocalNumber(value)}${row?.unit || dataset?.unit || ""}`, |
|
|
|
|
note: dataset?.title || "", |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
.filter(Boolean) |
|
|
|
|
.slice(0, 6) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function pickRowLabel(row, index) { |
|
|
|
|
if (!row || typeof row !== "object") return `指标${index + 1}` |
|
|
|
|
const direct = row.name || row.label || row.areaName || row.town || row.title || row.key |
|
|
|
|
if (direct) return String(direct) |
|
|
|
|
const firstText = Object.values(row).find((value) => typeof value === "string" && value.trim()) |
|
|
|
|
return firstText || `指标${index + 1}` |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function pickNumericValue(row) { |
|
|
|
|
if (!row || typeof row !== "object") return NaN |
|
|
|
|
const preferredKeys = [ |
|
|
|
|
"value", |
|
|
|
|
"count", |
|
|
|
|
"total", |
|
|
|
|
"rate", |
|
|
|
|
"ratio", |
|
|
|
|
"yakNumber", |
|
|
|
|
"livestockCapacity", |
|
|
|
|
"overloadRate", |
|
|
|
|
"onlineRate", |
|
|
|
|
"areaRange", |
|
|
|
|
"amount", |
|
|
|
|
"number", |
|
|
|
|
] |
|
|
|
|
for (const key of preferredKeys) { |
|
|
|
|
const number = toLocalNumber(row[key]) |
|
|
|
|
if (Number.isFinite(number)) return number |
|
|
|
|
} |
|
|
|
|
for (const value of Object.values(row)) { |
|
|
|
|
const number = toLocalNumber(value) |
|
|
|
|
if (Number.isFinite(number)) return number |
|
|
|
|
} |
|
|
|
|
return NaN |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function toLocalNumber(value) { |
|
|
|
|
if (value === undefined || value === null || value === "") return NaN |
|
|
|
|
const number = Number(String(value).replace(/,/g, "").replace(/%/g, "")) |
|
|
|
|
return Number.isFinite(number) ? number : NaN |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function formatLocalNumber(value) { |
|
|
|
|
const number = Number(value) |
|
|
|
|
if (!Number.isFinite(number)) return value || "--" |
|
|
|
|
return number.toLocaleString("zh-CN", { maximumFractionDigits: 2 }) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function escapeHtml(value) { |
|
|
|
|
return String(value || "") |
|
|
|
|
.replace(/&/g, "&") |
|
|
|
|
.replace(/</g, "<") |
|
|
|
|
.replace(/>/g, ">") |
|
|
|
|
.replace(/"/g, """) |
|
|
|
|
.replace(/'/g, "'") |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function sanitizeContext(context) { |
|
|
|
|
if (!context || typeof context !== "object") return null |
|
|
|
|
const { |
|
|
|
|
|