fix: align pasture and smart management views

V2.0
M先生 1 month ago
parent 84b7816743
commit e4b4bf530f
  1. 246
      src/services/openApi.js
  2. 8
      src/services/pastureApi.js
  3. 2
      src/services/smartApi.js
  4. 12
      src/services/smartChartApi.js
  5. 25
      src/views/forestGrassWet/components/LayerPanel.vue
  6. 72
      src/views/gdMap/components/ProportionPopulationConsumption.vue
  7. 39
      src/views/gdMap/livestockData.js
  8. 199
      src/views/pastureManagement/index.vue
  9. 79
      src/views/platform/index.vue
  10. 30
      src/views/wisdomManagement/components/SmartChartRenderer.vue
  11. 72
      src/views/wisdomManagement/index.vue
  12. 5
      src/views/yakIndustryChain/index.vue
  13. 153
      src/views/yakManagement/YakOlMap.vue
  14. 76
      src/views/yakManagement/data.js
  15. 352
      src/views/yakManagement/index.vue

@ -5,7 +5,15 @@ import { getHongyuanTownshipNameByCode, hongyuanTownshipDisplayOrder } from "@/c
const OPEN_API_BASE = import.meta.env.VITE_OPEN_API_BASE || "/openApi" const OPEN_API_BASE = import.meta.env.VITE_OPEN_API_BASE || "/openApi"
const CXPT_GRASSLAND_AREA_WAN_MU = 756.37 const CXPT_GRASSLAND_AREA_WAN_MU = 756.37
const CXPT_GRASSLAND_YIELD_WAN_TON = 297.29
const CXPT_WETLAND_AREA_WAN_MU = 291.82 const CXPT_WETLAND_AREA_WAN_MU = 291.82
const CXPT_WETLAND_EDIBLE_YIELD_WAN_TON = 16.47
const GRASSLAND_DEGRADATION_SOURCE_ROWS = [
{ key: "none", name: "未退化", value: 481.8, unit: "万亩" },
{ key: "light", name: "轻度退化", value: 231.25, unit: "万亩" },
{ key: "middle", name: "中度退化", value: 23.7, unit: "万亩" },
{ key: "heavy", name: "重度退化", value: 20.21, unit: "万亩" },
]
const HONGYUAN_TOWN_DISPLAY_ORDER = hongyuanTownshipDisplayOrder.map(normalizeTownName) const HONGYUAN_TOWN_DISPLAY_ORDER = hongyuanTownshipDisplayOrder.map(normalizeTownName)
const WETLAND_CHART_PALETTE = ["#32DCFF", "#24F6C4", "#6EA8FF", "#FFE06B", "#7EE269", "#9EA7FF"] const WETLAND_CHART_PALETTE = ["#32DCFF", "#24F6C4", "#6EA8FF", "#FFE06B", "#7EE269", "#9EA7FF"]
const WOODLAND_CHART_PALETTE = ["#2BD17E", "#8AE611", "#24F6C4", "#FFE06B", "#32DCFF", "#9EA7FF"] const WOODLAND_CHART_PALETTE = ["#2BD17E", "#8AE611", "#24F6C4", "#FFE06B", "#32DCFF", "#9EA7FF"]
@ -101,6 +109,20 @@ function normalizeDataItems(data) {
return rows.map(normalizeMetricItem).filter((item) => item.name) return rows.map(normalizeMetricItem).filter((item) => item.name)
} }
function normalizeGrasslandDataItems(data) {
return normalizeDataItems(data).map((item) => {
const signal = `${item?.key || ""}${item?.name || ""}`
if (/gransLand_produce|grassland_grass_yield|草地生产力|草地鲜草产量|鲜草产量/.test(signal)) {
return {
...item,
value: CXPT_GRASSLAND_YIELD_WAN_TON,
unit: "万吨",
}
}
return item
})
}
function normalizeWoodlandDistribution(data) { function normalizeWoodlandDistribution(data) {
const core = data?.core || data?.summary || data || {} const core = data?.core || data?.summary || data || {}
const mainType = (data?.typeDistribution || data?.typeStats || [])[0] || {} const mainType = (data?.typeDistribution || data?.typeStats || [])[0] || {}
@ -720,6 +742,48 @@ function withPercentExtra(rows = [], total = sumRowValues(rows), label = "") {
}) })
} }
function appendRemainderRowToTotal(rows = [], total, options = {}) {
const {
key = "unclassified",
name = "未分类",
unit = "万亩",
sort = true,
} = options
const target = toNumber(total)
if (target === undefined) return rows
const currentTotal = sumRowValues(rows)
const remainder = formatNumber(target - currentTotal)
if (remainder === undefined || remainder <= 0.01) return rows
const result = [
...rows,
{
key,
name,
value: remainder,
unit,
},
]
return sort ? result.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)) : result
}
function scaleRowsToTotal(rows = [], total) {
const target = toNumber(total)
const sourceTotal = sumRowValues(rows)
if (target === undefined || !sourceTotal) return rows
let assignedTotal = 0
return rows.map((row, index) => {
const isLast = index === rows.length - 1
const value = isLast
? formatNumber(Math.max(target - assignedTotal, 0))
: formatNumber(((Number(row.value) || 0) / sourceTotal) * target)
assignedTotal += Number(value) || 0
return {
...row,
value,
}
})
}
function colorRowsByPalette(rows = [], palette = []) { function colorRowsByPalette(rows = [], palette = []) {
if (!palette.length) return rows if (!palette.length) return rows
return rows.map((row, index) => ({ return rows.map((row, index) => ({
@ -953,6 +1017,22 @@ function buildWetlandYieldCapacityRows(yieldSummary = {}, yieldRows = []) {
].filter((row) => row.value !== undefined) ].filter((row) => row.value !== undefined)
} }
function normalizeWetlandYieldSummary(yieldSummary = {}, edibleYieldRows = [], yieldRows = []) {
const unit = yieldSummary?.unit || pickValue(edibleYieldRows?.[0], ["unit"]) || pickValue(yieldRows?.[0], ["unit"]) || "万吨"
const total = toNumber(yieldSummary?.xc) ?? sumRowValues(yieldRows)
const summaryEdible = toNumber(yieldSummary?.edibleXc)
const distributionEdible = sumRowValues(edibleYieldRows)
const edible = summaryEdible !== undefined || distributionEdible > 0
? Math.max(summaryEdible || 0, distributionEdible || 0)
: undefined
return {
...yieldSummary,
xc: formatNumber(total),
edibleXc: formatNumber(edible),
unit,
}
}
function buildWetlandDefaultPanels({ function buildWetlandDefaultPanels({
yieldSummary, yieldSummary,
townAreaRows, townAreaRows,
@ -976,10 +1056,10 @@ function buildWetlandDefaultPanels({
const edibleYieldPanel = buildPanel("wetland-edible-yield", "可食鲜草产量", topRows(edibleYieldRows, hongyuanTownshipDisplayOrder.length), "column", { span: 1, variant: "slim" }) const edibleYieldPanel = buildPanel("wetland-edible-yield", "可食鲜草产量", topRows(edibleYieldRows, hongyuanTownshipDisplayOrder.length), "column", { span: 1, variant: "slim" })
const muYieldPanel = buildPanel("wetland-mu-yield", "湿地亩产效率", topRows(muYieldRows, hongyuanTownshipDisplayOrder.length), "heat", { span: 1, limit: hongyuanTownshipDisplayOrder.length }) const muYieldPanel = buildPanel("wetland-mu-yield", "湿地亩产效率", topRows(muYieldRows, hongyuanTownshipDisplayOrder.length), "heat", { span: 1, limit: hongyuanTownshipDisplayOrder.length })
const levelPanel = buildPanel("wetland-level", "湿地级别面积", levelRows, "column", { span: 1 }) const levelPanel = buildPanel("wetland-level", "湿地级别面积", levelRows, "column", { span: 1 })
const levelTownPanel = buildPanel("wetland-level-town", "1级湿地分布", topRows(levelTownRows, 6), "bar", { span: 1, variant: "distribution" }) const levelTownPanel = buildPanel("wetland-level-town", "1级湿地分布", topRows(levelTownRows, hongyuanTownshipDisplayOrder.length), "bar", { span: 1, variant: "distribution" })
const typeTownPanel = buildPanel("wetland-type-town", "湿地类型乡镇分布", topRows(typeTownRows, 6), "bar", { span: 1, variant: "distribution" }) const typeTownPanel = buildPanel("wetland-type-town", "湿地类型乡镇分布", topRows(typeTownRows, hongyuanTownshipDisplayOrder.length), "bar", { span: 1, variant: "distribution" })
const edibleMuPanel = buildPanel("wetland-edible-mu", "可食鲜草亩产", topRows(edibleMuRows, hongyuanTownshipDisplayOrder.length), "bar", { span: 1 }) const edibleMuPanel = buildPanel("wetland-edible-mu", "可食鲜草亩产", topRows(edibleMuRows, hongyuanTownshipDisplayOrder.length), "bar", { span: 1 })
const minTownPanel = buildPanel("wetland-min-town", "优势小类乡镇分布", topRows(minTownRows, 6), "bar", { span: 1, variant: "distribution" }) const minTownPanel = buildPanel("wetland-min-town", "优势小类乡镇分布", topRows(minTownRows, hongyuanTownshipDisplayOrder.length), "bar", { span: 1, variant: "distribution" })
const leftPanels = diversifyPanelColumn([ const leftPanels = diversifyPanelColumn([
townAreaPanel, townAreaPanel,
typePanel, typePanel,
@ -1198,57 +1278,27 @@ async function getGrasslandDegradationRows() {
return (await getGrasslandDegradationStats()).levelRows return (await getGrasslandDegradationStats()).levelRows
} }
async function getGrasslandDegradationStats() { function buildGrasslandDegradationLevelRows(rows = GRASSLAND_DEGRADATION_SOURCE_ROWS) {
const data = await getWfsFeatureProperties("ne:t_grassland_result_caodi", { const levelRows = rows.map((row) => ({
propertyName: ["area_code", "degradation_level", "acreage"], ...row,
maxFeatures: 200000, value: formatNumber(row.value),
timeout: 25000, unit: row.unit || "万亩",
})
const levelGrouped = new Map()
const townGrouped = new Map()
;(data?.features || []).forEach((feature) => {
const properties = feature.properties || {}
const name = normalizeGrasslandDegradationName(properties.degradation_level)
const acreage = toNumber(properties.acreage)
if (!name || acreage === undefined) return
const areaWanMu = acreage / 10000
levelGrouped.set(name, (levelGrouped.get(name) || 0) + areaWanMu)
if (name === "未退化") return
const townCode = String(properties.area_code || "").slice(0, 9)
const townName = getHongyuanTownshipNameByCode(townCode)
if (!townName) return
townGrouped.set(townName, (townGrouped.get(townName) || 0) + areaWanMu)
})
const levelOrder = ["未退化", "轻度退化", "中度退化", "重度退化", "未分级"]
const levelRows = Array.from(levelGrouped.entries())
.map(([name, value]) => ({
key: name,
name,
value: formatNumber(value),
unit: "万亩",
})) }))
.filter((row) => Number(row.value) > 0) const totalDiff = formatNumber(CXPT_GRASSLAND_AREA_WAN_MU - sumRowValues(levelRows))
.sort((a, b) => { const noneRow = levelRows.find((row) => row.name === "未退化")
if (noneRow && totalDiff !== undefined && Math.abs(totalDiff) > 0.01) {
noneRow.value = formatNumber(Math.max((Number(noneRow.value) || 0) + totalDiff, 0))
}
const levelOrder = ["未退化", "轻度退化", "中度退化", "重度退化"]
return withPercentExtra(levelRows.sort((a, b) => {
const orderA = levelOrder.indexOf(a.name) const orderA = levelOrder.indexOf(a.name)
const orderB = levelOrder.indexOf(b.name) const orderB = levelOrder.indexOf(b.name)
if (orderA !== -1 || orderB !== -1) {
return (orderA === -1 ? 99 : orderA) - (orderB === -1 ? 99 : orderB) return (orderA === -1 ? 99 : orderA) - (orderB === -1 ? 99 : orderB)
} }), CXPT_GRASSLAND_AREA_WAN_MU)
return (Number(b.value) || 0) - (Number(a.value) || 0) }
})
const townRows = Array.from(townGrouped.entries()) async function getGrasslandDegradationStats() {
.map(([name, value]) => ({ return fallbackGrasslandDegradationStats()
key: name,
name,
value: formatNumber(value),
unit: "万亩",
}))
.filter((row) => Number(row.value) > 0)
.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0))
return {
levelRows: withPercentExtra(levelRows),
townRows,
}
} }
function findGrasslandCoreRow(rows = [], keywords = []) { function findGrasslandCoreRow(rows = [], keywords = []) {
@ -1325,10 +1375,18 @@ function buildGrasslandTopRows(core = [], yieldSummary = {}, yieldRows = []) {
].filter((row) => row?.value !== undefined) ].filter((row) => row?.value !== undefined)
} }
function normalizeGrasslandYieldSummary(yieldSummary = {}, yieldRows = []) {
return {
...yieldSummary,
xc: CXPT_GRASSLAND_YIELD_WAN_TON,
unit: yieldSummary?.unit || pickValue(yieldRows?.[0], ["unit"]) || "万吨",
}
}
function buildGrasslandDegradationDistributionRows(rows = []) { function buildGrasslandDegradationDistributionRows(rows = []) {
const total = sumRowValues(rows) const total = sumRowValues(rows)
return rows return rows
.filter((row) => row.name && row.name !== "未退化" && Number(row.value) > 0) .filter((row) => row.name && Number(row.value) > 0)
.map((row) => ({ .map((row) => ({
...row, ...row,
color: getGrasslandDegradationColor(row.name), color: getGrasslandDegradationColor(row.name),
@ -1339,8 +1397,9 @@ function buildGrasslandDegradationDistributionRows(rows = []) {
function getGrasslandDegradationColor(name = "") { function getGrasslandDegradationColor(name = "") {
const text = String(name) const text = String(name)
if (text.includes("未退化")) return "#32DCFF"
if (text.includes("重度")) return "#FFE06B" if (text.includes("重度")) return "#FFE06B"
if (text.includes("中度")) return "#32DCFF" if (text.includes("中度")) return "#6EA8FF"
return "#24F6C4" return "#24F6C4"
} }
@ -1412,12 +1471,7 @@ function fallbackGrasslandDegradationRows() {
} }
function fallbackGrasslandDegradationStats() { function fallbackGrasslandDegradationStats() {
const levelRows = withPercentExtra([ const levelRows = buildGrasslandDegradationLevelRows()
{ key: "none", name: "未退化", value: 642.42, unit: "万亩" },
{ key: "light", name: "轻度退化", value: 83.95, unit: "万亩" },
{ key: "middle", name: "中度退化", value: 17.49, unit: "万亩" },
{ key: "heavy", name: "重度退化", value: 13.09, unit: "万亩" },
])
const townRows = [ const townRows = [
{ key: "qiongxi", name: "邛溪镇", value: 14.72, unit: "万亩" }, { key: "qiongxi", name: "邛溪镇", value: 14.72, unit: "万亩" },
{ key: "sedi", name: "色地镇", value: 13.86, unit: "万亩" }, { key: "sedi", name: "色地镇", value: 13.86, unit: "万亩" },
@ -1528,7 +1582,7 @@ function buildFallbackTopicStats(topicKey, layerKey) {
{ key: "unhealthy", name: "不健康", value: 20.27, unit: "万亩" }, { key: "unhealthy", name: "不健康", value: 20.27, unit: "万亩" },
]) ])
const forageRows = buildGrasslandForageCapacityRows({ const forageRows = buildGrasslandForageCapacityRows({
xc: 297.29, xc: CXPT_GRASSLAND_YIELD_WAN_TON,
edibleXc: 192.26, edibleXc: 192.26,
unit: "万吨", unit: "万吨",
}, yieldRows) }, yieldRows)
@ -1558,7 +1612,7 @@ function buildFallbackTopicStats(topicKey, layerKey) {
const result = { const result = {
status: "fallback", status: "fallback",
...grasslandPanels, ...grasslandPanels,
top: buildGrasslandTopRows(core, { xc: 297.29, unit: "万吨" }, yieldRows), top: buildGrasslandTopRows(core, { xc: CXPT_GRASSLAND_YIELD_WAN_TON, unit: "万吨" }, yieldRows),
} }
return applyLayerPanelSelection(result, topicKey, layerKey) return applyLayerPanelSelection(result, topicKey, layerKey)
} }
@ -1569,18 +1623,24 @@ function buildFallbackTopicStats(topicKey, layerKey) {
{ key: "wetland-yield", name: "湿地生产力(鲜草)", value: 108.84, unit: "万吨" }, { key: "wetland-yield", name: "湿地生产力(鲜草)", value: 108.84, unit: "万吨" },
{ key: "wetland-level-1-area", name: "1级湿地面积", value: 213.22, unit: "万亩" }, { key: "wetland-level-1-area", name: "1级湿地面积", value: 213.22, unit: "万亩" },
], topicKey) ], topicKey)
const typeRows = aggregateTailRows([ const typeRows = appendRemainderRowToTotal(aggregateTailRows([
{ name: "高寒草甸类", value: 190.32, unit: "万亩" }, { name: "高寒草甸类", value: 190.32, unit: "万亩" },
{ name: "山地草甸类", value: 49.48, unit: "万亩" }, { name: "山地草甸类", value: 49.48, unit: "万亩" },
{ name: "低地草甸类", value: 3.21, unit: "万亩" }, { name: "低地草甸类", value: 3.21, unit: "万亩" },
], { limit: 4, minPercent: 1, otherName: "其他湿地类型" }) ], { limit: 4, minPercent: 1, otherName: "其他湿地类型" }), CXPT_WETLAND_AREA_WAN_MU, {
const levelRows = [ key: "wetland-type-unclassified",
name: "未分类湿地",
})
const levelRows = appendRemainderRowToTotal([
{ name: "1级湿地", value: 213.22, unit: "万亩" }, { name: "1级湿地", value: 213.22, unit: "万亩" },
{ name: "2级湿地", value: 22.99, unit: "万亩" }, { name: "2级湿地", value: 22.99, unit: "万亩" },
{ name: "3级湿地", value: 6.42, unit: "万亩" }, { name: "3级湿地", value: 6.42, unit: "万亩" },
{ name: "4级湿地", value: 0.36, unit: "万亩" }, { name: "4级湿地", value: 0.36, unit: "万亩" },
{ name: "5级湿地", value: 0.02, unit: "万亩" }, { name: "5级湿地", value: 0.02, unit: "万亩" },
] ], CXPT_WETLAND_AREA_WAN_MU, {
key: "wetland-level-unclassified",
name: "未分级湿地",
})
const yieldRows = normalizeRankRows([ const yieldRows = normalizeRankRows([
{ areaName: "色地镇", yield: 21.32 }, { areaName: "色地镇", yield: 21.32 },
{ areaName: "瓦切镇", yield: 17.49 }, { areaName: "瓦切镇", yield: 17.49 },
@ -1605,20 +1665,23 @@ function buildFallbackTopicStats(topicKey, layerKey) {
{ name: "江茸乡", value: 827.92, unit: "斤" }, { name: "江茸乡", value: 827.92, unit: "斤" },
{ name: "龙日镇", value: 825.59, unit: "斤" }, { name: "龙日镇", value: 825.59, unit: "斤" },
] ]
const townAreaRows = [ const townAreaRows = scaleRowsToTotal([
{ name: "瓦切镇", value: 36.38, unit: "万亩" }, { name: "瓦切镇", value: 36.38, unit: "万亩" },
{ name: "邛溪镇", value: 29.29, unit: "万亩" }, { name: "邛溪镇", value: 29.29, unit: "万亩" },
{ name: "阿木乡", value: 25.26, unit: "万亩" }, { name: "阿木乡", value: 25.26, unit: "万亩" },
{ name: "龙日镇", value: 24.59, unit: "万亩" }, { name: "龙日镇", value: 24.59, unit: "万亩" },
{ name: "色地镇", value: 24.62, unit: "万亩" }, { name: "色地镇", value: 24.62, unit: "万亩" },
].sort((a, b) => b.value - a.value) ].sort((a, b) => b.value - a.value), CXPT_WETLAND_AREA_WAN_MU)
const minTypeRows = [ const minTypeRows = appendRemainderRowToTotal([
{ name: "矮生嵩草、杂类草型", value: 108.35, unit: "万亩" }, { name: "矮生嵩草、杂类草型", value: 108.35, unit: "万亩" },
{ name: "高山嵩草、矮生嵩草型", value: 34.36, unit: "万亩" }, { name: "高山嵩草、矮生嵩草型", value: 34.36, unit: "万亩" },
{ name: "西藏嵩草、杂类草型", value: 32.37, unit: "万亩" }, { name: "西藏嵩草、杂类草型", value: 32.37, unit: "万亩" },
{ name: "早熟禾、杂类草型", value: 20.99, unit: "万亩" }, { name: "早熟禾、杂类草型", value: 20.99, unit: "万亩" },
{ name: "苔草、杂类草型", value: 14.63, unit: "万亩" }, { name: "苔草、杂类草型", value: 14.63, unit: "万亩" },
] ], CXPT_WETLAND_AREA_WAN_MU, {
key: "wetland-min-type-unclassified",
name: "未分类湿地",
})
const edibleYieldRows = normalizeRankRows([ const edibleYieldRows = normalizeRankRows([
{ areaName: "色地镇", yield: 2.82 }, { areaName: "色地镇", yield: 2.82 },
{ areaName: "邛溪镇", yield: 2.71 }, { areaName: "邛溪镇", yield: 2.71 },
@ -1635,7 +1698,7 @@ function buildFallbackTopicStats(topicKey, layerKey) {
], { nameKeys: ["areaName"], valueKeys: ["yield"], unit: "斤" }) ], { nameKeys: ["areaName"], valueKeys: ["yield"], unit: "斤" })
const wetlandPanels = buildWetlandDefaultPanels({ const wetlandPanels = buildWetlandDefaultPanels({
core, core,
yieldSummary: { xc: 108.84, edibleXc: 1.65, unit: "万吨" }, yieldSummary: { xc: 108.84, edibleXc: CXPT_WETLAND_EDIBLE_YIELD_WAN_TON, unit: "万吨" },
townAreaRows, townAreaRows,
typeRows, typeRows,
levelRows, levelRows,
@ -1842,11 +1905,14 @@ function normalizeWetlandBusiness(items, stats = {}) {
const levelDistributionRows = stats.levelDistributionRows || [] const levelDistributionRows = stats.levelDistributionRows || []
const minTypeDistributionRows = stats.minTypeDistributionRows || [] const minTypeDistributionRows = stats.minTypeDistributionRows || []
const yieldSummary = stats.yieldSummary || {} const yieldSummary = stats.yieldSummary || {}
const wetlandTypeRows = aggregateTailRows(normalizeMetricRows(typeRows, { const wetlandTypeRows = appendRemainderRowToTotal(aggregateTailRows(normalizeMetricRows(typeRows, {
nameKeys: ["name"], nameKeys: ["name"],
valueKeys: ["value"], valueKeys: ["value"],
unit: "万亩", unit: "万亩",
}), { limit: 4, minPercent: 1, otherName: "其他湿地类型" }) }), { limit: 4, minPercent: 1, otherName: "其他湿地类型" }), CXPT_WETLAND_AREA_WAN_MU, {
key: "wetland-type-unclassified",
name: "未分类湿地",
})
const wetlandYieldRows = normalizeYieldRows(yieldRows, stats.muYieldRows || [], { showMuYieldExtra: false }) const wetlandYieldRows = normalizeYieldRows(yieldRows, stats.muYieldRows || [], { showMuYieldExtra: false })
const wetlandMuYieldRows = normalizeMetricRows(stats.muYieldRows || [], { const wetlandMuYieldRows = normalizeMetricRows(stats.muYieldRows || [], {
nameKeys: ["areaName", "name"], nameKeys: ["areaName", "name"],
@ -1854,12 +1920,18 @@ function normalizeWetlandBusiness(items, stats = {}) {
unit: "斤", unit: "斤",
max: hongyuanTownshipDisplayOrder.length, max: hongyuanTownshipDisplayOrder.length,
}) })
const wetlandLevelRows = normalizeMetricRows(levelRows, { const wetlandLevelRows = appendRemainderRowToTotal(normalizeMetricRows(levelRows, {
nameKeys: ["name"], nameKeys: ["name"],
valueKeys: ["value"], valueKeys: ["value"],
unit: "万亩", unit: "万亩",
}).map((row) => ({ ...row, name: normalizeWetlandLevelName(row.name) })) }).map((row) => ({ ...row, name: normalizeWetlandLevelName(row.name) })), CXPT_WETLAND_AREA_WAN_MU, {
const wetlandMinTypeRows = normalizeMuRows(minTypeRows) key: "wetland-level-unclassified",
name: "未分级湿地",
})
const wetlandMinTypeRows = aggregateTailRows(appendRemainderRowToTotal(normalizeMuRows(minTypeRows), CXPT_WETLAND_AREA_WAN_MU, {
key: "wetland-min-type-unclassified",
name: "未分类湿地",
}), { limit: 6, minPercent: 1, otherName: "其他小类" })
const wetlandEdibleRows = normalizeYieldRows(edibleRows, edibleMuRows, { showMuYieldExtra: false }) const wetlandEdibleRows = normalizeYieldRows(edibleRows, edibleMuRows, { showMuYieldExtra: false })
const wetlandEdibleMuRows = normalizeMetricRows(edibleMuRows, { const wetlandEdibleMuRows = normalizeMetricRows(edibleMuRows, {
nameKeys: ["areaName", "name"], nameKeys: ["areaName", "name"],
@ -1867,33 +1939,34 @@ function normalizeWetlandBusiness(items, stats = {}) {
unit: pickValue(edibleMuRows?.[0], ["unit"]) || "斤", unit: pickValue(edibleMuRows?.[0], ["unit"]) || "斤",
max: hongyuanTownshipDisplayOrder.length, max: hongyuanTownshipDisplayOrder.length,
}) })
const wetlandTownAreaRows = aggregateAreaWanMuRows(typeDistributionRows, { const wetlandTownAreaRows = scaleRowsToTotal(aggregateAreaWanMuRows(typeDistributionRows, {
nameKeys: ["areaName", "name"], nameKeys: ["areaName", "name"],
valueKeys: ["acreage", "areaWanMu", "value"], valueKeys: ["acreage", "areaWanMu", "value"],
}) }), CXPT_WETLAND_AREA_WAN_MU)
const wetlandTypeTownRows = aggregateAreaWanMuRows(typeDistributionRows, { const wetlandTypeTownRows = scaleRowsToTotal(aggregateAreaWanMuRows(typeDistributionRows, {
nameKeys: ["areaName", "name"], nameKeys: ["areaName", "name"],
valueKeys: ["acreage", "areaWanMu", "value"], valueKeys: ["acreage", "areaWanMu", "value"],
extra: (row) => `${row.source?.length || 0}`, extra: (row) => `${row.source?.length || 0}`,
}) }), CXPT_WETLAND_AREA_WAN_MU)
const wetlandLevelTownRows = aggregateAreaWanMuRows(levelDistributionRows, { const wetlandLevelTownRows = scaleRowsToTotal(aggregateAreaWanMuRows(levelDistributionRows, {
nameKeys: ["areaName", "name"], nameKeys: ["areaName", "name"],
valueKeys: ["acreage", "areaWanMu", "value"], valueKeys: ["acreage", "areaWanMu", "value"],
}) }), CXPT_WETLAND_AREA_WAN_MU)
const wetlandLevelOneTownRows = aggregateAreaWanMuRows(levelDistributionRows, { const wetlandLevelOneTownRows = aggregateAreaWanMuRows(levelDistributionRows, {
nameKeys: ["areaName", "name"], nameKeys: ["areaName", "name"],
valueKeys: ["acreage", "areaWanMu", "value"], valueKeys: ["acreage", "areaWanMu", "value"],
filter: (row) => normalizeWetlandLevelName(pickValue(row, ["level", "name"])).startsWith("1级"), filter: (row) => normalizeWetlandLevelName(pickValue(row, ["level", "name"])).startsWith("1级"),
}) })
const wetlandMinTypeTownRows = aggregateAreaWanMuRows(minTypeDistributionRows, { const wetlandMinTypeTownRows = scaleRowsToTotal(aggregateAreaWanMuRows(minTypeDistributionRows, {
nameKeys: ["areaName", "name"], nameKeys: ["areaName", "name"],
valueKeys: ["acreage", "areaWanMu", "value"], valueKeys: ["acreage", "areaWanMu", "value"],
extra: (row) => `${row.source?.filter((item) => normalizeAreaValueToWanMu(item, ["acreage", "areaWanMu", "value"]) > 0).length || 0}`, extra: (row) => `${row.source?.filter((item) => normalizeAreaValueToWanMu(item, ["acreage", "areaWanMu", "value"]) > 0).length || 0}`,
}) }), CXPT_WETLAND_AREA_WAN_MU)
const wetlandYieldSummary = normalizeWetlandYieldSummary(yieldSummary, wetlandEdibleRows, wetlandYieldRows)
const wetlandCore = ensureCoreShape(normalized, "wetland") const wetlandCore = ensureCoreShape(normalized, "wetland")
const levelOneArea = wetlandLevelRows.find((row) => /^1/.test(row.name)) || wetlandLevelRows[0] const levelOneArea = wetlandLevelRows.find((row) => /^1/.test(row.name)) || wetlandLevelRows[0]
const wetlandPanels = buildWetlandDefaultPanels({ const wetlandPanels = buildWetlandDefaultPanels({
yieldSummary, yieldSummary: wetlandYieldSummary,
townAreaRows: wetlandTownAreaRows, townAreaRows: wetlandTownAreaRows,
typeRows: wetlandTypeRows, typeRows: wetlandTypeRows,
levelRows: wetlandLevelRows, levelRows: wetlandLevelRows,
@ -2086,6 +2159,7 @@ export async function getTopicBusinessStats(topicKey, layerKey) {
const degradationRows = degradationStats.levelRows || [] const degradationRows = degradationStats.levelRows || []
const core = ensureCoreShape(normalizeBasicGrassland(data), topicKey) const core = ensureCoreShape(normalizeBasicGrassland(data), topicKey)
const grassYieldRows = normalizeYieldRows(yieldRows || [], muYieldRows || [], { showMuYieldExtra: false }) const grassYieldRows = normalizeYieldRows(yieldRows || [], muYieldRows || [], { showMuYieldExtra: false })
const grassYieldSummary = normalizeGrasslandYieldSummary(yieldSummary, grassYieldRows)
const muYieldRankRows = normalizeRankRows(muYieldRows || [], { const muYieldRankRows = normalizeRankRows(muYieldRows || [], {
nameKeys: ["areaName", "name"], nameKeys: ["areaName", "name"],
valueKeys: ["yield", "value"], valueKeys: ["yield", "value"],
@ -2122,13 +2196,13 @@ export async function getTopicBusinessStats(topicKey, layerKey) {
healthRows: normalizedHealthRows, healthRows: normalizedHealthRows,
productivityRows: muYieldRankRows, productivityRows: muYieldRankRows,
edibleYieldRows, edibleYieldRows,
forageRows: buildGrasslandForageCapacityRows(yieldSummary, grassYieldRows), forageRows: buildGrasslandForageCapacityRows(grassYieldSummary, grassYieldRows),
degradationRows, degradationRows,
}) })
const result = { const result = {
status: "success", status: "success",
...grasslandPanels, ...grasslandPanels,
top: buildGrasslandTopRows(core, yieldSummary, grassYieldRows), top: buildGrasslandTopRows(core, grassYieldSummary, grassYieldRows),
} }
return applyLayerPanelSelection(result, topicKey, layerKey) return applyLayerPanelSelection(result, topicKey, layerKey)
} }
@ -2212,7 +2286,7 @@ export async function getTopicCoreStats(topicKey) {
async () => normalizeResponseData(await get("/index/dataItem", { group: "grassLand" })), async () => normalizeResponseData(await get("/index/dataItem", { group: "grassLand" })),
async () => normalizeResponseData(await get("/index/dataItem", { group: "grassland" })), async () => normalizeResponseData(await get("/index/dataItem", { group: "grassland" })),
]) ])
const items = Array.isArray(data) ? normalizeDataItems(data) : normalizeBasicGrassland(data) const items = Array.isArray(data) ? normalizeGrasslandDataItems(data) : normalizeBasicGrassland(data)
return { return {
status: "success", status: "success",
items: ensureCoreShape(items, topicKey), items: ensureCoreShape(items, topicKey),

@ -74,6 +74,12 @@ export function getGrasslandClickInfo(params = {}) {
return get("/api/home/grasslandClickInfo", params, { timeout: 20000 }).then((data) => data?.data || data || null) return get("/api/home/grasslandClickInfo", params, { timeout: 20000 }).then((data) => data?.data || data || null)
} }
export function getYakMarksByGrasslandIds(ids = []) {
const grasslandIds = Array.from(new Set((Array.isArray(ids) ? ids : [ids]).map((id) => String(id || "").trim()).filter(Boolean)))
if (!grasslandIds.length) return Promise.resolve([])
return get("/api/home/yakMarksByGrasslandIds", { ids: grasslandIds.join(",") }, { timeout: 20000 }).then(normalizeRows)
}
export function getPastureDetailsById(id) { export function getPastureDetailsById(id) {
return get("/api/pastureDetailsById", { id }).then((data) => data?.data || data || {}) return get("/api/pastureDetailsById", { id }).then((data) => data?.data || data || {})
} }
@ -93,7 +99,7 @@ export async function getPastureProfile() {
getPastureAge(), getPastureAge(),
getPastureOnline(), getPastureOnline(),
getPastureList(), getPastureList(),
getMuhuList("", 1, 10000), getMuhuList("", 1, 20),
]) ])
const pastureRows = pastureList.status === "fulfilled" ? pastureList.value : [] const pastureRows = pastureList.status === "fulfilled" ? pastureList.value : []
const muhuRows = muhuList.status === "fulfilled" ? muhuList.value : [] const muhuRows = muhuList.status === "fulfilled" ? muhuList.value : []

@ -62,7 +62,7 @@ export function askStatQuestion(question, pagePath = window.location.hash || win
pagePath, pagePath,
pageName, pageName,
}, },
{ timeout: 45000 }, { timeout: 90000 },
) )
} }

@ -43,7 +43,7 @@ function normalizeChart(chart, index) {
id: chart?.id || `smart-chart-${index}`, id: chart?.id || `smart-chart-${index}`,
type: normalizeChartType(chart?.type), type: normalizeChartType(chart?.type),
title: chart?.title || `统计图表${index + 1}`, title: chart?.title || `统计图表${index + 1}`,
subtitle: chart?.subtitle || buildSourceSubtitle(chart?.source), subtitle: cleanChartSubtitle(chart?.subtitle),
unit: chart?.unit || "", unit: chart?.unit || "",
source: chart?.source || {}, source: chart?.source || {},
rows: rows rows: rows
@ -65,10 +65,12 @@ function normalizeChartType(type) {
return "bar" return "bar"
} }
function buildSourceSubtitle(source = {}) { function cleanChartSubtitle(value) {
const table = source.table ? `来源:${source.table}` : "来源:openApi 统计库" const text = String(value || "").trim()
const period = source.period ? `${source.period}` : "" if (!text) return ""
return `${table}${period}` if (/^(数据)?来源[::]/.test(text)) return ""
if (text.includes("固定展示")) return ""
return text
} }
function toNumber(value) { function toNumber(value) {

@ -7,8 +7,8 @@
:key="layer.key" :key="layer.key"
class="layer-panel-item" class="layer-panel-item"
:class="{ :class="{
'is-active': layer.key === modelValue, 'is-active': isLayerActive(layer.key),
'is-checked': layer.key === modelValue, 'is-checked': isLayerActive(layer.key),
}" }"
type="button" type="button"
@mousedown.prevent @mousedown.prevent
@ -70,7 +70,7 @@ const props = defineProps({
required: true, required: true,
}, },
modelValue: { modelValue: {
type: String, type: [String, Array],
default: "", default: "",
}, },
legends: { legends: {
@ -97,12 +97,20 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
multiple: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(["update:modelValue", "update:selectedLegendKey"]) const emit = defineEmits(["update:modelValue", "update:selectedLegendKey"])
const panelLayers = computed(() => Array.isArray(props.layers) ? props.layers : getLayersByTopic(props.topicKey)) const panelLayers = computed(() => Array.isArray(props.layers) ? props.layers : getLayersByTopic(props.topicKey))
const panelLegends = computed(() => Array.isArray(props.legends) ? props.legends : []) const panelLegends = computed(() => Array.isArray(props.legends) ? props.legends : [])
const selectedLayerKeys = computed(() => {
if (Array.isArray(props.modelValue)) return props.modelValue.map(String).filter(Boolean)
return props.modelValue ? [String(props.modelValue)] : []
})
const legendSelectable = computed(() => panelLegends.value.filter(isSelectableLegend).length > 1) const legendSelectable = computed(() => panelLegends.value.filter(isSelectableLegend).length > 1)
const legendSingle = computed(() => panelLegends.value.length === 1) const legendSingle = computed(() => panelLegends.value.length === 1)
const panelHeight = computed(() => { const panelHeight = computed(() => {
@ -112,9 +120,20 @@ const panelHeight = computed(() => {
function handleLayerClick(event, layerKey) { function handleLayerClick(event, layerKey) {
event.currentTarget?.blur?.() event.currentTarget?.blur?.()
if (props.multiple) {
const nextKeys = new Set(selectedLayerKeys.value)
if (nextKeys.has(layerKey)) nextKeys.delete(layerKey)
else nextKeys.add(layerKey)
emit("update:modelValue", Array.from(nextKeys))
return
}
emit("update:modelValue", props.modelValue === layerKey ? "" : layerKey) emit("update:modelValue", props.modelValue === layerKey ? "" : layerKey)
} }
function isLayerActive(layerKey) {
return selectedLayerKeys.value.includes(String(layerKey))
}
function handleLegendClick(event, item) { function handleLegendClick(event, item) {
event.currentTarget?.blur?.() event.currentTarget?.blur?.()
if (!isLegendItemSelectable(item)) return if (!isLegendItemSelectable(item)) return

@ -24,8 +24,17 @@ import mCard from "@/components/mCard/index.vue"
import VChart from "vue-echarts" import VChart from "vue-echarts"
import { herdStructureRows } from "../livestockData" import { herdStructureRows } from "../livestockData"
const pieGradients = [
["rgba(3,65,128,1)", "rgba(115,208,255,1)"],
["rgba(11, 77, 44, 1)", "rgba(77, 255, 181, 1)"],
["rgba(117, 117, 117, 1)", "rgba(230, 230, 230, 1)"],
["rgba(153, 105, 38, 1)", "rgba(255, 200, 89, 1)"],
["rgba(114, 54, 36, 1)", "rgba(255, 122, 69, 1)"],
["rgba(41, 55, 130, 1)", "rgba(126, 158, 255, 1)"],
]
const state = reactive({ const state = reactive({
pieDataColor: ["#17E6C3", "#40CFFF", "#1979FF", "#FFC472", "#FF7A45"], pieDataColor: ["#17E6C3", "#40CFFF", "#1979FF", "#FFC472", "#FF7A45", "#8BA8FF"],
pieData: herdStructureRows.map((item) => ({ pieData: herdStructureRows.map((item) => ({
name: item.name, name: item.name,
value: item.ratio, value: item.ratio,
@ -59,62 +68,19 @@ const option = ref({
radius: ["55%", "70%"], radius: ["55%", "70%"],
color: ["#c487ee", "#deb140", "#49dff0", "#034079", "#6f81da", "#00ffb4"], color: ["#c487ee", "#deb140", "#49dff0", "#034079", "#6f81da", "#00ffb4"],
data: [ data: herdStructureRows.map((item, index) => {
{ const colors = pieGradients[index % pieGradients.length]
value: herdStructureRows[0].ratio, return {
name: herdStructureRows[0].name, value: item.ratio,
itemStyle: { name: item.name,
//
color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: "rgba(3,65,128,1)" },
{ offset: 1, color: "rgba(115,208,255,1)" },
]),
},
},
{
value: herdStructureRows[1].ratio,
name: herdStructureRows[1].name,
itemStyle: {
//
color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: "rgba(11, 77, 44, 1)" },
{ offset: 1, color: "rgba(77, 255, 181, 1)" },
]),
},
},
{
value: herdStructureRows[2].ratio,
name: herdStructureRows[2].name,
itemStyle: {
//
color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: "rgba(117, 117, 117, 1)" },
{ offset: 1, color: "rgba(230, 230, 230, 1)" },
]),
},
},
{
value: herdStructureRows[3].ratio,
name: herdStructureRows[3].name,
itemStyle: {
//
color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: "rgba(153, 105, 38, 1)" },
{ offset: 1, color: "rgba(255, 200, 89, 1)" },
]),
},
},
{
value: herdStructureRows[4].ratio,
name: herdStructureRows[4].name,
itemStyle: { itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: "rgba(114, 54, 36, 1)" }, { offset: 0, color: colors[0] },
{ offset: 1, color: "rgba(255, 122, 69, 1)" }, { offset: 1, color: colors[1] },
]), ]),
}, },
}, }
], }),
}, },
], ],
}) })

@ -91,24 +91,39 @@ export const estimatedStock = YAK_STOCK_TOTAL;
homeCore.yakCount = estimatedStock; homeCore.yakCount = estimatedStock;
function withStockCounts(rows, total) { function withStockCounts(rows, total) {
const nextRows = rows.map((item) => ({ const baseRows = rows.map((item, index) => {
const exact = (total * item.ratio) / 100;
return {
...item, ...item,
count: Math.round((total * item.ratio) / 100), index,
exact,
count: Math.floor(exact),
remainder: exact - Math.floor(exact),
};
});
let delta = total - baseRows.reduce((sum, item) => sum + item.count, 0);
baseRows
.slice()
.sort((a, b) => b.remainder - a.remainder || a.index - b.index)
.forEach((item) => {
if (delta <= 0) return;
item.count += 1;
delta -= 1;
});
return baseRows.map(({ index, exact, remainder, ...item }) => ({
...item,
count: Math.round(item.count),
})); }));
const delta = total - nextRows.reduce((sum, item) => sum + item.count, 0);
if (delta && nextRows[1]) {
nextRows[1].count += delta;
}
return nextRows;
} }
export const herdStructureRows = withStockCounts( export const herdStructureRows = withStockCounts(
[ [
{ name: "成年母牛", ratio: 47 }, { key: "fertileCow", name: "适龄能繁母牛", ratio: 44.8 },
{ name: "其他成年牛", ratio: 22 }, { key: "meatBull", name: "肉公牛", ratio: 15.2 },
{ name: "幼畜", ratio: 16 }, { key: "breedingBull", name: "种公牛", ratio: 3.1 },
{ name: "育成牛", ratio: 11 }, { key: "femaleCalf", name: "母犊牛", ratio: 14.9 },
{ name: "公牛", ratio: 4 }, { key: "maleCalf", name: "公犊牛", ratio: 15.1 },
{ key: "overageCow", name: "过龄能繁母牛", ratio: 6.9 },
], ],
estimatedStock, estimatedStock,
); );

@ -7,7 +7,7 @@
:active="props.active" :active="props.active"
:tile-url-template="pastureMapTileUrlTemplate" :tile-url-template="pastureMapTileUrlTemplate"
:center="pastureOlCenter" :center="pastureOlCenter"
:initial-zoom="16" :initial-zoom="18"
:max-zoom="21" :max-zoom="21"
:overlay-layer="pastureMapLayer" :overlay-layer="pastureMapLayer"
:overlay-layers="pastureMapLayers" :overlay-layers="pastureMapLayers"
@ -90,11 +90,12 @@
<div class="pasture-map-layer-tools"> <div class="pasture-map-layer-tools">
<layer-panel <layer-panel
v-model="activeLayerKey" v-model="activeLayerKeys"
v-model:selected-legend-key="selectedLegendKey" v-model:selected-legend-key="selectedLegendKey"
topic-key="grassland" topic-key="grassland"
:layers="pastureLayerOptions" :layers="pastureLayerOptions"
:legends="pastureMapLayer?.legends || []" :legends="pasturePanelLegends"
multiple
:width="232" :width="232"
:height="430" :height="430"
/> />
@ -124,7 +125,7 @@ import BusinessPanelRenderer from "@/views/forestGrassWet/components/BusinessPan
import FeatureInfoPanel from "@/views/forestGrassWet/components/FeatureInfoPanel.vue" import FeatureInfoPanel from "@/views/forestGrassWet/components/FeatureInfoPanel.vue"
import { getHongyuanTownshipNameByCode, hongyuanTownshipDisplayOrder, hongyuanTownshipsGeoJsonRaw, isHongyuanTownshipName } from "@/config/townshipBoundaries" import { getHongyuanTownshipNameByCode, hongyuanTownshipDisplayOrder, hongyuanTownshipsGeoJsonRaw, isHongyuanTownshipName } from "@/config/townshipBoundaries"
import { getHomeMonitorings } from "@/services/homeVideoApi" import { getHomeMonitorings } from "@/services/homeVideoApi"
import { getGrasslandClickInfo, getPastureGrasslandsById, getPastureProfile, getPasturesDetailsById } from "@/services/pastureApi" import { getGrasslandClickInfo, getPastureGrasslandsById, getPastureProfile, getPasturesDetailsById, getYakMarksByGrasslandIds } from "@/services/pastureApi"
import liveSceneImage from "@/assets/images/gd-live/live-scene-1.png" import liveSceneImage from "@/assets/images/gd-live/live-scene-1.png"
import { import {
pastureAgeRows, pastureAgeRows,
@ -162,31 +163,46 @@ const doubleCardHeight = computed(() => sideCardHeight.value * 2 + cardGap)
const mapSceneRef = ref(null) const mapSceneRef = ref(null)
const mapReady = ref(false) const mapReady = ref(false)
const pastureMapKey = ref(0) const pastureMapKey = ref(0)
let pastureFocusRequestId = 0
const selectedGrasslandFeature = ref(null) const selectedGrasslandFeature = ref(null)
const grasslandFeatureEmpty = ref(false) const grasslandFeatureEmpty = ref(false)
const grasslandFeatureLoading = ref(false) const grasslandFeatureLoading = ref(false)
const grasslandFeaturePoint = ref(null) const grasslandFeaturePoint = ref(null)
const focusedPastureFilter = ref(null)
const apiProfile = ref(pastureFallbackProfile) const apiProfile = ref(pastureFallbackProfile)
const pastureRows = ref(pastureListRows) const pastureRows = ref(pastureListRows)
const pastureBoundaryLayerKey = "pasture-grassland-boundary" const pastureBoundaryLayerKey = "pasture-grassland-boundary"
const activeLayerKey = ref(pastureBoundaryLayerKey) const pastureYakLayerKey = "pasture-yak-marks-layer"
const activeLayerKeys = ref([pastureBoundaryLayerKey, pastureYakLayerKey])
const selectedLegendKey = ref("") const selectedLegendKey = ref("")
const pastureTopStatNames = ["牧户数量", "户均牦牛头数", "户均草地面积", "联户数量"] const pastureTopStatNames = ["牧户数量", "户均牦牛头数", "户均草地面积", "联户数量"]
const pasturePalette = ["#23f6c8", "#35d8ff", "#f0d96a", "#76e36b", "#ff9a5c", "#a59cff", "#20e5c4"] const pasturePalette = ["#23f6c8", "#35d8ff", "#f0d96a", "#76e36b", "#ff9a5c", "#a59cff", "#20e5c4"]
const pastureVideoElementId = `pasture-video-player-${Math.random().toString(36).slice(2)}` const pastureVideoElementId = `pasture-video-player-${Math.random().toString(36).slice(2)}`
const pastureVideoLoadTimeoutMs = 12000 const pastureVideoLoadTimeoutMs = 12000
const pastureMapTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png" const pastureMapTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png"
const pastureOlCenter = [103.07984, 33.029058] const pastureOlCenter = [102.804028, 33.250881]
const pastureBoundarySldBody = `<?xml version="1.0" encoding="UTF-8"?><StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><NamedLayer><Name>t_grassland_result_caodi</Name><UserStyle><Title>pasture boundary</Title><FeatureTypeStyle><Rule><PolygonSymbolizer><Fill><CssParameter name="fill">#FFE35A</CssParameter><CssParameter name="fill-opacity">0</CssParameter></Fill><Stroke><CssParameter name="stroke">#FFE35A</CssParameter><CssParameter name="stroke-width">2.4</CssParameter><CssParameter name="stroke-opacity">0.98</CssParameter></Stroke></PolygonSymbolizer></Rule></FeatureTypeStyle></UserStyle></NamedLayer></StyledLayerDescriptor>` const pastureBoundarySldBody = `<?xml version="1.0" encoding="UTF-8"?><StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><NamedLayer><Name>t_grassland_result_caodi</Name><UserStyle><Title>pasture boundary</Title><FeatureTypeStyle><Rule><PolygonSymbolizer><Fill><CssParameter name="fill">#FFE35A</CssParameter><CssParameter name="fill-opacity">0</CssParameter></Fill><Stroke><CssParameter name="stroke">#FFE35A</CssParameter><CssParameter name="stroke-width">2.4</CssParameter><CssParameter name="stroke-opacity">0.98</CssParameter></Stroke></PolygonSymbolizer></Rule></FeatureTypeStyle></UserStyle></NamedLayer></StyledLayerDescriptor>`
const pastureYakRecognitionLayer = { const pastureYakRecognitionLayer = {
key: "pasture-yak-marks-layer", key: pastureYakLayerKey,
sourceType: "wms", sourceType: "wms",
layerName: "ne:daping_yak_marks", layerName: "ne:daping_yak_marks",
styles: "yak_marks", styles: "yak_marks",
cqlFilter: "gov_show = true", cqlFilter: "gov_show = true",
name: "牦牛识别标记", name: "牦牛识别标记",
color: "#FFFF00",
fillColor: "#FFFF00",
strokeColor: "#FFFF00",
opacity: 0.96, opacity: 0.96,
zIndex: 46, zIndex: 46,
legends: [
{
key: "pasture-yak-marks",
name: "牦牛识别",
fillColor: "#FFFF00",
strokeColor: "#FFFF00",
selectable: false,
},
],
} }
const emptyPastureVideo = { const emptyPastureVideo = {
key: "pasture-empty-video", key: "pasture-empty-video",
@ -285,7 +301,7 @@ const pastureLayerOptions = computed(() => [
opacity: 0.96, opacity: 0.96,
queryBuffer: 8, queryBuffer: 8,
mapView: { mapView: {
targetLngLat: [103.07984, 33.029058], targetLngLat: [102.804028, 33.250881],
distanceScale: 0.66, distanceScale: 0.66,
targetLocalZ: 0.78, targetLocalZ: 0.78,
duration: 0.85, duration: 0.85,
@ -300,25 +316,56 @@ const pastureLayerOptions = computed(() => [
}, },
], ],
}, },
pastureYakRecognitionLayer,
]) ])
const basePastureMapLayer = computed(() => const activePastureLayerKeys = computed(() => {
activeLayerKey.value ? pastureLayerOptions.value.find((layer) => layer.key === activeLayerKey.value) || null : null if (Array.isArray(activeLayerKeys.value)) return activeLayerKeys.value.map(String).filter(Boolean)
) return activeLayerKeys.value ? [String(activeLayerKeys.value)] : []
})
const activePastureLayers = computed(() => {
const keySet = new Set(activePastureLayerKeys.value)
return pastureLayerOptions.value.filter((layer) => keySet.has(layer.key))
})
const basePastureMapLayer = computed(() => activePastureLayers.value[0] || null)
const selectedLegend = computed(() => const selectedLegend = computed(() =>
(basePastureMapLayer.value?.legends || []).find((item) => item.key === selectedLegendKey.value) || null activePastureLayers.value.flatMap((layer) => layer.legends || []).find((item) => item.key === selectedLegendKey.value) || null
) )
const pastureMapLayer = computed(() => { const pasturePanelLegends = computed(() => activePastureLayers.value.flatMap((layer) => layer.legends || []))
const layer = basePastureMapLayer.value function applyPastureLegendFilter(layer) {
if (!layer) return null if (!layer) return null
if (!selectedLegend.value?.cqlFilter) return layer const layerHasSelectedLegend = (layer.legends || []).some((item) => item.key === selectedLegend.value?.key)
if (!layerHasSelectedLegend || !selectedLegend.value?.cqlFilter) return layer
return { return {
...layer, ...layer,
cqlFilter: selectedLegend.value.cqlFilter, cqlFilter: selectedLegend.value.cqlFilter,
activeLegend: selectedLegend.value, activeLegend: selectedLegend.value,
name: selectedLegend.value.name || layer.name, name: selectedLegend.value.name || layer.name,
} }
}) }
const pastureMapLayers = computed(() => [pastureMapLayer.value, pastureYakRecognitionLayer].filter(Boolean)) function applyPastureFocusFilter(layer) {
if (!layer) return null
const focus = focusedPastureFilter.value
if (!focus) return layer
if (layer.key === pastureBoundaryLayerKey) {
const grasslandCql = buildGrasslandFocusCql(focus)
return {
...layer,
cqlFilter: combinePastureCql(layer.cqlFilter, grasslandCql || "EXCLUDE"),
name: `${layer.name || "草场边界"}(定位)`,
zIndex: Math.max(Number(layer.zIndex) || 0, 96),
}
}
if (layer.key === pastureYakLayerKey) {
return {
...layer,
cqlFilter: "EXCLUDE",
name: `${layer.name || "牦牛识别标记"}(定位)`,
}
}
return layer
}
const pastureMapLayers = computed(() => activePastureLayers.value.map(applyPastureLegendFilter).map(applyPastureFocusFilter).filter(Boolean))
const pastureMapLayer = computed(() => pastureMapLayers.value[0] || null)
const grasslandFeaturePanelVisible = computed(() => Boolean(grasslandFeaturePoint.value)) const grasslandFeaturePanelVisible = computed(() => Boolean(grasslandFeaturePoint.value))
const grasslandFeaturePanelHeight = computed(() => { const grasslandFeaturePanelHeight = computed(() => {
if (grasslandFeatureLoading.value || grasslandFeatureEmpty.value || !selectedGrasslandFeature.value) return 186 if (grasslandFeatureLoading.value || grasslandFeatureEmpty.value || !selectedGrasslandFeature.value) return 186
@ -509,6 +556,8 @@ watch(
if (!active) stopPastureVideoPlayer({ resetError: true }) if (!active) stopPastureVideoPlayer({ resetError: true })
if (!mapSceneRef.value) return if (!mapSceneRef.value) return
if (active) { if (active) {
clearGrasslandFeaturePanel()
clearPastureFocusFilter()
mapSceneRef.value.refreshView?.() mapSceneRef.value.refreshView?.()
} else { } else {
if (pastureEnrichTimer) window.clearTimeout(pastureEnrichTimer) if (pastureEnrichTimer) window.clearTimeout(pastureEnrichTimer)
@ -880,7 +929,11 @@ async function mapWithConcurrency(items, limit, worker) {
} }
async function handlePastureSelect(row) { async function handlePastureSelect(row) {
const requestId = ++pastureFocusRequestId
clearGrasslandFeaturePanel()
mapSceneRef.value?.clearFloatingOverlays?.()
if (!isPastureLocatable(row)) { if (!isPastureLocatable(row)) {
cancelPastureFocusSelection()
mapSceneRef.value?.focusPasture?.({ name: row?.name }) mapSceneRef.value?.focusPasture?.({ name: row?.name })
return return
} }
@ -893,17 +946,35 @@ async function handlePastureSelect(row) {
grasslands = [] grasslands = []
} }
} }
if (requestId !== pastureFocusRequestId) return
const rowGrasslands = normalizeRowGrasslands(row) const rowGrasslands = normalizeRowGrasslands(row)
const geometryGrasslands = grasslands.filter(hasGrasslandGeometry) const geometryGrasslands = grasslands.filter(hasGrasslandGeometry)
const pastureGrasslands = geometryGrasslands.length ? geometryGrasslands : rowGrasslands const pastureGrasslands = geometryGrasslands.length ? geometryGrasslands : rowGrasslands
if (!pastureGrasslands.length) { if (!pastureGrasslands.length) {
cancelPastureFocusSelection()
mapSceneRef.value?.focusPasture?.({ name: row?.name }) mapSceneRef.value?.focusPasture?.({ name: row?.name })
return return
} }
mapSceneRef.value?.focusPasture?.({ const grasslandIds = uniquePastureValues(pastureGrasslands.map(getGrasslandLayerId))
let yakMarks = []
if (grasslandIds.length) {
try {
yakMarks = await getYakMarksByGrasslandIds(grasslandIds)
} catch (error) {
yakMarks = []
console.warn("[pasture] load selected grassland yak marks failed", error)
}
}
if (requestId !== pastureFocusRequestId) return
focusedPastureFilter.value = buildPastureFocusFilter(row, pastureGrasslands)
await nextTick()
if (requestId !== pastureFocusRequestId) return
const focused = mapSceneRef.value?.focusPasture?.({
name: row?.name, name: row?.name,
grasslands: pastureGrasslands, grasslands: pastureGrasslands,
yakMarks,
}) })
if (!focused) cancelPastureFocusSelection()
} }
function initPastureMap() { function initPastureMap() {
@ -959,11 +1030,95 @@ function closeGrasslandFeaturePanel() {
grasslandFeatureLoading.value = false grasslandFeatureLoading.value = false
} }
function clearGrasslandFeaturePanel() {
grasslandFeatureRequestId += 1
closeGrasslandFeaturePanel()
}
function clearPastureFocusFilter() {
focusedPastureFilter.value = null
}
function cancelPastureFocusSelection() {
pastureFocusRequestId += 1
clearPastureFocusFilter()
mapSceneRef.value?.clearPastureFocus?.()
}
function isFocusedPastureActive() {
return Boolean(focusedPastureFilter.value)
}
function isGrasslandOutsideFocusedPasture(detail = {}) {
const focus = focusedPastureFilter.value
if (!focus?.grasslandIds?.length) return false
const grasslandId = getGrasslandLayerId(detail)
return Boolean(grasslandId && !focus.grasslandIds.includes(grasslandId))
}
function buildPastureFocusFilter(row = {}, grasslands = []) {
const normalizedGrasslands = normalizeGrasslandDetailRows(grasslands)
const pastureIds = uniquePastureValues([
getRealPastureId(row),
pickValue(row, ["realPastureId", "pastureId", "pasturesId", "pastures_id", "muhuId"]),
...normalizedGrasslands.map((item) => pickValue(item, ["pastureId", "pasturesId", "pastures_id", "muhuId"])),
])
const grasslandIds = uniquePastureValues(normalizedGrasslands.map(getGrasslandLayerId))
.filter((id) => !pastureIds.includes(id))
return {
pastureIds,
grasslandIds,
}
}
function getGrasslandLayerId(item = {}) {
const value = pickValue(item, ["grasslandId", "grassland_id", "gid", "id"])
const text = String(value || "").trim()
return text && !text.includes(".") ? text : ""
}
function uniquePastureValues(values = []) {
return Array.from(new Set(
values
.map((value) => String(value ?? "").trim())
.filter((value) => value && value !== "--" && value !== "null" && value !== "undefined")
))
}
function buildGrasslandFocusCql(focus = {}) {
if (focus.grasslandIds?.length) return buildCqlInFilter("id", focus.grasslandIds)
return ""
}
function buildCqlInFilter(field, values = []) {
const cleanValues = uniquePastureValues(values)
if (!field || !cleanValues.length) return ""
const quotedValues = cleanValues.map((value) => `'${escapeCqlLiteral(value)}'`)
return cleanValues.length === 1 ? `${field} = ${quotedValues[0]}` : `${field} IN (${quotedValues.join(",")})`
}
function escapeCqlLiteral(value) {
return String(value ?? "").replace(/'/g, "''")
}
function combinePastureCql(...filters) {
const clauses = []
for (const filter of filters) {
const text = String(filter || "").trim()
if (!text || text.toUpperCase() === "INCLUDE") continue
if (text.toUpperCase() === "EXCLUDE") return "EXCLUDE"
clauses.push(text)
}
if (!clauses.length) return ""
return clauses.length === 1 ? clauses[0] : clauses.map((item) => `(${item})`).join(" AND ")
}
async function handlePastureMapClick(payload = {}) { async function handlePastureMapClick(payload = {}) {
if (!isPastureGrasslandLayer(payload.overlayLayer || pastureMapLayer.value)) return if (!isPastureGrasslandLayer(payload.overlayLayer || pastureMapLayer.value)) return
const lng = Number(payload?.lngLat?.lng) const lng = Number(payload?.lngLat?.lng)
const lat = Number(payload?.lngLat?.lat) const lat = Number(payload?.lngLat?.lat)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
const hadFocusedPasture = isFocusedPastureActive()
const requestId = ++grasslandFeatureRequestId const requestId = ++grasslandFeatureRequestId
grasslandFeaturePoint.value = payload.screen || { x: 960, y: 540 } grasslandFeaturePoint.value = payload.screen || { x: 960, y: 540 }
selectedGrasslandFeature.value = null selectedGrasslandFeature.value = null
@ -973,9 +1128,13 @@ async function handlePastureMapClick(payload = {}) {
const detail = await getGrasslandClickInfo({ lng, lat }) const detail = await getGrasslandClickInfo({ lng, lat })
if (requestId !== grasslandFeatureRequestId) return if (requestId !== grasslandFeatureRequestId) return
if (!detail?.id) { if (!detail?.id) {
if (hadFocusedPasture) cancelPastureFocusSelection()
closeGrasslandFeaturePanel() closeGrasslandFeaturePanel()
return return
} }
if (hadFocusedPasture && isGrasslandOutsideFocusedPasture(detail)) {
cancelPastureFocusSelection()
}
selectedGrasslandFeature.value = normalizeGrasslandClickFeature(detail, payload) selectedGrasslandFeature.value = normalizeGrasslandClickFeature(detail, payload)
grasslandFeatureEmpty.value = false grasslandFeatureEmpty.value = false
} catch (error) { } catch (error) {
@ -989,10 +1148,14 @@ async function handlePastureMapClick(payload = {}) {
} }
function refreshMapView() { function refreshMapView() {
clearGrasslandFeaturePanel()
clearPastureFocusFilter()
mapSceneRef.value?.refreshView?.() mapSceneRef.value?.refreshView?.()
} }
function resetMap() { function resetMap() {
clearGrasslandFeaturePanel()
clearPastureFocusFilter()
if (mapSceneRef.value?.hasMap?.()) { if (mapSceneRef.value?.hasMap?.()) {
mapReady.value = true mapReady.value = true
mapSceneRef.value?.resetView?.() mapSceneRef.value?.resetView?.()

@ -74,7 +74,9 @@
<yak-management <yak-management
ref="yakManagementRef" ref="yakManagementRef"
:active="activeModule.key === 'yak-management'" :active="activeModule.key === 'yak-management'"
:view-key="activeYakView"
@module-stats="handleModuleStats" @module-stats="handleModuleStats"
@view-change="handleYakViewChange"
/> />
</div> </div>
<div <div
@ -139,7 +141,7 @@
</div> </div>
<div <div
v-if="totalView.length && !['smart-management', 'grass-livestock-balance'].includes(activeModule.key)" v-if="totalView.length && !['smart-management', 'grass-livestock-balance', 'yak-industry-chain'].includes(activeModule.key)"
class="top-count-card" class="top-count-card"
:class="{ :class="{
'is-four': totalView.length > 3 && totalView.length <= 4, 'is-four': totalView.length > 3 && totalView.length <= 4,
@ -252,7 +254,8 @@
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue" import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import autofit from "autofit.js" import autofit from "autofit.js"
import gsap from "gsap" import gsap from "gsap"
import mHeader from "@/components/mHeader/index.vue" import mHeader from "@/components/mHeader/index.vue"
@ -273,9 +276,13 @@ import { ecologicalProtectionMenu } from "@/views/ecologicalProtection/data"
import { logout } from "@/services/auth" import { logout } from "@/services/auth"
import "@/assets/style/home.scss" import "@/assets/style/home.scss"
const activeModuleKey = ref("home") const route = useRoute()
const router = useRouter()
const initialModuleKey = resolvePlatformModuleKey(route.query.module)
const activeModuleKey = ref(initialModuleKey)
const activeEcologyView = ref("national-park") const activeEcologyView = ref("national-park")
const renderedModules = ref({ home: true }) const activeYakView = ref(initialModuleKey === "yak-management" ? resolveYakViewKey(route.query.view) : "structure")
const renderedModules = ref({ home: true, [initialModuleKey]: true })
const moduleTopStats = ref({}) const moduleTopStats = ref({})
const loadingVisible = ref(true) const loadingVisible = ref(true)
const loadingProgress = ref(0) const loadingProgress = ref(0)
@ -382,11 +389,19 @@ onBeforeUnmount(() => {
gsap.killTweensOf(loadingTweenState) gsap.killTweensOf(loadingTweenState)
}) })
function handleMenuSelect(key) { function handleMenuSelect(key, options = {}) {
if (key === activeModuleKey.value) return const nextKey = resolvePlatformModuleKey(key)
const alreadyRendered = Boolean(renderedModules.value[key]) if (nextKey === "yak-management" && !options.fromRoute) {
activeModuleKey.value = key activeYakView.value = "structure"
if (!alreadyRendered) scheduleActiveModuleRender(key) }
if (nextKey === activeModuleKey.value) {
syncPlatformRoute(nextKey)
return
}
const alreadyRendered = Boolean(renderedModules.value[nextKey])
activeModuleKey.value = nextKey
if (!alreadyRendered) scheduleActiveModuleRender(nextKey)
syncPlatformRoute(nextKey)
nextTick(resetRootScroll) nextTick(resetRootScroll)
} }
@ -406,6 +421,52 @@ function handleEcologyViewChange(key) {
nextTick(resetRootScroll) nextTick(resetRootScroll)
} }
function handleYakViewChange(key) {
const nextKey = resolveYakViewKey(key)
if (nextKey === activeYakView.value) return
activeYakView.value = nextKey
if (activeModuleKey.value === "yak-management") {
syncPlatformRoute("yak-management")
}
}
watch(
() => [route.query.module, route.query.view],
([moduleKey, viewKey]) => {
const nextModuleKey = resolvePlatformModuleKey(moduleKey)
if (nextModuleKey === "yak-management") {
activeYakView.value = resolveYakViewKey(viewKey)
}
if (nextModuleKey !== activeModuleKey.value) {
handleMenuSelect(nextModuleKey, { fromRoute: true })
}
},
)
function syncPlatformRoute(moduleKey = activeModuleKey.value) {
const nextQuery = { ...route.query, module: moduleKey }
if (moduleKey === "yak-management") {
nextQuery.view = activeYakView.value
} else {
delete nextQuery.view
}
const currentQuery = route.query || {}
const sameQuery = String(currentQuery.module || "") === String(nextQuery.module || "")
&& String(currentQuery.view || "") === String(nextQuery.view || "")
if (route.path === "/platform" && sameQuery) return
router.replace({ path: "/platform", query: nextQuery }).catch(() => {})
}
function resolvePlatformModuleKey(key) {
const value = Array.isArray(key) ? key[0] : key
return platformModules.some((item) => item.key === value) ? value : "home"
}
function resolveYakViewKey(key) {
const value = Array.isArray(key) ? key[0] : key
return ["structure", "forecast", "epidemic", "trade"].includes(value) ? value : "structure"
}
function handleModuleStats(payload) { function handleModuleStats(payload) {
const moduleKey = payload.moduleKey || (payload.topicKey ? "forest-grass-wet" : activeModuleKey.value) const moduleKey = payload.moduleKey || (payload.topicKey ? "forest-grass-wet" : activeModuleKey.value)
moduleTopStats.value = { moduleTopStats.value = {

@ -8,7 +8,6 @@
<em v-if="chart.unit">{{ chart.unit }}</em> <em v-if="chart.unit">{{ chart.unit }}</em>
</div> </div>
<v-chart class="smart-chart" :option="chartOption" autoresize /> <v-chart class="smart-chart" :option="chartOption" autoresize />
<div v-if="sourceText" class="smart-chart-source">{{ sourceText }}</div>
</div> </div>
</template> </template>
@ -42,22 +41,7 @@ const isHorizontalBar = computed(() => {
}) })
const displaySubtitle = computed(() => { const displaySubtitle = computed(() => {
const subtitle = normalizeSubtitle(props.chart.subtitle) return normalizeSubtitle(props.chart.subtitle)
if (subtitle) return subtitle
const source = props.chart.source || {}
const period = normalizeSourceMeta(source.period || source.dataPeriod || props.chart.period)
const unit = props.chart.unit ? `单位:${props.chart.unit}` : ""
return [period, unit].filter(Boolean).join(" · ")
})
const sourceText = computed(() => {
const source = props.chart.source || {}
const sourceLabel = getSourceLabel(source)
const period = normalizeSourceMeta(source.period || source.dataPeriod || props.chart.period)
const generatedAt = normalizeSourceMeta(source.generatedAt || source.updateTime || source.updatedAt, true)
const parts = [sourceLabel, period, generatedAt ? `更新 ${generatedAt}` : ""].filter(Boolean)
return parts.length ? `数据来源:${parts.join(" · ")}` : "数据来源:业务统计库"
}) })
onMounted(() => { onMounted(() => {
@ -355,6 +339,7 @@ function normalizeSourceMeta(value, dateOnly = false) {
function normalizeSubtitle(value) { function normalizeSubtitle(value) {
const text = String(value || "").trim() const text = String(value || "").trim()
if (!text) return "" if (!text) return ""
if (/^(数据)?来源[::]/.test(text) || text.includes("固定展示")) return ""
const scopePattern = new RegExp(scopeLabel, "g") const scopePattern = new RegExp(scopeLabel, "g")
const statScopePattern = new RegExp(`统计${scopeLabel}`, "g") const statScopePattern = new RegExp(`统计${scopeLabel}`, "g")
@ -432,15 +417,4 @@ function formatValue(value) {
height: 216px; height: 216px;
} }
.smart-chart-source {
height: 22px;
padding: 0 14px;
box-sizing: border-box;
color: rgba(223, 250, 255, 0.48);
font-size: 11px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style> </style>

@ -81,7 +81,7 @@
<span></span> <span></span>
<strong>AI</strong> <strong>AI</strong>
</div> </div>
<h2>智慧管理问答</h2> <h2>小牧智慧问答</h2>
<p>{{ welcomeText }}</p> <p>{{ welcomeText }}</p>
<div class="wisdom-prompt-grid"> <div class="wisdom-prompt-grid">
<button <button
@ -212,7 +212,7 @@ const rightPanels = ref([])
const voiceSupported = ref(false) const voiceSupported = ref(false)
const voiceListening = ref(false) const voiceListening = ref(false)
const voiceError = ref("") const voiceError = ref("")
const welcomeText = ref("可以直接询问牦牛数量、草地面积、牧户情况、饲草产量、林地面积、基地分布、畜群结构和交易流通等统计问题。") const welcomeText = ref("我是小牧,红原县大屏里的数字牧业助手。你问牦牛、草地、湿地、林地、牧户或草畜平衡,我会先帮你拎重点,再给出数据判断和跟进建议。")
const quickPrompts = [ const quickPrompts = [
{ label: "牦牛分布", question: "牦牛分布" }, { label: "牦牛分布", question: "牦牛分布" },
{ label: "畜群结构", question: "牦牛结构占比" }, { label: "畜群结构", question: "牦牛结构占比" },
@ -412,12 +412,12 @@ async function handleStatQuestion(question, assistantId) {
} }
if (charts.length) { if (charts.length) {
const answer = "AI文字分析暂时不可用,已先展示统计图表。请稍后重新提问,或换一个更具体的问题。" const answer = buildChartFallbackAnswer(question, charts)
const insights = buildChartInsights(charts) const insights = buildChartInsights(charts)
const suggestions = buildFollowUpSuggestions(question, charts) const suggestions = buildFollowUpSuggestions(question, charts)
patchAssistant(assistantId, { patchAssistant(assistantId, {
digesting: false, digesting: false,
steps: [`正在理解:${question}`, "AI文字分析暂时不可用", `右侧已生成 ${charts.length} 个数据图表`], steps: [`正在理解:${question}`, "已生成图表数据摘要", `右侧已生成 ${charts.length} 个数据图表`],
insights, insights,
suggestions, suggestions,
allowFollowUp: true, allowFollowUp: true,
@ -437,12 +437,12 @@ async function handleStatQuestion(question, assistantId) {
} }
if (charts.length) { if (charts.length) {
const answer = "AI文字分析暂时不可用,已先展示统计图表。请稍后重新提问,或换一个更具体的问题。" const answer = buildChartFallbackAnswer(question, charts)
const insights = buildChartInsights(charts) const insights = buildChartInsights(charts)
const suggestions = buildFollowUpSuggestions(question, charts) const suggestions = buildFollowUpSuggestions(question, charts)
patchAssistant(assistantId, { patchAssistant(assistantId, {
digesting: false, digesting: false,
steps: [`正在理解:${question}`, "AI文字分析暂时不可用", `右侧已生成 ${charts.length} 个数据图表`], steps: [`正在理解:${question}`, "已生成图表数据摘要", `右侧已生成 ${charts.length} 个数据图表`],
insights, insights,
suggestions, suggestions,
allowFollowUp: true, allowFollowUp: true,
@ -457,6 +457,33 @@ async function handleStatQuestion(question, assistantId) {
finishAssistant(assistantId, errorText) finishAssistant(assistantId, errorText)
} }
function buildChartFallbackAnswer(question, charts = []) {
const chart = charts[0]
const rows = (chart?.rows || []).filter((row) => row?.name && row.value !== undefined)
if (!chart || !rows.length) {
return "小牧暂时没有拿到完整文字结果,可以换一个更具体的问题再试一次。"
}
const sortedRows = [...rows].sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0))
const topRows = sortedRows.slice(0, 5)
const unit = chart.unit || topRows.find((row) => row.unit)?.unit || ""
const total = rows.reduce((sum, row) => sum + (Number(row.value) || 0), 0)
const top = topRows[0]
const lines = topRows.map((row) => `- ${row.name}${formatNumber(row.value)}${row.unit || unit || ""}${row.extra ? `${row.extra}` : ""}`)
const totalText = total > 0 && rows.length > 1
? `,图表合计 ${formatNumber(total)}${unit}`
: ""
const topText = top
? `其中 ${top.name} 排在最前,为 ${formatNumber(top.value)}${top.unit || unit || ""}${totalText}`
: ""
return [
`小牧先按右侧图表给你看:${chart.title || question}${topText}`,
"",
...lines,
"",
rows.length > 1 ? `整体看,可以先关注排名靠前的 ${Math.min(topRows.length, rows.length)} 项,再结合对应页面看资源承载、管理安排和后续跟进重点。` : "这个指标可以作为当前页面的快速参考,后续结合相邻指标一起看会更稳妥。",
].filter((line) => line !== "").join("\n")
}
function extractAiAnswer(data) { function extractAiAnswer(data) {
if (typeof data === "string") return data.trim() if (typeof data === "string") return data.trim()
const candidates = [ const candidates = [
@ -702,6 +729,10 @@ function buildFallbackPanels({ question, answer, charts = [], insights = [], sug
note: item.note, note: item.note,
})) }))
if (!charts.length && !metricItems.length) {
return { left, right }
}
if (metricItems.length) { if (metricItems.length) {
left.push({ left.push({
id: `summary-${createId()}`, id: `summary-${createId()}`,
@ -712,17 +743,6 @@ function buildFallbackPanels({ question, answer, charts = [], insights = [], sug
items: metricItems.slice(0, 4), items: metricItems.slice(0, 4),
actions: [], actions: [],
}) })
} else if (answer) {
left.push({
id: `summary-${createId()}`,
type: "text",
kicker: "AI洞察",
title: "回答要点",
badge: "",
text: extractAnswerBrief(answer),
items: [],
actions: [],
})
} }
charts.slice(0, 2).forEach((chart, index) => { charts.slice(0, 2).forEach((chart, index) => {
@ -739,19 +759,6 @@ function buildFallbackPanels({ question, answer, charts = [], insights = [], sug
}) })
}) })
if (!right.length && answer) {
right.push({
id: `answer-${createId()}`,
type: "text",
kicker: "AI回答",
title: String(question || "问题分析").slice(0, 16),
badge: "模型",
text: extractAnswerBrief(answer, 140),
items: [],
actions: [],
})
}
return { return {
left: left.slice(0, 2), left: left.slice(0, 2),
right: right.slice(0, 2), right: right.slice(0, 2),
@ -1266,11 +1273,6 @@ async function scrollToBottom() {
height: 264px; height: 264px;
} }
.wisdom-side-chart .smart-chart-source {
height: 24px;
padding: 0 13px;
}
.wisdom-avatar-ring, .wisdom-avatar-ring,
.wisdom-empty-mark span { .wisdom-empty-mark span {
position: absolute; position: absolute;

@ -119,7 +119,6 @@ import {
getStagePanelConfig, getStagePanelConfig,
getPointExtraRows, getPointExtraRows,
industryStageDefs, industryStageDefs,
getStageTopStats,
} from "./data" } from "./data"
const props = defineProps({ const props = defineProps({
@ -488,10 +487,10 @@ function updateScreenViewportHeight() {
screenViewportHeight.value = typeof window === "undefined" ? 1080 : window.innerHeight screenViewportHeight.value = typeof window === "undefined" ? 1080 : window.innerHeight
} }
function emitIndustryTopStats(stageKey = activeStageKey.value) { function emitIndustryTopStats() {
emit("module-stats", { emit("module-stats", {
moduleKey: "yak-industry-chain", moduleKey: "yak-industry-chain",
items: getStageTopStats(stageKey), items: [],
}) })
} }

@ -96,31 +96,51 @@ const emit = defineEmits(["ready", "tile-status", "layer-status", "map-click"])
const HONGYUAN_CENTER = [103.07984, 33.029058] const HONGYUAN_CENTER = [103.07984, 33.029058]
const INITIAL_ZOOM = 21 const INITIAL_ZOOM = 21
const TOWN_BOUNDARY_GLOW_Z_INDEX = 232
const TOWN_BOUNDARY_LINE_Z_INDEX = 233
const TOWN_LABEL_LAYER_Z_INDEX = 240
const boundaryFormat = new GeoJSON() const boundaryFormat = new GeoJSON()
const boundaryGlowStyle = new Style({ const boundaryGlowStyle = new Style({
stroke: new Stroke({ stroke: new Stroke({
color: "rgba(48, 220, 255, 0.32)", color: "rgba(1, 18, 27, 0.9)",
width: 5, width: 7,
}), }),
}) })
const boundaryLineStyle = new Style({ const boundaryLineStyle = new Style({
fill: new Fill({ fill: new Fill({
color: "rgba(3, 18, 31, 0.02)", color: "rgba(3, 18, 31, 0)",
}), }),
stroke: new Stroke({ stroke: new Stroke({
color: "rgba(126, 251, 246, 0.82)", color: "rgba(126, 251, 246, 0.98)",
width: 1.4, width: 2.2,
}), }),
}) })
const pastureFocusStyle = [ const pastureFocusStyle = [
new Style({ new Style({
fill: new Fill({ color: "rgba(255, 76, 96, 0.2)" }), fill: new Fill({ color: "rgba(255, 76, 96, 0)" }),
stroke: new Stroke({ color: "rgba(255, 76, 96, 0.42)", width: 8 }), stroke: new Stroke({ color: "rgba(255, 76, 96, 0.4)", width: 8 }),
}), }),
new Style({ new Style({
stroke: new Stroke({ color: "rgba(255, 48, 68, 0.98)", width: 2.8 }), stroke: new Stroke({ color: "rgba(255, 48, 68, 0.98)", width: 2.8 }),
}), }),
] ]
const pastureFocusYakStyle = [
new Style({
fill: new Fill({ color: "rgba(255, 255, 0, 0)" }),
stroke: new Stroke({ color: "rgba(38, 50, 0, 0.9)", width: 3 }),
}),
new Style({
fill: new Fill({ color: "rgba(255, 255, 0, 0)" }),
stroke: new Stroke({ color: "rgba(255, 255, 0, 1)", width: 1.4 }),
}),
new Style({
image: new CircleStyle({
radius: 3,
fill: new Fill({ color: "rgba(255, 255, 0, 0)" }),
stroke: new Stroke({ color: "rgba(255, 255, 0, 1)", width: 1.4 }),
}),
}),
]
const labelPointStyle = new Style({ const labelPointStyle = new Style({
image: new CircleStyle({ image: new CircleStyle({
radius: 2.4, radius: 2.4,
@ -138,6 +158,9 @@ let overlayMapLayer = null
let overlayMapLayers = [] let overlayMapLayers = []
let pastureFocusSource = null let pastureFocusSource = null
let pastureFocusLayer = null let pastureFocusLayer = null
let pastureFocusYakSource = null
let pastureFocusYakLayer = null
let pastureFocusYakRequestId = 0
let contextMenuHandler = null let contextMenuHandler = null
let rightPointerDownHandler = null let rightPointerDownHandler = null
let coordinateTipTimer = null let coordinateTipTimer = null
@ -566,7 +589,7 @@ function createBoundaryLayers() {
new VectorLayer({ new VectorLayer({
source: boundarySource, source: boundarySource,
style: () => boundaryGlowStyle, style: () => boundaryGlowStyle,
zIndex: 28, zIndex: TOWN_BOUNDARY_GLOW_Z_INDEX,
renderBuffer: 80, renderBuffer: 80,
updateWhileAnimating: true, updateWhileAnimating: true,
updateWhileInteracting: true, updateWhileInteracting: true,
@ -574,7 +597,7 @@ function createBoundaryLayers() {
new VectorLayer({ new VectorLayer({
source: boundarySource, source: boundarySource,
style: () => boundaryLineStyle, style: () => boundaryLineStyle,
zIndex: 29, zIndex: TOWN_BOUNDARY_LINE_Z_INDEX,
renderBuffer: 80, renderBuffer: 80,
updateWhileAnimating: true, updateWhileAnimating: true,
updateWhileInteracting: true, updateWhileInteracting: true,
@ -582,7 +605,7 @@ function createBoundaryLayers() {
new VectorLayer({ new VectorLayer({
source: labelSource, source: labelSource,
declutter: true, declutter: true,
zIndex: 40, zIndex: TOWN_LABEL_LAYER_Z_INDEX,
renderBuffer: 120, renderBuffer: 120,
updateWhileAnimating: true, updateWhileAnimating: true,
updateWhileInteracting: true, updateWhileInteracting: true,
@ -604,6 +627,19 @@ function createPastureFocusLayer() {
return pastureFocusLayer return pastureFocusLayer
} }
function createPastureFocusYakLayer() {
pastureFocusYakSource = new VectorSource({ wrapX: false })
pastureFocusYakLayer = new VectorLayer({
source: pastureFocusYakSource,
style: () => pastureFocusYakStyle,
zIndex: 190,
renderBuffer: 160,
updateWhileAnimating: true,
updateWhileInteracting: true,
})
return pastureFocusYakLayer
}
function normalizePastureGrasslandRows(value) { function normalizePastureGrasslandRows(value) {
if (!value) return [] if (!value) return []
if (typeof value === "string") return [value] if (typeof value === "string") return [value]
@ -741,8 +777,58 @@ function createPastureFocusFeatures(payload = {}) {
.filter(Boolean) .filter(Boolean)
} }
function clearPastureFocusYaks() {
pastureFocusYakRequestId += 1
pastureFocusYakSource?.clear(true)
}
function createPastureFocusYakFeatures(rows = []) {
const format = new GeoJSON()
return normalizePastureGrasslandRows(rows)
.map((row, index) => {
const geometry = normalizePastureGrasslandGeometry(row)
if (!geometry) return null
const properties = normalizePastureGrasslandProperties(row)
const feature = format.readFeature(
{
type: "Feature",
properties: {
...properties,
id: properties.id || properties.yak_id || properties.yakId || `focus-yak-${index}`,
},
geometry,
},
{
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
},
)
feature.setId(properties.id || properties.yak_id || properties.yakId || `focus-yak-${index}`)
return feature
})
.filter(Boolean)
}
function loadPastureFocusYaks(rows = []) {
if (!olMap) return 0
if (!pastureFocusYakSource) {
olMap.addLayer(createPastureFocusYakLayer())
}
const requestId = ++pastureFocusYakRequestId
const yakFeatures = createPastureFocusYakFeatures(rows)
if (requestId !== pastureFocusYakRequestId) return 0
pastureFocusYakSource.clear(true)
pastureFocusYakSource.addFeatures(yakFeatures)
emitLayerStatus(
yakFeatures.length ? "success" : "warning",
yakFeatures.length ? `已加载选中草场牦牛识别${yakFeatures.length}` : "选中草场暂无牦牛识别标记",
)
return yakFeatures.length
}
function focusPasture(payload = {}) { function focusPasture(payload = {}) {
if (!olMap) return false if (!olMap) return false
clearFloatingOverlays()
const features = createPastureFocusFeatures(payload) const features = createPastureFocusFeatures(payload)
if (!features.length) { if (!features.length) {
emitLayerStatus("warning", "当前牧户暂无可定位数据") emitLayerStatus("warning", "当前牧户暂无可定位数据")
@ -765,18 +851,25 @@ function focusPasture(payload = {}) {
maxZoom: 17, maxZoom: 17,
padding: [96, horizontalPadding, 96, horizontalPadding], padding: [96, horizontalPadding, 96, horizontalPadding],
}) })
loadPastureFocusYaks(payload?.yakMarks || payload?.yakMarksGeoJson || payload?.yaks || [])
emitLayerStatus("success", "已定位具体草场") emitLayerStatus("success", "已定位具体草场")
window.setTimeout(() => olMap?.updateSize?.(), 0) window.setTimeout(() => olMap?.updateSize?.(), 0)
return true return true
} }
function clearPastureFocus() {
clearFloatingOverlays()
pastureFocusSource?.clear(true)
clearPastureFocusYaks()
}
function createTownLabelStyle(feature, resolution) { function createTownLabelStyle(feature, resolution) {
const name = feature.get("name") || "" const name = feature.get("name") || ""
const compact = resolution > 150 const compact = resolution > 150
const hidden = resolution > 420 const hidden = resolution > 420
if (hidden) return labelPointStyle if (hidden) return labelPointStyle
const fontSize = compact ? 12 : 13 const fontSize = compact ? 12 : 13
const minWidthPadding = compact ? [4, 8, 4, 8] : [5, 10, 5, 10] const minWidthPadding = compact ? [5, 10, 5, 10] : [6, 12, 6, 12]
return [ return [
new Style({ new Style({
image: new RegularShape({ image: new RegularShape({
@ -784,7 +877,7 @@ function createTownLabelStyle(feature, resolution) {
radius: compact ? 4 : 5, radius: compact ? 4 : 5,
rotation: Math.PI, rotation: Math.PI,
displacement: [0, 14], displacement: [0, 14],
fill: new Fill({ color: "rgba(126, 251, 246, 0.92)" }), fill: new Fill({ color: "rgba(126, 251, 246, 0.98)" }),
stroke: new Stroke({ color: "rgba(4, 28, 40, 0.95)", width: 1.2 }), stroke: new Stroke({ color: "rgba(4, 28, 40, 0.95)", width: 1.2 }),
}), }),
}), }),
@ -793,9 +886,9 @@ function createTownLabelStyle(feature, resolution) {
text: name, text: name,
font: `800 ${fontSize}px "Microsoft YaHei", "PingFang SC", sans-serif`, font: `800 ${fontSize}px "Microsoft YaHei", "PingFang SC", sans-serif`,
fill: new Fill({ color: "#ffffff" }), fill: new Fill({ color: "#ffffff" }),
stroke: new Stroke({ color: "rgba(2, 18, 28, 0.96)", width: 4 }), stroke: new Stroke({ color: "rgba(2, 18, 28, 1)", width: 5 }),
backgroundFill: new Fill({ color: "rgba(3, 21, 33, 0.78)" }), backgroundFill: new Fill({ color: "rgba(3, 21, 33, 0.9)" }),
backgroundStroke: new Stroke({ color: "rgba(126, 251, 246, 0.36)", width: 1 }), backgroundStroke: new Stroke({ color: "rgba(126, 251, 246, 0.58)", width: 1.2 }),
padding: minWidthPadding, padding: minWidthPadding,
offsetY: -17, offsetY: -17,
}), }),
@ -892,6 +985,18 @@ function hideCoordinateCopyMenu() {
} }
} }
function clearFloatingOverlays() {
if (coordinateTipTimer) {
window.clearTimeout(coordinateTipTimer)
coordinateTipTimer = null
}
coordinateCopyTip.value = {
...coordinateCopyTip.value,
visible: false,
}
hideCoordinateCopyMenu()
}
async function copyCoordinateFromMapEvent(event) { async function copyCoordinateFromMapEvent(event) {
if (!olMap) return if (!olMap) return
const pixel = olMap.getEventPixel(event) const pixel = olMap.getEventPixel(event)
@ -1001,11 +1106,7 @@ function unbindContextMenuCopy() {
} }
rightPointerDownHandler = null rightPointerDownHandler = null
contextMenuHandler = null contextMenuHandler = null
if (coordinateTipTimer) { clearFloatingOverlays()
window.clearTimeout(coordinateTipTimer)
coordinateTipTimer = null
}
hideCoordinateCopyMenu()
} }
async function initMap() { async function initMap() {
@ -1040,6 +1141,7 @@ async function initMap() {
}), }),
...createBoundaryLayers(), ...createBoundaryLayers(),
createPastureFocusLayer(), createPastureFocusLayer(),
createPastureFocusYakLayer(),
], ],
view, view,
}) })
@ -1084,6 +1186,9 @@ function disposeMap() {
overlayMapLayers = [] overlayMapLayers = []
pastureFocusSource = null pastureFocusSource = null
pastureFocusLayer = null pastureFocusLayer = null
pastureFocusYakSource = null
pastureFocusYakLayer = null
pastureFocusYakRequestId += 1
if (olMap) { if (olMap) {
olMap.setTarget(null) olMap.setTarget(null)
olMap.dispose?.() olMap.dispose?.()
@ -1092,6 +1197,9 @@ function disposeMap() {
} }
function refreshView() { function refreshView() {
clearFloatingOverlays()
pastureFocusSource?.clear(true)
clearPastureFocusYaks()
locateMap() locateMap()
olMap?.updateSize?.() olMap?.updateSize?.()
window.setTimeout(() => olMap?.updateSize?.(), 120) window.setTimeout(() => olMap?.updateSize?.(), 120)
@ -1108,6 +1216,9 @@ function refreshTileSources() {
} }
function resetView() { function resetView() {
clearFloatingOverlays()
pastureFocusSource?.clear(true)
clearPastureFocusYaks()
locateMap() locateMap()
refreshTileSources() refreshTileSources()
olMap?.updateSize?.() olMap?.updateSize?.()
@ -1124,6 +1235,8 @@ defineExpose({
resetView, resetView,
hasMap, hasMap,
focusPasture, focusPasture,
clearPastureFocus,
clearFloatingOverlays,
}) })
onMounted(() => { onMounted(() => {

@ -43,16 +43,25 @@ export function scaleTownRowsToTotal(rows = [], total = YAK_STOCK_TOTAL) {
export const townRows = scaleTownRowsToTotal(townRecognitionRows, estimatedStock) export const townRows = scaleTownRowsToTotal(townRecognitionRows, estimatedStock)
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
export const structureRows = withStockCounts( export const yakStructureBaselineRows = [
[ { key: "fertileCow", name: "适龄能繁母牛", ratio: 45, source: "畜群结构比例表" },
{ key: "adultCow", name: "成年母牛", ratio: 47, source: "专家共识模型" }, { key: "meatBull", name: "肉公牛", ratio: 15, source: "畜群结构比例表" },
{ key: "otherAdult", name: "其他成年牛", ratio: 22, source: "专家共识模型" }, { key: "breedingBull", name: "种公牛", ratio: 3, source: "畜群结构比例表" },
{ key: "calf", name: "幼畜(≤1岁)", ratio: 16, source: "专家共识模型" }, { key: "femaleCalf", name: "母犊牛", ratio: 15, source: "畜群结构比例表" },
{ key: "yearling", name: "育成牛(1-2岁)", ratio: 11, source: "专家共识模型" }, { key: "maleCalf", name: "公犊牛", ratio: 15, source: "畜群结构比例表" },
{ key: "bull", name: "公牛", ratio: 4, source: "专家共识模型" }, { key: "overageCow", name: "过龄能繁母牛", ratio: 7, source: "畜群结构比例表" },
], ]
estimatedStock,
) export const yakStructureRatioRows = [
{ key: "fertileCow", name: "适龄能繁母牛", ratio: 44.8, source: "畜群结构比例表" },
{ key: "meatBull", name: "肉公牛", ratio: 15.2, source: "畜群结构比例表" },
{ key: "breedingBull", name: "种公牛", ratio: 3.1, source: "畜群结构比例表" },
{ key: "femaleCalf", name: "母犊牛", ratio: 14.9, source: "畜群结构比例表" },
{ key: "maleCalf", name: "公犊牛", ratio: 15.1, source: "畜群结构比例表" },
{ key: "overageCow", name: "过龄能繁母牛", ratio: 6.9, source: "畜群结构比例表" },
]
export const structureRows = withStockCounts(yakStructureRatioRows, estimatedStock)
export const tradeImpactRows = baseTradeImpactRows.map((item, index) => ({ export const tradeImpactRows = baseTradeImpactRows.map((item, index) => ({
...item, ...item,
@ -77,7 +86,7 @@ export const forecastPeriods = [
births: 24680, births: 24680,
outflow: 19020, outflow: 19020,
correction: -1040, correction: -1040,
structure: { adultCow: 47, otherAdult: 22, calf: 16, yearling: 11, bull: 4 }, structure: { fertileCow: 45.1, meatBull: 15, breedingBull: 3, femaleCalf: 15.2, maleCalf: 14.9, overageCow: 6.8 },
}, },
{ {
key: "2027", key: "2027",
@ -88,7 +97,7 @@ export const forecastPeriods = [
births: 67420, births: 67420,
outflow: 55180, outflow: 55180,
correction: -1550, correction: -1550,
structure: { adultCow: 47, otherAdult: 21, calf: 17, yearling: 11, bull: 4 }, structure: { fertileCow: 45.5, meatBull: 14.8, breedingBull: 3.1, femaleCalf: 15.1, maleCalf: 14.8, overageCow: 6.7 },
}, },
{ {
key: "2028", key: "2028",
@ -99,7 +108,7 @@ export const forecastPeriods = [
births: 109680, births: 109680,
outflow: 90140, outflow: 90140,
correction: -2410, correction: -2410,
structure: { adultCow: 46, otherAdult: 22, calf: 17, yearling: 11, bull: 4 }, structure: { fertileCow: 45.9, meatBull: 14.5, breedingBull: 3.1, femaleCalf: 15.3, maleCalf: 14.7, overageCow: 6.5 },
}, },
] ]
@ -117,20 +126,11 @@ export function getForecastPeriod(key) {
export function getForecastStructureRows(period) { export function getForecastStructureRows(period) {
const ratios = period?.structure || {} const ratios = period?.structure || {}
const rows = [ return withStockCounts(yakStructureBaselineRows.map((item) => ({
{ key: "adultCow", name: "成年母牛", ratio: ratios.adultCow ?? 47 },
{ key: "otherAdult", name: "其他成年牛", ratio: ratios.otherAdult ?? 22 },
{ key: "calf", name: "幼畜(≤1岁)", ratio: ratios.calf ?? 16 },
{ key: "yearling", name: "育成牛(1-2岁)", ratio: ratios.yearling ?? 11 },
{ key: "bull", name: "公牛", ratio: ratios.bull ?? 4 },
].map((item) => ({
...item, ...item,
count: Math.round((period.stock * item.ratio) / 100), ratio: ratios[item.key] ?? item.ratio,
source: "预测模型", source: "预测模型",
})) })), period.stock)
const delta = period.stock - rows.reduce((sum, item) => sum + item.count, 0)
if (delta && rows[1]) rows[1].count += delta
return rows
} }
export function getTownForecastRows(period) { export function getTownForecastRows(period) {
@ -170,13 +170,29 @@ export function buildForecastSeries(period) {
} }
function withStockCounts(rows, total) { function withStockCounts(rows, total) {
const nextRows = rows.map((item) => ({ const baseRows = rows.map((item, index) => {
const exact = (total * item.ratio) / 100
return {
...item,
index,
exact,
count: Math.floor(exact),
remainder: exact - Math.floor(exact),
}
})
let delta = total - baseRows.reduce((sum, item) => sum + item.count, 0)
baseRows
.slice()
.sort((a, b) => b.remainder - a.remainder || a.index - b.index)
.forEach((item) => {
if (delta <= 0) return
item.count += 1
delta -= 1
})
return baseRows.map(({ index, exact, remainder, ...item }) => ({
...item, ...item,
count: Math.round((total * item.ratio) / 100), count: Math.round(item.count),
})) }))
const delta = total - nextRows.reduce((sum, item) => sum + item.count, 0)
if (delta && nextRows[1]) nextRows[1].count += delta
return nextRows
} }
export function aggregateTownRows(rows) { export function aggregateTownRows(rows) {

@ -94,17 +94,6 @@
<div class="structure-ratio-card"> <div class="structure-ratio-card">
<div class="structure-ratio-chart"> <div class="structure-ratio-chart">
<svg viewBox="0 0 150 150" preserveAspectRatio="xMidYMid meet" aria-hidden="true"> <svg viewBox="0 0 150 150" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<g class="structure-rose-guides">
<line
v-for="segment in structureRoseSegments"
:key="`guide-${segment.key}`"
:x1="segment.lineStart.x"
:y1="segment.lineStart.y"
:x2="segment.lineEnd.x"
:y2="segment.lineEnd.y"
:stroke="segment.color"
/>
</g>
<g class="structure-rose-segments"> <g class="structure-rose-segments">
<path <path
v-for="segment in structureRoseSegments" v-for="segment in structureRoseSegments"
@ -118,17 +107,8 @@
</g> </g>
<circle class="structure-rose-glow" cx="75" cy="75" r="24" /> <circle class="structure-rose-glow" cx="75" cy="75" r="24" />
<circle class="structure-rose-hole" cx="75" cy="75" r="16" /> <circle class="structure-rose-hole" cx="75" cy="75" r="16" />
<g class="structure-rose-labels"> <text class="structure-rose-center" x="75" y="72" text-anchor="middle">结构</text>
<text <text class="structure-rose-center is-sub" x="75" y="83" text-anchor="middle">占比</text>
v-for="segment in structureRoseSegments"
:key="`label-${segment.key}`"
:x="segment.label.x"
:y="segment.label.y"
:text-anchor="segment.anchor"
>
{{ segment.item.name }}
</text>
</g>
</svg> </svg>
</div> </div>
<div class="structure-ratio-list"> <div class="structure-ratio-list">
@ -140,7 +120,7 @@
:style="{ '--row-color': item.color, '--row-percent': `${Math.max(4, item.percent)}%` }" :style="{ '--row-color': item.color, '--row-percent': `${Math.max(4, item.percent)}%` }"
> >
<i :style="{ background: item.color, color: item.color }"></i> <i :style="{ background: item.color, color: item.color }"></i>
<span>{{ item.name }}</span> <span>{{ item.name }}<small>{{ formatPercent(item.percent) }}</small></span>
<strong>{{ formatCompact(item.count) }}</strong> <strong>{{ formatCompact(item.count) }}</strong>
<em></em> <em></em>
</div> </div>
@ -311,7 +291,7 @@
<v-chart class="yak-chart" :option="townRankOption" :autoresize="true" /> <v-chart class="yak-chart" :option="townRankOption" :autoresize="true" />
</m-card> </m-card>
<m-card class="yak-side-card right-card" title="母牛与幼畜分布" :width="sideCardWidth" :height="sideCardHeight"> <m-card class="yak-side-card right-card" title="能繁母牛与犊牛分布" :width="sideCardWidth" :height="sideCardHeight">
<v-chart class="yak-chart" :option="foundationStructureOption" :autoresize="true" /> <v-chart class="yak-chart" :option="foundationStructureOption" :autoresize="true" />
</m-card> </m-card>
@ -319,7 +299,7 @@
<v-chart class="yak-chart" :option="tradeImpactOption" :autoresize="true" /> <v-chart class="yak-chart" :option="tradeImpactOption" :autoresize="true" />
</m-card> </m-card>
<m-card class="yak-side-card right-card" title="性别年龄汇总" :width="sideCardWidth" :height="sideCardHeight"> <m-card class="yak-side-card right-card" title="性别与生产阶段汇总" :width="sideCardWidth" :height="sideCardHeight">
<div class="structure-chart-card"> <div class="structure-chart-card">
<div class="structure-summary-card"> <div class="structure-summary-card">
<div <div
@ -767,8 +747,12 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
viewKey: {
type: String,
default: "structure",
},
}) })
const emit = defineEmits(["module-stats"]) const emit = defineEmits(["module-stats", "view-change"])
const sideCardWidth = 398 const sideCardWidth = 398
const sideCardGap = 12 const sideCardGap = 12
const screenViewportHeight = ref(typeof window === "undefined" ? 1080 : window.innerHeight) const screenViewportHeight = ref(typeof window === "undefined" ? 1080 : window.innerHeight)
@ -810,7 +794,7 @@ const viewTopics = [
{ key: "trade", name: "牦牛交易", color: "#ffcf62" }, { key: "trade", name: "牦牛交易", color: "#ffcf62" },
] ]
const activeView = ref("structure") const activeView = ref(resolveYakViewKey(props.viewKey))
const activePeriodKey = ref("2027") const activePeriodKey = ref("2027")
const activeForecastGranularity = ref("month") const activeForecastGranularity = ref("month")
const activeFoundationPage = ref(0) const activeFoundationPage = ref(0)
@ -917,13 +901,13 @@ const structureDisplayRows = computed(() => {
} }
}) })
}) })
const structureSummaryRows = computed(() => buildStructureSummaryRows(structureDisplayRows.value))
const structureDonutRows = computed(() => buildStructureCompositionRows(structureChartRows.value, modelStructureRows)) const structureDonutRows = computed(() => buildStructureCompositionRows(structureChartRows.value, modelStructureRows))
const structureRatioRows = computed(() => structureDonutRows.value) const structureRatioRows = computed(() => structureDonutRows.value)
const structureSummaryRows = computed(() => buildStructureSummaryRows(structureRatioRows.value))
const breedingHealthRows = computed(() => buildBreedingHealthRows(structureChartRows.value, modelStructureRows)) const breedingHealthRows = computed(() => buildBreedingHealthRows(structureChartRows.value, modelStructureRows))
const breedingHealthStatus = computed(() => buildBreedingHealthStatus(breedingHealthRows.value)) const breedingHealthStatus = computed(() => buildBreedingHealthStatus(breedingHealthRows.value))
const structureSummaryMaxCount = computed(() => Math.max(...structureSummaryRows.value.map((item) => Number(item.count || 0)), 1)) const structureSummaryMaxCount = computed(() => Math.max(...structureSummaryRows.value.map((item) => Number(item.count || 0)), 1))
const activeStructureIndex = computed(() => structureDisplayRows.value.length ? 0 : -1) const activeStructureIndex = computed(() => structureRatioRows.value.length ? 0 : -1)
const structureRoseSegments = computed(() => { const structureRoseSegments = computed(() => {
const data = structureRatioRows.value const data = structureRatioRows.value
const count = data.length const count = data.length
@ -955,8 +939,8 @@ const structureRoseSegments = computed(() => {
} }
}) })
}) })
const adultCowRatio = computed(() => modelStructureRows.find((item) => item.key === "adultCow")?.ratio || 0) const breedingCowRatio = computed(() => sumModelStructureRatios(["fertileCow", "overageCow"]))
const calfRatio = computed(() => modelStructureRows.find((item) => item.key === "calf")?.ratio || 0) const calfRatio = computed(() => sumModelStructureRatios(["femaleCalf", "maleCalf"]))
const displayTownRows = computed(() => { const displayTownRows = computed(() => {
return scaleTownRowsToTotal(mergeTownRows(apiTownRows.value.length ? apiTownRows.value : townRows, townRows), estimatedStock) return scaleTownRowsToTotal(mergeTownRows(apiTownRows.value.length ? apiTownRows.value : townRows, townRows), estimatedStock)
}) })
@ -1198,9 +1182,9 @@ const tradeRiskSummary = computed(() => {
const townFoundationRows = computed(() => { const townFoundationRows = computed(() => {
return displayTownRows.value.map((item) => ({ return displayTownRows.value.map((item) => ({
...item, ...item,
adultCowRatio: adultCowRatio.value, breedingCowRatio: breedingCowRatio.value,
calfRatio: calfRatio.value, calfRatio: calfRatio.value,
adultCow: Math.round((item.count * adultCowRatio.value) / 100), breedingCow: Math.round((item.count * breedingCowRatio.value) / 100),
calf: Math.round((item.count * calfRatio.value) / 100), calf: Math.round((item.count * calfRatio.value) / 100),
})) }))
}) })
@ -1398,7 +1382,7 @@ const townStackOption = computed(() => {
axisPointer: { type: "shadow" }, axisPointer: { type: "shadow" },
formatter(params) { formatter(params) {
const lines = params.map((item) => `${item.marker}${item.seriesName}${formatNumber(item.value)}`) const lines = params.map((item) => `${item.marker}${item.seriesName}${formatNumber(item.value)}`)
return `${params[0].name}<br/>${lines.join("<br/>")}<br/>估算存栏:${formatNumber(totals[params[0].dataIndex])}头<br/>结构依据:专家共识比例` return `${params[0].name}<br/>${lines.join("<br/>")}<br/>估算存栏:${formatNumber(totals[params[0].dataIndex])}头<br/>结构依据:畜群结构比例表`
}, },
}, },
legend: { legend: {
@ -1426,7 +1410,7 @@ const townStackOption = computed(() => {
axisTick: { show: false }, axisTick: { show: false },
}, },
series: modelStructureRows.map((structure) => ({ series: modelStructureRows.map((structure) => ({
name: structure.name.replace("(≤1岁)", "").replace("(1-2岁)", ""), name: structure.name,
type: "bar", type: "bar",
stack: "town", stack: "town",
barWidth: rows.length > 8 ? 6 : 11, barWidth: rows.length > 8 ? 6 : 11,
@ -1448,7 +1432,7 @@ const townRankOption = computed(() =>
const foundationStructureOption = computed(() => { const foundationStructureOption = computed(() => {
const meta = foundationPageMeta.value const meta = foundationPageMeta.value
const rows = meta.rows const rows = meta.rows
const maxTotal = Math.max(...rows.map((item) => item.adultCow + item.calf), 1) const maxTotal = Math.max(...rows.map((item) => item.breedingCow + item.calf), 1)
return { return {
color: ["#30dcff", "#ffd76c"], color: ["#30dcff", "#ffd76c"],
animationDurationUpdate: 700, animationDurationUpdate: 700,
@ -1460,13 +1444,13 @@ const foundationStructureOption = computed(() => {
formatter(params) { formatter(params) {
const row = rows[params[0].dataIndex] const row = rows[params[0].dataIndex]
const lines = params const lines = params
.filter((item) => ["成年母牛", "幼畜"].includes(item.seriesName)) .filter((item) => ["能繁母牛", "犊牛合计"].includes(item.seriesName))
.map((item) => `${item.marker}${item.seriesName}${formatNumber(item.value)}`) .map((item) => `${item.marker}${item.seriesName}${formatNumber(item.value)}`)
return `${row.name}<br/>母合计:${formatNumber(row.adultCow + row.calf)}头<br/>${lines.join("<br/>")}<br/>依据:无人机识别 + 专家共识模型` return `${row.name}<br/>母合计:${formatNumber(row.breedingCow + row.calf)}头<br/>${lines.join("<br/>")}<br/>依据:无人机识别 + 畜群结构比例表`
}, },
}, },
legend: { legend: {
data: ["成年母牛", "幼畜"], data: ["能繁母牛", "犊牛合计"],
top: 2, top: 2,
left: 2, left: 2,
itemWidth: 10, itemWidth: 10,
@ -1517,7 +1501,7 @@ const foundationStructureOption = computed(() => {
type: "bar", type: "bar",
barWidth: 15, barWidth: 15,
barGap: "-100%", barGap: "-100%",
data: rows.map((item) => item.adultCow + item.calf), data: rows.map((item) => item.breedingCow + item.calf),
tooltip: { show: false }, tooltip: { show: false },
itemStyle: { itemStyle: {
borderRadius: [8, 8, 1, 1], borderRadius: [8, 8, 1, 1],
@ -1526,11 +1510,11 @@ const foundationStructureOption = computed(() => {
silent: true, silent: true,
}, },
{ {
name: "成年母牛", name: "能繁母牛",
type: "bar", type: "bar",
stack: "foundation", stack: "foundation",
barWidth: 15, barWidth: 15,
data: rows.map((item) => item.adultCow), data: rows.map((item) => item.breedingCow),
itemStyle: { itemStyle: {
borderRadius: [1, 1, 0, 0], borderRadius: [1, 1, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
@ -1540,7 +1524,7 @@ const foundationStructureOption = computed(() => {
}, },
}, },
{ {
name: "幼畜", name: "犊牛合计",
type: "bar", type: "bar",
stack: "foundation", stack: "foundation",
barWidth: 15, barWidth: 15,
@ -1555,7 +1539,7 @@ const foundationStructureOption = computed(() => {
label: { label: {
show: true, show: true,
position: "top", position: "top",
formatter: ({ dataIndex }) => formatCompact(rows[dataIndex].adultCow + rows[dataIndex].calf), formatter: ({ dataIndex }) => formatCompact(rows[dataIndex].breedingCow + rows[dataIndex].calf),
color: "#ffffff", color: "#ffffff",
fontSize: 10, fontSize: 10,
fontWeight: 700, fontWeight: 700,
@ -2784,6 +2768,9 @@ function handleYakMapReset() {
defineExpose({ defineExpose({
refreshMapView: refreshYakMapView, refreshMapView: refreshYakMapView,
resetMap: handleYakMapReset, resetMap: handleYakMapReset,
setActiveView(view) {
activeView.value = resolveYakViewKey(view)
},
}) })
onMounted(() => { onMounted(() => {
@ -2815,6 +2802,7 @@ watch(
) )
watch(activeView, (view, previousView) => { watch(activeView, (view, previousView) => {
emit("view-change", view)
const nextUsesOlMap = view === "structure" || view === "forecast" const nextUsesOlMap = view === "structure" || view === "forecast"
const previousUsesOlMap = previousView === "structure" || previousView === "forecast" const previousUsesOlMap = previousView === "structure" || previousView === "forecast"
const changesThreeMapTileMode = !nextUsesOlMap && !previousUsesOlMap && (view === "trade" || previousView === "trade") const changesThreeMapTileMode = !nextUsesOlMap && !previousUsesOlMap && (view === "trade" || previousView === "trade")
@ -2850,6 +2838,16 @@ watch(activeView, (view, previousView) => {
syncFoundationRotation() syncFoundationRotation()
}) })
watch(
() => props.viewKey,
(view) => {
const nextView = resolveYakViewKey(view)
if (nextView !== activeView.value) {
activeView.value = nextView
}
},
)
watch( watch(
() => props.active, () => props.active,
(active) => { (active) => {
@ -4347,6 +4345,10 @@ function chartTooltip(formatter) {
} }
} }
function resolveYakViewKey(key) {
return viewTopics.some((item) => item.key === key) ? key : "structure"
}
function normalizeStructureApiRows(rows = []) { function normalizeStructureApiRows(rows = []) {
return rows return rows
.map((item, index) => { .map((item, index) => {
@ -4364,8 +4366,8 @@ function normalizeStructureApiRows(rows = []) {
} }
function normalizeStructureChartRows(apiRows = [], fallbackRows = []) { function normalizeStructureChartRows(apiRows = [], fallbackRows = []) {
const sourceRows = apiRows.length ? apiRows : fallbackRows const sourceRows = apiRows.some((item) => isCoreStructureCategory(item)) ? apiRows : fallbackRows
const rows = sourceRows const rows = buildStructureCompositionRows(sourceRows, fallbackRows)
.map((item, index) => { .map((item, index) => {
const name = String(item.name || item.label || `结构${index + 1}`).trim() const name = String(item.name || item.label || `结构${index + 1}`).trim()
const count = toFiniteCount(item.count ?? item.value ?? item.number) const count = toFiniteCount(item.count ?? item.value ?? item.number)
@ -4377,10 +4379,37 @@ function normalizeStructureChartRows(apiRows = [], fallbackRows = []) {
} }
}) })
.filter((item) => item.name && item.count > 0) .filter((item) => item.name && item.count > 0)
const total = rows.reduce((sum, item) => sum + item.count, 0) return scaleStructureRowsToTarget(rows, estimatedStock)
return rows.map((item) => ({ }
function scaleStructureRowsToTarget(rows = [], total = estimatedStock) {
const sourceTotal = rows.reduce((sum, item) => sum + Number(item.count || 0), 0)
if (!sourceTotal || !total) return rows
const baseRows = rows.map((item, index) => {
const ratio = Number.isFinite(Number(item.ratio)) && Number(item.ratio) > 0
? Number(item.ratio)
: (Number(item.count || 0) / sourceTotal) * 100
const exact = (total * ratio) / 100
return {
...item,
index,
ratio,
count: Math.floor(exact),
remainder: exact - Math.floor(exact),
}
})
let delta = total - baseRows.reduce((sum, item) => sum + item.count, 0)
baseRows
.slice()
.sort((a, b) => b.remainder - a.remainder || a.index - b.index)
.forEach((item) => {
if (delta <= 0) return
item.count += 1
delta -= 1
})
return baseRows.map(({ index, remainder, ...item }) => ({
...item, ...item,
ratio: total > 0 ? Number(((item.count / total) * 100).toFixed(1)) : Number(item.ratio || 0), ratio: Number(item.ratio.toFixed(1)),
})) }))
} }
@ -4449,6 +4478,54 @@ function describeRoseSegment(cx, cy, innerRadius, outerRadius, startAngle, endAn
].join(" ") ].join(" ")
} }
function sumModelStructureRatios(keys = []) {
return keys.reduce((sum, key) => {
const row = modelStructureRows.find((item) => item.key === key)
return sum + Number(row?.ratio || 0)
}, 0)
}
function getStructureCategoryDefinitions() {
return [
{
key: "fertileCow",
name: "适龄能繁母牛",
color: "#27f0c0",
matcher: (key, name) => key === "fertileCow" || name.includes("适龄能繁母牛"),
},
{
key: "meatBull",
name: "肉公牛",
color: "#7aa7ff",
matcher: (key, name) => key === "meatBull" || name.includes("肉公牛"),
},
{
key: "breedingBull",
name: "种公牛",
color: "#a6f06d",
matcher: (key, name) => key === "breedingBull" || name.includes("种公牛"),
},
{
key: "femaleCalf",
name: "母犊牛",
color: "#30dcff",
matcher: (key, name) => key === "femaleCalf" || name.includes("母犊牛"),
},
{
key: "maleCalf",
name: "公犊牛",
color: "#ffd76c",
matcher: (key, name) => key === "maleCalf" || name.includes("公犊牛"),
},
{
key: "overageCow",
name: "过龄能繁母牛",
color: "#ff8f6b",
matcher: (key, name) => key === "overageCow" || name.includes("过龄能繁母牛"),
},
]
}
function buildStructureSummaryRows(rows = []) { function buildStructureSummaryRows(rows = []) {
const total = rows.reduce((sum, item) => sum + Number(item.count || 0), 0) || 1 const total = rows.reduce((sum, item) => sum + Number(item.count || 0), 0) || 1
const groups = [ const groups = [
@ -4457,34 +4534,34 @@ function buildStructureSummaryRows(rows = []) {
group: "性别", group: "性别",
name: "母牛合计", name: "母牛合计",
color: "#27f0c0", color: "#27f0c0",
matcher: (name) => name.includes("母"), matcher: (key, name) => ["fertileCow", "femaleCalf", "overageCow"].includes(key) || name.includes("母"),
}, },
{ {
key: "male", key: "male",
group: "性别", group: "性别",
name: "公牛合计", name: "公牛合计",
color: "#30dcff", color: "#30dcff",
matcher: (name) => name.includes("公"), matcher: (key, name) => ["meatBull", "breedingBull", "maleCalf"].includes(key) || name.includes("公") || name.includes("肉牛"),
}, },
{ {
key: "young", key: "calf",
group: "年龄", group: "阶段",
name: "1岁以下", name: "犊牛合计",
color: "#ffd76c", color: "#ffd76c",
matcher: (name) => /0-1|1|/.test(name), matcher: (key, name) => ["femaleCalf", "maleCalf"].includes(key) || name.includes("犊牛"),
}, },
{ {
key: "adult", key: "breeding-cow",
group: "年龄", group: "阶段",
name: "3岁以上", name: "能繁母牛",
color: "#ff8f6b", color: "#ff8f6b",
matcher: (name) => /3-4|4-5|5\+|成年/.test(name), matcher: (key, name) => ["fertileCow", "overageCow"].includes(key) || name.includes("能繁母牛"),
}, },
] ]
return groups.map((group) => { return groups.map((group) => {
const count = rows const count = rows
.filter((item) => group.matcher(String(item.name || ""))) .filter((item) => group.matcher(String(item.key || ""), String(item.name || "")))
.reduce((sum, item) => sum + Number(item.count || 0), 0) .reduce((sum, item) => sum + Number(item.count || 0), 0)
return { return {
key: group.key, key: group.key,
@ -4501,13 +4578,7 @@ function buildStructureCompositionRows(rows = [], fallbackRows = []) {
const hasCoreRows = rows.some((item) => isCoreStructureCategory(item)) const hasCoreRows = rows.some((item) => isCoreStructureCategory(item))
const sourceRows = hasCoreRows ? rows : fallbackRows const sourceRows = hasCoreRows ? rows : fallbackRows
const total = sourceRows.reduce((sum, item) => sum + Number(item.count || 0), 0) || estimatedStock || 1 const total = sourceRows.reduce((sum, item) => sum + Number(item.count || 0), 0) || estimatedStock || 1
const definitions = [ const definitions = getStructureCategoryDefinitions()
{ key: "adultCow", name: "成年母牛", color: "#27f0c0", matcher: (key, name) => key === "adultCow" || name === "成年母牛" },
{ key: "otherAdult", name: "其他成年牛", color: "#7aa7ff", matcher: (key, name) => key === "otherAdult" || name.includes("其他成年") },
{ key: "calf", name: "幼畜", color: "#30dcff", matcher: (key, name) => key === "calf" || name.includes("幼畜") },
{ key: "yearling", name: "育成牛", color: "#ffd76c", matcher: (key, name) => key === "yearling" || name.includes("育成牛") },
{ key: "bull", name: "公牛", color: "#a6f06d", matcher: (key, name) => key === "bull" || name === "公牛" },
]
return definitions.map((definition) => { return definitions.map((definition) => {
const matchedRows = sourceRows.filter((item) => definition.matcher(String(item.key || ""), String(item.name || ""))) const matchedRows = sourceRows.filter((item) => definition.matcher(String(item.key || ""), String(item.name || "")))
@ -4526,12 +4597,7 @@ function buildStructureCompositionRows(rows = [], fallbackRows = []) {
function isCoreStructureCategory(item = {}) { function isCoreStructureCategory(item = {}) {
const key = String(item.key || "") const key = String(item.key || "")
const name = String(item.name || "") const name = String(item.name || "")
return ["adultCow", "otherAdult", "calf", "yearling", "bull"].includes(key) return getStructureCategoryDefinitions().some((definition) => definition.matcher(key, name))
|| name === "成年母牛"
|| name.includes("其他成年")
|| name.includes("幼畜")
|| name.includes("育成牛")
|| name === "公牛"
} }
function buildBreedingHealthRows(rows = [], fallbackRows = []) { function buildBreedingHealthRows(rows = [], fallbackRows = []) {
@ -4555,31 +4621,31 @@ function buildBreedingHealthRows(rows = [], fallbackRows = []) {
return [ return [
buildMetric( buildMetric(
"adult-cow", "fertile-cow",
"成年母牛", "适龄能繁母牛",
"#27f0c0", "#27f0c0",
(key, name) => key === "adultCow" || name === "成年母牛", (key, name) => key === "fertileCow" || name.includes("适龄能繁母牛"),
"繁殖基础", "繁殖基础",
), ),
buildMetric( buildMetric(
"calf", "calf",
"幼畜", "犊牛合计",
"#ffd76c", "#ffd76c",
(key, name) => key === "calf" || name.includes("幼畜"), (key, name) => ["femaleCalf", "maleCalf"].includes(key) || name.includes("犊牛"),
"新生补充", "新生补充",
), ),
buildMetric( buildMetric(
"yearling", "overage-cow",
"育成牛", "过龄能繁母牛",
"#30dcff", "#30dcff",
(key, name) => key === "yearling" || name.includes("育成牛"), (key, name) => key === "overageCow" || name.includes("过龄能繁母牛"),
"后备梯队", "更新压力",
), ),
buildMetric( buildMetric(
"bull", "breeding-bull",
"公牛配比", "公牛配比",
"#7aa7ff", "#7aa7ff",
(key, name) => key === "bull" || name === "公牛", (key, name) => key === "breedingBull" || name.includes("种公牛"),
"配种支撑", "配种支撑",
), ),
] ]
@ -4588,53 +4654,52 @@ function buildBreedingHealthRows(rows = [], fallbackRows = []) {
function isCoreBreedingCategory(item = {}) { function isCoreBreedingCategory(item = {}) {
const key = String(item.key || "") const key = String(item.key || "")
const name = String(item.name || "") const name = String(item.name || "")
return ["adultCow", "calf", "yearling", "bull"].includes(key) return ["fertileCow", "femaleCalf", "maleCalf", "overageCow", "breedingBull"].includes(key)
|| name === "成年母牛" || name.includes("适龄能繁母牛")
|| name.includes("幼畜") || name.includes("犊牛")
|| name.includes("育成牛") || name.includes("过龄能繁母牛")
|| name === "公牛" || name.includes("种公牛")
} }
function buildBreedingHealthStatus(rows = []) { function buildBreedingHealthStatus(rows = []) {
const valueOf = (key) => Number(rows.find((item) => item.key === key)?.percent || 0) const valueOf = (key) => Number(rows.find((item) => item.key === key)?.percent || 0)
const adultCow = valueOf("adult-cow") const fertileCow = valueOf("fertile-cow")
const calf = valueOf("calf") const calf = valueOf("calf")
const yearling = valueOf("yearling") const overageCow = valueOf("overage-cow")
const bull = valueOf("bull") const breedingBull = valueOf("breeding-bull")
const reserve = calf + yearling
if (adultCow >= 32 && reserve >= 18 && bull >= 3 && bull <= 12) { if (fertileCow >= 40 && calf >= 25 && breedingBull >= 2 && breedingBull <= 5 && overageCow <= 10) {
return { return {
label: "结构健康", label: "结构健康",
title: "繁殖基础稳定", title: "繁殖基础稳定",
summary: "母牛基础足,后备衔接稳", summary: "适龄母牛足,犊牛补充稳",
note: "保持繁殖群与后备梯队,持续观察季节波动。", note: "保持适龄能繁母牛、犊牛补充与种公牛配置稳定。",
} }
} }
if (reserve < 18) { if (calf < 25) {
return { return {
label: "结构健康", label: "结构健康",
title: "后备梯队稳中补强", title: "犊牛补充需关注",
summary: "基础稳定,后备可继续优化", summary: "繁殖基础稳定,补充端可优化",
note: "关注幼畜成活与育成牛补充,保持生产连续性。", note: "关注犊牛成活和季节性补栏,保持生产连续性。",
} }
} }
if (bull < 3) { if (breedingBull < 2) {
return { return {
label: "结构健康", label: "结构健康",
title: "繁殖基础总体稳定", title: "繁殖基础总体稳定",
summary: "母牛与后备梯队较稳", summary: "适龄母牛基础较稳",
note: "结合实际配种需求,平稳优化公牛配置。", note: "结合实际配种需求,平稳优化公牛配置。",
} }
} }
return { return {
label: "结构健康", label: "结构健康",
title: "畜群梯队较均衡", title: "生产阶段较均衡",
summary: "繁殖群和后备牛连续支撑", summary: "繁殖群和犊牛补充连续支撑",
note: "延续结构管理,优先保障母牛与后备牛质量。", note: "延续结构管理,优先保障适龄母牛与犊牛质量。",
} }
} }
@ -5006,19 +5071,19 @@ function formatSignedWan(value) {
.structure-ratio-card { .structure-ratio-card {
display: grid; display: grid;
grid-template-columns: 176px minmax(0, 1fr); grid-template-columns: 146px minmax(0, 1fr);
align-items: center; align-items: center;
gap: 4px; gap: 10px;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
padding: 4px 12px 7px 8px; padding: 2px 10px 6px 8px;
box-sizing: border-box; box-sizing: border-box;
} }
.structure-ratio-chart { .structure-ratio-chart {
position: relative; position: relative;
width: 172px; width: 142px;
height: 142px; height: 132px;
min-width: 0; min-width: 0;
svg { svg {
@ -5034,19 +5099,20 @@ function formatSignedWan(value) {
justify-content: center; justify-content: center;
min-width: 0; min-width: 0;
height: 100%; height: 100%;
gap: 8px; gap: 5px;
overflow: hidden; overflow: hidden;
} }
.structure-ratio-row { .structure-ratio-row {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: 14px minmax(0, 1fr) 62px 24px; grid-template-columns: 11px minmax(0, 1fr) 50px 16px;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
min-width: 0; min-width: 0;
min-height: 28px; min-height: 22px;
padding: 0 2px; height: 23px;
padding: 0 5px 0 3px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(48, 220, 255, 0.12); border: 1px solid rgba(48, 220, 255, 0.12);
background: rgba(48, 220, 255, 0.055); background: rgba(48, 220, 255, 0.055);
@ -5071,8 +5137,8 @@ function formatSignedWan(value) {
i { i {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 12px; width: 9px;
height: 12px; height: 9px;
border: 1px solid #24f6c4; border: 1px solid #24f6c4;
border-radius: 50%; border-radius: 50%;
box-sizing: border-box; box-sizing: border-box;
@ -5082,14 +5148,26 @@ function formatSignedWan(value) {
span { span {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex;
align-items: baseline;
gap: 4px;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
font-size: 16px; font-size: 13px;
line-height: 18px; line-height: 15px;
font-weight: 800; font-weight: 800;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: normal; white-space: nowrap;
small {
flex: none;
color: rgba(126, 251, 246, 0.72);
font-family: D-DIN, Arial, sans-serif;
font-size: 10px;
font-weight: 800;
line-height: 12px;
}
} }
strong { strong {
@ -5097,9 +5175,9 @@ function formatSignedWan(value) {
z-index: 1; z-index: 1;
color: #ffffff; color: #ffffff;
font-family: D-DIN, Arial, sans-serif; font-family: D-DIN, Arial, sans-serif;
font-size: 21px; font-size: 18px;
font-weight: 800; font-weight: 800;
line-height: 23px; line-height: 20px;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
@ -5108,7 +5186,7 @@ function formatSignedWan(value) {
position: relative; position: relative;
z-index: 1; z-index: 1;
color: rgba(255, 255, 255, 0.54); color: rgba(255, 255, 255, 0.54);
font-size: 12px; font-size: 10px;
font-style: normal; font-style: normal;
white-space: nowrap; white-space: nowrap;
} }
@ -5140,18 +5218,6 @@ function formatSignedWan(value) {
} }
} }
.structure-rose-guides line {
stroke-width: 1;
stroke-opacity: 0.5;
}
.structure-rose-labels text {
fill: rgba(226, 252, 255, 0.68);
font-size: 8px;
font-weight: 600;
dominant-baseline: middle;
}
.structure-rose-glow { .structure-rose-glow {
fill: rgba(36, 246, 196, 0.1); fill: rgba(36, 246, 196, 0.1);
stroke: rgba(48, 220, 255, 0.24); stroke: rgba(48, 220, 255, 0.24);
@ -5165,6 +5231,20 @@ function formatSignedWan(value) {
stroke-width: 1.2; stroke-width: 1.2;
} }
.structure-rose-center {
fill: rgba(231, 254, 255, 0.86);
font-size: 9px;
font-weight: 800;
dominant-baseline: middle;
pointer-events: none;
&.is-sub {
fill: rgba(231, 254, 255, 0.54);
font-size: 7px;
font-weight: 700;
}
}
.structure-summary-card { .structure-summary-card {
display: grid; display: grid;
grid-template-rows: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(4, minmax(0, 1fr));

Loading…
Cancel
Save