fix: refine screen issue acceptance updates

V2.0
M先生 4 weeks ago
parent 610bfc5319
commit 2c5ae7c42f
  1. 4
      src/config/layers.js
  2. 11
      src/services/homeVideoApi.js
  3. 56
      src/views/forestGrassWet/map.js
  4. 26
      src/views/gdMap/map.js
  5. 211
      src/views/grassLivestockBalance/index.vue
  6. 116
      src/views/home/index.vue
  7. 108
      src/views/pastureManagement/index.vue
  8. 18
      src/views/yakManagement/YakOlMap.vue
  9. 38
      src/views/yakManagement/data.js
  10. 41
      src/views/yakManagement/index.vue

@ -15,11 +15,11 @@ export const SATELLITE_TEXTURE_ZOOM = 9
export const NATIONAL_PARK_ZONING_COLORS = {
core: {
fillColor: "#24F6C4",
strokeColor: "#B7FFF2",
strokeColor: "#FFB15E",
},
general: {
fillColor: "#FFE06B",
strokeColor: "#FFF2A0",
strokeColor: "#FF8F3D",
},
}

@ -14,6 +14,10 @@ const HONGYUAN_TOWN_NAMES = [
"麦洼乡",
"色地镇",
]
const MONITORING_FALLBACK_ROWS = [
{ id: "fallback-qiongxi-pasture", name: "邛溪镇家庭生态牧场监控", number: "CAM-001", statusName: "预览点位" },
{ id: "fallback-wache-grassland", name: "瓦切镇草场监控", number: "CAM-002", statusName: "预览点位" },
]
function joinBase(path) {
if (/^https?:\/\//.test(path)) return path
@ -139,11 +143,18 @@ function normalizeMonitoring(row, index, total, validTotal) {
}
export async function getHomeMonitorings(params = {}) {
try {
const data = await requestJson(joinBase("/api/home/monitorings"), {
params,
timeout: 18000,
}).then(unwrapResponse)
const rows = Array.isArray(data) ? data : data?.rows || data?.list || []
const playableRows = rows.filter(isPlayableMonitoring)
if (playableRows.length) {
return playableRows.map((row, index) => normalizeMonitoring(row, index, rows.length, playableRows.length))
}
} catch (error) {
console.warn("[homeVideo] fallback monitorings used", error)
}
return MONITORING_FALLBACK_ROWS.map((row, index) => normalizeMonitoring(row, index, MONITORING_FALLBACK_ROWS.length, 0))
}

@ -96,6 +96,11 @@ function toFiniteNumber(value, fallback = 0) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}
function clampNumber(value, min, max) {
const number = Number(value)
if (!Number.isFinite(number)) return min
return Math.max(min, Math.min(max, number))
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
@ -1592,20 +1597,29 @@ export class World extends Mini3d {
const baseStyle = this.getVectorFeatureStyle(layer, feature)
this.drawVectorFeature(context, feature.geometry, {
...baseStyle,
fill: "#001D2C",
stroke: "#001D2C",
fill: "#351A05",
stroke: "#351A05",
polygonAlpha: 0,
lineAlpha: 0.58,
lineWidth: 12,
lineAlpha: 0.52,
lineWidth: 9,
glow: true,
})
this.drawVectorFeature(context, feature.geometry, {
...baseStyle,
fill: "#F8FFFF",
stroke: "#F8FFFF",
fill: "#FFE3AA",
stroke: "#FFE3AA",
polygonAlpha: 0,
lineAlpha: 0.96,
lineWidth: 3.2,
lineAlpha: 0.98,
lineWidth: 2.8,
glow: false,
})
this.drawVectorFeature(context, feature.geometry, {
...baseStyle,
fill: "#FF8F3D",
stroke: "#FF8F3D",
polygonAlpha: 0,
lineAlpha: 0.9,
lineWidth: 1.45,
glow: false,
})
})
@ -2332,11 +2346,11 @@ export class World extends Mini3d {
return {
fill: legend.fillColor || "#30DCFF",
stroke: legend.strokeColor || "#F2FFFF",
polygonAlpha: 0.36,
lineAlpha: 1,
lineWidth: 5.8,
polygonAlpha: 0.28,
lineAlpha: 0.95,
lineWidth: 3.2,
pointRadius: 4.5,
glow: true,
glow: false,
}
}
if (layer?.boundaryOnly) {
@ -3916,6 +3930,8 @@ export class World extends Mini3d {
label.setParent(labelGroup)
label.userData = {
...province,
labelType: "town-label",
baseScale: 0.018,
townName: province.name,
}
return label
@ -5362,9 +5378,25 @@ export class World extends Mini3d {
geoProjection(args) {
return geoMercator().center(this.geoProjectionCenter).scale(this.geoProjectionScale).translate([0, 0])(args)
}
updateTownLabelScale() {
const labels = this.townNameLabels || this.otherLabel || []
const camera = this.camera?.instance
const target = this.camera?.controls?.target
if (!labels.length || !camera || !target) return
const distance = camera.position.distanceTo(target)
const scale = clampNumber(distance * 0.00072, 0.011, 0.026)
const opacity = distance > 45 ? "0.76" : distance < 22 ? "0.94" : "1"
labels.forEach((label) => {
if (label?.userData?.labelType !== "town-label") return
label.scale.set(scale, scale, scale)
const element = label.element?.querySelector?.(".other-label")
if (element) element.style.opacity = opacity
})
}
update(delta) {
super.update(delta)
this.updateDynamicSatelliteTiles()
this.updateTownLabelScale()
this.updateTradeFlowPulses(delta)
this.interactionManager && this.interactionManager.update()
}

@ -71,6 +71,11 @@ function normalizeMapGeoJsonData(data) {
})
return parsed
}
function clampNumber(value, min, max) {
const number = Number(value)
if (!Number.isFinite(number)) return min
return Math.max(min, Math.min(max, number))
}
export class World extends Mini3d {
constructor(canvas, assets, options = {}) {
super(canvas)
@ -966,6 +971,11 @@ export class World extends Mini3d {
)
label3d.setLabelStyle(label, 0.018, "x")
label.setParent(labelGroup)
label.userData = {
...province,
labelType: "town-label",
baseScale: 0.018,
}
return label
}
function labelStyle03(data, label3d, labelGroup) {
@ -1325,8 +1335,24 @@ export class World extends Mini3d {
geoProjection(args) {
return geoMercator().center(this.geoProjectionCenter).scale(this.geoProjectionScale).translate([0, 0])(args)
}
updateTownLabelScale() {
const labels = this.otherLabel || []
const camera = this.camera?.instance
const target = this.camera?.controls?.target
if (!labels.length || !camera || !target) return
const distance = camera.position.distanceTo(target)
const scale = clampNumber(distance * 0.00072, 0.011, 0.026)
const opacity = distance > 45 ? "0.76" : distance < 22 ? "0.94" : "1"
labels.forEach((label) => {
if (label?.userData?.labelType !== "town-label") return
label.scale.set(scale, scale, scale)
const element = label.element?.querySelector?.(".other-label")
if (element) element.style.opacity = opacity
})
}
update() {
super.update()
this.updateTownLabelScale()
this.interactionManager && this.interactionManager.update()
}
pauseScene() {

@ -204,6 +204,27 @@
</div>
</div>
</div>
<div class="balance-overload-ranking">
<div class="balance-section-title">
<span>超载率排行</span>
<small>乡镇 Top10</small>
</div>
<div class="balance-rank-list">
<div
v-for="item in overloadRankRows"
:key="`overload-${item.key || item.name}`"
class="balance-rank-row"
:class="getOverloadRankClass(item)"
:style="{ '--rank-progress': `${getOverloadRankProgress(item)}%` }"
>
<span>{{ item.rank }}</span>
<strong>{{ item.name }}</strong>
<div><i></i></div>
<em>{{ formatPercent(item.overloadRate) }}</em>
</div>
</div>
</div>
</div>
</section>
</div>
@ -437,6 +458,36 @@ const workflowRows = computed(() => [
])
const activeAdviceRows = computed(() => analysisDone.value ? splitAdvice(activeBalance.value, activeDerived.value.risk) : [])
const overloadRankSourceRows = computed(() =>
townRows.value
.filter((item) => Number.isFinite(Number(item.overloadRate)))
.slice()
.sort((a, b) => Number(b.overloadRate) - Number(a.overloadRate))
.slice(0, 10)
)
const overloadRankMax = computed(() => Math.max(
1,
...overloadRankSourceRows.value.map((item) => Math.max(0, Number(item.overloadRate) || 0)),
))
const overloadRankRows = computed(() =>
overloadRankSourceRows.value.map((item, index) => ({
...item,
rank: index + 1,
}))
)
function getOverloadRankProgress(item) {
const rate = Math.max(0, Number(item?.overloadRate) || 0)
return Math.max(6, Math.min(100, (rate / overloadRankMax.value) * 100))
}
function getOverloadRankClass(item) {
const rate = Number(item?.overloadRate)
if (!Number.isFinite(rate)) return "is-empty"
if (rate >= 2.5) return "is-alert"
if (rate >= 2) return "is-warning"
return "is-stable"
}
function updateScreenViewportHeight() {
screenViewportHeight.value = typeof window === "undefined" ? 1080 : window.innerHeight
@ -509,8 +560,9 @@ function startAnalysis() {
let requestFailed = false
const analysisRequest = refreshActiveBalance().catch((error) => {
requestFailed = true
analysisError.value = error?.message || "未查询到该对象的草畜平衡结果"
return null
console.warn("[grassLivestockBalance] fallback balance row used", error)
analysisError.value = "接口暂不可用,已使用本地兜底口径"
return activeBalance.value
})
activeAnalysisRequest = analysisRequest
analysisTimer = window.setInterval(() => {
@ -530,12 +582,15 @@ function startAnalysis() {
if (destroyed || analysisState.value !== "running" || activeAnalysisRequest !== analysisRequest) return
activeAnalysisRequest = null
clearAnalysisTimer()
if (requestFailed || !apiResults.value[activeBalance.value?.key]) {
if (!activeBalance.value) {
analysisProgress.value = 0
processStageIndex.value = 0
analysisState.value = "idle"
return
}
if (!requestFailed && !apiResults.value[activeBalance.value?.key]) {
analysisError.value = "未查询到接口结果,已使用本地兜底口径"
}
analysisProgress.value = 100
processStageIndex.value = analysisProcessRows.value.length - 1
analysisState.value = "done"
@ -1997,13 +2052,14 @@ function getProcessStepState(index) {
.balance-detail {
display: grid;
grid-template-columns: minmax(0, 1.26fr) minmax(0, 0.74fr);
gap: 15px;
grid-template-columns: minmax(0, 1.04fr) minmax(0, 0.82fr) 248px;
gap: 12px;
min-height: 0;
}
.balance-process,
.balance-decision {
.balance-decision,
.balance-overload-ranking {
position: relative;
min-width: 0;
min-height: 0;
@ -2183,6 +2239,120 @@ function getProcessStepState(index) {
gap: 12px;
}
.balance-overload-ranking {
display: grid;
grid-template-rows: 28px minmax(0, 1fr);
gap: 10px;
}
.balance-rank-list {
position: relative;
z-index: 1;
display: grid;
align-content: start;
gap: 5px;
min-height: 0;
overflow: hidden;
}
.balance-rank-row {
position: relative;
display: grid;
grid-template-columns: 24px 58px minmax(0, 1fr) 50px;
align-items: center;
gap: 7px;
height: 25px;
min-width: 0;
padding: 0 8px 0 6px;
background:
linear-gradient(90deg, rgba(48, 220, 255, 0.08), transparent 62%),
rgba(4, 33, 46, 0.62);
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 0 100%);
box-sizing: border-box;
&::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid rgba(48, 220, 255, 0.08);
clip-path: inherit;
pointer-events: none;
}
> span {
position: relative;
z-index: 1;
width: 20px;
height: 18px;
color: rgba(126, 251, 246, 0.9);
font-family: D-DIN, Arial, sans-serif;
font-size: 12px;
line-height: 18px;
font-weight: 800;
text-align: center;
background: rgba(126, 251, 246, 0.08);
}
strong {
position: relative;
z-index: 1;
min-width: 0;
overflow: hidden;
color: rgba(237, 253, 255, 0.9);
font-size: 12px;
line-height: 18px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
div {
position: relative;
z-index: 1;
height: 5px;
overflow: hidden;
background: rgba(126, 251, 246, 0.08);
i {
display: block;
width: var(--rank-progress);
height: 100%;
background: linear-gradient(90deg, rgba(126, 251, 246, 0.35), #7efbf6);
box-shadow: 0 0 10px rgba(126, 251, 246, 0.22);
}
}
em {
position: relative;
z-index: 1;
color: #7efbf6;
font-family: D-DIN, Arial, sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 800;
text-align: right;
white-space: nowrap;
}
&.is-warning div i {
background: linear-gradient(90deg, rgba(255, 215, 108, 0.32), #ffd76c);
box-shadow: 0 0 10px rgba(255, 215, 108, 0.2);
}
&.is-warning em {
color: #ffd76c;
}
&.is-alert div i {
background: linear-gradient(90deg, rgba(255, 143, 107, 0.3), #ff8f6b);
box-shadow: 0 0 10px rgba(255, 143, 107, 0.24);
}
&.is-alert em {
color: #ffb15e;
}
}
.balance-conclusion {
position: relative;
z-index: 1;
@ -2542,7 +2712,8 @@ function getProcessStepState(index) {
.balance-dashboard,
.balance-compare,
.balance-process,
.balance-decision {
.balance-decision,
.balance-overload-ranking {
background: linear-gradient(180deg, rgba(7, 48, 62, 0.9), rgba(3, 28, 42, 0.9));
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 0 100%);
@ -2607,7 +2778,8 @@ function getProcessStepState(index) {
}
.balance-process,
.balance-decision {
.balance-decision,
.balance-overload-ranking {
padding: 14px 16px;
}
@ -2712,12 +2884,13 @@ function getProcessStepState(index) {
}
.balance-sheet.is-ready .balance-detail {
grid-template-columns: minmax(0, 1.18fr) minmax(360px, 0.82fr);
gap: 14px;
grid-template-columns: minmax(0, 1fr) minmax(318px, 0.78fr) 238px;
gap: 12px;
}
.balance-sheet.is-ready .balance-process,
.balance-sheet.is-ready .balance-decision {
.balance-sheet.is-ready .balance-decision,
.balance-sheet.is-ready .balance-overload-ranking {
overflow: hidden;
padding: 13px 15px;
}
@ -2732,6 +2905,11 @@ function getProcessStepState(index) {
gap: 10px;
}
.balance-sheet.is-ready .balance-overload-ranking {
grid-template-rows: 26px minmax(0, 1fr);
gap: 8px;
}
.balance-sheet.is-ready .balance-section-title {
height: 26px;
}
@ -2807,4 +2985,15 @@ function getProcessStepState(index) {
line-height: 18px;
-webkit-line-clamp: 2;
}
.balance-sheet.is-ready .balance-rank-list {
gap: 4px;
}
.balance-sheet.is-ready .balance-rank-row {
grid-template-columns: 22px 54px minmax(0, 1fr) 48px;
gap: 6px;
height: 23px;
padding-right: 7px;
}
</style>

@ -138,20 +138,27 @@
:disabled="homeVideoRows.length <= 1"
@click="showPrevVideo"
></button>
<div class="home-video-grid" :class="{ 'is-single': visibleHomeVideos.length <= 1 }">
<div
:key="panel.live.key"
:ref="setHomeVideoScreenRef"
v-for="item in visibleHomeVideos"
:key="item.key"
:ref="(el) => setHomeVideoItemRef(el, item)"
class="home-video-screen"
:class="{ 'is-playing': videoPlaying, 'is-loading': videoLoading }"
:style="videoScreenStyle(panel.live)"
@click="playCurrentVideo"
:class="{
'is-active': item.isActive,
'is-playing': videoPlaying && item.isActive,
'is-loading': videoLoading && item.isActive,
}"
:style="videoScreenStyle(item.live, item.isActive)"
@click="playHomeVideoAt(item.index)"
>
<div :id="videoElementId" class="home-video-player"></div>
<div v-if="!videoPlaying" class="home-video-scan"></div>
<div v-if="!videoPlaying && panel.live.hdPlayUrl" class="home-video-play"></div>
<div v-if="videoLoading" class="home-video-loading">加载中</div>
<div v-if="videoError" class="home-video-error">{{ videoError }}</div>
<div class="home-video-point-name">{{ panel.live.title }}</div>
<div v-if="item.isActive" :id="videoElementId" class="home-video-player"></div>
<div v-if="!videoPlaying || !item.isActive" class="home-video-scan"></div>
<div v-if="(!videoPlaying || !item.isActive) && item.live.hdPlayUrl" class="home-video-play"></div>
<div v-if="videoLoading && item.isActive" class="home-video-loading">加载中</div>
<div v-if="videoError && item.isActive" class="home-video-error">{{ videoError }}</div>
<div class="home-video-point-name">{{ item.live.title }}</div>
</div>
</div>
<button
class="home-video-nav home-video-next"
@ -237,8 +244,10 @@ import { chartTooltip, withChartDefaults } from "@/utils/chartTheme"
import {
buildForecastSeries,
estimatedStock,
formatForecastDayLabel,
forecastPeriods,
getForecastPeriod,
getForecastMonthCount,
structureRows,
} from "@/views/yakManagement/data"
import { balanceTownRows } from "@/views/grassLivestockBalance/data"
@ -417,6 +426,7 @@ const activeVideoIndex = computed(() => {
return (chartMotionTick.value + activeVideoManualIndex.value) % length
})
const currentHomeVideo = computed(() => homeVideoRows.value[activeVideoIndex.value] || emptyHomeVideo)
const visibleHomeVideos = computed(() => buildVideoPreviewRows(homeVideoRows.value, activeVideoIndex.value, emptyHomeVideo))
const leftPanels = computed(() => [
pickTopicPanel("grassland", "grassland-town"),
@ -833,6 +843,28 @@ function setHomeVideoScreenRef(el) {
homeVideoScreenRef.value = el || null
}
function setHomeVideoItemRef(el, item) {
if (item?.isActive) homeVideoScreenRef.value = el || null
}
function buildVideoPreviewRows(rows = [], activeIndex = 0, emptyRow = emptyHomeVideo) {
const sourceRows = Array.isArray(rows) ? rows : []
if (!sourceRows.length) {
return [{ key: `empty-${emptyRow.key || "video"}`, index: 0, live: emptyRow, isActive: true }]
}
const count = Math.min(2, sourceRows.length)
return Array.from({ length: count }, (_, offset) => {
const index = (activeIndex + offset + sourceRows.length) % sourceRows.length
const live = sourceRows[index] || emptyRow
return {
key: `${live.key || live.title || "video"}-${index}`,
index,
live,
isActive: index === activeIndex,
}
})
}
function startChartMotion() {
if (!props.active || destroyed) return
stopChartMotion()
@ -877,9 +909,9 @@ async function loadHomeMonitorings() {
}
}
function videoScreenStyle(live) {
function videoScreenStyle(live, isActive = true) {
const image = live?.image || liveSceneImage
return videoPlaying.value
return videoPlaying.value && isActive
? undefined
: {
backgroundImage: `linear-gradient(180deg, rgba(0, 16, 26, 0.08), rgba(0, 16, 26, 0.32)), url(${image})`,
@ -903,6 +935,21 @@ function stepVideo(step) {
chartMotionTick.value = 0
}
function playHomeVideoAt(index) {
const length = homeVideoRows.value.length
if (!length) {
void playCurrentVideo()
return
}
const targetIndex = ((Number(index) || 0) + length) % length
if (targetIndex !== activeVideoIndex.value || videoPlayer || videoLoading.value) {
stopVideoPlayer({ resetError: true })
}
activeVideoManualIndex.value = targetIndex
chartMotionTick.value = 0
void nextTick(playCurrentVideo)
}
function homeBalanceGaugeProgress(item) {
const value = Math.max(0, Number(item?.value || 0))
const maxValue = Math.max(5, ...homeBalanceRows.value.map((row) => Math.max(0, Number(row.value || 0))))
@ -1337,14 +1384,13 @@ function getForecastGranularity(key) {
function buildForecastSeriesByGranularity(period, granularityKey = "month") {
if (granularityKey === "month") return buildForecastSeries(period)
const dayCount = Math.max(1, Math.round(Number(period.months || 0) * 30))
const monthCount = getForecastMonthCount(period)
const dayCount = Math.max(1, Math.round(Math.max(1, monthCount - 1) * 30))
const stepDays = granularityKey === "week" ? 7 : 1
const count = Math.ceil(dayCount / stepDays) + 1
const labels = Array.from({ length: count }, (_, index) => {
const day = Math.min(dayCount, index * stepDays)
if (index === 0) return "2026-06"
if (day >= dayCount) return period.label
return granularityKey === "week" ? `${index}` : `${day}`
return formatForecastDayLabel(day)
})
const stock = labels.map((_, index) => {
const day = Math.min(dayCount, index * stepDays)
@ -2114,20 +2160,32 @@ function handleOutsideFeaturePointerDown(event) {
position: relative;
display: block;
height: 100%;
padding: 2px 18px;
padding: 2px 24px;
box-sizing: border-box;
pointer-events: auto;
}
.home-video-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
height: 100%;
min-height: 0;
}
.home-video-grid.is-single {
grid-template-columns: minmax(0, 1fr);
}
.home-video-screen {
position: relative;
width: 88%;
width: 100%;
height: 100%;
margin: 0 auto;
margin: 0;
min-width: 0;
overflow: hidden;
cursor: pointer;
border: 1px solid rgba(48, 220, 255, 0.34);
border: 1px solid rgba(48, 220, 255, 0.25);
background-color: #020d14;
background-position: center;
background-size: cover;
@ -2137,6 +2195,18 @@ function handleOutsideFeaturePointerDown(event) {
0 0 16px rgba(18, 145, 190, 0.14);
}
.home-video-screen.is-active {
border-color: rgba(126, 251, 246, 0.66);
box-shadow:
inset 0 0 0 1px rgba(10, 255, 214, 0.12),
inset 0 0 26px rgba(48, 220, 255, 0.18),
0 0 18px rgba(18, 202, 204, 0.18);
}
.home-video-screen:not(.is-active) {
opacity: 0.88;
}
.home-video-screen.is-playing {
background-image: none !important;
}
@ -2187,11 +2257,11 @@ function handleOutsideFeaturePointerDown(event) {
}
.home-video-prev {
left: 25px;
left: 18px;
}
.home-video-next {
right: 25px;
right: 18px;
}
.home-video-screen::before {

@ -39,20 +39,27 @@
>
</button>
<div class="pasture-video-grid" :class="{ 'is-single': visiblePastureVideos.length <= 1 }">
<div
:key="currentPastureVideo.key"
:ref="setPastureVideoScreenRef"
v-for="item in visiblePastureVideos"
:key="item.key"
:ref="(el) => setPastureVideoItemRef(el, item)"
class="pasture-video-screen"
:class="{ 'is-playing': pastureVideoPlaying, 'is-loading': pastureVideoLoading }"
:style="pastureVideoScreenStyle(currentPastureVideo)"
@click="playCurrentPastureVideo"
:class="{
'is-active': item.isActive,
'is-playing': pastureVideoPlaying && item.isActive,
'is-loading': pastureVideoLoading && item.isActive,
}"
:style="pastureVideoScreenStyle(item.live, item.isActive)"
@click="playPastureVideoAt(item.index)"
>
<div :id="pastureVideoElementId" class="pasture-video-player"></div>
<div v-if="!pastureVideoPlaying" class="pasture-video-scan"></div>
<div v-if="!pastureVideoPlaying && currentPastureVideo.hdPlayUrl" class="pasture-video-play"></div>
<div v-if="pastureVideoLoading" class="pasture-video-loading">加载中</div>
<div v-if="pastureVideoError" class="pasture-video-error">{{ pastureVideoError }}</div>
<div class="pasture-video-point-name">{{ currentPastureVideo.title }}</div>
<div v-if="item.isActive" :id="pastureVideoElementId" class="pasture-video-player"></div>
<div v-if="!pastureVideoPlaying || !item.isActive" class="pasture-video-scan"></div>
<div v-if="(!pastureVideoPlaying || !item.isActive) && item.live.hdPlayUrl" class="pasture-video-play"></div>
<div v-if="pastureVideoLoading && item.isActive" class="pasture-video-loading">加载中</div>
<div v-if="pastureVideoError && item.isActive" class="pasture-video-error">{{ pastureVideoError }}</div>
<div class="pasture-video-point-name">{{ item.live.title }}</div>
</div>
</div>
<button
class="pasture-video-nav pasture-video-next"
@ -190,7 +197,7 @@ const pastureVideoElementId = `pasture-video-player-${Math.random().toString(36)
const pastureVideoLoadTimeoutMs = 12000
const pastureMapTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png"
const pastureOlCenter = [103.07984, 33.029058]
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">1.2</CssParameter><CssParameter name="stroke-opacity">0.92</CssParameter></Stroke></PolygonSymbolizer></Rule></FeatureTypeStyle></UserStyle></NamedLayer></StyledLayerDescriptor>`
const pastureYakRecognitionLayer = {
key: pastureYakLayerKey,
sourceType: "wms",
@ -506,6 +513,7 @@ const activePastureVideoIndex = computed(() => {
return activePastureVideoManualIndex.value % length
})
const currentPastureVideo = computed(() => pastureVideoRows.value[activePastureVideoIndex.value] || emptyPastureVideo)
const visiblePastureVideos = computed(() => buildPastureVideoPreviewRows(pastureVideoRows.value, activePastureVideoIndex.value, emptyPastureVideo))
function normalizeVisualRows(rows = []) {
return (Array.isArray(rows) ? rows : [])
@ -596,9 +604,31 @@ function setPastureVideoScreenRef(el) {
pastureVideoScreenRef.value = el || null
}
function pastureVideoScreenStyle(live) {
function setPastureVideoItemRef(el, item) {
if (item?.isActive) pastureVideoScreenRef.value = el || null
}
function buildPastureVideoPreviewRows(rows = [], activeIndex = 0, emptyRow = emptyPastureVideo) {
const sourceRows = Array.isArray(rows) ? rows : []
if (!sourceRows.length) {
return [{ key: `empty-${emptyRow.key || "video"}`, index: 0, live: emptyRow, isActive: true }]
}
const count = Math.min(2, sourceRows.length)
return Array.from({ length: count }, (_, offset) => {
const index = (activeIndex + offset + sourceRows.length) % sourceRows.length
const live = sourceRows[index] || emptyRow
return {
key: `${live.key || live.title || "video"}-${index}`,
index,
live,
isActive: index === activeIndex,
}
})
}
function pastureVideoScreenStyle(live, isActive = true) {
const image = live?.image || liveSceneImage
return pastureVideoPlaying.value
return pastureVideoPlaying.value && isActive
? undefined
: {
backgroundImage: `linear-gradient(180deg, rgba(0, 16, 26, 0.08), rgba(0, 16, 26, 0.32)), url(${image})`,
@ -620,6 +650,20 @@ function stepPastureVideo(step) {
activePastureVideoManualIndex.value = (activePastureVideoIndex.value + step + length) % length
}
function playPastureVideoAt(index) {
const length = pastureVideoRows.value.length
if (!length) {
void playCurrentPastureVideo()
return
}
const targetIndex = ((Number(index) || 0) + length) % length
if (targetIndex !== activePastureVideoIndex.value || pastureVideoPlayer || pastureVideoLoading.value) {
stopPastureVideoPlayer({ resetError: true })
}
activePastureVideoManualIndex.value = targetIndex
void nextTick(playCurrentPastureVideo)
}
async function playCurrentPastureVideo() {
const live = currentPastureVideo.value
if (!live?.hdPlayUrl || !live?.token) {
@ -2233,20 +2277,32 @@ function isLngLatInPolygon(point, polygon) {
position: relative;
display: block;
height: 100%;
padding: 2px 18px;
padding: 2px 24px;
box-sizing: border-box;
pointer-events: auto;
}
.pasture-video-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
height: 100%;
min-height: 0;
}
.pasture-video-grid.is-single {
grid-template-columns: minmax(0, 1fr);
}
.pasture-video-screen {
position: relative;
width: 88%;
width: 100%;
height: 100%;
min-width: 0;
margin: 0 auto;
margin: 0;
overflow: hidden;
cursor: pointer;
border: 1px solid rgba(48, 220, 255, 0.34);
border: 1px solid rgba(48, 220, 255, 0.25);
background-color: #020d14;
background-position: center;
background-size: cover;
@ -2256,6 +2312,18 @@ function isLngLatInPolygon(point, polygon) {
0 0 16px rgba(18, 145, 190, 0.14);
}
.pasture-video-screen.is-active {
border-color: rgba(126, 251, 246, 0.66);
box-shadow:
inset 0 0 0 1px rgba(10, 255, 214, 0.12),
inset 0 0 26px rgba(48, 220, 255, 0.18),
0 0 18px rgba(18, 202, 204, 0.18);
}
.pasture-video-screen:not(.is-active) {
opacity: 0.88;
}
.pasture-video-screen.is-playing {
background-image: none !important;
}
@ -2310,11 +2378,11 @@ function isLngLatInPolygon(point, polygon) {
}
.pasture-video-prev {
left: 25px;
left: 18px;
}
.pasture-video-next {
right: 25px;
right: 18px;
}
.pasture-video-screen::before {

@ -865,18 +865,22 @@ function clearPastureFocus() {
function createTownLabelStyle(feature, resolution) {
const name = feature.get("name") || ""
const compact = resolution > 150
const hidden = resolution > 420
const viewResolution = Number(resolution) || 0
const compact = viewResolution > 180
const close = viewResolution < 55
const hidden = viewResolution > 520
if (hidden) return labelPointStyle
const fontSize = compact ? 12 : 13
const minWidthPadding = compact ? [5, 10, 5, 10] : [6, 12, 6, 12]
const fontSize = close ? 14 : compact ? 11 : 12.5
const minWidthPadding = close ? [6, 12, 6, 12] : compact ? [4, 8, 4, 8] : [5, 10, 5, 10]
const triangleRadius = close ? 5 : compact ? 3.8 : 4.4
const offsetY = close ? -20 : compact ? -15 : -17
return [
new Style({
image: new RegularShape({
points: 3,
radius: compact ? 4 : 5,
radius: triangleRadius,
rotation: Math.PI,
displacement: [0, 14],
displacement: [0, close ? 16 : compact ? 12 : 14],
fill: new Fill({ color: "rgba(126, 251, 246, 0.98)" }),
stroke: new Stroke({ color: "rgba(4, 28, 40, 0.95)", width: 1.2 }),
}),
@ -890,7 +894,7 @@ function createTownLabelStyle(feature, resolution) {
backgroundFill: new Fill({ color: "rgba(3, 21, 33, 0.9)" }),
backgroundStroke: new Stroke({ color: "rgba(126, 251, 246, 0.58)", width: 1.2 }),
padding: minWidthPadding,
offsetY: -17,
offsetY,
}),
}),
]

@ -120,6 +120,9 @@ export const forecastFactorRows = [
{ name: "幼畜存活", value: 73 },
]
const FORECAST_START_YEAR = 2026
const FORECAST_START_MONTH = 6
export function getForecastPeriod(key) {
return forecastPeriods.find((item) => item.key === key) || forecastPeriods[1]
}
@ -153,22 +156,43 @@ export function getTownForecastRows(period) {
}
export function buildForecastSeries(period) {
const count = period.months + 1
const count = getForecastMonthCount(period)
const labels = Array.from({ length: count }, (_, index) => {
if (index === 0) return "2026-06"
if (index === period.months) return period.label
return `+${index}`
return formatForecastMonthLabel(index)
})
const stock = labels.map((_, index) => {
const ratio = index / period.months
const ratio = getForecastStepRatio(index, count)
const seasonal = Math.sin(index / 2.6) * Math.sin(Math.PI * ratio) * Math.min(360, period.months * 18)
return Math.round(estimatedStock + (period.stock - estimatedStock) * ratio + seasonal)
})
const births = labels.map((_, index) => Math.round((period.births / period.months) * index))
const outflow = labels.map((_, index) => Math.round((period.outflow / period.months) * index))
const births = labels.map((_, index) => Math.round(period.births * getForecastStepRatio(index, count)))
const outflow = labels.map((_, index) => Math.round(period.outflow * getForecastStepRatio(index, count)))
return { labels, stock, births, outflow }
}
export function getForecastMonthCount(period = {}) {
return Math.max(1, Math.round(Number(period.months || 1)))
}
export function getForecastStepRatio(index, count) {
if (count <= 1) return 1
return Math.min(1, Math.max(0, index / (count - 1)))
}
export function formatForecastMonthLabel(monthOffset = 0) {
const date = new Date(Date.UTC(FORECAST_START_YEAR, FORECAST_START_MONTH - 1 + monthOffset, 1))
return `${String(date.getUTCFullYear()).slice(-2)}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`
}
export function formatForecastDayLabel(dayOffset = 0) {
const date = new Date(Date.UTC(FORECAST_START_YEAR, FORECAST_START_MONTH - 1, 1 + dayOffset))
return [
String(date.getUTCFullYear()).slice(-2),
String(date.getUTCMonth() + 1).padStart(2, "0"),
String(date.getUTCDate()).padStart(2, "0"),
].join("-")
}
function withStockCounts(rows, total) {
const baseRows = rows.map((item, index) => {
const exact = (total * item.ratio) / 100

@ -727,8 +727,10 @@ import {
canonicalTownName,
estimatedStock,
expertCorrection,
formatForecastDayLabel,
forecastFactorRows,
forecastPeriods,
getForecastMonthCount,
genderAgeRows,
getForecastPeriod,
getForecastStructureRows,
@ -1830,6 +1832,12 @@ const forecastTownOption = computed(() => buildHorizontalBarOption(forecastTownR
const forecastStructureOption = computed(() => {
const rows = forecastStructureRows.value
const legendRich = rows.reduce((acc, item, index) => {
const color = item.color || palette[index % palette.length]
acc[`name${index}`] = { color, fontSize: 10, fontWeight: 800 }
acc[`percent${index}`] = { color, fontSize: 10, fontFamily: "D-DIN, Arial, sans-serif", fontWeight: 800 }
return acc
}, {})
return {
color: palette,
tooltip: chartTooltip((params) => {
@ -1844,10 +1852,11 @@ const forecastStructureOption = computed(() => {
itemHeight: 8,
itemGap: 6,
formatter(name) {
const row = rows.find((item) => item.name === name)
return row ? `${name} ${formatPercent(row.ratio)}` : name
const index = rows.findIndex((item) => item.name === name)
const row = rows[index]
return row ? `{name${index}|${name}} {percent${index}|${formatPercent(row.ratio)}}` : name
},
textStyle: { color: "rgba(222, 250, 255, 0.8)", fontSize: 10 },
textStyle: { color: "rgba(222, 250, 255, 0.8)", fontSize: 10, rich: legendRich },
},
series: [
{
@ -4095,7 +4104,6 @@ function emitModuleStats() {
items: [
{ zh: "流通总量", value: tradeMetricValue("headCount"), unit: "头" },
{ zh: "出证记录", value: tradeMetricValue("recordCount"), unit: "条" },
{ zh: "交易销售", value: tradeMetricValue("saleHeadCount"), unit: "头" },
{ zh: "运输车辆", value: tradeMetricValue("vehicleCount"), unit: "辆" },
],
})
@ -4245,14 +4253,13 @@ function getForecastGranularity(key) {
function buildForecastSeriesByGranularity(period, granularityKey = "month") {
if (granularityKey === "month") return buildForecastSeries(period)
const dayCount = Math.max(1, Math.round(Number(period.months || 0) * 30))
const monthCount = getForecastMonthCount(period)
const dayCount = Math.max(1, Math.round(Math.max(1, monthCount - 1) * 30))
const stepDays = granularityKey === "week" ? 7 : 1
const count = Math.ceil(dayCount / stepDays) + 1
const labels = Array.from({ length: count }, (_, index) => {
const day = Math.min(dayCount, index * stepDays)
if (index === 0) return "2026-06"
if (day >= dayCount) return period.label
return granularityKey === "week" ? `${index}` : `${day}`
return formatForecastDayLabel(day)
})
const stock = labels.map((_, index) => {
const day = Math.min(dayCount, index * stepDays)
@ -5153,16 +5160,17 @@ function formatSignedWan(value) {
gap: 4px;
min-width: 0;
overflow: hidden;
color: rgba(255, 255, 255, 0.9);
color: var(--row-color);
font-size: 13px;
line-height: 15px;
font-weight: 800;
text-shadow: 0 0 9px color-mix(in srgb, var(--row-color) 42%, transparent);
text-overflow: ellipsis;
white-space: nowrap;
small {
flex: none;
color: rgba(126, 251, 246, 0.72);
color: color-mix(in srgb, var(--row-color) 78%, rgba(255, 255, 255, 0.65));
font-family: D-DIN, Arial, sans-serif;
font-size: 10px;
font-weight: 800;
@ -5173,12 +5181,13 @@ function formatSignedWan(value) {
strong {
position: relative;
z-index: 1;
color: #ffffff;
color: var(--row-color);
font-family: D-DIN, Arial, sans-serif;
font-size: 18px;
font-weight: 800;
line-height: 20px;
text-align: right;
text-shadow: 0 0 10px color-mix(in srgb, var(--row-color) 42%, transparent);
white-space: nowrap;
}
@ -5192,7 +5201,7 @@ function formatSignedWan(value) {
}
&.is-active {
color: #ffffff;
color: var(--row-color);
border-color: rgba(48, 220, 255, 0.22);
background: linear-gradient(90deg, rgba(48, 220, 255, 0.18), rgba(48, 220, 255, 0.04));
}
@ -5275,10 +5284,11 @@ function formatSignedWan(value) {
align-items: center;
gap: 8px;
overflow: hidden;
color: rgba(242, 254, 255, 0.88);
color: var(--structure-color);
font-size: 13px;
line-height: 17px;
font-weight: 800;
text-shadow: 0 0 9px color-mix(in srgb, var(--structure-color) 38%, transparent);
text-overflow: ellipsis;
white-space: nowrap;
}
@ -5295,12 +5305,13 @@ function formatSignedWan(value) {
strong {
grid-column: 3;
grid-row: 1;
color: #ffffff;
color: var(--structure-color);
font-family: D-DIN, Arial, sans-serif;
font-size: 14px;
line-height: 15px;
font-weight: 800;
text-align: right;
text-shadow: 0 0 10px color-mix(in srgb, var(--structure-color) 38%, transparent);
white-space: nowrap;
em {
@ -5336,7 +5347,7 @@ function formatSignedWan(value) {
small {
grid-column: 3;
grid-row: 2;
color: rgba(226, 252, 255, 0.78);
color: color-mix(in srgb, var(--structure-color) 76%, rgba(255, 255, 255, 0.72));
font-family: D-DIN, Arial, sans-serif;
font-size: 10px;
line-height: 11px;

Loading…
Cancel
Save