feat: optimize screen v2 map views

V2.0
M先生 1 month ago
parent 0a47026d7e
commit 4f02a8d41c
  1. 206
      package-lock.json
  2. 1
      package.json
  3. 5
      src/router/index.js
  4. 72
      src/services/ndviTimeline.js
  5. 227
      src/utils/chartTheme.js
  6. 894
      src/views/chartStyleGuide/index.vue
  7. 205
      src/views/ecologicalProtection/data.js
  8. 43
      src/views/ecologicalProtection/index.vue
  9. 323
      src/views/forestGrassWet/components/GrasslandNdviTimeline.vue
  10. 39
      src/views/forestGrassWet/components/LayerPanel.vue
  11. 88
      src/views/forestGrassWet/index.vue
  12. 219
      src/views/forestGrassWet/map.js
  13. 36
      src/views/forestGrassWet/map.vue
  14. 176
      src/views/forestGrassWet/satelliteTiles.js
  15. 36
      src/views/grassLivestockBalance/data.js
  16. 3
      src/views/grassLivestockBalance/index.vue
  17. 218
      src/views/home/index.vue
  18. 295
      src/views/platform/index.vue
  19. 28
      src/views/wisdomManagement/components/SmartChartRenderer.vue
  20. 785
      src/views/wisdomManagement/index.vue
  21. 720
      src/views/yakManagement/YakOlMap.vue
  22. 620
      src/views/yakManagement/index.vue
  23. 14
      vite.config.js

206
package-lock.json generated

@ -15,6 +15,7 @@
"ezuikit-js": "^0.7.2",
"gsap": "^3.12.2",
"normalize.css": "^8.0.1",
"ol": "^10.9.0",
"point-in-polygon": "^1.1.0",
"shpjs": "^6.2.0",
"three": "0.161.0",
@ -768,6 +769,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@petamoriken/float16": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
"license": "MIT"
},
"node_modules/@types/rbush": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
@ -915,6 +928,16 @@
"integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==",
"license": "MIT"
},
"node_modules/@zarrita/storage": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
"license": "MIT",
"dependencies": {
"reference-spec-reader": "^0.2.0",
"unzipit": "2.0.0"
}
},
"node_modules/autofit.js": {
"version": "3.2.8",
"resolved": "https://registry.npmmirror.com/autofit.js/-/autofit.js-3.2.8.tgz",
@ -993,6 +1016,12 @@
"node": ">=8"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
@ -1087,6 +1116,25 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/geotiff": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
"license": "MIT",
"dependencies": {
"@petamoriken/float16": "^3.9.3",
"lerc": "^3.0.0",
"pako": "^2.0.4",
"parse-headers": "^2.0.2",
"quick-lru": "^6.1.1",
"web-worker": "^1.5.0",
"xml-utils": "^1.10.2",
"zstddec": "^0.2.0"
},
"engines": {
"node": ">=10.19"
}
},
"node_modules/gsap": {
"version": "3.14.2",
"resolved": "https://registry.npmmirror.com/gsap/-/gsap-3.14.2.tgz",
@ -1134,6 +1182,12 @@
"node": ">=0.10.0"
}
},
"node_modules/lerc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
"license": "Apache-2.0"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@ -1188,12 +1242,79 @@
"integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==",
"license": "MIT"
},
"node_modules/numcodecs": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
"license": "MIT",
"dependencies": {
"fflate": "^0.8.0"
}
},
"node_modules/numcodecs/node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/ol": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
"license": "BSD-2-Clause",
"dependencies": {
"@types/rbush": "4.0.0",
"earcut": "^3.0.0",
"geotiff": "^3.0.5 || ^3.1.0-beta.0",
"pbf": "4.0.1",
"rbush": "^4.0.0",
"zarrita": "^0.7.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/openlayers"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parse-headers": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
"license": "MIT"
},
"node_modules/parsedbf": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parsedbf/-/parsedbf-2.0.0.tgz",
"integrity": "sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==",
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -1269,6 +1390,39 @@
}
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/rbush": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
"license": "MIT",
"dependencies": {
"quickselect": "^3.0.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz",
@ -1283,12 +1437,27 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reference-spec-reader": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
"license": "MIT"
},
"node_modules/resize-detector": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/resize-detector/-/resize-detector-0.3.0.tgz",
"integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==",
"license": "MIT"
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz",
@ -1380,6 +1549,15 @@
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/unzipit": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz",
@ -1536,6 +1714,12 @@
"vue": "^3.5.0"
}
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
"license": "Apache-2.0"
},
"node_modules/wkt-parser": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.5.tgz",
@ -1545,6 +1729,22 @@
"url": "https://github.com/sponsors/ahocevar"
}
},
"node_modules/xml-utils": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
"license": "CC0-1.0"
},
"node_modules/zarrita": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
"license": "MIT",
"dependencies": {
"@zarrita/storage": "^0.2.0",
"numcodecs": "^0.3.2"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
@ -1553,6 +1753,12 @@
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zstddec": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
"license": "MIT AND BSD-3-Clause"
}
}
}

@ -16,6 +16,7 @@
"ezuikit-js": "^0.7.2",
"gsap": "^3.12.2",
"normalize.css": "^8.0.1",
"ol": "^10.9.0",
"point-in-polygon": "^1.1.0",
"shpjs": "^6.2.0",
"three": "0.161.0",

@ -3,6 +3,7 @@ import { fetchCurrentUser, getAuthorizationToken, getLoginPath } from "@/service
const platform = () => import("@/views/platform/index.vue")
const login = () => import("@/views/login/index.vue")
const chartStyleGuide = () => import("@/views/chartStyleGuide/index.vue")
const router = createRouter({
history: createWebHashHistory(),
routes: [
@ -23,6 +24,10 @@ const router = createRouter({
path: "/forest-grass-wet",
component: platform,
},
{
path: "/chart-style-guide",
component: chartStyleGuide,
},
{
path: "/:pathMatch(.*)",

@ -0,0 +1,72 @@
const NDVI_TIMELINE_URL = "/jsyApi/api/map/ndvi_timeline"
export async function getGrasslandNdviTimeline() {
const response = await fetch(NDVI_TIMELINE_URL, {
credentials: "include",
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`)
error.status = response.status
throw error
}
const data = await response.json().catch(() => ({}))
if (data?.code === 401 || data?.code === "401") {
const error = new Error(data?.msg || data?.message || "NDVI 时间轴未授权")
error.status = 401
throw error
}
const rows = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
return normalizeNdviTimelineRows(rows)
}
function normalizeNdviTimelineRows(rows = []) {
const seen = new Set()
return rows
.map((row, index) => {
const layerName = normalizeTimelineLayerName(pickValue(row, ["url", "layerName", "layer_name", "layer", "name"]))
if (!layerName) return null
const rawDate = pickValue(row, ["date", "month", "time", "label"]) || layerName
const label = formatTimelineLabel(rawDate)
const key = `${layerName}-${rawDate || index}`
return {
key,
label,
date: String(rawDate),
layerName,
source: row,
}
})
.filter(Boolean)
.filter((item) => {
if (seen.has(item.key)) return false
seen.add(item.key)
return true
})
}
function pickValue(item, keys) {
for (const key of keys) {
if (item?.[key] !== undefined && item?.[key] !== null && item?.[key] !== "") {
return item[key]
}
}
return undefined
}
function normalizeTimelineLayerName(value) {
const text = String(value || "").trim()
if (!text) return ""
if (text.includes(":") || /^https?:\/\//i.test(text)) return text
return `ne:${text}`
}
function formatTimelineLabel(value) {
const text = String(value || "").trim()
if (!text) return ""
const match = text.match(/(\d{4})[-_/年. ]?(0?[1-9]|1[0-2])(?:月)?/)
if (match) return `${match[1]}.${String(Number(match[2])).padStart(2, "0")}`
return text
}

@ -124,11 +124,238 @@ export function withChartDefaults(option = {}) {
return normalized
}
export function createSystemBarOption({
categories = [],
rows = [],
unit = "",
name = "指标",
orientation = "vertical",
colorIndex = 0,
grid = {},
showLabel = true,
} = {}) {
const color = chartPalette[colorIndex % chartPalette.length]
const horizontal = orientation === "horizontal"
const data = rows.map((item, index) => ({
name: item.name || categories[index],
value: Number(item.value) || 0,
itemStyle: {
borderRadius: horizontal ? [0, 8, 8, 0] : [8, 8, 0, 0],
color: horizontal ? horizontalGradient(item.color || color, "rgba(48, 220, 255, 0.14)", item.color || color) : verticalGradient(item.color || color),
},
}))
return withChartDefaults({
color: chartPalette,
grid: chartGrid(horizontal ? { left: 72, right: showLabel ? 58 : 26, top: 24, bottom: 22, ...grid } : { left: 46, right: 24, top: 28, bottom: 42, ...grid }),
tooltip: chartTooltip((params) => `${params.marker}${params.name}${formatChartValue(params.value)}${unit}`),
xAxis: horizontal ? valueAxis() : categoryAxis(categories, { axisLabel: { rotate: categories.length > 6 ? 24 : 0 } }),
yAxis: horizontal ? categoryAxis(categories, { inverse: true, axisLabel: { color: "rgba(225, 249, 255, 0.82)" } }) : valueAxis(),
series: [
{
name,
type: "bar",
barWidth: horizontal ? 12 : categories.length > 7 ? 14 : 20,
data,
label: {
show: showLabel,
position: horizontal ? "right" : "top",
color: "#f7feff",
fontSize: 10,
formatter: ({ value }) => formatChartValue(value),
},
},
],
})
}
export function createSystemGroupedBarOption({ categories = [], series = [], unit = "", grid = {} } = {}) {
return withChartDefaults({
color: chartPalette,
grid: chartGrid({ left: 46, right: 20, top: 42, bottom: 42, ...grid }),
tooltip: chartTooltip((params) => formatAxisTooltip(params, unit), { trigger: "axis", axisPointer: { type: "shadow" } }),
legend: chartLegend({ top: 0, right: 4 }),
xAxis: categoryAxis(categories),
yAxis: valueAxis(),
series: series.map((item, index) => ({
name: item.name,
type: "bar",
barWidth: 10,
data: item.values || [],
itemStyle: {
borderRadius: [6, 6, 0, 0],
color: verticalGradient(chartPalette[index % chartPalette.length]),
},
})),
})
}
export function createSystemStackedBarOption({ categories = [], series = [], unit = "", grid = {} } = {}) {
return withChartDefaults({
color: chartPalette,
grid: chartGrid({ left: 48, right: 22, top: 42, bottom: 42, ...grid }),
tooltip: chartTooltip((params) => formatAxisTooltip(params, unit), { trigger: "axis", axisPointer: { type: "shadow" } }),
legend: chartLegend({ top: 0, right: 4 }),
xAxis: categoryAxis(categories),
yAxis: valueAxis(),
series: series.map((item, index) => ({
name: item.name,
type: "bar",
stack: "total",
barWidth: 18,
data: item.values || [],
itemStyle: {
color: verticalGradient(chartPalette[index % chartPalette.length]),
},
})),
})
}
export function createSystemLineOption({ categories = [], series = [], unit = "", area = false, grid = {} } = {}) {
return withChartDefaults({
color: chartPalette,
grid: chartGrid({ left: 46, right: 24, top: 42, bottom: 38, ...grid }),
tooltip: chartTooltip((params) => formatAxisTooltip(params, unit), {
trigger: "axis",
axisPointer: {
type: "line",
lineStyle: { color: "rgba(255, 224, 107, 0.54)" },
},
}),
legend: chartLegend({ top: 0, right: 4 }),
xAxis: categoryAxis(categories),
yAxis: valueAxis(),
series: series.map((item, index) => {
const color = item.color || chartPalette[index % chartPalette.length]
return {
name: item.name,
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 7,
data: item.values || [],
lineStyle: {
width: 3,
color,
shadowColor: `${color}55`,
shadowBlur: 10,
},
itemStyle: {
color: "#041522",
borderColor: color,
borderWidth: 2,
},
areaStyle: area
? {
color: verticalGradient(color, `${color}08`),
}
: undefined,
}
}),
})
}
export function createSystemPieOption({ rows = [], unit = "", name = "结构占比", rose = false, donut = true } = {}) {
return withChartDefaults({
color: chartPalette,
tooltip: chartTooltip((params) => `${params.marker}${params.name}${formatChartValue(params.value)}${unit}<br/>占比:${formatChartValue(params.percent)}%`),
legend: chartLegend({
orient: "vertical",
right: 6,
top: "middle",
itemWidth: 9,
itemHeight: 9,
}),
series: [
{
name,
type: "pie",
radius: donut ? ["45%", rose ? "74%" : "66%"] : rose ? [12, "72%"] : "64%",
center: ["36%", "52%"],
roseType: rose ? "radius" : undefined,
avoidLabelOverlap: true,
itemStyle: {
borderColor: "rgba(3, 18, 31, 0.94)",
borderWidth: 3,
},
label: {
color: "#f7feff",
fontSize: 10,
formatter: "{b}\n{d}%",
},
labelLine: {
length: 8,
length2: 8,
lineStyle: { color: "rgba(223, 250, 255, 0.42)" },
},
data: rows.map((row, index) => ({
name: row.name,
value: Number(row.value) || 0,
itemStyle: { color: row.color || chartPalette[index % chartPalette.length] },
})),
},
],
})
}
export function createSystemRadarOption({ indicators = [], rows = [], name = "综合评价" } = {}) {
return withChartDefaults({
color: chartPalette,
tooltip: chartTooltip(),
legend: chartLegend({ top: 0, right: 4 }),
radar: {
center: ["50%", "58%"],
radius: "62%",
indicator: indicators,
splitNumber: 4,
axisName: {
color: "rgba(223, 250, 255, 0.78)",
fontSize: 10,
},
axisLine: { lineStyle: { color: "rgba(48, 220, 255, 0.18)" } },
splitLine: { lineStyle: { color: "rgba(48, 220, 255, 0.16)" } },
splitArea: {
areaStyle: {
color: ["rgba(48, 220, 255, 0.04)", "rgba(36, 246, 196, 0.02)"],
},
},
},
series: [
{
name,
type: "radar",
data: rows.map((row, index) => ({
name: row.name,
value: row.values || [],
areaStyle: { color: `${chartPalette[index % chartPalette.length]}24` },
lineStyle: { color: chartPalette[index % chartPalette.length], width: 2 },
itemStyle: { color: chartPalette[index % chartPalette.length] },
})),
},
],
})
}
export function formatChartValue(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "--"
return Number.isInteger(number) ? number.toLocaleString("zh-CN") : number.toLocaleString("zh-CN", { maximumFractionDigits: 2 })
}
function stripAxisOverrides(overrides = {}) {
const { axisLabel, axisLine, axisTick, splitLine, ...rest } = overrides
return rest
}
function formatAxisTooltip(params, unit = "") {
const rows = Array.isArray(params) ? params : [params]
const title = rows[0]?.axisValueLabel || rows[0]?.name || ""
const body = rows
.map((item) => `${item.marker}${item.seriesName || item.name}${formatChartValue(item.value)}${unit}`)
.join("<br/>")
return `${title}<br/>${body}`
}
function normalizeChartTypography(target) {
if (!target || typeof target !== "object") return
const styleKeys = ["textStyle", "axisLabel", "nameTextStyle", "label", "axisName"]

@ -0,0 +1,894 @@
<template>
<div class="chart-guide-screen">
<div class="guide-bg guide-bg-grid"></div>
<div class="guide-bg guide-bg-glow"></div>
<header class="guide-header">
<div>
<div class="guide-kicker">Chart Style System</div>
<h1>图表样式规范</h1>
</div>
<div class="guide-palette" aria-label="系统图表色板">
<span
v-for="color in paletteItems"
:key="color"
:style="{ background: color }"
></span>
</div>
</header>
<main class="guide-layout">
<section class="guide-chart-grid" aria-label="系统图表示例">
<m-card
v-for="(card, index) in chartCards"
:key="card.key"
class="guide-chart-card"
:class="`is-${card.size || 'standard'}`"
:width="getCardSize(card).width"
:height="getCardSize(card).height"
:title="`${formatChartNo(index)} ${card.title}`"
:data-unit="card.unit"
>
<div class="guide-card-content">
<v-chart class="guide-chart" :option="card.option" autoresize />
</div>
</m-card>
</section>
</main>
</div>
</template>
<script setup>
import VChart from "vue-echarts"
import mCard from "@/components/mCard/index.vue"
import {
categoryAxis,
chartGrid,
chartLegend,
chartPalette,
chartTooltip,
createSystemBarOption,
createSystemGroupedBarOption,
createSystemLineOption,
createSystemPieOption,
createSystemRadarOption,
createSystemStackedBarOption,
formatChartValue,
valueAxis,
verticalGradient,
withChartDefaults,
} from "@/utils/chartTheme"
const paletteItems = chartPalette
const cardSizes = {
standard: { width: 398, height: 190, label: "398x190" },
tall: { width: 398, height: 392, label: "398x392" },
}
const townCategories = ["瓦切镇", "安曲镇", "邛溪镇", "龙日镇", "刷经寺镇", "麦洼乡", "查尔玛乡"]
const yakRows = [
{ name: "瓦切镇", value: 74721 },
{ name: "安曲镇", value: 65780 },
{ name: "邛溪镇", value: 63466 },
{ name: "龙日镇", value: 54691 },
{ name: "刷经寺镇", value: 45940 },
{ name: "麦洼乡", value: 38202 },
{ name: "查尔玛乡", value: 31766 },
]
const resourceCategories = ["草地", "湿地", "林地", "牧场", "保护地"]
const months = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月"]
const chartCards = [
{
key: "bar-vertical",
type: "柱状图",
title: "林草湿资源面积",
unit: "单位:万亩",
option: createSystemBarOption({
categories: resourceCategories,
rows: [
{ name: "草地", value: 486.2 },
{ name: "湿地", value: 82.4 },
{ name: "林地", value: 126.8 },
{ name: "牧场", value: 58.6 },
{ name: "保护地", value: 214.5 },
],
unit: "万亩",
name: "资源面积",
showLabel: false,
}),
},
{
key: "bar-horizontal",
type: "横向柱状图",
title: "乡镇牦牛存栏排行",
unit: "单位:头",
option: createSystemBarOption({
categories: townCategories,
rows: yakRows,
unit: "头",
name: "牦牛存栏",
orientation: "horizontal",
colorIndex: 1,
}),
},
{
key: "grouped-bar",
type: "分组柱状图",
title: "林草湿年度对比",
unit: "单位:万亩",
option: createSystemGroupedBarOption({
categories: ["2021", "2022", "2023", "2024", "2025"],
unit: "万亩",
series: [
{ name: "草地", values: [472, 476, 481, 486, 489] },
{ name: "湿地", values: [76, 79, 81, 82, 84] },
{ name: "林地", values: [119, 121, 124, 127, 130] },
],
}),
},
{
key: "stacked-bar",
type: "堆叠柱状图",
title: "草地利用结构",
unit: "单位:万亩",
option: createSystemStackedBarOption({
categories: ["瓦切", "安曲", "邛溪", "龙日", "刷经寺"],
unit: "万亩",
series: [
{ name: "可利用草地", values: [66, 52, 48, 44, 38] },
{ name: "禁牧草地", values: [18, 21, 16, 13, 12] },
{ name: "退化治理", values: [9, 7, 8, 6, 5] },
],
}),
},
{
key: "line-trend",
type: "折线图",
title: "牦牛存栏趋势",
unit: "单位:万头",
option: createSystemLineOption({
categories: months,
unit: "万头",
series: [
{ name: "2025年", values: [45.2, 45.8, 46.4, 47.1, 48.3, 49.1, 49.8, 50.2] },
{ name: "2024年", values: [42.9, 43.5, 44.2, 44.8, 45.9, 46.5, 47.1, 47.7] },
],
}),
},
{
key: "area-line",
type: "面积折线图",
title: "草地产草量走势",
unit: "单位:万吨",
option: createSystemLineOption({
categories: months,
unit: "万吨",
area: true,
series: [
{ name: "鲜草产量", values: [12.4, 14.1, 19.8, 28.6, 36.2, 43.5, 46.8, 41.2] },
],
}),
},
{
key: "donut",
type: "环图",
title: "湿地类型结构",
unit: "单位:%",
option: createSystemPieOption({
unit: "万亩",
name: "湿地结构",
rows: [
{ name: "沼泽湿地", value: 36.8 },
{ name: "河流湿地", value: 21.3 },
{ name: "湖泊湿地", value: 14.6 },
{ name: "人工湿地", value: 9.7 },
],
}),
},
{
key: "rose",
type: "玫瑰图",
title: "林地类型占比",
unit: "单位:万亩",
option: createSystemPieOption({
unit: "万亩",
name: "林地结构",
rose: true,
donut: false,
rows: [
{ name: "乔木林地", value: 42.6 },
{ name: "灌木林地", value: 35.2 },
{ name: "疏林地", value: 18.4 },
{ name: "未成林地", value: 8.7 },
],
}),
},
{
key: "radar",
type: "雷达图",
title: "生态资源综合评价",
unit: "单位:分",
size: "tall",
option: createSystemRadarOption({
indicators: [
{ name: "覆盖度", max: 100 },
{ name: "稳定性", max: 100 },
{ name: "承载力", max: 100 },
{ name: "保护等级", max: 100 },
{ name: "治理成效", max: 100 },
],
rows: [
{ name: "当前值", values: [86, 78, 72, 91, 83] },
{ name: "目标值", values: [92, 86, 84, 95, 90] },
],
}),
},
{
key: "dual-axis",
type: "柱线组合",
title: "草畜平衡压力趋势",
unit: "左:万头 / 右:%",
size: "tall",
option: createDualAxisOption(),
},
{
key: "gauge",
type: "仪表盘",
title: "草畜平衡预警指数",
unit: "单位:分",
option: createGaugeOption(),
},
{
key: "scatter",
type: "散点气泡图",
title: "牧户规模与识别风险",
unit: "X:头 / Y:%",
option: createScatterOption(),
},
{
key: "heatmap",
type: "热力图",
title: "巡护事件时段热力",
unit: "单位:件",
option: createHeatmapOption(),
},
{
key: "funnel",
type: "漏斗图",
title: "生态问题处置转化",
unit: "单位:件",
option: createFunnelOption(),
},
{
key: "progress-ring",
type: "进度环",
title: "治理任务完成率",
unit: "单位:%",
option: createProgressRingOption(),
},
{
key: "polar-bar",
type: "极坐标柱图",
title: "月度遥感识别强度",
unit: "单位:次",
option: createPolarBarOption(),
},
{
key: "pictorial",
type: "象形柱图",
title: "重点乡镇牦牛识别",
unit: "单位:千头",
option: createPictorialBarOption(),
},
{
key: "candlestick",
type: "K线图",
title: "牦牛交易价格波动",
unit: "单位:元/公斤",
size: "tall",
option: createCandlestickOption(),
},
{
key: "boxplot",
type: "箱线图",
title: "草地产草量区间",
unit: "单位:公斤/亩",
size: "tall",
option: createBoxplotOption(),
},
{
key: "waterfall",
type: "瀑布图",
title: "草地恢复面积贡献",
unit: "单位:万亩",
option: createWaterfallOption(),
},
{
key: "nested-pie",
type: "嵌套环图",
title: "林地保护类型结构",
unit: "单位:万亩",
size: "tall",
option: createNestedPieOption(),
},
]
function formatChartNo(index) {
return `C${String(index + 1).padStart(2, "0")}`
}
function getCardSize(card) {
return cardSizes[card.size || "standard"] || cardSizes.standard
}
function createDualAxisOption() {
const categories = ["1月", "2月", "3月", "4月", "5月", "6月"]
return withChartDefaults({
color: chartPalette,
grid: chartGrid({ left: 48, right: 48, top: 42, bottom: 38 }),
tooltip: chartTooltip((params) => axisRows(params), { trigger: "axis", axisPointer: { type: "shadow" } }),
legend: chartLegend({ top: 0, right: 4 }),
xAxis: categoryAxis(categories),
yAxis: [
valueAxis({ axisLabel: { formatter: "{value}" } }),
valueAxis({
axisLabel: { formatter: "{value}%" },
splitLine: { show: false },
}),
],
series: [
{
name: "理论载畜量",
type: "bar",
barWidth: 18,
data: [42.5, 43.2, 45.8, 48.1, 49.6, 51.2],
itemStyle: { borderRadius: [7, 7, 0, 0], color: verticalGradient(chartPalette[0]) },
},
{
name: "压力指数",
type: "line",
yAxisIndex: 1,
smooth: true,
symbolSize: 7,
data: [72, 75, 79, 83, 86, 88],
lineStyle: { width: 3, color: chartPalette[2], shadowColor: "rgba(255, 224, 107, .45)", shadowBlur: 10 },
itemStyle: { color: "#041522", borderColor: chartPalette[2], borderWidth: 2 },
},
],
})
}
function createGaugeOption() {
return withChartDefaults({
series: [
{
type: "gauge",
startAngle: 210,
endAngle: -30,
min: 0,
max: 100,
radius: "88%",
center: ["50%", "58%"],
progress: {
show: true,
width: 14,
itemStyle: { color: chartPalette[2] },
},
axisLine: {
lineStyle: {
width: 14,
color: [[1, "rgba(48, 220, 255, 0.14)"]],
},
},
axisTick: { show: false },
splitLine: { length: 8, lineStyle: { color: "rgba(225, 249, 255, 0.42)", width: 1 } },
axisLabel: { distance: 18, color: "rgba(225, 249, 255, 0.56)", fontSize: 10 },
pointer: { width: 4, length: "58%", itemStyle: { color: chartPalette[2] } },
anchor: { show: true, size: 7, itemStyle: { color: chartPalette[2] } },
title: { offsetCenter: [0, "52%"], color: "rgba(225, 249, 255, 0.68)", fontSize: 12 },
detail: {
valueAnimation: true,
offsetCenter: [0, "28%"],
color: "#ffe06b",
fontSize: 28,
fontWeight: 700,
formatter: "{value}",
},
data: [{ value: 78, name: "中度预警" }],
},
],
})
}
function createScatterOption() {
const rows = [
[280, 12, 18, "牧户A"],
[420, 18, 24, "牧户B"],
[510, 32, 42, "牧户C"],
[760, 26, 35, "牧户D"],
[980, 44, 58, "牧户E"],
[1210, 38, 52, "牧户F"],
[1460, 62, 75, "牧户G"],
]
return withChartDefaults({
grid: chartGrid({ left: 48, right: 26, top: 28, bottom: 38 }),
tooltip: chartTooltip((params) => {
const [count, risk, times, name] = params.value
return `${params.marker}${name}<br/>存栏:${formatChartValue(count)}头<br/>风险:${risk}%<br/>识别:${times}`
}),
xAxis: valueAxis({ name: "存栏", nameTextStyle: { color: "rgba(225, 249, 255, .56)" } }),
yAxis: valueAxis({ name: "风险", axisLabel: { formatter: "{value}%" }, nameTextStyle: { color: "rgba(225, 249, 255, .56)" } }),
series: [
{
name: "牧户",
type: "scatter",
symbolSize: (value) => Math.max(12, Math.min(42, value[2] / 2)),
data: rows,
itemStyle: {
color: "rgba(50, 220, 255, 0.72)",
borderColor: "#24f6c4",
borderWidth: 1,
shadowColor: "rgba(36, 246, 196, .35)",
shadowBlur: 12,
},
},
],
})
}
function createHeatmapOption() {
const days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
const periods = ["凌晨", "上午", "中午", "下午", "夜间"]
const data = periods.flatMap((period, y) => days.map((day, x) => [x, y, [4, 8, 11, 15, 20][y] + x * 2 + (x === 4 ? 8 : 0)]))
return withChartDefaults({
grid: chartGrid({ left: 50, right: 20, top: 34, bottom: 36 }),
tooltip: chartTooltip((params) => `${days[params.value[0]]} ${periods[params.value[1]]}<br/>事件:${params.value[2]}`),
xAxis: categoryAxis(days),
yAxis: categoryAxis(periods),
visualMap: {
min: 0,
max: 40,
show: false,
inRange: { color: ["rgba(48, 220, 255, .12)", "rgba(36, 246, 196, .72)", "#ffe06b"] },
},
series: [
{
type: "heatmap",
data,
label: { show: true, color: "#e7fbff", fontSize: 10 },
itemStyle: { borderColor: "rgba(3, 18, 31, .86)", borderWidth: 2 },
},
],
})
}
function createFunnelOption() {
return withChartDefaults({
color: chartPalette,
tooltip: chartTooltip((params) => `${params.marker}${params.name}${formatChartValue(params.value)}`),
series: [
{
type: "funnel",
left: "9%",
top: 18,
width: "82%",
height: "78%",
minSize: "32%",
maxSize: "100%",
sort: "descending",
gap: 4,
label: { color: "#e7fbff", fontSize: 11, formatter: "{b} {c}" },
labelLine: { lineStyle: { color: "rgba(225, 249, 255, .34)" } },
itemStyle: { borderColor: "rgba(3, 18, 31, .9)", borderWidth: 2 },
data: [
{ name: "问题发现", value: 168 },
{ name: "派单核查", value: 142 },
{ name: "处置整改", value: 118 },
{ name: "复核通过", value: 96 },
{ name: "闭环归档", value: 84 },
],
},
],
})
}
function createProgressRingOption() {
return withChartDefaults({
tooltip: chartTooltip((params) => `${params.marker}${params.name}${params.value}%`),
series: [
{
type: "pie",
radius: ["62%", "76%"],
center: ["50%", "52%"],
silent: true,
label: { show: false },
data: [
{ name: "已完成", value: 72, itemStyle: { color: chartPalette[0] } },
{ name: "未完成", value: 28, itemStyle: { color: "rgba(48, 220, 255, .12)" } },
],
},
{
type: "pie",
radius: ["42%", "46%"],
center: ["50%", "52%"],
silent: true,
label: {
position: "center",
formatter: "72%\n完成",
color: "#ffe06b",
fontSize: 22,
lineHeight: 30,
fontWeight: 700,
},
data: [{ value: 1, itemStyle: { color: "rgba(255, 224, 107, .28)" } }],
},
],
})
}
function createPolarBarOption() {
const monthsShort = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]
return withChartDefaults({
angleAxis: {
type: "category",
data: monthsShort,
axisLabel: { color: "rgba(225, 249, 255, .62)", fontSize: 10 },
axisLine: { lineStyle: { color: "rgba(48, 220, 255, .22)" } },
axisTick: { show: false },
},
radiusAxis: {
axisLabel: { color: "rgba(225, 249, 255, .44)", fontSize: 10 },
splitLine: { lineStyle: { color: "rgba(48, 220, 255, .1)" } },
axisLine: { show: false },
},
polar: { radius: "68%", center: ["50%", "54%"] },
tooltip: chartTooltip((params) => `${params.name}月:${formatChartValue(params.value)}`),
series: [
{
type: "bar",
coordinateSystem: "polar",
data: [18, 22, 26, 34, 46, 58, 64, 61, 45, 32, 24, 19],
itemStyle: { color: verticalGradient(chartPalette[1]) },
},
],
})
}
function createPictorialBarOption() {
const categories = ["瓦切", "安曲", "邛溪", "刷经寺"]
const values = [76, 63, 58, 41]
return withChartDefaults({
grid: chartGrid({ left: 42, right: 22, top: 28, bottom: 38 }),
tooltip: chartTooltip((params) => `${params.name}${params.value}千头`),
xAxis: categoryAxis(categories),
yAxis: valueAxis(),
series: [
{
type: "pictorialBar",
symbol: "roundRect",
symbolRepeat: true,
symbolSize: [18, 8],
symbolMargin: 3,
data: values,
itemStyle: { color: chartPalette[0] },
},
],
})
}
function createCandlestickOption() {
const days = ["01", "02", "03", "04", "05", "06", "07", "08", "09"]
return withChartDefaults({
grid: chartGrid({ left: 48, right: 24, top: 28, bottom: 38 }),
tooltip: chartTooltip((params) => {
const [open, close, low, high] = params.value
return `${params.name}日<br/>开盘:${open}<br/>收盘:${close}<br/>最低:${low}<br/>最高:${high}`
}),
xAxis: categoryAxis(days),
yAxis: valueAxis({ scale: true }),
series: [
{
type: "candlestick",
data: [
[31.2, 32.4, 30.8, 33.1],
[32.4, 31.9, 31.2, 33.5],
[31.9, 33.2, 31.5, 34.1],
[33.2, 34.0, 32.7, 34.6],
[34.0, 33.5, 32.9, 34.2],
[33.5, 35.1, 33.1, 35.8],
[35.1, 34.6, 33.8, 35.4],
[34.6, 36.0, 34.1, 36.4],
[36.0, 35.7, 35.2, 36.8],
],
itemStyle: {
color: chartPalette[0],
color0: chartPalette[4],
borderColor: chartPalette[0],
borderColor0: chartPalette[4],
},
},
],
})
}
function createBoxplotOption() {
return withChartDefaults({
grid: chartGrid({ left: 48, right: 24, top: 28, bottom: 38 }),
tooltip: chartTooltip((params) => `${params.name}<br/>最小:${params.value[1]}<br/>Q1:${params.value[2]}<br/>中位:${params.value[3]}<br/>Q3:${params.value[4]}<br/>最大:${params.value[5]}`),
xAxis: categoryAxis(["瓦切", "安曲", "邛溪", "龙日"]),
yAxis: valueAxis(),
series: [
{
type: "boxplot",
data: [
[120, 148, 176, 205, 238],
[96, 126, 158, 184, 220],
[142, 168, 196, 224, 252],
[110, 132, 160, 188, 216],
],
itemStyle: {
color: "rgba(36, 246, 196, .18)",
borderColor: chartPalette[0],
borderWidth: 2,
},
},
],
})
}
function createWaterfallOption() {
const categories = ["期初", "补播", "围栏", "退化", "灾害", "期末"]
const assist = [0, 486, 518, 542, 529, 0]
const values = [486, 32, 24, -13, -8, 521]
return withChartDefaults({
grid: chartGrid({ left: 48, right: 22, top: 28, bottom: 38 }),
tooltip: chartTooltip((params) => `${params.name}${formatChartValue(values[params.dataIndex])}万亩`),
xAxis: categoryAxis(categories),
yAxis: valueAxis(),
series: [
{ type: "bar", stack: "total", data: assist, itemStyle: { color: "transparent" }, emphasis: { disabled: true } },
{
type: "bar",
stack: "total",
barWidth: 18,
data: values.map((value) => Math.abs(value)),
itemStyle: {
borderRadius: [6, 6, 0, 0],
color: (params) => (values[params.dataIndex] < 0 ? chartPalette[4] : verticalGradient(chartPalette[0])),
},
},
],
})
}
function createNestedPieOption() {
return withChartDefaults({
color: chartPalette,
tooltip: chartTooltip((params) => `${params.marker}${params.name}${formatChartValue(params.value)}万亩`),
series: [
{
type: "pie",
radius: [0, "34%"],
center: ["50%", "52%"],
label: { color: "#e7fbff", fontSize: 10, position: "inside" },
data: [
{ name: "公益林", value: 68 },
{ name: "商品林", value: 34 },
],
},
{
type: "pie",
radius: ["46%", "70%"],
center: ["50%", "52%"],
label: { color: "#e7fbff", fontSize: 10, formatter: "{b}\n{d}%" },
labelLine: { length: 8, length2: 8, lineStyle: { color: "rgba(223, 250, 255, .38)" } },
data: [
{ name: "天然林", value: 42 },
{ name: "人工林", value: 26 },
{ name: "用材林", value: 18 },
{ name: "经济林", value: 16 },
],
},
],
})
}
function axisRows(params) {
const rows = Array.isArray(params) ? params : [params]
return rows
.map((item) => `${item.marker}${item.seriesName || item.name}${formatChartValue(item.value)}${item.seriesType === "line" ? "%" : ""}`)
.join("<br/>")
}
</script>
<style scoped lang="scss">
.chart-guide-screen {
position: relative;
height: 100vh;
padding: 32px 36px 42px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
color: #e7fbff;
background:
radial-gradient(circle at 50% 18%, rgba(36, 246, 196, 0.12), transparent 31%),
linear-gradient(180deg, #04101d 0%, #020914 46%, #010611 100%);
font-family: AlibabaPuHuiTi, "Microsoft YaHei", Arial, sans-serif;
}
.guide-bg {
position: fixed;
pointer-events: none;
inset: 0;
}
.guide-bg-grid {
opacity: 0.48;
background-image:
linear-gradient(rgba(48, 220, 255, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(48, 220, 255, 0.08) 1px, transparent 1px);
background-size: 58px 58px;
mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.72) 58%, transparent 100%);
}
.guide-bg-glow {
opacity: 0.68;
background:
radial-gradient(circle at 15% 18%, rgba(50, 220, 255, 0.13), transparent 24%),
radial-gradient(circle at 82% 28%, rgba(255, 224, 107, 0.09), transparent 22%);
}
.guide-header,
.guide-layout {
position: relative;
z-index: 1;
}
.guide-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 28px;
margin-bottom: 14px;
}
.guide-kicker {
margin-bottom: 8px;
color: #24f6c4;
font-size: 12px;
line-height: 1;
letter-spacing: 0;
text-transform: uppercase;
}
.guide-header h1 {
margin: 0;
color: #f4fdff;
font-size: 32px;
line-height: 1.08;
font-weight: 700;
text-shadow: 0 0 18px rgba(48, 220, 255, 0.3);
}
.guide-palette {
display: grid;
grid-template-columns: repeat(6, 34px);
gap: 8px;
padding: 10px;
border: 1px solid rgba(48, 220, 255, 0.26);
background: rgba(3, 26, 42, 0.72);
}
.guide-palette span {
display: block;
width: 34px;
height: 18px;
box-shadow: 0 0 14px currentColor;
}
.guide-layout {
display: block;
}
.guide-chart-grid {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.guide-chart-card {
flex: 0 0 auto;
}
.guide-chart-card::after {
content: attr(data-unit);
position: absolute;
top: 9px;
right: 16px;
z-index: 4;
max-width: 112px;
color: rgba(255, 224, 107, 0.92);
font-size: 12px;
line-height: 1;
font-weight: 700;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.guide-chart-card :deep(.m-card-hd-title) {
right: 150px;
color: #ffffff !important;
background: none !important;
-webkit-text-fill-color: #ffffff !important;
text-shadow: none !important;
}
.guide-chart-card :deep(.m-card-bd-content) {
top: 40px;
bottom: 10px;
color: #f3feff;
overflow: hidden;
}
.guide-chart-card :deep(.m-card-bd-bg) {
filter: brightness(1.08) saturate(1.04);
}
.guide-chart-card :deep(.m-card-bd-bg::after) {
content: "";
position: absolute;
inset: 4px 5px 6px;
z-index: 0;
pointer-events: none;
border: 1px solid rgba(48, 220, 255, 0.14);
background:
linear-gradient(180deg, rgba(17, 73, 92, 0.16), rgba(6, 29, 44, 0.14)),
linear-gradient(90deg, rgba(48, 220, 255, 0.1), transparent 42%, rgba(48, 220, 255, 0.06));
box-shadow:
inset 0 0 0 1px rgba(48, 220, 255, 0.06),
inset 0 12px 28px rgba(48, 220, 255, 0.04);
}
.guide-card-content {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.guide-chart {
width: 100%;
height: 100%;
}
@media (max-width: 1180px) {
.chart-guide-screen {
padding: 24px 20px 34px;
}
.guide-header {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 760px) {
.chart-guide-screen {
padding: 20px 14px 28px;
}
.guide-header h1 {
font-size: 26px;
}
.guide-palette {
grid-template-columns: repeat(3, 34px);
}
}
</style>

@ -285,13 +285,15 @@ export const ecologicalProtectionMenu = [
]
export const parkTownRows = [
{ name: "麦洼乡", grasslandAreaWanMu: 31.41, wetlandAreaWanMu: 11.16, ecologyJobs: 179, yakCount: 61720, livestockCount: 63507, cover: 85, villages: "洞拉村、滚塘村、让里村、茸塔玛村" },
{ name: "瓦切镇", grasslandAreaWanMu: 31.42, wetlandAreaWanMu: 11.15, ecologyJobs: 160, yakCount: 33887, livestockCount: 34589, cover: 85, villages: "唐日村、德香村、日干村" },
{ name: "色地镇", grasslandAreaWanMu: 31.42, wetlandAreaWanMu: 11.16, ecologyJobs: 39, yakCount: 9572, livestockCount: 9843, cover: 86, villages: "色永村" },
{ name: "麦洼乡", protectedAreaWanMu: 65.94, coreProtectionAreaWanMu: 28.69, generalControlAreaWanMu: 37.25, grasslandAreaWanMu: 31.41, wetlandAreaWanMu: 11.16, ecologyJobs: 179, yakCount: 61720, livestockCount: 63507, cover: 85, villages: "洞拉村、滚塘村、让里村、茸塔玛村" },
{ name: "瓦切镇", protectedAreaWanMu: 74.44, coreProtectionAreaWanMu: 31.1, generalControlAreaWanMu: 43.34, grasslandAreaWanMu: 31.42, wetlandAreaWanMu: 11.15, ecologyJobs: 160, yakCount: 33887, livestockCount: 34589, cover: 85, villages: "唐日村、德香村、日干村" },
{ name: "色地镇", protectedAreaWanMu: 20.93, coreProtectionAreaWanMu: 7.6, generalControlAreaWanMu: 13.33, grasslandAreaWanMu: 31.42, wetlandAreaWanMu: 11.16, ecologyJobs: 39, yakCount: 9572, livestockCount: 9843, cover: 86, villages: "色永村" },
]
export const parkSummary = {
name: "若尔盖国家公园红原区域",
nationalParkTotalAreaWanMu: 2074.39,
hongyuanDelimitedAreaWanMu: 161.31,
totalAreaWanMu: 161.31,
grasslandAreaWanMu: 94.25,
wetlandAreaWanMu: 33.47,
@ -311,9 +313,9 @@ parkSummary.otherProtectedAreaWanMu = round(
)
export const parkCoreRows = [
{ key: "park-total-area", name: "保护区总面积", value: parkSummary.totalAreaWanMu, unit: "万亩", icon: "area" },
{ key: "park-grassland", name: "草地面积", value: parkSummary.grasslandAreaWanMu, unit: "万亩", icon: "leaf" },
{ key: "park-wetland", name: "湿地面积", value: parkSummary.wetlandAreaWanMu, unit: "万亩", icon: "stack" },
{ key: "park-national-area", name: "国家公园总面积", value: parkSummary.nationalParkTotalAreaWanMu, unit: "万亩", icon: "area" },
{ key: "park-hongyuan-area", name: "红原县划定面积", value: parkSummary.hongyuanDelimitedAreaWanMu, unit: "万亩", icon: "area" },
{ key: "park-yak-count", name: "牦牛数量", value: parkSummary.yakCount, unit: "", icon: "stack" },
]
const parkResourceStructureRows = [
@ -339,12 +341,13 @@ const parkTownListRows = parkTownRows.map((item) => ({
}))
const parkZoningRows = [
{ key: "core-protection", name: "核心保护区", value: 67.36, unit: "万亩" },
{ key: "general-control", name: "一般控制区", value: 93.95, unit: "万亩" },
{ key: "core-protection", name: "核心保护区", value: 67.39, unit: "万亩" },
{ key: "general-control", name: "一般控制区", value: 93.92, unit: "万亩" },
]
const parkGrasslandProductivityWanTon = 43.66
const parkEdibleGrasslandWanTon = round(parkGrasslandProductivityWanTon * (192.26 / 297.29), 2)
const parkGrasslandCapacityPerMu = 0.06229833103624831
const parkVillageWeights = {
麦洼乡: [
{ name: "洞拉村", grassland: 0.29, wetland: 0.34, household: 0.3, yak: 0.32, edible: 0.31, woodland: 0.3 },
@ -364,6 +367,12 @@ const parkVillageWeights = {
const parkTownLivestockTotal = sumBy(parkTownRows, "livestockCount") || sumBy(parkTownRows, "yakCount") || 1
const parkTownBusinessRows = parkTownRows.map((item) => ({
...item,
value: item.protectedAreaWanMu,
unit: "万亩",
segments: {
core: item.coreProtectionAreaWanMu,
general: item.generalControlAreaWanMu,
},
householdCount: Math.round((parkSummary.householdCount * Number(item.livestockCount || item.yakCount || 0)) / parkTownLivestockTotal),
edibleGrasslandWanTon: round(
(Number(item.grasslandAreaWanMu || 0) / Math.max(parkSummary.grasslandAreaWanMu, 1)) * parkEdibleGrasslandWanTon,
@ -374,6 +383,25 @@ const parkHouseholdDelta = parkSummary.householdCount - sumBy(parkTownBusinessRo
if (parkHouseholdDelta && parkTownBusinessRows[0]) {
parkTownBusinessRows[0].householdCount += parkHouseholdDelta
}
parkTownBusinessRows.forEach((item) => {
const bearingCapacity = round(Number(item.grasslandAreaWanMu || 0) * 10000 * parkGrasslandCapacityPerMu, 0)
const livestockCapacity = Number(item.livestockCount || item.yakCount || 0)
item.bearingCapacity = bearingCapacity
item.livestockCapacity = livestockCapacity
item.overloadRate = bearingCapacity ? round(((livestockCapacity - bearingCapacity) / bearingCapacity) * 100, 1) : 0
item.searchText = [
item.name,
`划定面积${item.protectedAreaWanMu}万亩`,
`牦牛${item.yakCount}`,
`牧户${item.householdCount}`,
`超载率${item.overloadRate}%`,
].join(" ")
})
const parkBalanceBearingCapacity = sumBy(parkTownBusinessRows, "bearingCapacity")
const parkBalanceLivestockCapacity = sumBy(parkTownBusinessRows, "livestockCapacity")
parkSummary.balanceOverloadRate = parkBalanceBearingCapacity
? round(((parkBalanceLivestockCapacity - parkBalanceBearingCapacity) / parkBalanceBearingCapacity) * 100, 1)
: 0
const withParkPercentExtra = (rows, total = sumBy(rows, "value")) =>
rows.map((item) => ({
@ -438,11 +466,74 @@ const parkWoodlandVillageRows = buildParkVillageRows({
weightKey: "woodland",
})
const parkTownDisplayOrder = ["瓦切镇", "麦洼乡", "色地镇"]
const sortParkTownRows = (rows) => parkTownDisplayOrder
.map((name) => rows.find((item) => item.name === name))
.filter(Boolean)
const parkTownAreaRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
key: `${item.name}-area`,
name: item.name,
value: item.protectedAreaWanMu,
unit: "万亩",
extra: `核心 ${item.coreProtectionAreaWanMu} / 一般 ${item.generalControlAreaWanMu}`,
}))
const parkTownZoningRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
key: `${item.name}-zoning`,
name: item.name,
value: item.protectedAreaWanMu,
unit: "万亩",
segments: {
core: item.coreProtectionAreaWanMu,
general: item.generalControlAreaWanMu,
},
}))
const parkTownYakRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
key: `${item.name}-yak`,
name: item.name,
value: item.yakCount,
unit: "头",
extra: `${round((item.yakCount / Math.max(parkSummary.yakCount, 1)) * 100, 1)}%`,
}))
const parkTownBalanceRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
key: `${item.name}-balance`,
name: item.name,
value: item.overloadRate,
unit: "%",
extra: `载畜 ${item.livestockCapacity}`,
}))
const parkTownEcologyJobRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
key: `${item.name}-job`,
name: item.name,
value: item.ecologyJobs,
unit: "个",
extra: `牧户 ${item.householdCount}`,
}))
const parkTownLedgerRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
...item,
key: `${item.name}-ledger`,
name: item.name,
value: item.protectedAreaWanMu,
unit: "万亩",
canLocate: true,
}))
export const parkTopStats = [
{ zh: "国家公园面积", value: parkSummary.totalAreaWanMu, unit: "万亩", decimals: 2 },
{ zh: "国家公园总面积", value: parkSummary.nationalParkTotalAreaWanMu, unit: "万亩", decimals: 2 },
{ zh: "红原县划定面积", value: parkSummary.hongyuanDelimitedAreaWanMu, unit: "万亩", decimals: 2 },
{ zh: "牦牛数量", value: parkSummary.yakCount, unit: "头", decimals: 0 },
{ zh: "牧户数量", value: parkSummary.householdCount, unit: "户", decimals: 0 },
{ zh: "草畜平衡超载率", value: parkSummary.balanceOverloadRate, unit: "%", decimals: 1 },
{ zh: "瓦切镇面积", value: parkTownBusinessRows.find((item) => item.name === "瓦切镇")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 },
{ zh: "麦洼乡面积", value: parkTownBusinessRows.find((item) => item.name === "麦洼乡")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 },
{ zh: "色地镇面积", value: parkTownBusinessRows.find((item) => item.name === "色地镇")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 },
{ zh: "草地面积", value: parkSummary.grasslandAreaWanMu, unit: "万亩", decimals: 2 },
{ zh: "湿地面积", value: parkSummary.wetlandAreaWanMu, unit: "万亩", decimals: 2 },
{ zh: "牦牛数量", value: parkSummary.yakCount, unit: "头", decimals: 0 },
]
export const restorationTopStats = [
@ -474,56 +565,64 @@ export function getEcologicalTopStats(viewKey) {
export const parkPanels = {
left: [
{
key: "park-resource-scope",
title: "显示范围资源占比",
type: "donut",
rows: parkResourceScopeRows,
key: "park-town-area",
title: "乡镇划定面积",
type: "column",
variant: "plain",
rows: parkTownAreaRows,
},
{
key: "park-wetland-village",
title: "湿地分布村级对比",
type: "line",
rows: parkWetlandVillageRows.slice(0, 8),
key: "park-town-zoning",
title: "乡镇管控区构成",
type: "stackColumn",
segments: [
{ key: "core", name: "核心保护区" },
{ key: "general", name: "一般控制区" },
],
rows: parkTownZoningRows,
},
{
key: "park-woodland-village",
title: "林地分布村级排行",
type: "heat",
rows: parkWoodlandVillageRows.slice(0, 8),
key: "park-town-yak",
title: "乡镇牦牛数量",
type: "bar",
variant: "raceBar",
rows: parkTownYakRows,
},
{
key: "park-grassland-village",
title: "草地分布村级排行",
type: "peak",
rows: parkGrasslandVillageRows.slice(0, 8),
key: "park-town-balance",
title: "草畜平衡超载率",
type: "heat",
rows: parkTownBalanceRows,
},
],
right: [
{
key: "park-household-village",
title: "牧户分布村级排行",
type: "column",
variant: "pin",
rows: parkHouseholdVillageRows.slice(0, 8),
},
{
key: "park-yak-village",
title: "牦牛分布村级排行",
type: "bar",
variant: "distribution",
rows: parkYakVillageRows.slice(0, 8),
key: "park-zoning-structure",
title: "管控区面积结构",
type: "donut",
rows: withParkPercentExtra(parkZoningRows),
},
{
key: "park-edible-grass-village",
title: "可食草地资源分布",
type: "split",
rows: parkEdibleGrassVillageRows.slice(0, 8),
key: "park-town-ecology-jobs",
title: "生态管护岗位",
type: "peak",
rows: parkTownEcologyJobRows,
},
{
key: "park-zoning-ledger",
title: "核心区和一般区分区面积",
type: "structure",
rows: withParkPercentExtra(parkZoningRows),
key: "park-town-ledger",
title: "乡镇保护管护清单",
type: "projectTable",
span: 2,
searchPlaceholder: "搜索乡镇",
searchKeys: ["name", "searchText"],
columns: [
{ key: "name", label: "乡镇", field: "name", width: "18%", className: "business-project-col-name" },
{ key: "area", label: "面积", field: "protectedAreaWanMu", format: "number", unit: "万亩", width: "22%", align: "right", strong: true, className: "business-project-col-scale" },
{ key: "yak", label: "牦牛", field: "yakCount", format: "number", unit: "头", width: "21%", align: "right" },
{ key: "household", label: "牧户", field: "householdCount", format: "number", unit: "户", width: "18%", align: "right" },
{ key: "rate", label: "超载率", field: "overloadRate", format: "number", unit: "%", width: "21%", align: "right", strong: true },
],
rows: parkTownLedgerRows,
},
],
}
@ -739,11 +838,17 @@ export function getEcologicalMapRanking(viewKey) {
}
return {
status: "success",
title: "国家公园覆盖范围",
title: "国家公园乡镇划定面积",
scope: "town",
hideTownNames: false,
metricLabel: "",
unit: "",
rows: [],
onlyRankedTownLabels: true,
metricLabel: "划定面积",
unit: "万亩",
rows: parkTownAreaRows.map((item) => ({
name: item.name,
value: item.value,
unit: item.unit,
metricLabel: "划定面积",
})),
}
}

@ -55,14 +55,21 @@
@focus-project="handleAnalysisProjectFocus"
/>
<div class="fgw-map-layer-tools" :class="{ 'is-analysis': isEngineeringAnalysis }">
<div
class="fgw-map-layer-tools"
:class="{
'is-analysis': isEngineeringAnalysis,
'is-national-park': isNationalPark,
}"
>
<layer-panel
v-model="activeLayerKey"
v-model:selected-legend-key="selectedLegendKey"
:topic-key="currentView"
:legends="activeLayer?.legends || []"
:width="248"
:height="430"
:width="layerPanelWidth"
:height="layerPanelHeight"
:legend-only="isNationalPark"
/>
</div>
@ -165,6 +172,7 @@ let mapLoadTimer = null
const currentView = computed(() =>
["national-park", "restoration", "engineering-analysis"].includes(props.activeView) ? props.activeView : "national-park"
)
const isNationalPark = computed(() => currentView.value === "national-park")
const isEngineeringAnalysis = computed(() => currentView.value === "engineering-analysis")
const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(currentView.value, activeLayerKey.value) : null)
const selectedLegend = computed(() =>
@ -191,6 +199,8 @@ const rightPanels = computed(() => currentPanels.value.right || [])
const panelHeight = 190
const panelGap = 12
const doublePanelHeight = panelHeight * 2 + panelGap
const layerPanelWidth = computed(() => isNationalPark.value ? 178 : 248)
const layerPanelHeight = computed(() => isNationalPark.value ? 118 : 430)
const featurePanelVisible = computed(() => Boolean(featurePanelPoint.value))
const featurePanelHeight = computed(() => {
if (featureLoading.value || featureEmpty.value || !selectedFeature.value) return 186
@ -386,6 +396,15 @@ function handleLayerStatus(status) {
}
function handlePanelRowSelect(panel, row) {
if (currentView.value === "national-park" && row?.name) {
closeFeaturePanel()
mapSceneRef.value?.focusTown?.(row.name, {
distanceScale: 0.66,
targetLocalZ: 0.84,
cameraOffset: { x: 0, y: 1.05, z: 0 },
})
return
}
if (currentView.value !== "restoration" || panel?.key !== "restoration-projects") return
const projectName = row?.projectName || row?.name
if (!projectName) return
@ -482,6 +501,24 @@ function hideLoading() {
transform: scale(0.92);
transform-origin: right top;
}
&.is-national-park {
top: auto;
left: auto;
right: 452px;
bottom: 144px;
width: 178px;
height: 118px;
opacity: 0.92;
.layer-panel-legend {
min-height: 0;
}
.layer-panel-legend-row {
height: 28px;
}
}
}
.ecological-bottom-topic {

@ -0,0 +1,323 @@
<template>
<div class="grassland-ndvi-timeline">
<div class="grassland-ndvi-timeline-head">
<span>草地健康/退化</span>
<strong>{{ activeItem?.label || statusText }}</strong>
</div>
<div v-if="loading" class="grassland-ndvi-timeline-state">加载月份数据</div>
<div v-else-if="!items.length" class="grassland-ndvi-timeline-state">暂无月份数据</div>
<div v-else class="grassland-ndvi-timeline-body">
<button class="grassland-ndvi-timeline-nav" type="button" title="上一期" @click="step(-1)"></button>
<div
class="grassland-ndvi-timeline-track"
:style="{ '--timeline-active-left': activePosition, '--timeline-fill-width': timelineFillWidth }"
role="radiogroup"
aria-label="草地健康退化月份"
>
<div class="grassland-ndvi-timeline-line"></div>
<div class="grassland-ndvi-timeline-fill"></div>
<div class="grassland-ndvi-timeline-thumb">
<i></i>
</div>
<button
v-for="(item, index) in items"
:key="item.key"
class="grassland-ndvi-timeline-mark"
:class="{ 'is-active': item.key === activeItem?.key, 'is-past': index <= currentIndex }"
type="button"
role="radio"
:aria-checked="item.key === activeItem?.key"
:style="{ left: getItemPosition(index) }"
:title="item.date"
@click="selectItem(item)"
>
<span class="grassland-ndvi-timeline-tick"></span>
<span class="grassland-ndvi-timeline-label">{{ item.label }}</span>
</button>
</div>
<button class="grassland-ndvi-timeline-nav" type="button" title="下一期" @click="step(1)"></button>
</div>
</div>
</template>
<script setup>
import { computed } from "vue"
const props = defineProps({
items: {
type: Array,
default: () => [],
},
modelValue: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(["update:modelValue"])
const TIMELINE_EDGE_OFFSET = 42
const activeIndex = computed(() => props.items.findIndex((item) => item.key === props.modelValue))
const currentIndex = computed(() => activeIndex.value === -1 ? Math.max(0, props.items.length - 1) : activeIndex.value)
const activeItem = computed(() => props.items[currentIndex.value] || null)
const statusText = computed(() => (props.loading ? "加载中" : "未选择"))
const activePosition = computed(() => getItemPosition(currentIndex.value))
const timelineFillWidth = computed(() => getTimelineFillWidth(currentIndex.value))
function step(direction) {
if (!props.items.length) return
const nextIndex = Math.min(props.items.length - 1, Math.max(0, currentIndex.value + direction))
emit("update:modelValue", props.items[nextIndex].key)
}
function selectItem(item) {
if (!item || item.key === props.modelValue) return
emit("update:modelValue", item.key)
}
function getItemRatio(index) {
if (!props.items.length || index < 0) return 0
if (props.items.length === 1) return 0.5
return index / (props.items.length - 1)
}
function getItemPosition(index) {
const ratio = getItemRatio(index)
const percent = formatCssNumber(ratio * 100)
const offset = TIMELINE_EDGE_OFFSET * (1 - ratio * 2)
return `calc(${percent}% ${offset < 0 ? "-" : "+"} ${formatCssNumber(Math.abs(offset))}px)`
}
function getTimelineFillWidth(index) {
const ratio = getItemRatio(index)
if (ratio <= 0) return "0px"
return `calc(${formatCssNumber(ratio * 100)}% - ${formatCssNumber(TIMELINE_EDGE_OFFSET * 2 * ratio)}px)`
}
function formatCssNumber(value) {
return Number(value.toFixed(4)).toString()
}
</script>
<style lang="scss" scoped>
.grassland-ndvi-timeline {
width: 680px;
height: 68px;
box-sizing: border-box;
padding: 8px 12px 9px;
border: 1px solid rgba(72, 226, 255, 0.28);
background:
linear-gradient(180deg, rgba(6, 36, 50, 0.76), rgba(3, 18, 30, 0.66)),
linear-gradient(90deg, rgba(28, 191, 255, 0.08), rgba(38, 246, 198, 0.12), rgba(28, 191, 255, 0.08));
backdrop-filter: blur(6px);
box-shadow:
inset 0 1px 0 rgba(187, 250, 255, 0.12),
inset 0 0 18px rgba(28, 191, 255, 0.08),
0 10px 28px rgba(0, 12, 26, 0.34);
color: #efffff;
pointer-events: all;
}
.grassland-ndvi-timeline-head {
display: flex;
align-items: center;
justify-content: space-between;
height: 18px;
margin-bottom: 8px;
span {
position: relative;
padding-left: 10px;
color: rgba(232, 252, 255, 0.86);
font-size: 13px;
font-weight: 700;
&::before {
content: "";
position: absolute;
left: 0;
top: 4px;
width: 4px;
height: 10px;
background: #26f6c6;
box-shadow: 0 0 10px rgba(38, 246, 198, 0.72);
}
}
strong {
color: #29ffcf;
font-family: D-DIN, Arial, sans-serif;
font-size: 20px;
line-height: 18px;
letter-spacing: 0;
text-shadow: 0 0 12px rgba(41, 255, 207, 0.42);
}
}
.grassland-ndvi-timeline-state {
display: flex;
align-items: center;
justify-content: center;
height: 30px;
color: rgba(228, 251, 255, 0.68);
font-size: 13px;
}
.grassland-ndvi-timeline-body {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px;
align-items: center;
gap: 12px;
}
.grassland-ndvi-timeline-nav {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid rgba(72, 226, 255, 0.34);
background: rgba(7, 35, 48, 0.68);
color: rgba(230, 253, 255, 0.88);
font-size: 22px;
line-height: 20px;
cursor: pointer;
outline: none;
&:hover,
&:focus-visible {
color: #ffffff;
border-color: rgba(48, 220, 255, 0.78);
box-shadow: 0 0 12px rgba(48, 220, 255, 0.26);
}
}
.grassland-ndvi-timeline-track {
position: relative;
height: 34px;
margin: 0 8px;
}
.grassland-ndvi-timeline-line,
.grassland-ndvi-timeline-fill {
position: absolute;
left: 42px;
top: 8px;
height: 3px;
border-radius: 999px;
}
.grassland-ndvi-timeline-line {
right: 42px;
background: rgba(90, 199, 228, 0.28);
box-shadow: inset 0 0 8px rgba(0, 7, 18, 0.28);
}
.grassland-ndvi-timeline-fill {
right: auto;
width: var(--timeline-fill-width);
background: linear-gradient(90deg, #35bff8, #29ffcf);
box-shadow: 0 0 12px rgba(41, 255, 207, 0.36);
}
.grassland-ndvi-timeline-thumb {
position: absolute;
z-index: 3;
left: var(--timeline-active-left);
top: 8px;
width: 0;
height: 0;
pointer-events: none;
transform: translateX(-50%);
i {
content: "";
position: absolute;
left: -9px;
top: -9px;
width: 18px;
height: 18px;
box-sizing: border-box;
border: 2px solid rgba(240, 255, 255, 0.92);
border-radius: 50%;
background: #29ffcf;
box-shadow:
0 0 0 5px rgba(41, 255, 207, 0.12),
0 0 18px rgba(41, 255, 207, 0.66);
}
}
.grassland-ndvi-timeline-mark {
position: absolute;
z-index: 2;
top: 0;
display: block;
width: 84px;
height: 34px;
padding: 0;
border: 0;
background: transparent;
color: rgba(232, 250, 255, 0.76);
cursor: pointer;
outline: none;
transform: translateX(-50%);
.grassland-ndvi-timeline-tick {
position: absolute;
left: 50%;
top: 4px;
width: 2px;
height: 11px;
border-radius: 999px;
background: rgba(148, 234, 255, 0.74);
box-shadow: 0 0 8px rgba(48, 220, 255, 0.24);
transform: translateX(-50%);
}
.grassland-ndvi-timeline-label {
position: absolute;
left: 50%;
top: 18px;
width: 78px;
box-sizing: border-box;
overflow: visible;
padding: 0 3px;
color: rgba(232, 250, 255, 0.62);
font-size: 12px;
font-weight: 700;
line-height: 16px;
text-align: center;
text-overflow: clip;
white-space: nowrap;
transform: translateX(-50%);
}
&:hover,
&:focus-visible,
&.is-active {
color: #ffffff;
.grassland-ndvi-timeline-tick {
background: #29ffcf;
box-shadow: 0 0 12px rgba(41, 255, 207, 0.58);
}
.grassland-ndvi-timeline-label {
color: #ffffff;
}
}
&.is-active {
.grassland-ndvi-timeline-label {
width: 78px;
padding: 0 8px;
border: 1px solid rgba(41, 255, 207, 0.34);
background: rgba(4, 39, 48, 0.78);
box-shadow: 0 0 14px rgba(41, 255, 207, 0.16);
}
}
}
</style>

@ -1,7 +1,7 @@
<template>
<m-card title="图层控制" :width="width" :height="panelHeight">
<div class="layer-panel">
<div class="layer-panel-list">
<m-card :title="legendOnly ? '图例' : '图层控制'" :width="width" :height="panelHeight">
<div class="layer-panel" :class="{ 'is-legend-only': legendOnly }">
<div v-if="!legendOnly" class="layer-panel-list">
<button
v-for="layer in panelLayers"
:key="layer.key"
@ -93,6 +93,10 @@ const props = defineProps({
type: Array,
default: null,
},
legendOnly: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(["update:modelValue", "update:selectedLegendKey"])
@ -137,6 +141,35 @@ function isLegendItemSelectable(item) {
padding: 12px 12px 14px;
overflow: hidden;
&.is-legend-only {
grid-template-rows: minmax(0, 1fr);
gap: 0;
padding: 10px 12px 12px;
.layer-panel-legend {
padding-top: 0;
border-top: 0;
}
.layer-panel-section-title {
display: none;
}
.layer-panel-legend-grid {
grid-template-columns: 1fr;
justify-content: stretch;
gap: 8px;
max-height: 100%;
padding-right: 0;
overflow: hidden;
}
.layer-panel-legend-row {
min-height: 28px;
padding: 4px 2px;
}
}
&-list {
display: flex;
flex-direction: column;

@ -53,6 +53,14 @@
/>
</div>
<div v-if="ndviTimelineVisible" class="fgw-ndvi-timeline">
<grassland-ndvi-timeline
v-model="selectedNdviTimelineKey"
:items="ndviTimelineRows"
:loading="ndviTimelineStatus === 'loading'"
/>
</div>
<div v-if="featurePanelVisible" class="fgw-map-feature-info" :style="featurePanelStyle">
<feature-info-panel
:feature="selectedFeature"
@ -92,11 +100,13 @@ import gsap from "gsap"
import { Assets } from "./assets"
import { forestGrassWetTopics, getLayerByKey, getTopicConfig } from "@/config/layers"
import { getLayerTownRanking, getTopicBusinessStats } from "@/services/openApi"
import { getGrasslandNdviTimeline } from "@/services/ndviTimeline"
import MapScene from "./map.vue"
import TopicSwitcher from "./components/TopicSwitcher.vue"
import LayerPanel from "./components/LayerPanel.vue"
import FeatureInfoPanel from "./components/FeatureInfoPanel.vue"
import BusinessPanelRenderer from "./components/BusinessPanelRenderer.vue"
import GrasslandNdviTimeline from "./components/GrasslandNdviTimeline.vue"
import mCard from "@/components/mCard/index.vue"
const props = defineProps({
@ -129,6 +139,9 @@ const featureLoading = ref(false)
const featurePanelPoint = ref(null)
const progress = ref(0)
const loadingVisible = ref(props.showLoading)
const ndviTimelineRows = ref([])
const ndviTimelineStatus = ref("idle")
const selectedNdviTimelineKey = ref("")
const layerStatus = ref({
type: "idle",
text: "等待 GeoServer 图层加载",
@ -141,6 +154,7 @@ let mapLoadStarted = false
let mapLoadTimer = null
let rankingRequestId = 0
let businessRequestId = 0
let ndviTimelineRequestId = 0
let resettingTopicLayer = false
let resettingLegend = false
@ -156,19 +170,34 @@ const mapRankingLayerKeys = new Set([
const activeTopic = computed(() => getTopicConfig(activeTopicKey.value))
const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(activeTopicKey.value, activeLayerKey.value) : null)
const ndviTimelineActive = computed(() => activeTopicKey.value === "grassland" && activeLayerKey.value === "grassland-health")
const ndviTimelineVisible = computed(() => ndviTimelineActive.value)
const selectedNdviTimelineItem = computed(() =>
ndviTimelineRows.value.find((item) => item.key === selectedNdviTimelineKey.value) || null
)
const selectedLegend = computed(() =>
(baseActiveLayer.value?.legends || []).find((item) => item.key === selectedLegendKey.value) || null
)
const activeLayer = computed(() => {
const layer = baseActiveLayer.value
if (!layer) return layer
if (!selectedLegend.value?.cqlFilter) return layer
return {
const visibleLayer = selectedLegend.value?.cqlFilter
? {
...layer,
cqlFilter: selectedLegend.value.cqlFilter,
activeLegend: selectedLegend.value,
name: selectedLegend.value.name || layer.name,
}
: layer
const timelineItem = selectedNdviTimelineItem.value
if (!ndviTimelineActive.value || !timelineItem?.layerName) return visibleLayer
return {
...visibleLayer,
layerName: timelineItem.layerName,
name: `${layer.name} ${timelineItem.label}`,
timelineDate: timelineItem.label,
timelineItem,
}
})
const panelHeight = computed(() => 190)
const panelGap = computed(() => 12)
@ -257,6 +286,19 @@ watch(selectedLegendKey, () => {
loadMapRanking(activeTopicKey.value, activeLayerKey.value, selectedLegend.value)
})
watch(ndviTimelineActive, (active) => {
if (active) {
loadNdviTimeline()
return
}
selectedNdviTimelineKey.value = ""
})
watch(selectedNdviTimelineKey, () => {
if (!ndviTimelineActive.value) return
closeFeaturePanel()
})
watch(
() => props.active,
(active) => {
@ -343,6 +385,39 @@ async function loadBusinessStats(topicKey, layerKey = activeLayerKey.value) {
applyBusinessStats(result, topicKey)
}
async function loadNdviTimeline(force = false) {
if (!ndviTimelineActive.value) return
if (!force && ndviTimelineRows.value.length) {
ensureSelectedNdviTimeline()
return
}
const requestId = ++ndviTimelineRequestId
ndviTimelineStatus.value = "loading"
try {
const rows = await getGrasslandNdviTimeline()
if (destroyed || requestId !== ndviTimelineRequestId) return
ndviTimelineRows.value = rows
ndviTimelineStatus.value = rows.length ? "success" : "empty"
ensureSelectedNdviTimeline()
} catch (error) {
if (destroyed || requestId !== ndviTimelineRequestId) return
ndviTimelineRows.value = []
selectedNdviTimelineKey.value = ""
ndviTimelineStatus.value = "error"
}
}
function ensureSelectedNdviTimeline() {
if (!ndviTimelineRows.value.length) {
selectedNdviTimelineKey.value = ""
return
}
const existing = ndviTimelineRows.value.some((item) => item.key === selectedNdviTimelineKey.value)
if (!existing) {
selectedNdviTimelineKey.value = ndviTimelineRows.value[ndviTimelineRows.value.length - 1].key
}
}
function applyBusinessStats(result, topicKey) {
const selected = selectTopicBusinessStats(result)
coreStats.value = selected.core || []
@ -607,6 +682,15 @@ function hideLoading() {
}
}
.fgw-ndvi-timeline {
position: absolute;
left: 50%;
bottom: 112px;
z-index: 7;
transform: translateX(-50%);
pointer-events: all;
}
.fgw-map-feature-info {
right: 456px;
bottom: 150px;

@ -59,12 +59,15 @@ import scatterData from "./map/scatter"
import infoData from "./map/infoData"
import gsap from "gsap"
import { InteractionManager } from "three.interactive"
import { applyMapTextureUv, createHongyuanSatelliteTexture } from "./satelliteTiles"
import { applyMapTextureUv, createHongyuanSatelliteTexture, getHongyuanSatelliteTileRange } from "./satelliteTiles"
import { ecologicalProtectionLayers, forestGrassWetLayers, HONGYUAN_BOUNDS, SATELLITE_TEXTURE_ZOOM, WMS_IMAGE_SIZE } from "@/config/layers"
import { buildWmsGetMapUrl, getFeatureInfo, getWfsFeatureProperties, normalizeFeatureInfo } from "@/services/geoserver"
const DEFAULT_MAP_CAMERA_POSITION = new Vector3(0, 22.8, 18.8)
const DEFAULT_MAP_CAMERA_POSITION = new Vector3(-0.17427287762525134, 13.678992786206543, 20.688611202093714)
const DEFAULT_MAP_TARGET = new Vector3(0, 0, 0)
const INTRO_MAP_CAMERA_POSITION = new Vector3(-7.2, 24.2, 31.2)
const INTRO_MAP_CAMERA_POSITION = new Vector3(-13.767695123014105, 12.990152163077308, 39.28228164159694)
const CUSTOM_SATELLITE_MAX_ZOOM = 21
const DYNAMIC_SATELLITE_TILE_WINDOW = 9
const DYNAMIC_SATELLITE_CHECK_INTERVAL = 260
function sortByValue(data) {
data.sort((a, b) => b.value - a.value)
return data
@ -178,6 +181,17 @@ export class World extends Mini3d {
this.assets = assets
this.callbacks = callbacks
this.tileUrlTemplate = callbacks.tileUrlTemplate || ""
this.usesCustomSatelliteTiles = Boolean(this.tileUrlTemplate)
this.dynamicSatelliteMaxZoom = CUSTOM_SATELLITE_MAX_ZOOM
this.dynamicSatelliteTileWindow = DYNAMIC_SATELLITE_TILE_WINDOW
this.dynamicSatelliteOverlay = null
this.dynamicSatelliteMaterial = null
this.dynamicSatelliteTexture = null
this.dynamicSatelliteCurrentKey = ""
this.dynamicSatelliteLoadingKey = ""
this.dynamicSatelliteRequestId = 0
this.dynamicSatelliteLastCheck = 0
this.topicLayerLoadId = 0
this.visibleTopicLayers = []
this.mapAnimationComplete = false
@ -341,7 +355,7 @@ export class World extends Mini3d {
this.focusMapTopMaterial,
{
duration: 1,
opacity: 1,
opacity: this.getFocusMapTopOpacity(),
ease: "circ.out",
},
"focusMapOpacity"
@ -768,6 +782,8 @@ export class World extends Mini3d {
geoProjectionCenter: this.geoProjectionCenter,
geoProjectionScale: this.geoProjectionScale,
zoom: SATELLITE_TEXTURE_ZOOM,
tileUrlTemplate: this.tileUrlTemplate,
onTileStatus: (status) => this.callbacks.onTileStatus?.(status),
})
this.enhanceTexture(satellite.texture)
let [topMaterial, sideMaterial] = this.createProvinceMaterial()
@ -787,16 +803,18 @@ export class World extends Mini3d {
color: 0xffffff,
map: satellite.texture,
transparent: true,
opacity: 0.94,
roughness: 0.82,
opacity: this.usesCustomSatelliteTiles ? 1 : 0.94,
roughness: this.usesCustomSatelliteTiles ? 0.7 : 0.82,
// fog: false,
})
let faceGradientShader = new GradientShader(faceMaterial, {
if (!this.usesCustomSatelliteTiles) {
new GradientShader(faceMaterial, {
// uColor1: 0x2a6e92,
// uColor2: 0x102736,
uColor1: 0x18dce2,
uColor2: 0x0b8fa9,
})
}
this.defaultMaterial = faceMaterial
this.defaultLightMaterial = this.defaultMaterial.clone()
this.defaultLightMaterial.color = new Color("rgba(128,244,255,1)")
@ -809,8 +827,10 @@ export class World extends Mini3d {
position: new Vector3(0, 0, this.depth + 0.22),
data: mapJsonData,
material: faceMaterial,
renderOrder: 8,
renderOrder: this.usesCustomSatelliteTiles ? 14 : 8,
})
this.satelliteMapTop = mapTop
this.satelliteMaterial = faceMaterial
applyMapTextureUv(mapTop, satellite.uvBounds)
mapTop.mapGroup.children.map((group) => {
group.children.map((mesh) => {
@ -1060,6 +1080,152 @@ export class World extends Mini3d {
const duration = view && Number.isFinite(view.duration) ? view.duration : 0.75
this.animateMapCamera(position, target, duration)
}
getFocusMapTopOpacity() {
return this.usesCustomSatelliteTiles ? 0.16 : 1
}
getDynamicSatelliteZoom(distance) {
if (!this.usesCustomSatelliteTiles) return SATELLITE_TEXTURE_ZOOM
if (distance <= 4.2) return Math.min(this.dynamicSatelliteMaxZoom, 21)
if (distance <= 6.4) return Math.min(this.dynamicSatelliteMaxZoom, 19)
if (distance <= 8.4) return Math.min(this.dynamicSatelliteMaxZoom, 17)
if (distance <= 11) return Math.min(this.dynamicSatelliteMaxZoom, 15)
if (distance <= 14.5) return Math.min(this.dynamicSatelliteMaxZoom, 13)
if (distance <= 18) return Math.min(this.dynamicSatelliteMaxZoom, 11)
return SATELLITE_TEXTURE_ZOOM
}
getCameraTargetLngLat() {
const target = this.camera?.controls?.target?.clone?.()
if (!target) return [102.58, 32.72]
this.focusMapGroup?.updateWorldMatrix?.(true, false)
const local = this.focusMapGroup ? this.focusMapGroup.worldToLocal(target) : target
const projection = geoMercator()
.center(this.geoProjectionCenter)
.scale(this.geoProjectionScale)
.translate([0, 0])
const lngLat = projection.invert?.([local.x, -local.y]) || [102.58, 32.72]
return [
Math.max(HONGYUAN_BOUNDS.west, Math.min(HONGYUAN_BOUNDS.east, Number(lngLat[0]) || 102.58)),
Math.max(HONGYUAN_BOUNDS.south, Math.min(HONGYUAN_BOUNDS.north, Number(lngLat[1]) || 32.72)),
]
}
getDynamicSatelliteTileWindow(zoom) {
if (zoom >= 21) return 3
if (zoom >= 19) return 5
if (zoom >= 17) return 7
if (zoom >= 15) return this.dynamicSatelliteTileWindow
if (zoom >= 13) return Math.min(11, this.dynamicSatelliteTileWindow + 2)
return Math.min(11, this.dynamicSatelliteTileWindow + 2)
}
getDynamicSatelliteKey(zoom, tileRange) {
if (!tileRange) return ""
return `${zoom}:${tileRange.minX}-${tileRange.maxX}:${tileRange.minY}-${tileRange.maxY}`
}
updateDynamicSatelliteTiles(force = false) {
if (!this.usesCustomSatelliteTiles || !this.mapAnimationComplete) return
const now = Date.now()
if (!force && now - this.dynamicSatelliteLastCheck < DYNAMIC_SATELLITE_CHECK_INTERVAL) return
this.dynamicSatelliteLastCheck = now
const distance = this.camera.instance.position.distanceTo(this.camera.controls.target)
const zoom = this.getDynamicSatelliteZoom(distance)
if (zoom <= SATELLITE_TEXTURE_ZOOM) {
this.hideDynamicSatelliteOverlay()
return
}
const centerLngLat = this.getCameraTargetLngLat()
const tileWindow = this.getDynamicSatelliteTileWindow(zoom)
const tileRange = getHongyuanSatelliteTileRange({
zoom,
centerLngLat,
maxCols: tileWindow,
maxRows: tileWindow,
})
const key = this.getDynamicSatelliteKey(zoom, tileRange)
if (!key || key === this.dynamicSatelliteCurrentKey || key === this.dynamicSatelliteLoadingKey) return
this.loadDynamicSatelliteOverlay({ zoom, centerLngLat, maxTiles: tileWindow, key })
}
loadDynamicSatelliteOverlay({ zoom, centerLngLat, maxTiles, key }) {
const requestId = ++this.dynamicSatelliteRequestId
this.dynamicSatelliteLoadingKey = key
let satellite
satellite = createHongyuanSatelliteTexture({
geoProjectionCenter: this.geoProjectionCenter,
geoProjectionScale: this.geoProjectionScale,
zoom,
centerLngLat,
maxCols: maxTiles,
maxRows: maxTiles,
tileUrlTemplate: this.tileUrlTemplate,
transparentBackground: true,
applyTone: false,
onTileStatus: (status) => {
this.callbacks.onTileStatus?.({
...status,
text: status.text?.replace("影像底图", "高清影像底图") || status.text,
})
if (status.type === "loading") return
if (requestId !== this.dynamicSatelliteRequestId) {
satellite?.texture?.dispose?.()
return
}
this.dynamicSatelliteLoadingKey = ""
if (Number(status.loaded || 0) <= 0) {
if (this.dynamicSatelliteCurrentKey === key) this.hideDynamicSatelliteOverlay()
return
}
this.applyDynamicSatelliteOverlay(satellite, key)
},
})
this.applyDynamicSatelliteOverlay(satellite, key)
}
ensureDynamicSatelliteOverlay() {
if (this.dynamicSatelliteOverlay) return
const material = new MeshBasicMaterial({
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
side: DoubleSide,
fog: false,
})
const mesh = new Mesh(new PlaneGeometry(1, 1), material)
mesh.name = "dynamic-satellite-overlay"
mesh.renderOrder = 15
mesh.visible = false
mesh.position.z = this.depth + 0.248
this.focusMapGroup?.add(mesh)
this.dynamicSatelliteOverlay = mesh
this.dynamicSatelliteMaterial = material
}
applyDynamicSatelliteOverlay(satellite, key) {
if (!satellite?.texture || !satellite?.uvBounds) return
this.ensureDynamicSatelliteOverlay()
const bounds = satellite.uvBounds
const width = Math.max(0.0001, bounds.right - bounds.left)
const height = Math.max(0.0001, bounds.top - bounds.bottom)
this.dynamicSatelliteOverlay.position.set(
(bounds.left + bounds.right) / 2,
(bounds.bottom + bounds.top) / 2,
this.depth + 0.248,
)
this.dynamicSatelliteOverlay.scale.set(width, height, 1)
this.dynamicSatelliteOverlay.visible = true
if (this.dynamicSatelliteTexture && this.dynamicSatelliteTexture !== satellite.texture) {
this.dynamicSatelliteTexture.dispose?.()
}
this.dynamicSatelliteTexture = satellite.texture
this.dynamicSatelliteMaterial.map = satellite.texture
this.dynamicSatelliteMaterial.opacity = 1
this.dynamicSatelliteMaterial.needsUpdate = true
this.dynamicSatelliteCurrentKey = key
}
hideDynamicSatelliteOverlay() {
if (this.dynamicSatelliteOverlay) this.dynamicSatelliteOverlay.visible = false
if (this.dynamicSatelliteLoadingKey) {
this.dynamicSatelliteRequestId += 1
this.dynamicSatelliteLoadingKey = ""
}
this.dynamicSatelliteCurrentKey = ""
}
ensureMapRevealed() {
if (this.mapAnimationComplete) return
this.animateTl?.progress?.(1, false)
@ -1069,7 +1235,7 @@ export class World extends Mini3d {
this.focusMapGroup.position.set(0, 0, 0)
this.focusMapGroup.scale.set(1, 1, 1)
}
if (this.focusMapTopMaterial) this.focusMapTopMaterial.opacity = 1
if (this.focusMapTopMaterial) this.focusMapTopMaterial.opacity = this.getFocusMapTopOpacity()
if (this.focusMapSideMaterial) {
this.focusMapSideMaterial.opacity = 1
this.focusMapSideMaterial.transparent = false
@ -1081,6 +1247,7 @@ export class World extends Mini3d {
if (this.scatterGroup) this.scatterGroup.visible = true
if (this.InfoPointGroup) this.InfoPointGroup.visible = this.showTownRankingPointMarkers && this.hasTownRankingRows()
this.applyLayerMapView(this.currentTopicLayer, { force: true })
this.updateDynamicSatelliteTiles(true)
this.updateYakIndustryPointMarkers(this.currentTopicLayer, this.currentVectorFeatures)
this.updateYakIndustryPointLabels(this.currentTopicLayer, this.currentVectorFeatures)
if (shouldHideMapLabels(this.currentTownRanking)) {
@ -1348,13 +1515,22 @@ export class World extends Mini3d {
const baseStyle = this.getVectorFeatureStyle(layer, feature)
this.drawVectorFeature(context, feature.geometry, {
...baseStyle,
fill: baseStyle.stroke,
stroke: baseStyle.stroke,
fill: "#001D2C",
stroke: "#001D2C",
polygonAlpha: 0,
lineAlpha: 1,
lineWidth: 8,
lineAlpha: 0.58,
lineWidth: 12,
glow: true,
})
this.drawVectorFeature(context, feature.geometry, {
...baseStyle,
fill: "#F8FFFF",
stroke: "#F8FFFF",
polygonAlpha: 0,
lineAlpha: 0.96,
lineWidth: 3.2,
glow: false,
})
})
}
drawSelectedVectorOverlay(context) {
@ -1869,10 +2045,10 @@ export class World extends Mini3d {
if (layer?.key === "national-park-zoning") {
return {
fill: legend.fillColor || "#30DCFF",
stroke: legend.strokeColor || legend.fillColor || "#F2FFFF",
polygonAlpha: 0.54,
stroke: legend.strokeColor || "#F2FFFF",
polygonAlpha: 0.36,
lineAlpha: 1,
lineWidth: 4.8,
lineWidth: 5.8,
pointRadius: 4.5,
glow: true,
}
@ -2118,6 +2294,7 @@ export class World extends Mini3d {
color: 0xffffff,
transparent: true,
opacity: 0,
depthWrite: !this.usesCustomSatelliteTiles,
fog: false,
side: DoubleSide,
})
@ -2252,6 +2429,7 @@ export class World extends Mini3d {
calloutMode,
showBars: ranking?.showBars ?? false,
showPointMarkers: ranking?.showPointMarkers ?? calloutMode,
onlyRankedTownLabels: ranking?.onlyRankedTownLabels ?? false,
}
}
getTownRankingRows(ranking = this.currentTownRanking, limit = 7, useFallback = true) {
@ -2912,7 +3090,13 @@ export class World extends Mini3d {
const canShowZeroStat = Boolean(ranking && rows.length && (meta.metricLabel || meta.unit || meta.title))
this.townNameLabels.forEach((label) => {
const townName = label.userData?.townName || label.userData?.name
const row = rowByName.get(normalizeTownName(townName)) || (canShowZeroStat
const rankedRow = rowByName.get(normalizeTownName(townName))
if (meta.onlyRankedTownLabels && !rankedRow) {
this.clearTownNameLabelStat(label)
label.visible = false
return
}
const row = rankedRow || (canShowZeroStat
? { name: townName, value: 0, unit: meta.unit, metricLabel: meta.metricLabel, rank: null }
: null)
if (!row) {
@ -4886,6 +5070,7 @@ export class World extends Mini3d {
}
update(delta) {
super.update(delta)
this.updateDynamicSatelliteTiles()
this.updateTradeFlowPulses(delta)
this.interactionManager && this.interactionManager.update()
}

@ -15,8 +15,12 @@ const props = defineProps({
type: Object,
default: null,
},
tileUrlTemplate: {
type: String,
default: "",
},
});
const emit = defineEmits(["feature-loading", "feature-info", "layer-status", "draw-boundary", "scene-mounted"]);
const emit = defineEmits(["feature-loading", "feature-info", "layer-status", "tile-status", "draw-boundary", "scene-mounted"]);
const canvasRef = ref(null);
const canvasMap = shallowRef(null);
let resizeObserver = null;
@ -40,7 +44,9 @@ function loadMap(assets) {
onFeatureLoading: (loading) => emit("feature-loading", loading),
onFeatureInfo: (payload) => emit("feature-info", payload),
onLayerStatus: (status) => emit("layer-status", status),
onTileStatus: (status) => emit("tile-status", status),
onDrawBoundary: (payload) => emit("draw-boundary", payload),
tileUrlTemplate: props.tileUrlTemplate,
});
canvasMap.value.time.pause();
activeLayerSignature = getLayerSignature(props.activeLayer);
@ -72,6 +78,10 @@ function resume() {
if (!canvasMap.value) return;
connectMapResizeObserver();
scheduleMapResize();
window.requestAnimationFrame(() => {
canvasMap.value?.label3d?.resize?.();
canvasMap.value?.label3d?.update?.();
});
canvasMap.value.resumeScene?.();
if (!canvasMap.value.resumeScene) canvasMap.value.time.resume();
}
@ -188,6 +198,26 @@ function cleanupMapResizeObserver() {
resizeCheckTimer = null;
}
}
function refreshView() {
if (!canvasMap.value) return;
connectMapResizeObserver();
const refresh = () => {
if (!canvasMap.value) return;
canvasMap.value?.sizes?.init?.();
canvasMap.value?.resize?.();
canvasMap.value?.label3d?.resize?.();
canvasMap.value?.label3d?.update?.();
canvasMap.value?.setTownRanking?.(props.mapRanking);
};
scheduleMapResize();
window.requestAnimationFrame(refresh);
window.setTimeout(() => {
refresh();
}, 80);
window.setTimeout(() => {
refresh();
}, 220);
}
watch(
() => props.activeLayer,
(layer) => {
@ -229,6 +259,7 @@ defineExpose({
undoBoundaryPoint,
clearAnalysisBoundary,
showAnalysisResult,
refreshView,
destroyMap,
canvasMap,
});
@ -242,6 +273,8 @@ function getLayerSignature(layer) {
item.styles || "",
item.cqlFilter || "",
item.activeLegend?.key || "",
item.timelineDate || "",
item.timelineItem?.key || "",
item.sourceType || "",
item.dataPath || "",
item.fallbackPath || "",
@ -269,6 +302,7 @@ function getMapRankingSignature(ranking) {
ranking.showBars ?? "",
ranking.showTownNames ?? "",
ranking.showMapLabels ?? "",
ranking.onlyRankedTownLabels ?? "",
ranking.rows?.length || 0,
ranking.rows?.map((row) => `${row.key || row.name || ""}:${row.value ?? ""}`).join(",") || "",
].join("|");

@ -8,7 +8,10 @@ import {
import { geoMercator } from "d3-geo"
const TILE_SIZE = 256
const SATELLITE_TILE_URL = "https://map.shuxitech.com/vt/lyrs=s&hl=zh-CN&x={x}&y={y}&z={z}"
const DEFAULT_SATELLITE_TILE_URL = "https://map.shuxitech.com/vt/lyrs=s&hl=zh-CN&x={x}&y={y}&z={z}"
const TILE_IMAGE_CACHE_LIMIT = 900
const TILE_LOAD_CONCURRENCY = 10
const tileImageCache = new Map()
const HONGYUAN_BOUNDS = {
west: 101.852492,
@ -34,13 +37,37 @@ function tileToLngLat(x, y, zoom) {
return [lng, lat]
}
function getTileRange(bounds, zoom) {
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value))
}
function getTileRange(bounds, zoom, { centerLngLat, maxCols, maxRows } = {}) {
const northWest = lngLatToTile(bounds.west, bounds.north, zoom)
const southEast = lngLatToTile(bounds.east, bounds.south, zoom)
const minX = Math.floor(northWest.x)
const maxX = Math.floor(southEast.x)
const minY = Math.floor(northWest.y)
const maxY = Math.floor(southEast.y)
const fullMinX = Math.floor(northWest.x)
const fullMaxX = Math.floor(southEast.x)
const fullMinY = Math.floor(northWest.y)
const fullMaxY = Math.floor(southEast.y)
let minX = fullMinX
let maxX = fullMaxX
let minY = fullMinY
let maxY = fullMaxY
let centerX = Math.floor((fullMinX + fullMaxX) / 2)
let centerY = Math.floor((fullMinY + fullMaxY) / 2)
if (centerLngLat && Number.isFinite(maxCols) && Number.isFinite(maxRows)) {
const centerLng = clamp(Number(centerLngLat[0]), bounds.west, bounds.east)
const centerLat = clamp(Number(centerLngLat[1]), bounds.south, bounds.north)
const centerTile = lngLatToTile(centerLng, centerLat, zoom)
centerX = Math.floor(centerTile.x)
centerY = Math.floor(centerTile.y)
const cols = Math.max(1, Math.min(Math.floor(maxCols), fullMaxX - fullMinX + 1))
const rows = Math.max(1, Math.min(Math.floor(maxRows), fullMaxY - fullMinY + 1))
minX = clamp(centerX - Math.floor(cols / 2), fullMinX, fullMaxX - cols + 1)
minY = clamp(centerY - Math.floor(rows / 2), fullMinY, fullMaxY - rows + 1)
maxX = minX + cols - 1
maxY = minY + rows - 1
}
return {
zoom,
@ -48,11 +75,23 @@ function getTileRange(bounds, zoom) {
maxX,
minY,
maxY,
centerX,
centerY,
cols: maxX - minX + 1,
rows: maxY - minY + 1,
}
}
export function getHongyuanSatelliteTileRange({
zoom = 11,
centerLngLat,
maxCols,
maxRows,
bounds = HONGYUAN_BOUNDS,
} = {}) {
return getTileRange(bounds, zoom, { centerLngLat, maxCols, maxRows })
}
function createProjectedBounds(tileRange, projection) {
const [west, north] = tileToLngLat(tileRange.minX, tileRange.minY, tileRange.zoom)
const [east, south] = tileToLngLat(tileRange.maxX + 1, tileRange.maxY + 1, tileRange.zoom)
@ -101,51 +140,137 @@ function drawPlaceholder(ctx, canvas) {
}
function loadImage(url) {
const cached = tileImageCache.get(url)
if (cached) return cached
return new Promise((resolve) => {
const image = new Image()
image.crossOrigin = "anonymous"
image.onload = () => resolve(image)
image.onerror = () => resolve(null)
image.onerror = () => {
tileImageCache.delete(url)
resolve(null)
}
image.src = url
})
}
function tileUrl(x, y, z) {
return SATELLITE_TILE_URL.replace("{x}", x).replace("{y}", y).replace("{z}", z)
function loadCachedImage(url) {
const cached = tileImageCache.get(url)
if (cached) return cached
const request = loadImage(url)
tileImageCache.set(url, request)
while (tileImageCache.size > TILE_IMAGE_CACHE_LIMIT) {
const oldestKey = tileImageCache.keys().next().value
if (!oldestKey) break
tileImageCache.delete(oldestKey)
}
return request
}
async function loadTiles(ctx, canvas, texture, tileRange) {
function tileUrl(x, y, z, template = DEFAULT_SATELLITE_TILE_URL) {
return String(template || DEFAULT_SATELLITE_TILE_URL)
.replace(/\{x\}/g, x)
.replace(/\{y\}/g, y)
.replace(/\{z\}/g, z)
}
async function loadTiles(ctx, canvas, texture, tileRange, { tileUrlTemplate, onTileStatus, applyTone = true } = {}) {
const tasks = []
const total = tileRange.cols * tileRange.rows
let loaded = 0
let failed = 0
onTileStatus?.({
type: "loading",
text: `正在加载${tileRange.zoom}级影像底图`,
loaded,
failed,
total,
zoom: tileRange.zoom,
tileRange,
tileUrlTemplate,
})
ctx.save()
ctx.filter = "brightness(0.92) contrast(1.22) saturate(1.06)"
for (let x = tileRange.minX; x <= tileRange.maxX; x += 1) {
for (let y = tileRange.minY; y <= tileRange.maxY; y += 1) {
tasks.push(
loadImage(tileUrl(x, y, tileRange.zoom)).then((image) => {
if (!image) return
ctx.drawImage(image, (x - tileRange.minX) * TILE_SIZE, (y - tileRange.minY) * TILE_SIZE, TILE_SIZE, TILE_SIZE)
tasks.push({ x, y })
}
}
tasks.sort((a, b) => (
Math.abs(a.x - tileRange.centerX) + Math.abs(a.y - tileRange.centerY)
) - (
Math.abs(b.x - tileRange.centerX) + Math.abs(b.y - tileRange.centerY)
))
let taskIndex = 0
async function worker() {
while (taskIndex < tasks.length) {
const task = tasks[taskIndex]
taskIndex += 1
const image = await loadCachedImage(tileUrl(task.x, task.y, tileRange.zoom, tileUrlTemplate))
if (!image) {
failed += 1
} else {
loaded += 1
ctx.drawImage(image, (task.x - tileRange.minX) * TILE_SIZE, (task.y - tileRange.minY) * TILE_SIZE, TILE_SIZE, TILE_SIZE)
texture.needsUpdate = true
}),
)
}
onTileStatus?.({
type: "loading",
text: `正在加载${tileRange.zoom}级影像底图`,
loaded,
failed,
total,
zoom: tileRange.zoom,
tileRange,
tileUrlTemplate,
})
}
}
await Promise.allSettled(tasks)
await Promise.all(Array.from({ length: Math.min(TILE_LOAD_CONCURRENCY, tasks.length) }, worker))
ctx.restore()
drawSatelliteTone(ctx, canvas)
if (applyTone) drawSatelliteTone(ctx, canvas)
texture.needsUpdate = true
const coverageRatio = total > 0 ? loaded / total : 0
const statusType = loaded === 0 ? "error" : coverageRatio >= 0.75 ? "success" : "warning"
const statusText = loaded === total
? `${tileRange.zoom}级影像底图已加载`
: loaded > 0
? `${tileRange.zoom}级影像底图已加载(${loaded}/${total}张有效瓦片)`
: `${tileRange.zoom}级影像底图加载失败`
onTileStatus?.({
type: statusType,
text: statusText,
loaded,
failed,
total,
zoom: tileRange.zoom,
tileRange,
tileUrlTemplate,
})
}
export function createHongyuanSatelliteTexture({
geoProjectionCenter,
geoProjectionScale,
zoom = 11,
tileUrlTemplate = DEFAULT_SATELLITE_TILE_URL,
onTileStatus,
centerLngLat,
maxCols,
maxRows,
transparentBackground = false,
applyTone = true,
} = {}) {
const tileRange = getTileRange(HONGYUAN_BOUNDS, zoom)
const tileRange = getTileRange(HONGYUAN_BOUNDS, zoom, { centerLngLat, maxCols, maxRows })
const canvas = document.createElement("canvas")
canvas.width = tileRange.cols * TILE_SIZE
canvas.height = tileRange.rows * TILE_SIZE
const ctx = canvas.getContext("2d")
if (transparentBackground) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
} else {
drawPlaceholder(ctx, canvas)
}
const texture = new CanvasTexture(canvas)
texture.colorSpace = SRGBColorSpace
@ -161,13 +286,24 @@ export function createHongyuanSatelliteTexture({
.translate([0, 0])
const uvBounds = createProjectedBounds(tileRange, projection)
loadTiles(ctx, canvas, texture, tileRange).catch((error) => {
loadTiles(ctx, canvas, texture, tileRange, { tileUrlTemplate, onTileStatus, applyTone }).catch((error) => {
console.warn("load satellite tiles failed:", error)
onTileStatus?.({
type: "error",
text: `${tileRange.zoom}级影像底图加载失败`,
loaded: 0,
failed: tileRange.cols * tileRange.rows,
total: tileRange.cols * tileRange.rows,
zoom: tileRange.zoom,
tileRange,
tileUrlTemplate,
})
})
return {
texture,
uvBounds,
tileRange,
}
}

@ -17,18 +17,26 @@ export const COUNTY_TARGET = {
export const RISK_LEVELS = [
{
key: "healthy",
label: "草畜平衡",
shortLabel: "平衡",
range: " 0%",
description: "承载力覆盖实际载畜量,当前放牧压力处于合理区间。",
label: "合理",
shortLabel: "合理",
range: "< 0%",
description: "承载力高于实际载畜量,当前放牧压力处于合理区间。",
actions: ["保持当前载畜水平与轮牧节奏", "定期复测草地生产力和载畜量", "关注极端天气对牧草生长的影响"],
},
{
key: "balanced",
label: "基本实现草畜平衡",
shortLabel: "平衡",
range: "0% - 5%",
description: "超载率在5%以内,基本实现草畜平衡。",
actions: ["保持当前载畜水平与轮牧节奏", "持续跟踪草地生产力和载畜量变化", "关注局部草场季节性放牧压力"],
},
{
key: "watch",
label: "轻度超载",
shortLabel: "关注",
range: "0% - 10%",
description: "已出现轻度超载迹象,建议提前介入调控。",
range: "> 5% - 10%",
description: "超载率超过5%,已出现轻度超载迹象,建议提前介入调控。",
actions: ["适度压减超载畜群规模", "延长冬春舍饲补饲周期", "增加人工草场补饲供应"],
},
{
@ -112,10 +120,11 @@ export function formatPercent(value) {
export function getRiskLevel(value) {
const rate = toNumber(value)
if (rate === null || rate <= 0) return RISK_LEVELS[0]
if (rate <= 10) return RISK_LEVELS[1]
if (rate <= 20) return RISK_LEVELS[2]
return RISK_LEVELS[3]
if (rate === null || rate < 0) return RISK_LEVELS[0]
if (rate <= 5) return RISK_LEVELS[1]
if (rate <= 10) return RISK_LEVELS[2]
if (rate <= 20) return RISK_LEVELS[3]
return RISK_LEVELS[4]
}
function calculateLivestockCapacity(yakCount) {
@ -256,7 +265,7 @@ export function normalizeBalanceResult(source = {}, fallback = {}) {
bearingCapacity,
livestockCapacity,
overloadRate,
level: pickValue(source, fieldMap.level, risk.label),
level: overloadRate === null ? pickValue(source, fieldMap.level, risk.label) : risk.label,
advice: pickValue(source, fieldMap.advice, fallback.advice || risk.actions.join(";")),
year: pickValue(source, fieldMap.year, fallback.year || "当前年度"),
source: source.raw || source.source || fallback.source,
@ -331,9 +340,12 @@ export function splitAdvice(row = {}, risk = getRiskLevel(row.overloadRate)) {
? text.split(/[。;;\n]/).map((item) => item.trim()).filter(Boolean)
: []
const unsuitableWhenOverloaded = ["提高放牧畜禽的密度", "增加草地的利用率", "密度不能过高"]
const outdatedSafeAdvice = ["轻度超载", "已出现超载", "超载迹象", "压减超载畜群规模"]
const safeRisk = ["healthy", "balanced"].includes(risk.key)
const cleanedApiPoints = apiPoints.filter((item) => {
if (unsuitableWhenOverloaded.some((keyword) => item.includes(keyword))) return false
if (risk.key === "healthy") return true
if (safeRisk && outdatedSafeAdvice.some((keyword) => item.includes(keyword))) return false
if (safeRisk) return true
return !unsuitableWhenOverloaded.some((keyword) => item.includes(keyword))
})
return [...(risk.actions || []), ...cleanedApiPoints]

@ -1847,7 +1847,8 @@ function getProcessStepState(index) {
text-shadow: 0 0 20px rgba(255, 215, 108, 0.34);
}
&.is-healthy strong {
&.is-healthy strong,
&.is-balanced strong {
color: #7efbf6;
}

@ -133,6 +133,25 @@
@click="showNextVideo"
></button>
</div>
<div v-else-if="panel.key === 'yak-forecast-trend'" class="home-forecast-trend-card">
<div class="home-forecast-density-switcher" aria-label="预测粒度切换">
<button
v-for="item in forecastGranularityOptions"
:key="item.key"
type="button"
:class="{ active: item.key === activeForecastGranularity }"
@click="activeForecastGranularity = item.key"
>
{{ item.label }}
</button>
</div>
<v-chart
:ref="(el) => setHomeChartRef(panel.key, el)"
class="home-chart home-forecast-kline-chart"
:option="panel.option"
:autoresize="true"
/>
</div>
<v-chart
v-else-if="panel.option"
:ref="(el) => setHomeChartRef(panel.key, el)"
@ -161,6 +180,7 @@ import { getTopicBusinessStats } from "@/services/openApi"
import { chartTooltip, withChartDefaults } from "@/utils/chartTheme"
import {
buildForecastSeries,
estimatedStock,
forecastPeriods,
getForecastPeriod,
structureRows,
@ -195,6 +215,7 @@ let homeIdleWorkLoaded = false
const chartMotionTick = ref(0)
const homeVideoRows = ref([])
const activeVideoManualIndex = ref(0)
const activeForecastGranularity = ref("month")
const videoPlaying = ref(false)
const videoLoading = ref(false)
const videoError = ref("")
@ -205,7 +226,11 @@ const rightChartPanelHeight = sidePanelHeight
const videoPanelHeight = rightChartPanelHeight
const videoLoadTimeoutMs = 12000
const forecastTrendPeriod = getForecastPeriod("2028")
const trendSeries = buildForecastSeries(forecastTrendPeriod)
const forecastGranularityOptions = [
{ key: "day", label: "日", unit: "日" },
{ key: "week", label: "周", unit: "周" },
{ key: "month", label: "月", unit: "月" },
]
const HOME_VIDEO_ACCESS_TOKEN = String(import.meta.env.VITE_EZVIZ_ACCESS_TOKEN || "").trim()
const homeLayerTopics = [
...forestGrassWetTopics.map(({ key, name, color }) => ({ key, name: name.replace("资源", ""), color })),
@ -246,7 +271,9 @@ const homeMapLayer = computed(() => {
})
const activeStructureIndex = computed(() => getLoopIndex(structureRows.length))
const activeForecastYearIndex = computed(() => getLoopIndex(forecastPeriods.length))
const activeForecastTrendIndex = computed(() => getLoopIndex(trendSeries.labels.length))
const activeForecastGranularityMeta = computed(() => getForecastGranularity(activeForecastGranularity.value))
const trendSeries = computed(() => buildForecastSeriesByGranularity(forecastTrendPeriod, activeForecastGranularityMeta.value.key))
const activeForecastTrendIndex = computed(() => getLoopIndex(trendSeries.value.labels.length))
const fallbackHomeVideoRows = [
{
key: "fallback-video-1",
@ -461,16 +488,47 @@ const threeYearForecastOption = computed(() => withChartDefaults({
}))
const forecastTrendOption = computed(() => {
const granularity = activeForecastGranularityMeta.value
const series = trendSeries.value
const rows = buildForecastKLineRows(series)
const axisInterval = getForecastAxisInterval(rows.length, granularity.key)
return withChartDefaults({
animation: true,
animationDuration: 1200,
animationDurationUpdate: 900,
animationEasing: "cubicOut",
color: ["#30dcff", "#ffd76c", "#ff8f6b"],
tooltip: { ...chartTooltip(), trigger: "axis" },
tooltip: {
...chartTooltip(),
trigger: "axis",
triggerOn: "mousemove|click",
axisPointer: {
type: "cross",
crossStyle: { color: "rgba(126, 251, 246, 0.42)" },
label: {
color: "#eaffff",
backgroundColor: "rgba(5, 31, 51, 0.96)",
},
},
formatter(params) {
const items = Array.isArray(params) ? params : [params]
const candle = items.find((item) => item.seriesType === "candlestick")
const row = candle?.data || rows[candle?.dataIndex ?? items[0]?.dataIndex ?? 0]
if (!row) return ""
const change = row.close - row.open
return [
`${row.label}${granularity.unit}度预测`,
`预测存栏:${formatNumber(row.close)}`,
`较上期:${formatSigned(change)}`,
`繁殖补充:${formatNumber(row.birth)}`,
`出栏扣减:${formatNumber(row.outflow)}`,
].join("<br/>")
},
},
legend: {
top: 2,
right: 2,
data: ["预测存栏", "繁殖补充", "出栏扣减"],
itemWidth: 10,
itemHeight: 7,
itemGap: 7,
@ -479,15 +537,16 @@ const forecastTrendOption = computed(() => {
grid: { left: 42, right: 32, top: 30, bottom: 24 },
xAxis: {
type: "category",
boundaryGap: false,
data: trendSeries.labels,
axisLabel: { color: "rgba(223, 250, 255, 0.62)", fontSize: 10, interval: 4 },
boundaryGap: true,
data: series.labels,
axisLabel: { color: "rgba(223, 250, 255, 0.62)", fontSize: 10, interval: axisInterval },
axisLine: { lineStyle: { color: "rgba(48, 220, 255, 0.28)" } },
axisTick: { show: false },
},
yAxis: [
{
type: "value",
scale: true,
axisLabel: { color: "rgba(223, 250, 255, 0.58)", fontSize: 10, formatter: formatCompact },
splitLine: { lineStyle: { color: "rgba(48, 220, 255, 0.1)", type: "dashed" } },
},
@ -498,9 +557,38 @@ const forecastTrendOption = computed(() => {
},
],
series: [
buildLineSeries("预测存栏", trendSeries.stock, "#30dcff", 0, true, activeForecastTrendIndex.value),
buildLineSeries("繁殖补充", trendSeries.births, "#ffd76c", 1, false, activeForecastTrendIndex.value),
buildLineSeries("出栏扣减", trendSeries.outflow, "#ff8f6b", 1, false, activeForecastTrendIndex.value),
{
name: "预测存栏",
type: "candlestick",
yAxisIndex: 0,
barWidth: getForecastBarWidth(rows.length, granularity.key),
data: rows,
itemStyle: {
color: "rgba(48, 220, 255, 0.82)",
color0: "rgba(255, 143, 107, 0.82)",
borderColor: "#30dcff",
borderColor0: "#ff8f6b",
borderWidth: 1.2,
},
},
{
name: "预测存栏线",
type: "line",
yAxisIndex: 0,
smooth: true,
symbol: "circle",
symbolSize: 3,
data: rows.map((item, index) => ({
value: item.close,
symbolSize: index === activeForecastTrendIndex.value ? 7 : 3,
itemStyle: { color: index === activeForecastTrendIndex.value ? "#ffffff" : "#30dcff" },
})),
lineStyle: { width: 1.6, color: "rgba(48, 220, 255, 0.86)" },
itemStyle: { color: "#30dcff" },
tooltip: { show: false },
},
buildLineSeries("繁殖补充", series.births, "#ffd76c", 1, false, activeForecastTrendIndex.value),
buildLineSeries("出栏扣减", series.outflow, "#ff8f6b", 1, false, activeForecastTrendIndex.value),
],
})
})
@ -940,6 +1028,67 @@ function buildLineSeries(name, data, color, yAxisIndex = 0, area = false, active
}
}
function getForecastGranularity(key) {
return forecastGranularityOptions.find((item) => item.key === key) || forecastGranularityOptions[2]
}
function buildForecastSeriesByGranularity(period, granularityKey = "month") {
if (granularityKey === "month") return buildForecastSeries(period)
const dayCount = Math.max(1, Math.round(Number(period.months || 0) * 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}`
})
const stock = labels.map((_, index) => {
const day = Math.min(dayCount, index * stepDays)
const ratio = day / dayCount
const seasonal = Math.sin(index / (granularityKey === "week" ? 1.8 : 8.5)) * 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 * (Math.min(dayCount, index * stepDays) / dayCount)))
const outflow = labels.map((_, index) => Math.round(period.outflow * (Math.min(dayCount, index * stepDays) / dayCount)))
return { labels, stock, births, outflow }
}
function getForecastAxisInterval(count, granularityKey) {
if (granularityKey === "day") return Math.max(10, Math.ceil(count / 6))
if (granularityKey === "week") return Math.max(2, Math.ceil(count / 7))
return count > 24 ? 4 : count > 14 ? 2 : 1
}
function getForecastBarWidth(count, granularityKey) {
if (granularityKey === "day") return count > 700 ? 2 : count > 360 ? 3 : 4
if (granularityKey === "week") return count > 110 ? 4 : 6
return count > 24 ? 7 : 10
}
function buildForecastKLineRows(series) {
return (series.labels || []).map((label, index) => {
const open = Number(series.stock?.[Math.max(index - 1, 0)] || 0)
const close = Number(series.stock?.[index] || open)
const birth = Math.max(0, Number(series.births?.[index] || 0) - Number(series.births?.[Math.max(index - 1, 0)] || 0))
const outflow = Math.max(0, Number(series.outflow?.[index] || 0) - Number(series.outflow?.[Math.max(index - 1, 0)] || 0))
const wickPadding = Math.max(120, Math.round((birth + outflow) * 0.18))
const high = Math.max(open, close) + Math.max(wickPadding, Math.round(birth * 0.32))
const low = Math.max(0, Math.min(open, close) - Math.max(wickPadding, Math.round(outflow * 0.32)))
return {
label,
open,
close,
low,
high,
birth,
outflow,
value: [open, close, low, high],
}
})
}
function formatNumber(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "--"
@ -948,6 +1097,12 @@ function formatNumber(value) {
})
}
function formatSigned(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "--"
return `${number > 0 ? "+" : ""}${formatNumber(number)}`
}
function formatCompact(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "--"
@ -1326,6 +1481,51 @@ function isHomeLegendItemSelectable(item) {
height: 100%;
}
.home-forecast-trend-card {
position: relative;
width: 100%;
height: 100%;
}
.home-forecast-kline-chart {
padding-top: 14px;
box-sizing: border-box;
}
.home-forecast-density-switcher {
position: absolute;
top: 0;
left: 4px;
z-index: 3;
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid rgba(48, 220, 255, 0.22);
border-radius: 3px;
background: rgba(4, 24, 37, 0.72);
box-shadow: inset 0 0 10px rgba(48, 220, 255, 0.08);
button {
min-width: 24px;
height: 18px;
padding: 0 6px;
border: 0;
border-radius: 2px;
color: rgba(226, 250, 255, 0.66);
font-size: 10px;
line-height: 18px;
font-weight: 800;
background: transparent;
cursor: pointer;
&.active {
color: #041c2b;
background: linear-gradient(180deg, #7efbf6, #30dcff);
box-shadow: 0 0 10px rgba(48, 220, 255, 0.42);
}
}
}
.home-video-monitor {
position: relative;
display: block;

@ -10,6 +10,7 @@
'is-pasture-management': activeModule.key === 'pasture-management',
'is-yak-industry-chain': activeModule.key === 'yak-industry-chain',
'is-smart-management': activeModule.key === 'smart-management',
'is-ecology-national-park': activeModule.key === 'ecological-protection' && activeBottomKey === 'national-park',
}"
id="large-screen"
>
@ -129,9 +130,33 @@
<div
v-if="totalView.length && !['smart-management', 'grass-livestock-balance'].includes(activeModule.key)"
class="top-count-card"
:class="{ 'is-four': totalView.length > 3 }"
:class="{
'is-four': totalView.length > 3 && totalView.length <= 4,
'is-multi': totalView.length > 4,
'is-national-park-stats': activeModule.key === 'ecological-protection' && activeBottomKey === 'national-park',
}"
>
<div v-if="isNationalParkTopStats" class="np-stat-shell">
<div class="np-stat-primary-row">
<div v-for="item in nationalParkPrimaryStats" :key="item.zh" class="np-stat np-stat-primary">
<div class="np-stat-name">{{ item.zh }}</div>
<div class="np-stat-value-line">
<span class="np-stat-value">{{ formatTopStatValue(item) }}</span>
<span class="np-stat-unit">{{ item.unit }}</span>
</div>
</div>
</div>
<div class="np-stat-secondary-row">
<div v-for="item in nationalParkSecondaryStats" :key="item.zh" class="np-stat np-stat-secondary">
<span class="np-stat-name">{{ item.zh }}</span>
<span class="np-stat-value">{{ formatTopStatValue(item) }}</span>
<span class="np-stat-unit">{{ item.unit }}</span>
</div>
</div>
</div>
<template v-else>
<m-count-card v-for="item in totalView" :key="item.zh" :info="item" />
</template>
</div>
<div v-if="activeModule.status !== 'ready'" class="module-coming">
@ -253,6 +278,9 @@ const activeBottomKey = computed(() => {
if (activeModuleKey.value === "ecological-protection") return activeEcologyView.value
return activeModuleKey.value
})
const isNationalParkTopStats = computed(
() => activeModule.value.key === "ecological-protection" && activeBottomKey.value === "national-park",
)
const totalView = computed(() => {
const currentStats = moduleTopStats.value[activeModuleKey.value]
if (Array.isArray(currentStats)) return currentStats
@ -283,6 +311,8 @@ const totalView = computed(() => {
},
])
})
const nationalParkPrimaryStats = computed(() => totalView.value.slice(0, 5))
const nationalParkSecondaryStats = computed(() => totalView.value.slice(5, 10))
onMounted(() => {
screenAutofit = autofit.init({
@ -389,7 +419,7 @@ function hidePlatformLoading() {
}
function buildTopCountItems(items = []) {
return items.slice(0, 4).map((item, index) => {
return items.slice(0, 10).map((item, index) => {
const value = Number(item.value) || 0
const normalized = {
zh: item.zh || item.name || `指标${index + 1}`,
@ -405,6 +435,12 @@ function buildTopCountItems(items = []) {
})
}
function formatTopStatValue(item = {}) {
const value = Number(item.value) || 0
const decimals = item.decimals ?? (Number.isInteger(value) ? 0 : 2)
return value.toFixed(decimals)
}
function renderModule(key) {
if (!["home", "forest-grass-wet", "ecological-protection", "grass-livestock-balance", "yak-management", "pasture-management", "yak-industry-chain", "smart-management"].includes(key)) return
if (renderedModules.value[key]) return
@ -486,6 +522,12 @@ function handleLogout() {
}
}
&.is-ecology-national-park {
.top-menu-mid-space {
width: 820px;
}
}
&.is-grass-livestock-balance {
.large-screen-wrap::after {
opacity: 0.24;
@ -665,6 +707,255 @@ function handleLogout() {
font-size: 10px;
}
}
&.is-multi {
left: 390px;
right: 390px;
top: 102px;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-auto-rows: 40px;
column-gap: 12px;
row-gap: 16px;
z-index: 8;
& > div {
justify-content: flex-start;
}
.count-card {
min-height: 40px;
}
.count-card-left {
flex: 0 1 auto;
}
.count-card-icon {
display: none;
}
.count-card-right {
flex: 0 0 auto;
padding-left: 8px;
}
.count-card-right .value {
margin-right: 4px;
font-size: 22px;
}
.count-card-right .unit {
padding-top: 11px;
font-size: 12px;
opacity: 0.62;
}
.count-card-title .title-zh {
max-width: none;
font-size: 14px;
line-height: 1.18;
white-space: nowrap;
}
.count-card-title .title-en {
display: none;
}
& > div:nth-child(6) {
grid-column: 2;
}
& > div:nth-child(7) {
grid-column: 3;
}
& > div:nth-child(8) {
grid-column: 4;
}
}
&.is-national-park-stats {
left: 50%;
right: auto;
top: 100px;
width: 1100px;
height: 112px;
position: absolute;
transform: translateX(-50%);
display: block;
z-index: 7;
&::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 64px;
pointer-events: none;
border-top: 1px solid rgba(75, 230, 255, 0.26);
border-bottom: 1px solid rgba(75, 230, 255, 0.12);
background:
radial-gradient(ellipse at 50% 0%, rgba(65, 230, 255, 0.18), transparent 64%),
linear-gradient(90deg, transparent, rgba(40, 210, 255, 0.1) 14%, rgba(40, 210, 255, 0.045) 50%, rgba(40, 210, 255, 0.1) 86%, transparent),
linear-gradient(180deg, rgba(6, 73, 103, 0.14), rgba(2, 20, 36, 0.02));
box-shadow: inset 0 1px 0 rgba(205, 253, 255, 0.12), 0 0 18px rgba(34, 210, 255, 0.06);
clip-path: polygon(14px 0, calc(100% - 14px) 0, 100% 16px, 100% 100%, 0 100%, 0 16px);
}
&::after {
content: "";
position: absolute;
left: 80px;
right: 80px;
top: 66px;
height: 1px;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(64, 230, 255, 0.52), transparent);
}
& > div {
padding: 0 !important;
}
.np-stat-shell {
position: relative;
display: flex;
flex-direction: column;
gap: 11px;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.np-stat-primary-row,
.np-stat-secondary-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
width: 100%;
}
.np-stat-primary-row {
position: relative;
height: 64px;
padding: 0 12px;
box-sizing: border-box;
}
.np-stat-secondary-row {
position: relative;
height: 34px;
padding: 0 20px;
border-top: 1px solid rgba(56, 220, 255, 0.18);
border-bottom: 1px solid rgba(56, 220, 255, 0.1);
background:
linear-gradient(90deg, transparent, rgba(42, 215, 255, 0.1) 10%, rgba(42, 215, 255, 0.025) 50%, rgba(42, 215, 255, 0.1) 90%, transparent),
linear-gradient(180deg, rgba(13, 88, 116, 0.16), rgba(3, 24, 42, 0.03));
clip-path: polygon(10px 0, 100% 0, calc(100% - 10px) 100%, 0 100%);
box-sizing: border-box;
}
.np-stat {
position: relative;
min-width: 0;
box-sizing: border-box;
text-align: center;
}
.np-stat-primary {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 5px 8px 4px;
}
.np-stat-primary::after {
content: "";
position: absolute;
right: 0;
top: 14px;
bottom: 14px;
width: 1px;
background: linear-gradient(180deg, transparent, rgba(88, 232, 255, 0.34), transparent);
}
.np-stat-primary:last-child::after {
content: none;
}
.np-stat-secondary {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 8px;
}
.np-stat-secondary::before {
content: "";
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(66, 238, 255, 0.96);
box-shadow: 0 0 8px rgba(66, 238, 255, 0.52);
flex: 0 0 auto;
}
.np-stat-name {
overflow: hidden;
color: rgba(206, 250, 255, 0.78);
font-size: 14px;
font-weight: 600;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.np-stat-value-line {
display: flex;
align-items: baseline;
justify-content: center;
min-width: 0;
}
.np-stat-value {
color: #f7feff;
font-family: D-DIN;
font-size: 31px;
font-weight: 700;
line-height: 1;
text-shadow: 0 0 14px rgba(255, 255, 255, 0.42), 0 0 18px rgba(48, 220, 255, 0.16);
white-space: nowrap;
}
.np-stat-unit {
margin-left: 4px;
color: rgba(220, 252, 255, 0.7);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.np-stat-secondary .np-stat-name {
flex: 0 1 auto;
max-width: 78px;
font-size: 12px;
font-weight: 500;
}
.np-stat-secondary .np-stat-value {
flex: 0 0 auto;
font-size: 20px;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
}
.np-stat-secondary .np-stat-unit {
flex: 0 0 auto;
margin-left: 2px;
font-size: 10px;
}
}
}
.bottom-menu-item {

@ -42,8 +42,11 @@ const isHorizontalBar = computed(() => {
const sourceText = computed(() => {
const source = props.chart.source || {}
const parts = [source.mode, source.table, source.period, source.generatedAt].filter(Boolean)
return parts.length ? parts.join(" / ") : ""
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].filter(Boolean)
return parts.length ? `统计口径:${parts.join(" / ")}` : "统计口径:业务统计库"
})
onMounted(() => {
@ -321,6 +324,27 @@ function shouldUseHorizontalBar(rows = []) {
return rows.length > 6 || rows.some((row) => String(row.name || "").length > 4)
}
function getSourceLabel(source = {}) {
const sourceName = normalizeSourceMeta(source.sourceName || source.name || source.label)
if (sourceName) return sourceName
const mode = String(source.mode || "").trim()
if (/openapi|api|stat/i.test(mode)) return "业务统计库"
if (/local|mock|fallback/i.test(mode)) return "本地统计"
return "业务统计库"
}
function normalizeSourceMeta(value, dateOnly = false) {
const text = String(value || "").trim()
if (!text) return ""
if (dateOnly && /^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10)
if (isTechnicalSourceText(text)) return ""
return text
}
function isTechnicalSourceText(text) {
return /^[a-zA-Z0-9_./:-]+$/.test(text) && (text.includes("_") || text.length > 24)
}
function formatValue(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "--"

@ -7,6 +7,73 @@
<div class="wisdom-scan"></div>
</div>
<aside v-if="leftPanels.length" class="wisdom-side wisdom-side-left">
<section v-for="panel in leftPanels" :key="panel.id" class="wisdom-side-panel" :class="`is-${panel.type}`">
<header class="wisdom-side-header">
<div>
<span>{{ panel.kicker }}</span>
<strong>{{ panel.title }}</strong>
</div>
<em v-if="panel.badge">{{ panel.badge }}</em>
</header>
<div v-if="panel.items?.length" class="wisdom-side-metrics">
<div v-for="item in panel.items" :key="`${panel.id}-${item.label}`" class="wisdom-side-metric">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<em v-if="item.note">{{ item.note }}</em>
</div>
</div>
<div v-if="panel.text" class="wisdom-side-text" v-html="renderMarkdown(panel.text)"></div>
<div v-if="panel.actions?.length" class="wisdom-side-actions">
<button
v-for="action in panel.actions"
:key="action.question"
type="button"
:disabled="loading"
@click="sendMessage(action.question)"
>
{{ action.label }}
</button>
</div>
</section>
</aside>
<aside v-if="rightPanels.length" class="wisdom-side wisdom-side-right">
<section v-for="panel in rightPanels" :key="panel.id" class="wisdom-side-panel" :class="`is-${panel.type}`">
<header class="wisdom-side-header">
<div>
<span>{{ panel.kicker }}</span>
<strong>{{ panel.title }}</strong>
</div>
<em v-if="panel.badge">{{ panel.badge }}</em>
</header>
<smart-chart-renderer
v-if="panel.chart"
class="wisdom-side-chart"
:chart="panel.chart"
/>
<div v-if="panel.items?.length" class="wisdom-side-metrics">
<div v-for="item in panel.items" :key="`${panel.id}-${item.label}`" class="wisdom-side-metric">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<em v-if="item.note">{{ item.note }}</em>
</div>
</div>
<div v-if="panel.text" class="wisdom-side-text" v-html="renderMarkdown(panel.text)"></div>
<div v-if="panel.actions?.length" class="wisdom-side-actions">
<button
v-for="action in panel.actions"
:key="action.question"
type="button"
:disabled="loading"
@click="sendMessage(action.question)"
>
{{ action.label }}
</button>
</div>
</section>
</aside>
<main class="wisdom-chat">
<div ref="dialogueRef" class="wisdom-dialogue" :class="{ 'is-empty': !messages.length }">
<section v-if="!messages.length" class="wisdom-empty-state">
@ -51,21 +118,6 @@
<div v-if="message.digesting" class="wisdom-digesting">
<span></span><span></span><span></span>
<em>{{ message.digestText || "小牧正在消化中" }}</em>
</div>
<div v-if="message.charts?.length" class="wisdom-chart-list">
<smart-chart-renderer
v-for="chart in message.charts"
:key="chart.id"
:chart="chart"
@ready="scrollToBottom"
/>
</div>
<div v-if="message.insights?.length" class="wisdom-insight-list">
<div v-for="insight in message.insights" :key="`${insight.label}-${insight.value}`" class="wisdom-insight-item">
<span>{{ insight.label }}</span>
<strong>{{ insight.value }}</strong>
<em>{{ insight.note }}</em>
</div>
</div>
<div v-if="message.visibleText" class="wisdom-answer" v-html="renderMarkdown(message.visibleText)"></div>
<span v-if="message.streaming" class="wisdom-stream-caret"></span>
@ -106,7 +158,18 @@
placeholder="输入牧户、区域或草畜平衡问题"
@keydown.enter.prevent="sendMessage()"
/>
<button type="submit" :disabled="loading || !currentText.trim()">
<button
type="button"
class="wisdom-voice-button"
:class="{ 'is-listening': voiceListening }"
:disabled="loading || !voiceSupported"
:title="voiceSupported ? (voiceListening ? '停止语音输入' : '语音输入') : '当前浏览器不支持语音输入'"
:aria-label="voiceSupported ? (voiceListening ? '停止语音输入' : '语音输入') : '当前浏览器不支持语音输入'"
@click="toggleVoiceInput"
>
<span></span>
</button>
<button class="wisdom-submit-button" type="submit" :disabled="loading || !currentText.trim()">
<span v-if="loading" class="wisdom-button-loading"></span>
<span>{{ loading ? "消化中" : "发送" }}</span>
</button>
@ -125,6 +188,7 @@ import {
queryBalanceTargets,
} from "@/services/smartApi"
import { getSmartChartsForQuestion } from "@/services/smartChartApi"
import { getRiskLevel } from "@/views/grassLivestockBalance/data"
const SmartChartRenderer = defineAsyncComponent({
loader: () => import("./components/SmartChartRenderer.vue"),
@ -143,6 +207,11 @@ const currentText = ref("")
const messages = ref([])
const loading = ref(false)
const dialogueRef = ref(null)
const leftPanels = ref([])
const rightPanels = ref([])
const voiceSupported = ref(false)
const voiceListening = ref(false)
const voiceError = ref("")
const welcomeText = ref("可以直接询问牦牛数量、草地面积、牧户情况、饲草产量、林地面积、基地分布、畜群结构和交易流通等统计问题。")
const quickPrompts = [
{ label: "牦牛分布", question: "牦牛分布" },
@ -153,6 +222,7 @@ const quickPrompts = [
{ label: "公益林保护", question: "公益林保护等级统计" },
]
let streamTimer = null
let speechRecognition = null
onMounted(() => {
getXiaomuWelcome()
@ -161,10 +231,12 @@ onMounted(() => {
if (typeof text === "string" && text.trim()) welcomeText.value = text
})
.catch(() => {})
initSpeechRecognition()
})
onBeforeUnmount(() => {
stopStreaming()
stopVoiceInput()
})
watch(
@ -172,6 +244,7 @@ watch(
(active) => {
if (active) return
stopStreaming()
stopVoiceInput()
loading.value = false
messages.value = messages.value.map((message) =>
message.streaming ? { ...message, streaming: false, digesting: false } : message,
@ -183,9 +256,69 @@ function createId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function initSpeechRecognition() {
if (typeof window === "undefined") return
const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition
voiceSupported.value = Boolean(Recognition)
if (!Recognition) return
speechRecognition = new Recognition()
speechRecognition.lang = "zh-CN"
speechRecognition.continuous = false
speechRecognition.interimResults = true
speechRecognition.maxAlternatives = 1
speechRecognition.onstart = () => {
voiceListening.value = true
voiceError.value = ""
}
speechRecognition.onresult = (event) => {
const results = Array.from(event.results || [])
const transcript = results
.map((result) => result?.[0]?.transcript || "")
.join("")
.trim()
if (transcript) currentText.value = transcript
}
speechRecognition.onerror = (event) => {
voiceListening.value = false
voiceError.value = event?.error || "语音输入失败"
}
speechRecognition.onend = () => {
voiceListening.value = false
}
}
function toggleVoiceInput() {
if (!speechRecognition || loading.value) return
if (voiceListening.value) {
stopVoiceInput()
return
}
try {
voiceError.value = ""
speechRecognition.start()
} catch (error) {
voiceError.value = error?.message || "语音输入启动失败"
voiceListening.value = false
}
}
function stopVoiceInput() {
if (!speechRecognition || !voiceListening.value) return
try {
speechRecognition.stop()
} catch (error) {
voiceError.value = error?.message || "语音输入已结束"
} finally {
voiceListening.value = false
}
}
async function sendMessage(text = currentText.value) {
const question = String(text || "").trim()
if (!question || loading.value) return
stopVoiceInput()
const userMessage = {
id: createId(),
@ -235,36 +368,53 @@ async function handleStatQuestion(question, assistantId) {
if (answerResult.status === "fulfilled") {
const data = answerResult.value
const answer = data?.answer || "小牧暂时没有查到相关统计数据,可以换一种说法再问一次。"
const displayAnswer = chartError ? `${answer}\n\n**图表状态**\n- ${getFriendlyErrorMessage(chartError, "图表接口暂时不可用,已先返回文字分析。")}` : answer
const insights = charts.length ? buildChartInsights(charts) : []
const suggestions = buildFollowUpSuggestions(question, charts)
const steps = [`正在理解:${question}`, "已查询统计库", "已生成统计回答"]
if (charts.length) {
steps.push(`已生成 ${charts.length} 个数据图表`)
steps.push(`右侧已生成 ${charts.length} 个数据图表`)
} else if (chartError) {
steps.push("图表生成暂时不可用")
}
patchAssistant(assistantId, {
digesting: false,
steps,
insights: charts.length ? buildChartInsights(charts) : [],
suggestions: buildFollowUpSuggestions(question, charts),
insights,
suggestions,
allowFollowUp: true,
})
streamAssistantText(assistantId, chartError ? `${answer}\n\n**图表状态**\n- ${getFriendlyErrorMessage(chartError, "图表接口暂时不可用,已先返回文字分析。")}` : answer, charts)
updateDynamicPanels({
question,
answer: displayAnswer,
charts,
insights,
suggestions,
aiPanels: extractAiPanels(data),
})
streamAssistantText(assistantId, displayAnswer, charts)
return
}
if (charts.length) {
const answer = "文字分析服务暂时没有返回结果,已按统计库真实数据生成可视化图表。"
const insights = buildChartInsights(charts)
const suggestions = buildFollowUpSuggestions(question, charts)
patchAssistant(assistantId, {
digesting: false,
steps: [`正在理解:${question}`, "已查询统计库", `已生成 ${charts.length} 个数据图表`, "文字分析暂时不可用"],
insights: buildChartInsights(charts),
suggestions: buildFollowUpSuggestions(question, charts),
steps: [`正在理解:${question}`, "已查询统计库", `右侧已生成 ${charts.length} 个数据图表`, "文字分析暂时不可用"],
insights,
suggestions,
allowFollowUp: true,
})
streamAssistantText(assistantId, "文字分析服务暂时没有返回结果,已按统计库真实数据生成可视化图表。", charts)
updateDynamicPanels({ question, answer, charts, insights, suggestions })
streamAssistantText(assistantId, answer, charts)
return
}
finishAssistant(assistantId, getFriendlyErrorMessage(answerResult.reason, "小牧暂时没有获取到统计结果,请稍后再试。"))
const errorText = getFriendlyErrorMessage(answerResult.reason, "小牧暂时没有获取到统计结果,请稍后再试。")
updateDynamicPanels({ question, answer: errorText, charts: [], insights: [], suggestions: buildFollowUpSuggestions(question, []) })
finishAssistant(assistantId, errorText)
}
async function handleBalanceQuestion(question, assistantId) {
@ -341,11 +491,13 @@ async function requestBalanceAdvice(choice, assistantId) {
try {
const data = await getLivestockBalance(params)
const answer = formatBalanceAdvice(name, data || {})
patchAssistant(assistantId, {
digesting: false,
steps: [`已锁定:${name}`, "已生成草畜平衡建议"],
})
streamAssistantText(assistantId, formatBalanceAdvice(name, data || {}))
updateBalancePanels(name, data || {}, answer)
streamAssistantText(assistantId, answer)
} catch (error) {
finishAssistant(assistantId, getFriendlyErrorMessage(error, "小牧刚才没有消化成功,请稍后再问一次。"))
}
@ -376,8 +528,9 @@ function getBalanceTargetParams(choice) {
function formatBalanceAdvice(name, result = {}) {
const areaRange = pick(result, ["areaRange", "arearange", "area_range", "area", "acreage"], 0)
const yakNumber = pick(result, ["yakNumber", "yaknumber", "yak_number", "livestock_capacity", "livestockCapacity", "livestockcapacity"], 0)
const level = pick(result, ["level", "conclusion"], "暂无等级")
const overloadRate = pick(result, ["overloadRate", "overloadrate", "overload_rate"], 0)
const overloadRate = pick(result, ["overloadRate", "overloadrate", "overload_rate"], "")
const risk = overloadRate === "" ? null : getRiskLevel(overloadRate)
const level = risk?.label || pick(result, ["level", "conclusion"], "暂无等级")
const advice = pick(result, ["advice", "balanceAdvice", "balanceadvice", "balance_advice"], "暂无建议,请稍后再试。")
return `**${name}草畜平衡建议**
@ -385,7 +538,7 @@ function formatBalanceAdvice(name, result = {}) {
- 草地面积${formatNumber(areaRange)}
- 牦牛/载畜量${formatNumber(yakNumber)}
- 草畜平衡等级${level}
- 超载率${formatNumber(overloadRate)}%
- 超载率${formatNumber(overloadRate === "" ? 0 : overloadRate)}%
**建议**
${advice}`
@ -405,6 +558,208 @@ function formatNumber(value) {
return number.toLocaleString("zh-CN", { maximumFractionDigits: 2 })
}
function extractAiPanels(data = {}) {
const panelPayload = data?.panels || data?.sidePanels || data?.layout || {}
return {
leftPanels: normalizePanelList(data?.leftPanels || panelPayload?.leftPanels || panelPayload?.left || []),
rightPanels: normalizePanelList(data?.rightPanels || panelPayload?.rightPanels || panelPayload?.right || []),
}
}
function normalizePanelList(panels = []) {
const list = Array.isArray(panels) ? panels : []
return list
.map((panel, index) => {
const type = normalizePanelType(panel?.type)
const chart = panel?.chart || panel?.data?.chart || null
return {
id: panel?.id || `ai-panel-${Date.now()}-${index}`,
type,
kicker: panel?.kicker || panel?.category || (type === "chart" ? "图表" : "AI洞察"),
title: panel?.title || panel?.name || "动态内容",
badge: panel?.badge || panel?.unit || "",
text: panel?.text || panel?.content || panel?.summary || "",
chart,
items: normalizePanelItems(panel?.items || panel?.metrics || panel?.data?.items),
actions: normalizePanelActions(panel?.actions || panel?.suggestions),
}
})
.filter((panel) => panel.title && (panel.text || panel.chart || panel.items.length || panel.actions.length))
}
function normalizePanelType(type) {
if (["chart", "metrics", "metric", "actions", "suggestions", "text"].includes(type)) return type === "metric" ? "metrics" : type
return "text"
}
function normalizePanelItems(items = []) {
const list = Array.isArray(items) ? items : []
return list
.map((item, index) => ({
label: item?.label || item?.name || `指标${index + 1}`,
value: item?.value ?? item?.count ?? item?.number ?? "--",
note: item?.note || item?.desc || item?.description || "",
}))
.filter((item) => item.label)
}
function normalizePanelActions(actions = []) {
const list = Array.isArray(actions) ? actions : []
return list
.map((action) => {
const question = action?.question || action?.value || action?.text || action?.label
return {
label: action?.label || action?.name || question,
question,
}
})
.filter((action) => action.label && action.question)
.slice(0, 4)
}
function updateDynamicPanels({ question, answer, charts = [], insights = [], suggestions = [], aiPanels } = {}) {
const aiLeft = aiPanels?.leftPanels || []
const aiRight = aiPanels?.rightPanels || []
if (aiLeft.length || aiRight.length) {
leftPanels.value = aiLeft
rightPanels.value = aiRight
return
}
const fallbackPanels = buildFallbackPanels({ question, answer, charts, insights, suggestions })
leftPanels.value = fallbackPanels.left
rightPanels.value = fallbackPanels.right
}
function buildFallbackPanels({ question, answer, charts = [], insights = [], suggestions = [] } = {}) {
const left = []
const right = []
const metricItems = (insights.length ? insights : buildChartInsights(charts)).map((item) => ({
label: item.label,
value: item.value,
note: item.note,
}))
if (metricItems.length) {
left.push({
id: `summary-${createId()}`,
type: "metrics",
kicker: "AI洞察",
title: "数据摘要",
badge: "实时",
items: metricItems.slice(0, 4),
actions: [],
})
} else if (answer) {
left.push({
id: `summary-${createId()}`,
type: "text",
kicker: "AI洞察",
title: "回答要点",
badge: "",
text: extractAnswerBrief(answer),
items: [],
actions: [],
})
}
if (suggestions.length) {
left.push({
id: `suggestion-${createId()}`,
type: "actions",
kicker: "继续追问",
title: "关联问题",
badge: String(suggestions.length),
actions: normalizePanelActions(suggestions),
items: [],
text: "",
})
}
charts.slice(0, 2).forEach((chart, index) => {
right.push({
id: `chart-${chart.id || index}-${createId()}`,
type: "chart",
kicker: index === 0 ? "可视分析" : "关联图表",
title: chart.title || `统计图表${index + 1}`,
badge: chart.unit || "",
chart,
items: [],
actions: [],
text: "",
})
})
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 {
left: left.slice(0, 2),
right: right.slice(0, 2),
}
}
function updateBalancePanels(name, result = {}, answer = "") {
const areaRange = pick(result, ["areaRange", "arearange", "area_range", "area", "acreage"], 0)
const yakNumber = pick(result, ["yakNumber", "yaknumber", "yak_number", "livestock_capacity", "livestockCapacity", "livestockcapacity"], 0)
const overloadRate = pick(result, ["overloadRate", "overloadrate", "overload_rate"], "")
const risk = overloadRate === "" ? null : getRiskLevel(overloadRate)
const level = risk?.label || pick(result, ["level", "conclusion"], "暂无等级")
leftPanels.value = [
{
id: `balance-${createId()}`,
type: "metrics",
kicker: "草畜平衡",
title: name,
badge: level,
items: [
{ label: "草地面积", value: `${formatNumber(areaRange)}`, note: "承载基础" },
{ label: "牦牛/载畜量", value: `${formatNumber(yakNumber)}`, note: "当前口径" },
{ label: "超载率", value: `${formatNumber(overloadRate === "" ? 0 : overloadRate)}%`, note: level },
],
actions: [],
},
]
rightPanels.value = [
{
id: `balance-advice-${createId()}`,
type: "text",
kicker: "AI建议",
title: "管理建议",
badge: "模型",
text: extractAnswerBrief(answer, 180),
items: [],
actions: normalizePanelActions([
{ label: "看牦牛分布", question: "牦牛分布" },
{ label: "看草地面积", question: "草地面积分布" },
]),
},
]
}
function extractAnswerBrief(answer, limit = 120) {
const text = String(answer || "")
.replace(/\*\*/g, "")
.replace(/^#+\s*/gm, "")
.split(/\r?\n/)
.map((line) => line.replace(/^[-*]\s+/, "").trim())
.filter(Boolean)
.slice(0, 4)
.join("\n")
return text.length > limit ? `${text.slice(0, limit)}...` : text
}
function buildChartInsights(charts = []) {
const insights = []
charts.slice(0, 2).forEach((chart) => {
@ -517,35 +872,28 @@ function getFriendlyErrorMessage(error, fallback) {
return /[a-zA-Z]{4,}/.test(message) ? fallback : message || fallback
}
function streamAssistantText(id, text, charts = []) {
function streamAssistantText(id, text) {
stopStreaming()
const source = String(text || "")
const chunkSize = source.length > 1200 ? 36 : source.length > 700 ? 24 : 14
let index = 0
const keepChartsInView = charts.length > 0
patchAssistant(id, {
fullText: source,
visibleText: "",
streaming: true,
choices: [],
charts,
charts: [],
})
if (keepChartsInView) scrollToLatestChart()
const tick = () => {
index = Math.min(index + chunkSize, source.length)
patchAssistant(id, {
visibleText: source.slice(0, index),
streaming: index < source.length,
}, !keepChartsInView)
})
if (index >= source.length) {
streamTimer = null
if (charts.length) {
patchAssistant(id, {
charts,
}, false)
}
loading.value = false
return
}
@ -629,18 +977,6 @@ async function scrollToBottom() {
})
}
async function scrollToLatestChart() {
await nextTick()
window.requestAnimationFrame(() => {
const container = dialogueRef.value
const chartList = container?.querySelector(".wisdom-message.is-assistant:last-child .wisdom-chart-list")
if (!container || !chartList) return
const containerRect = container.getBoundingClientRect()
const chartRect = chartList.getBoundingClientRect()
container.scrollTop += chartRect.top - containerRect.top - 12
})
}
</script>
<style lang="scss">
@ -668,7 +1004,7 @@ async function scrollToLatestChart() {
right: 430px;
top: 150px;
bottom: 102px;
opacity: 0.24;
opacity: 0.18;
background-image:
linear-gradient(rgba(48, 220, 255, 0.14) 1px, transparent 1px),
linear-gradient(90deg, rgba(48, 220, 255, 0.14) 1px, transparent 1px);
@ -709,10 +1045,208 @@ async function scrollToLatestChart() {
border-radius: 50%;
transform: translate(-50%, -50%);
background: conic-gradient(from 0deg, transparent, rgba(48, 220, 255, 0.2), transparent 24%);
opacity: 0.34;
opacity: 0.2;
animation: wisdomScanSpin 8s linear infinite;
}
.wisdom-side {
position: absolute;
top: 138px;
bottom: 76px;
z-index: 5;
width: 360px;
overflow-y: auto;
display: grid;
align-content: start;
gap: 14px;
padding: 8px 0;
box-sizing: border-box;
scrollbar-width: none;
}
.wisdom-side::-webkit-scrollbar {
width: 0;
height: 0;
}
.wisdom-side-left {
left: 40px;
}
.wisdom-side-right {
right: 40px;
}
.wisdom-side-panel {
min-height: 0;
border: 1px solid rgba(48, 220, 255, 0.22);
background:
linear-gradient(135deg, rgba(6, 37, 50, 0.9), rgba(4, 19, 32, 0.86)),
radial-gradient(circle at 18% 0%, rgba(39, 240, 192, 0.12), transparent 34%);
box-shadow: inset 0 0 24px rgba(48, 220, 255, 0.07), 0 14px 28px rgba(0, 0, 0, 0.22);
padding: 13px;
box-sizing: border-box;
}
.wisdom-side-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.wisdom-side-header span {
display: block;
margin-bottom: 5px;
color: rgba(48, 220, 255, 0.72);
font-size: 12px;
}
.wisdom-side-header strong {
display: block;
max-width: 220px;
color: #fff;
font-size: 17px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wisdom-side-header em {
flex: 0 0 auto;
padding: 3px 8px;
border: 1px solid rgba(255, 215, 108, 0.32);
color: #ffd76c;
font-size: 12px;
font-style: normal;
background: rgba(255, 215, 108, 0.08);
}
.wisdom-side-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.wisdom-side-metric {
min-width: 0;
padding: 10px;
border: 1px solid rgba(48, 220, 255, 0.16);
background: rgba(3, 22, 35, 0.58);
box-sizing: border-box;
}
.wisdom-side-metric span,
.wisdom-side-metric em {
display: block;
color: rgba(223, 250, 255, 0.55);
font-size: 12px;
font-style: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wisdom-side-metric strong {
display: block;
margin: 4px 0;
color: #ffd76c;
font-family: "D-DIN", Arial, sans-serif;
font-size: 17px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wisdom-side-text {
color: rgba(237, 253, 255, 0.88);
font-size: 13px;
line-height: 1.7;
}
.wisdom-side-text h3 {
margin: 0 0 8px;
color: #fff;
font-size: 15px;
}
.wisdom-side-text p {
margin: 7px 0;
}
.wisdom-side-text ul,
.wisdom-side-text ol {
margin: 8px 0 8px 18px;
padding: 0;
}
.wisdom-side-text strong {
color: #ffd76c;
}
.wisdom-side-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wisdom-side-text + .wisdom-side-actions,
.wisdom-side-metrics + .wisdom-side-actions {
margin-top: 12px;
}
.wisdom-side-actions button {
height: 30px;
min-width: 84px;
padding: 0 10px;
border: 1px solid rgba(48, 220, 255, 0.28);
background: linear-gradient(135deg, rgba(8, 48, 62, 0.76), rgba(6, 28, 43, 0.76));
color: rgba(255, 215, 108, 0.9);
font-size: 12px;
cursor: pointer;
}
.wisdom-side-actions button:hover:not(:disabled) {
border-color: rgba(255, 215, 108, 0.62);
color: #fff;
}
.wisdom-side-chart.smart-chart-card {
width: 100%;
height: 268px;
margin-top: 0;
}
.wisdom-side-chart .smart-chart-header {
height: 48px;
padding: 9px 11px 0;
}
.wisdom-side-chart .smart-chart-header strong {
font-size: 14px;
max-width: 210px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wisdom-side-chart .smart-chart-header span,
.wisdom-side-chart .smart-chart-header em {
font-size: 11px;
}
.wisdom-side-chart .smart-chart {
height: 198px;
}
.wisdom-side-chart .smart-chart-source {
height: 20px;
padding: 0 11px;
}
.wisdom-avatar-ring,
.wisdom-empty-mark span {
position: absolute;
@ -741,7 +1275,7 @@ async function scrollToLatestChart() {
top: 138px;
bottom: 76px;
z-index: 4;
width: min(860px, calc(100% - 360px));
width: clamp(600px, calc(100% - 900px), 720px);
display: flex;
flex-direction: column;
min-width: 0;
@ -861,20 +1395,14 @@ async function scrollToLatestChart() {
max-width: min(680px, calc(100% - 48px));
box-sizing: border-box;
border: 1px solid rgba(48, 220, 255, 0.18);
background: rgba(4, 22, 34, 0.82);
box-shadow: inset 0 0 24px rgba(48, 220, 255, 0.06), 0 12px 30px rgba(0, 0, 0, 0.24);
background: rgba(4, 22, 34, 0.92);
box-shadow: inset 0 0 24px rgba(48, 220, 255, 0.06), 0 14px 34px rgba(0, 0, 0, 0.34);
color: rgba(237, 253, 255, 0.92);
font-size: 14px;
line-height: 1.7;
padding: 13px 15px;
}
.wisdom-chart-list {
display: grid;
gap: 14px;
margin-top: 12px;
}
.smart-chart-loading {
width: min(620px, 100%);
height: 292px;
@ -889,44 +1417,6 @@ async function scrollToLatestChart() {
box-shadow: inset 0 0 24px rgba(48, 220, 255, 0.06);
}
.wisdom-insight-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.wisdom-insight-item {
min-width: 0;
padding: 10px 11px;
border: 1px solid rgba(48, 220, 255, 0.16);
background: rgba(3, 22, 35, 0.62);
box-shadow: inset 0 0 18px rgba(48, 220, 255, 0.05);
}
.wisdom-insight-item span,
.wisdom-insight-item em {
display: block;
color: rgba(223, 250, 255, 0.55);
font-size: 12px;
font-style: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wisdom-insight-item strong {
display: block;
margin: 3px 0;
color: #ffd76c;
font-family: "D-DIN", Arial, sans-serif;
font-size: 16px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wisdom-suggestion-list {
display: flex;
flex-wrap: wrap;
@ -1122,7 +1612,7 @@ async function scrollToLatestChart() {
color: rgba(223, 250, 255, 0.42);
}
.wisdom-input-shell button {
.wisdom-submit-button {
width: 92px;
height: 36px;
border: 1px solid rgba(255, 215, 108, 0.46);
@ -1135,7 +1625,70 @@ async function scrollToLatestChart() {
cursor: pointer;
}
.wisdom-input-shell button:disabled {
.wisdom-voice-button {
position: relative;
flex: 0 0 auto;
width: 38px;
height: 36px;
border: 1px solid rgba(48, 220, 255, 0.32);
background: rgba(6, 35, 49, 0.76);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.wisdom-voice-button span {
position: relative;
width: 10px;
height: 17px;
border: 2px solid #30dcff;
border-radius: 8px;
box-sizing: border-box;
}
.wisdom-voice-button span::before {
content: "";
position: absolute;
left: 50%;
bottom: -8px;
width: 14px;
height: 8px;
border: 2px solid #30dcff;
border-top: 0;
border-radius: 0 0 10px 10px;
transform: translateX(-50%);
}
.wisdom-voice-button span::after {
content: "";
position: absolute;
left: 50%;
bottom: -12px;
width: 12px;
height: 2px;
border-radius: 2px;
background: #30dcff;
transform: translateX(-50%);
}
.wisdom-voice-button.is-listening {
border-color: rgba(255, 215, 108, 0.62);
background: rgba(255, 215, 108, 0.14);
box-shadow: 0 0 18px rgba(255, 215, 108, 0.22);
}
.wisdom-voice-button.is-listening span,
.wisdom-voice-button.is-listening span::before {
border-color: #ffd76c;
}
.wisdom-voice-button.is-listening span::after {
background: #ffd76c;
}
.wisdom-submit-button:disabled,
.wisdom-voice-button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
@ -1194,4 +1747,32 @@ async function scrollToLatestChart() {
opacity: 0;
}
}
@media (max-width: 1480px) {
.wisdom-side {
width: 300px;
}
.wisdom-side-left {
left: 24px;
}
.wisdom-side-right {
right: 24px;
}
.wisdom-chat {
width: clamp(520px, calc(100% - 700px), 720px);
}
}
@media (max-width: 1280px) {
.wisdom-side {
display: none;
}
.wisdom-chat {
width: min(860px, calc(100% - 80px));
}
}
</style>

@ -0,0 +1,720 @@
<template>
<div class="yak-ol-map">
<div ref="mapEl" class="yak-ol-map__canvas"></div>
<div
v-if="coordinateCopyTip.visible"
class="yak-ol-map__copy-tip"
:class="{ 'is-error': coordinateCopyTip.error }"
:style="{ left: `${coordinateCopyTip.x}px`, top: `${coordinateCopyTip.y}px` }"
>
{{ coordinateCopyTip.text }}
</div>
<div
v-if="coordinateCopyMenu.visible"
class="yak-ol-map__copy-menu"
:class="{ 'is-error': coordinateCopyMenu.error }"
:style="{ left: `${coordinateCopyMenu.x}px`, top: `${coordinateCopyMenu.y}px` }"
@click.stop
>
<span>{{ coordinateCopyMenu.text }}</span>
<button type="button" @click="handleCoordinateMenuCopy">复制</button>
</div>
</div>
</template>
<script setup>
import "ol/ol.css"
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import Map from "ol/Map.js"
import View from "ol/View.js"
import Feature from "ol/Feature.js"
import GeoJSON from "ol/format/GeoJSON.js"
import TileLayer from "ol/layer/Tile.js"
import VectorLayer from "ol/layer/Vector.js"
import Point from "ol/geom/Point.js"
import TileWMS from "ol/source/TileWMS.js"
import XYZ from "ol/source/XYZ.js"
import VectorSource from "ol/source/Vector.js"
import { defaults as defaultControls } from "ol/control/defaults.js"
import { unByKey } from "ol/Observable.js"
import { fromLonLat, toLonLat } from "ol/proj.js"
import { Circle as CircleStyle, Fill, RegularShape, Stroke, Style, Text } from "ol/style.js"
import { hongyuanTownshipLabelPoints, hongyuanTownshipsGeoJson } from "@/config/townshipBoundaries"
const props = defineProps({
active: {
type: Boolean,
default: true,
},
tileUrlTemplate: {
type: String,
default: "/hy-result/{z}/{x}/{y}.png",
},
center: {
type: Array,
default: () => [103.07984, 33.029058],
},
minZoom: {
type: Number,
default: 3,
},
maxZoom: {
type: Number,
default: 21,
},
})
const emit = defineEmits(["ready", "tile-status", "layer-status"])
const HONGYUAN_CENTER = [103.07984, 33.029058]
const INITIAL_ZOOM = 21
const boundaryFormat = new GeoJSON()
const boundaryGlowStyle = new Style({
stroke: new Stroke({
color: "rgba(48, 220, 255, 0.32)",
width: 5,
}),
})
const boundaryLineStyle = new Style({
fill: new Fill({
color: "rgba(3, 18, 31, 0.02)",
}),
stroke: new Stroke({
color: "rgba(126, 251, 246, 0.82)",
width: 1.4,
}),
})
const labelPointStyle = new Style({
image: new CircleStyle({
radius: 2.4,
fill: new Fill({ color: "rgba(234, 255, 255, 0.92)" }),
stroke: new Stroke({ color: "rgba(48, 220, 255, 0.9)", width: 1 }),
}),
})
const mapEl = ref(null)
let olMap = null
let baseSource = null
let yakWmsSource = null
let contextMenuHandler = null
let rightPointerDownHandler = null
let coordinateTipTimer = null
let lastRightClickCopyTime = 0
let eventKeys = []
let wmsEventKeys = []
const coordinateCopyTip = ref({
visible: false,
text: "",
x: 0,
y: 0,
error: false,
})
const coordinateCopyMenu = ref({
visible: false,
text: "",
x: 0,
y: 0,
error: false,
})
const pendingTiles = new Set()
const tileState = {
requested: 0,
loaded: 0,
failed: 0,
zoom: INITIAL_ZOOM,
}
function tileCoordKey(event) {
const coord = event?.tile?.getTileCoord?.()
return Array.isArray(coord) ? coord.join("/") : ""
}
function currentZoom() {
const zoom = Number(olMap?.getView?.()?.getZoom?.())
return Number.isFinite(zoom) ? Math.round(zoom) : INITIAL_ZOOM
}
function normalizeCenter(center = props.center) {
const [lng, lat] = Array.isArray(center) ? center : []
const nextLng = Number(lng)
const nextLat = Number(lat)
return Number.isFinite(nextLng) && Number.isFinite(nextLat) ? [nextLng, nextLat] : HONGYUAN_CENTER
}
function locateMap(center = props.center) {
const view = olMap?.getView?.()
if (!view) return
resetTileState(INITIAL_ZOOM)
view.setCenter(fromLonLat(normalizeCenter(center)))
view.setZoom(INITIAL_ZOOM)
window.setTimeout(() => olMap?.updateSize?.(), 0)
}
function resetTileState(zoom = currentZoom()) {
pendingTiles.clear()
tileState.requested = 0
tileState.loaded = 0
tileState.failed = 0
tileState.zoom = zoom
}
function emitTileStatus(type, text) {
emit("tile-status", {
type,
text,
loaded: tileState.loaded,
failed: tileState.failed,
total: tileState.requested,
zoom: tileState.zoom,
tileUrlTemplate: props.tileUrlTemplate,
})
}
function emitLayerStatus(type, text) {
emit("layer-status", {
type,
text,
})
}
function buildBaseSource() {
baseSource = new XYZ({
url: props.tileUrlTemplate,
minZoom: props.minZoom,
maxZoom: props.maxZoom,
crossOrigin: "anonymous",
interpolate: true,
transition: 0,
wrapX: false,
})
eventKeys.push(
baseSource.on("tileloadstart", (event) => {
const key = tileCoordKey(event)
if (key && pendingTiles.has(key)) return
if (key) pendingTiles.add(key)
tileState.requested += 1
tileState.zoom = currentZoom()
emitTileStatus("loading", `正在加载 hy-result ${tileState.zoom}级影像底图`)
}),
)
eventKeys.push(
baseSource.on("tileloadend", (event) => {
const key = tileCoordKey(event)
if (key && !pendingTiles.has(key)) return
if (key) pendingTiles.delete(key)
tileState.loaded += 1
tileState.zoom = currentZoom()
const done = pendingTiles.size === 0
emitTileStatus(done ? "success" : "loading", done ? "hy-result 影像底图已加载" : `正在加载 hy-result ${tileState.zoom}级影像底图`)
}),
)
eventKeys.push(
baseSource.on("tileloaderror", (event) => {
const key = tileCoordKey(event)
if (key && !pendingTiles.has(key)) return
if (key) pendingTiles.delete(key)
tileState.failed += 1
tileState.zoom = currentZoom()
const type = tileState.loaded > 0 ? "warning" : "error"
emitTileStatus(type, type === "warning" ? "hy-result 影像底图覆盖不足" : "hy-result 影像底图加载失败")
}),
)
return baseSource
}
function buildYakWmsSource() {
yakWmsSource = new TileWMS({
url: "/geoserver/ne/wms",
params: {
SERVICE: "WMS",
VERSION: "1.1.1",
LAYERS: "ne:daping_yak_marks",
STYLES: "yak_marks",
CQL_FILTER: "gov_show = true",
FORMAT: "image/png",
TRANSPARENT: true,
TILED: true,
},
serverType: "geoserver",
crossOrigin: "anonymous",
transition: 0,
})
wmsEventKeys.push(yakWmsSource.on("tileloadstart", () => emitLayerStatus("loading", "正在加载牦牛识别标记")))
wmsEventKeys.push(yakWmsSource.on("tileloadend", () => emitLayerStatus("success", "牦牛识别标记已加载")))
wmsEventKeys.push(yakWmsSource.on("tileloaderror", () => emitLayerStatus("error", "牦牛识别标记加载失败")))
return yakWmsSource
}
function createBoundaryLayers() {
const boundaryFeatures = boundaryFormat.readFeatures(hongyuanTownshipsGeoJson, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
})
const boundarySource = new VectorSource({
features: boundaryFeatures,
})
const labelFeatures = hongyuanTownshipLabelPoints
.filter((item) => item.name && Array.isArray(item.center))
.map((item) => {
const feature = new Feature({
geometry: new Point(fromLonLat(item.center)),
name: item.name,
})
feature.setId(`yak-town-label-${item.name}`)
return feature
})
const labelSource = new VectorSource({
features: labelFeatures,
})
return [
new VectorLayer({
source: boundarySource,
style: boundaryGlowStyle,
zIndex: 28,
renderBuffer: 80,
updateWhileAnimating: true,
updateWhileInteracting: true,
}),
new VectorLayer({
source: boundarySource,
style: boundaryLineStyle,
zIndex: 29,
renderBuffer: 80,
updateWhileAnimating: true,
updateWhileInteracting: true,
}),
new VectorLayer({
source: labelSource,
declutter: true,
zIndex: 40,
renderBuffer: 120,
updateWhileAnimating: true,
updateWhileInteracting: true,
style: createTownLabelStyle,
}),
]
}
function createTownLabelStyle(feature, resolution) {
const name = feature.get("name") || ""
const compact = resolution > 150
const hidden = resolution > 420
if (hidden) return labelPointStyle
const fontSize = compact ? 12 : 13
const minWidthPadding = compact ? [4, 8, 4, 8] : [5, 10, 5, 10]
return [
new Style({
image: new RegularShape({
points: 3,
radius: compact ? 4 : 5,
rotation: Math.PI,
displacement: [0, 14],
fill: new Fill({ color: "rgba(126, 251, 246, 0.92)" }),
stroke: new Stroke({ color: "rgba(4, 28, 40, 0.95)", width: 1.2 }),
}),
}),
new Style({
text: new Text({
text: name,
font: `800 ${fontSize}px "Microsoft YaHei", "PingFang SC", sans-serif`,
fill: new Fill({ color: "#ffffff" }),
stroke: new Stroke({ color: "rgba(2, 18, 28, 0.96)", width: 4 }),
backgroundFill: new Fill({ color: "rgba(3, 21, 33, 0.78)" }),
backgroundStroke: new Stroke({ color: "rgba(126, 251, 246, 0.36)", width: 1 }),
padding: minWidthPadding,
offsetY: -17,
}),
}),
]
}
function formatCoordinate(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "0.000000"
return number.toFixed(6)
}
async function copyTextToClipboard(text) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return
} catch (error) {
// Fall back to textarea copy below. Some browsers do not allow clipboard writes from contextmenu.
}
}
const textarea = document.createElement("textarea")
textarea.value = text
textarea.setAttribute("readonly", "readonly")
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "0"
textarea.style.opacity = "0"
try {
document.body.appendChild(textarea)
textarea.focus({ preventScroll: true })
textarea.select()
textarea.setSelectionRange(0, text.length)
const success = document.execCommand("copy")
if (!success) throw new Error("copy failed")
} finally {
document.body.removeChild(textarea)
}
}
function showCoordinateCopyTip(event, text, error = false) {
if (coordinateTipTimer) window.clearTimeout(coordinateTipTimer)
coordinateCopyMenu.value = {
...coordinateCopyMenu.value,
visible: false,
}
const tipWidth = 270
const tipHeight = 32
const viewportWidth = window.innerWidth || 1920
const viewportHeight = window.innerHeight || 1080
coordinateCopyTip.value = {
visible: true,
text,
x: Math.min(Math.max(12, event.clientX + 12), Math.max(12, viewportWidth - tipWidth)),
y: Math.min(Math.max(12, event.clientY + 12), Math.max(12, viewportHeight - tipHeight)),
error,
}
coordinateTipTimer = window.setTimeout(() => {
coordinateCopyTip.value = {
...coordinateCopyTip.value,
visible: false,
}
coordinateTipTimer = null
}, 1500)
}
function showCoordinateCopyMenu(event, text, error = false) {
if (coordinateTipTimer) {
window.clearTimeout(coordinateTipTimer)
coordinateTipTimer = null
}
const menuWidth = 300
const menuHeight = 38
const viewportWidth = window.innerWidth || 1920
const viewportHeight = window.innerHeight || 1080
coordinateCopyTip.value = {
...coordinateCopyTip.value,
visible: false,
}
coordinateCopyMenu.value = {
visible: true,
text,
x: Math.min(Math.max(12, event.clientX + 12), Math.max(12, viewportWidth - menuWidth)),
y: Math.min(Math.max(12, event.clientY + 12), Math.max(12, viewportHeight - menuHeight)),
error,
}
}
function hideCoordinateCopyMenu() {
coordinateCopyMenu.value = {
...coordinateCopyMenu.value,
visible: false,
}
}
async function copyCoordinateFromMapEvent(event) {
if (!olMap) return
const pixel = olMap.getEventPixel(event)
const coordinate = olMap.getCoordinateFromPixel(pixel)
if (!coordinate) return
const [lng, lat] = toLonLat(coordinate)
const text = `${formatCoordinate(lng)}, ${formatCoordinate(lat)}`
try {
await copyTextToClipboard(text)
showCoordinateCopyTip(event, `已复制 ${text}`)
} catch (error) {
showCoordinateCopyMenu(event, text, true)
}
}
function handleRightPointerDown(event) {
if (event.button !== 2) {
hideCoordinateCopyMenu()
return
}
event.preventDefault()
event.stopPropagation()
lastRightClickCopyTime = Date.now()
copyCoordinateFromMapEvent(event)
}
async function handleCoordinateMenuCopy() {
const text = coordinateCopyMenu.value.text
if (!text) return
try {
await copyTextToClipboard(text)
coordinateCopyMenu.value = {
...coordinateCopyMenu.value,
visible: false,
error: false,
}
coordinateCopyTip.value = {
visible: true,
text: `已复制 ${text}`,
x: coordinateCopyMenu.value.x,
y: coordinateCopyMenu.value.y,
error: false,
}
if (coordinateTipTimer) window.clearTimeout(coordinateTipTimer)
coordinateTipTimer = window.setTimeout(() => {
coordinateCopyTip.value = {
...coordinateCopyTip.value,
visible: false,
}
coordinateTipTimer = null
}, 1500)
} catch (error) {
coordinateCopyMenu.value = {
...coordinateCopyMenu.value,
error: true,
}
}
}
function handleContextMenu(event) {
event.preventDefault()
event.stopPropagation()
if (Date.now() - lastRightClickCopyTime < 800) return
lastRightClickCopyTime = Date.now()
copyCoordinateFromMapEvent(event)
}
function bindContextMenuCopy() {
const viewport = olMap?.getViewport?.()
if (!viewport || contextMenuHandler) return
rightPointerDownHandler = (event) => {
handleRightPointerDown(event)
}
contextMenuHandler = (event) => {
handleContextMenu(event)
}
viewport.addEventListener("pointerdown", rightPointerDownHandler)
viewport.addEventListener("contextmenu", contextMenuHandler)
}
function unbindContextMenuCopy() {
const viewport = olMap?.getViewport?.()
if (viewport && rightPointerDownHandler) {
viewport.removeEventListener("pointerdown", rightPointerDownHandler)
}
if (viewport && contextMenuHandler) {
viewport.removeEventListener("contextmenu", contextMenuHandler)
}
rightPointerDownHandler = null
contextMenuHandler = null
if (coordinateTipTimer) {
window.clearTimeout(coordinateTipTimer)
coordinateTipTimer = null
}
hideCoordinateCopyMenu()
}
async function initMap() {
if (olMap || !props.active) return
await nextTick()
if (!mapEl.value || olMap || !props.active) return
resetTileState(INITIAL_ZOOM)
const view = new View({
center: fromLonLat(normalizeCenter()),
zoom: INITIAL_ZOOM,
minZoom: props.minZoom,
maxZoom: props.maxZoom,
enableRotation: false,
constrainResolution: false,
smoothResolutionConstraint: true,
})
olMap = new Map({
target: mapEl.value,
controls: defaultControls({
attribution: false,
rotate: false,
zoom: false,
}),
layers: [
new TileLayer({
source: buildBaseSource(),
preload: 1,
zIndex: 1,
}),
new TileLayer({
source: buildYakWmsSource(),
opacity: 0.96,
zIndex: 20,
}),
...createBoundaryLayers(),
],
view,
})
bindContextMenuCopy()
eventKeys.push(
view.on("change:resolution", () => {
const zoom = currentZoom()
if (zoom !== tileState.zoom) {
resetTileState(zoom)
emitTileStatus("loading", `正在加载 hy-result ${zoom}级影像底图`)
}
}),
)
window.setTimeout(() => {
olMap?.updateSize?.()
emit("ready")
}, 0)
}
function disposeMap() {
unbindContextMenuCopy()
if (eventKeys.length) {
unByKey(eventKeys)
eventKeys = []
}
if (wmsEventKeys.length) {
unByKey(wmsEventKeys)
wmsEventKeys = []
}
pendingTiles.clear()
baseSource = null
yakWmsSource = null
if (olMap) {
olMap.setTarget(null)
olMap.dispose?.()
olMap = null
}
}
onMounted(() => {
initMap()
})
onBeforeUnmount(() => {
disposeMap()
})
watch(
() => props.active,
(active) => {
if (active) {
initMap()
window.setTimeout(() => {
locateMap()
olMap?.updateSize?.()
}, 0)
}
},
)
watch(
() => props.center,
() => {
locateMap()
},
{ deep: true },
)
watch(
() => props.tileUrlTemplate,
() => {
if (!baseSource) return
resetTileState(currentZoom())
baseSource.setUrl(props.tileUrlTemplate)
baseSource.refresh()
emitTileStatus("loading", `正在加载 hy-result ${tileState.zoom}级影像底图`)
},
)
</script>
<style scoped lang="scss">
.yak-ol-map {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
background: #03121c;
}
.yak-ol-map__canvas {
width: 100%;
height: 100%;
}
.yak-ol-map__copy-tip {
position: fixed;
z-index: 30;
max-width: 260px;
padding: 7px 10px;
border: 1px solid rgba(126, 251, 246, 0.4);
border-radius: 3px;
color: rgba(235, 255, 255, 0.94);
font-size: 12px;
font-weight: 700;
line-height: 1;
background: rgba(3, 21, 33, 0.86);
box-shadow: 0 0 14px rgba(48, 220, 255, 0.16);
pointer-events: none;
transform: translateY(-50%);
white-space: nowrap;
&.is-error {
border-color: rgba(255, 143, 107, 0.55);
color: #ffe0d6;
}
}
.yak-ol-map__copy-menu {
position: fixed;
z-index: 30;
display: flex;
height: 34px;
align-items: center;
gap: 8px;
padding: 0 8px 0 10px;
border: 1px solid rgba(126, 251, 246, 0.42);
border-radius: 3px;
color: rgba(235, 255, 255, 0.94);
font-size: 12px;
font-weight: 700;
line-height: 1;
background: rgba(3, 21, 33, 0.92);
box-shadow: 0 0 14px rgba(48, 220, 255, 0.18);
pointer-events: all;
white-space: nowrap;
span {
font-variant-numeric: tabular-nums;
}
button {
height: 22px;
padding: 0 8px;
border: 1px solid rgba(126, 251, 246, 0.38);
border-radius: 2px;
color: #031822;
font-size: 12px;
font-weight: 800;
background: linear-gradient(180deg, #7efbf6, #30dcff);
cursor: pointer;
}
&.is-error {
border-color: rgba(255, 215, 108, 0.48);
}
}
:deep(.ol-viewport) {
background: #03121c;
}
</style>

@ -1,14 +1,29 @@
<template>
<div class="yak-management">
<map-scene
v-show="!isYakOlMapView"
:key="yakMapSceneKey"
ref="mapSceneRef"
class="yak-map-scene"
:active-layer="activeYakMapLayer"
:map-ranking="mapRanking"
:active-layer="threeYakMapLayer"
:map-ranking="threeYakMapRanking"
:tile-url-template="yakMapTileUrlTemplate"
@layer-status="handleYakLayerStatus"
@tile-status="handleYakTileStatus"
/>
<div class="yak-map-scrim"></div>
<div v-if="!mapReady" class="yak-map-loading">3D地图加载中</div>
<yak-ol-map
v-if="isYakOlMapView"
class="yak-ol-map-layer"
:active="props.active && isYakOlMapView"
:tile-url-template="yakMapTileUrlTemplate"
:center="yakOlMapCenter"
:max-zoom="21"
@ready="handleYakOlReady"
@layer-status="handleYakLayerStatus"
@tile-status="handleYakTileStatus"
/>
<div class="yak-map-scrim" :class="{ 'is-ol-map': isYakOlMapView }"></div>
<div v-if="yakMapLoadingVisible" class="yak-map-loading">{{ yakMapLoadingText }}</div>
<div class="yak-management-ui">
<div v-if="activeView === 'trade' && tradeYears.length" class="trade-year-switcher" aria-label="交易年份切换">
<span>交易年份</span>
@ -22,6 +37,18 @@
{{ year }}
</button>
</div>
<div v-if="activeView === 'epidemic' && epidemicYears.length" class="epidemic-year-switcher" aria-label="防疫年份切换">
<span>防疫年份</span>
<button
v-for="year in epidemicYears"
:key="year"
type="button"
:class="{ active: Number(year) === Number(activeEpidemicYear) }"
@click="handleEpidemicYearSelect(year)"
>
{{ year }}
</button>
</div>
<div v-if="activeView === 'forecast'" class="forecast-year-switcher" aria-label="预测年份切换">
<span>预测年份</span>
<button
@ -58,7 +85,11 @@
</m-card>
<m-card class="yak-side-card left-card" title="畜群结构占比" :width="sideCardWidth" :height="sideCardHeight">
<div class="structure-chart-card">
<v-chart class="yak-chart" :option="structureDonutOption" :autoresize="true" />
<div v-if="structureLoading" class="yak-data-status">结构接口加载中</div>
<div v-else-if="structureError" class="yak-data-status is-warning">{{ structureError }}</div>
</div>
</m-card>
<m-card class="yak-side-card left-card" title="母牛/公牛年龄结构" :width="sideCardWidth" :height="sideCardHeight">
@ -101,7 +132,20 @@
</m-card>
<m-card class="yak-side-card left-card" title="未来存栏趋势" :width="sideCardWidth" :height="sideCardHeight">
<v-chart class="yak-chart" :option="forecastTrendOption" :autoresize="true" />
<div class="forecast-trend-card">
<div class="forecast-density-switcher" aria-label="预测粒度切换">
<button
v-for="item in forecastGranularityOptions"
:key="item.key"
type="button"
:class="{ active: item.key === activeForecastGranularity }"
@click="activeForecastGranularity = item.key"
>
{{ item.label }}
</button>
</div>
<v-chart class="yak-chart forecast-kline-chart" :option="forecastTrendOption" :autoresize="true" />
</div>
</m-card>
<m-card class="yak-side-card left-card" title="月度数量预测" :width="sideCardWidth" :height="sideCardHeight">
@ -272,7 +316,11 @@
</m-card>
<m-card class="yak-side-card right-card" title="结构明细" :width="sideCardWidth" :height="sideCardHeight">
<div class="structure-chart-card">
<v-chart class="yak-chart" :option="structureDetailPieOption" :autoresize="true" />
<div v-if="structureLoading" class="yak-data-status">结构接口加载中</div>
<div v-else-if="structureError" class="yak-data-status is-warning">{{ structureError }}</div>
</div>
</m-card>
</template>
@ -316,7 +364,7 @@
<m-card class="yak-side-card right-card epidemic-ledger-card" title="防疫记录台账" :width="sideCardWidth" :height="epidemicLedgerCardHeight">
<div class="epidemic-record-table is-double">
<div class="epidemic-record-toolbar">
<span> {{ formatNumber(epidemicTotal) }} </span>
<span>{{ activeEpidemicYear }} {{ formatNumber(epidemicTotal) }} </span>
<button type="button" @click="openEpidemicLedgerModal()">查看更多</button>
</div>
<div class="epidemic-record-header">
@ -439,7 +487,7 @@
<div class="epidemic-ledger-dialog-head">
<div>
<span>牦牛防疫台账</span>
<strong>{{ formatNumber(epidemicLedgerTotal) }} 条记录</strong>
<strong>{{ activeEpidemicYear }} {{ formatNumber(epidemicLedgerTotal) }} 条记录</strong>
</div>
<button type="button" @click="closeEpidemicLedgerModal">关闭</button>
</div>
@ -657,6 +705,7 @@ import MPie from "@/components/mPie/index.vue"
import MapScene from "@/views/forestGrassWet/map.vue"
import TopicSwitcher from "@/views/forestGrassWet/components/TopicSwitcher.vue"
import { Assets } from "@/views/forestGrassWet/assets"
import YakOlMap from "./YakOlMap.vue"
import {
getYakEpidemicAnalysis,
getYakEpidemicDetail,
@ -664,6 +713,7 @@ import {
getYakEpidemicStats,
getYakEpidemicVillageStats,
getYakForecastRows,
getYakStructureRows,
getYakTownRows,
getYakTradeOptions,
getYakTradeOverview,
@ -683,7 +733,7 @@ import {
getTownForecastRows,
recognitionTotal,
scaleTownRowsToTotal,
structureRows,
structureRows as modelStructureRows,
townRows,
tradeDeduction,
tradeImpactRows,
@ -703,6 +753,10 @@ const epidemicLedgerCardHeight = sideCardHeight * 2 + 12
const tradeSourceCardHeight = sideCardHeight + 24
const tradeLedgerCardHeight = sideCardHeight * 2 - 12
const epidemicImageBaseUrl = "https://qkl-hy.oss-cn-chengdu.aliyuncs.com/yak-vaccine-images"
const yakMapTileUrlTemplate = "/hy-result/{z}/{x}/{y}.png"
const yakStructureMapCenter = [103.07984, 33.029058]
const yakForecastMapCenter = [102.682809, 33.131561]
const yakMapSceneRevision = "hy-result-dynamic-zoom21-v4"
const palette = ["#30dcff", "#27f0c0", "#ffd76c", "#7aa7ff", "#ff8f6b", "#a6f06d"]
const epidemicPieColors = ["#24f6c4", "#32dcff", "#ffe06b", "#7ee269", "#ff9a62", "#9ea7ff"]
const tradePalette = ["#30dcff", "#ffd76c", "#27f0c0", "#7aa7ff", "#ff8f6b", "#a6f06d", "#9ea7ff"]
@ -721,9 +775,15 @@ const viewTopics = [
const activeView = ref("structure")
const activePeriodKey = ref("2027")
const activeForecastGranularity = ref("month")
const activeFoundationPage = ref(0)
const apiTownRows = ref([])
const apiMonthlyRows = ref([])
const apiStructureRows = ref([])
const structureLoading = ref(false)
const structureError = ref("")
const epidemicYears = [2025, 2026]
const activeEpidemicYear = ref(2026)
const epidemicAnalysis = ref({ metrics: {} })
const epidemicStats = ref({ rows: [], year: null })
const epidemicVillageStats = ref({ rows: [] })
@ -743,13 +803,7 @@ const epidemicLedgerSelectedDetail = ref(null)
const epidemicLedgerDetailLoading = ref(false)
const epidemicImagePreviewVisible = ref(false)
const epidemicImagePreviewUrl = ref("")
const epidemicLedgerFilters = ref({
keyword: "",
town: "",
medicine: "",
startDate: "",
endDate: "",
})
const epidemicLedgerFilters = ref(createEpidemicLedgerFilters())
const tradeOptions = ref({ years: [], latestYear: null })
const tradeOverview = ref({ metrics: {}, hasData: false })
const tradeRecords = ref([])
@ -777,6 +831,9 @@ const tradeLedgerModalFilters = ref({
})
const mapSceneRef = ref(null)
const mapReady = ref(false)
const olMapReady = ref(false)
const tileStatus = ref({ type: "idle", text: "" })
const topicLayerStatus = ref({ type: "idle", text: "" })
let mapAssets = null
let mapAssetsReady = false
let mapLoadStarted = false
@ -788,12 +845,21 @@ let epidemicLedgerDetailToken = 0
const forecastRotationInterval = 5000
const foundationPageSize = 6
const foundationRotationInterval = 4200
const forecastGranularityOptions = [
{ key: "day", label: "日", unit: "日", stepDays: 1 },
{ key: "week", label: "周", unit: "周", stepDays: 7 },
{ key: "month", label: "月", unit: "月", stepDays: 30 },
]
const activePeriod = computed(() => getForecastPeriod(activePeriodKey.value))
const yakMapSceneKey = computed(() => `yak-management-map-${yakMapTileUrlTemplate}-${yakMapSceneRevision}`)
const stockNetAdjustment = computed(() => expertCorrection + tradeDeduction)
const activeForecastChange = computed(() => activePeriod.value.stock - estimatedStock)
const adultCowRatio = computed(() => structureRows.find((item) => item.key === "adultCow")?.ratio || 0)
const calfRatio = computed(() => structureRows.find((item) => item.key === "calf")?.ratio || 0)
const structureChartRows = computed(() => normalizeStructureChartRows(apiStructureRows.value, modelStructureRows))
const structureChartTotal = computed(() => structureChartRows.value.reduce((sum, item) => sum + Number(item.count || 0), 0))
const structureSourceLabel = computed(() => (apiStructureRows.value.length ? "接口同步" : "专家共识模型"))
const adultCowRatio = computed(() => modelStructureRows.find((item) => item.key === "adultCow")?.ratio || 0)
const calfRatio = computed(() => modelStructureRows.find((item) => item.key === "calf")?.ratio || 0)
const displayTownRows = computed(() => {
return scaleTownRowsToTotal(mergeTownRows(apiTownRows.value.length ? apiTownRows.value : townRows, townRows), estimatedStock)
})
@ -814,7 +880,7 @@ const epidemicMedicinePieRows = computed(() =>
)
const epidemicMedicinePieKey = computed(() => epidemicMedicinePieRows.value.map((item) => `${item.name}:${item.value}`).join("|") || "epidemic-medicine-empty")
const epidemicOwnerRows = computed(() => normalizeAnalysisRows(epidemicAnalysis.value?.ownerRanks).slice(0, 8))
const epidemicVaccineTypeCount = computed(() => Math.max(epidemicMedicineRows.value.filter((item) => item.name !== "未填写").length, 1))
const epidemicVaccineTypeCount = computed(() => getEpidemicVaccineTypeCount(epidemicAnalysis.value))
const epidemicTopVaccineName = computed(() => epidemicMedicineRows.value.find((item) => item.name && item.name !== "未填写")?.name || "-")
const epidemicRecordTotal = computed(() => getMetricNumber(epidemicMetrics.value, ["total", "vaccine"]))
const epidemicOwnerTotal = computed(() => getMetricNumber(epidemicMetrics.value, ["owners"]))
@ -1022,9 +1088,17 @@ const centerMapRows = computed(() => buildCenterMapRows(activeMapRows.value, act
const yakRecognitionLayer = computed(() => ({
key: "yak-marks-layer",
layerName: "ne:daping_yak_marks",
styles: "yak_marks",
cqlFilter: "gov_show = true",
name: "牦牛识别标记",
opacity: 0.96,
queryBuffer: 10,
mapView: {
targetLngLat: [102.58, 32.72],
distanceScale: 0.82,
targetLocalZ: 0.76,
duration: 0.85,
},
legends: [
{
key: "yak-marks",
@ -1065,6 +1139,21 @@ const activeYakMapLayer = computed(() => {
if (activeView.value === "trade") return null
return yakRecognitionLayer.value
})
const isYakOlMapView = computed(() => activeView.value === "structure" || activeView.value === "forecast")
const yakOlMapCenter = computed(() => (activeView.value === "forecast" ? yakForecastMapCenter : yakStructureMapCenter))
const threeYakMapLayer = computed(() => {
if (isYakOlMapView.value) return null
return activeYakMapLayer.value
})
const threeYakMapRanking = computed(() => {
if (isYakOlMapView.value) return null
return mapRanking.value
})
const yakMapLoadingVisible = computed(() => {
if (isYakOlMapView.value) return !olMapReady.value
return !mapReady.value
})
const yakMapLoadingText = computed(() => (isYakOlMapView.value ? "影像地图加载中" : "3D地图加载中"))
const mapRanking = computed(() => {
if (activeView.value === "trade") {
return {
@ -1085,7 +1174,7 @@ const mapRanking = computed(() => {
title: activeView.value === "forecast"
? `${activePeriod.value.shortLabel}预测存栏`
: activeView.value === "epidemic"
? "村级防疫信息"
? `${activeEpidemicYear.value}年村级防疫信息`
: "乡镇估算存栏",
metricLabel: activeView.value === "forecast" ? "预测" : activeView.value === "epidemic" ? "防疫" : "存栏",
unit: "万头",
@ -1099,9 +1188,11 @@ const mapRanking = computed(() => {
}
})
const structureDonutOption = computed(() => ({
const structureDonutOption = computed(() => {
const rows = structureChartRows.value
return {
color: palette,
tooltip: chartTooltip((params) => `${params.name}<br/>结构占比:${params.percent}%<br/>估算数量:${formatNumber(params.value)}头<br/>来源:无人机识别 + 专家共识模型`),
tooltip: chartTooltip((params) => `${params.name}<br/>结构占比:${params.percent}%<br/>数量:${formatNumber(params.value)}头<br/>来源:${structureSourceLabel.value}`),
legend: {
orient: "vertical",
right: 8,
@ -1124,14 +1215,17 @@ const structureDonutOption = computed(() => ({
},
labelLine: { lineStyle: { color: "rgba(223, 250, 255, 0.36)" } },
itemStyle: { borderColor: "rgba(3, 18, 31, 0.9)", borderWidth: 3 },
data: structureRows.map((item) => ({ name: item.name, value: item.count })),
data: rows.map((item) => ({ name: item.name, value: item.count })),
},
],
}))
}
})
const structureDetailPieOption = computed(() => ({
const structureDetailPieOption = computed(() => {
const rows = structureChartRows.value
return {
color: palette,
tooltip: chartTooltip((params) => `${params.name}<br/>占比:${params.percent}%<br/>估算数量:${formatNumber(params.value)}`),
tooltip: chartTooltip((params) => `${params.name}<br/>占比:${params.percent}%<br/>数量:${formatNumber(params.value)}<br/>来源:${structureSourceLabel.value}`),
legend: {
orient: "vertical",
right: 6,
@ -1140,7 +1234,7 @@ const structureDetailPieOption = computed(() => ({
itemHeight: 8,
itemGap: 6,
formatter(name) {
const row = structureRows.find((item) => item.name === name)
const row = rows.find((item) => item.name === name)
if (!row) return name
return `${name} ${formatPercent(row.ratio)} ${formatCompact(row.count)}`
},
@ -1155,7 +1249,7 @@ const structureDetailPieOption = computed(() => ({
left: "28%",
top: "45%",
style: {
text: formatCompact(estimatedStock),
text: formatCompact(structureChartTotal.value || estimatedStock),
fill: "#ffffff",
fontFamily: "D-DIN, Arial, sans-serif",
fontSize: 19,
@ -1186,10 +1280,11 @@ const structureDetailPieOption = computed(() => ({
shadowBlur: 8,
shadowColor: "rgba(0, 0, 0, 0.28)",
},
data: structureRows.map((item) => ({ name: item.name, value: item.count })),
data: rows.map((item) => ({ name: item.name, value: item.count })),
},
],
}))
}
})
const genderAgeOption = computed(() => ({
color: ["#ffd76c", "#30dcff"],
@ -1282,7 +1377,7 @@ const townStackOption = computed(() => {
axisLine: { show: false },
axisTick: { show: false },
},
series: structureRows.map((structure) => ({
series: modelStructureRows.map((structure) => ({
name: structure.name.replace("(≤1岁)", "").replace("(1-2岁)", ""),
type: "bar",
stack: "town",
@ -1510,13 +1605,43 @@ const tradeImpactOption = computed(() => {
})
const forecastTrendOption = computed(() => {
const series = buildForecastSeries(activePeriod.value)
const granularity = getForecastGranularity(activeForecastGranularity.value)
const series = buildForecastSeriesByGranularity(activePeriod.value, granularity.key)
const rows = buildForecastKLineRows(series)
const axisInterval = getForecastAxisInterval(rows.length, granularity.key)
return {
color: ["#30dcff", "#ffd76c", "#ff8f6b"],
tooltip: { ...chartTooltip(), trigger: "axis" },
tooltip: {
...chartTooltip(),
trigger: "axis",
triggerOn: "mousemove|click",
axisPointer: {
type: "cross",
crossStyle: { color: "rgba(126, 251, 246, 0.42)" },
label: {
color: "#eaffff",
backgroundColor: "rgba(5, 31, 51, 0.96)",
},
},
formatter(params) {
const items = Array.isArray(params) ? params : [params]
const candle = items.find((item) => item.seriesType === "candlestick")
const row = candle?.data || rows[candle?.dataIndex ?? items[0]?.dataIndex ?? 0]
if (!row) return ""
const change = row.close - row.open
return [
`${row.label}${granularity.unit}度预测`,
`预测存栏:${formatNumber(row.close)}`,
`较上期:${formatSigned(change)}`,
`繁殖补充:${formatNumber(row.birth)}`,
`出栏扣减:${formatNumber(row.outflow)}`,
].join("<br/>")
},
},
legend: {
top: 4,
right: 8,
data: ["预测存栏", "繁殖补充", "出栏扣减"],
itemWidth: 12,
itemHeight: 8,
textStyle: { color: "rgba(222, 250, 255, 0.78)", fontSize: 10 },
@ -1524,12 +1649,12 @@ const forecastTrendOption = computed(() => {
grid: { left: 44, right: 40, top: 34, bottom: 28 },
xAxis: {
type: "category",
boundaryGap: false,
boundaryGap: true,
data: series.labels,
axisLabel: {
color: "rgba(223, 250, 255, 0.62)",
fontSize: 10,
interval: activePeriod.value.months > 18 ? 4 : activePeriod.value.months > 12 ? 2 : 1,
interval: axisInterval,
},
axisLine: { lineStyle: { color: "rgba(48, 220, 255, 0.28)" } },
axisTick: { show: false },
@ -1537,6 +1662,7 @@ const forecastTrendOption = computed(() => {
yAxis: [
{
type: "value",
scale: true,
axisLabel: { color: "rgba(223, 250, 255, 0.58)", fontSize: 10, formatter: formatCompact },
splitLine: { lineStyle: { color: "rgba(48, 220, 255, 0.1)", type: "dashed" } },
},
@ -1547,7 +1673,32 @@ const forecastTrendOption = computed(() => {
},
],
series: [
buildLineSeries("预测存栏", series.stock, "#30dcff", 0, true),
{
name: "预测存栏",
type: "candlestick",
yAxisIndex: 0,
barWidth: getForecastBarWidth(rows.length, granularity.key),
data: rows,
itemStyle: {
color: "rgba(48, 220, 255, 0.82)",
color0: "rgba(255, 143, 107, 0.82)",
borderColor: "#30dcff",
borderColor0: "#ff8f6b",
borderWidth: 1.4,
},
},
{
name: "预测存栏线",
type: "line",
yAxisIndex: 0,
smooth: true,
symbol: "circle",
symbolSize: 4,
data: rows.map((item) => item.close),
lineStyle: { width: 1.8, color: "rgba(48, 220, 255, 0.86)" },
itemStyle: { color: "#30dcff", borderColor: "rgba(3, 18, 28, 0.9)", borderWidth: 1 },
tooltip: { show: false },
},
buildLineSeries("繁殖补充", series.births, "#ffd76c", 1),
buildLineSeries("出栏扣减", series.outflow, "#ff8f6b", 1),
],
@ -2211,14 +2362,28 @@ onBeforeUnmount(() => {
})
watch(
[activeView, activePeriod, displayTownRows, epidemicEarTagTotal, epidemicRecordTotal, activeTradeYear, tradeMetrics],
[activeView, activePeriod, displayTownRows, epidemicEarTagTotal, epidemicRecordTotal, activeEpidemicYear, activeTradeYear, tradeMetrics],
() => {
emitModuleStats()
},
{ immediate: true },
)
watch(activeView, () => {
watch(activeView, (view, previousView) => {
const nextUsesOlMap = view === "structure" || view === "forecast"
const previousUsesOlMap = previousView === "structure" || previousView === "forecast"
if (nextUsesOlMap && !previousUsesOlMap) {
olMapReady.value = false
tileStatus.value = { type: "idle", text: "" }
topicLayerStatus.value = { type: "idle", text: "" }
}
if (!nextUsesOlMap && previousUsesOlMap) {
nextTick(() => {
mapSceneRef.value?.resume?.()
mapSceneRef.value?.refreshView?.()
window.setTimeout(() => mapSceneRef.value?.refreshView?.(), 180)
})
}
syncForecastRotation()
syncFoundationRotation()
})
@ -2315,6 +2480,30 @@ function normalizeEpidemicTownName(name) {
return String(name || "").trim()
}
function resolveEpidemicYear(year) {
const numericYear = Number(year)
if (Number.isFinite(numericYear) && epidemicYears.includes(numericYear)) return numericYear
return epidemicYears[epidemicYears.length - 1]
}
function getEpidemicYearRange(year = activeEpidemicYear.value) {
const targetYear = resolveEpidemicYear(year)
return {
startDate: `${targetYear}-01-01`,
endDate: `${targetYear}-12-31`,
}
}
function createEpidemicLedgerFilters() {
return {
keyword: "",
town: "",
medicine: "",
startDate: "",
endDate: "",
}
}
function normalizeAnalysisRows(rows = []) {
if (!Array.isArray(rows)) return []
return rows
@ -2326,6 +2515,11 @@ function normalizeAnalysisRows(rows = []) {
.filter((item) => item.name && Number.isFinite(item.value))
}
function getEpidemicVaccineTypeCount(analysis = {}) {
const rows = normalizeAnalysisRows(analysis?.medicineCounts)
return Math.max(rows.filter((item) => item.name !== "未填写").length, 1)
}
function normalizeTradeYears(...sources) {
const years = sources
.flatMap((source) => Array.isArray(source) ? source : [source])
@ -2457,6 +2651,53 @@ function normalizeEpidemicVillageRows(rows = []) {
.sort((a, b) => Number(b.earTagCount || 0) - Number(a.earTagCount || 0))
}
function buildYearScopedEpidemicVillageStats(analysis = {}, villageStats = {}) {
const payload = Array.isArray(villageStats) ? { rows: villageStats } : villageStats || {}
const result = payload.result || {}
const baseRows = normalizeEpidemicVillageRows(payload.rows || payload.data || payload.list || payload.records || result.rows || [])
const villageLocationMap = new Map()
baseRows.forEach((row) => {
getEpidemicVillageMatchKeys(row.village).forEach((key) => {
if (!villageLocationMap.has(key)) villageLocationMap.set(key, row)
})
})
const townRows = normalizeAnalysisRows(analysis?.townCounts)
const fallbackTown = townRows.length === 1 ? normalizeEpidemicTownName(townRows[0].name) : ""
const vaccineTypeCount = getEpidemicVaccineTypeCount(analysis)
const rows = normalizeAnalysisRows(analysis?.villageCounts)
.map((item, index) => {
const village = String(item.name || "").trim()
const locationRow = getEpidemicVillageMatchKeys(village)
.map((key) => villageLocationMap.get(key))
.find(Boolean) || {}
const recordCount = Number(item.value || 0)
return {
key: locationRow.key || `${locationRow.town || fallbackTown || "防疫"}-${village}-${index}`,
town: locationRow.town || fallbackTown,
village,
recordCount,
earTagCount: Math.round(recordCount / vaccineTypeCount),
imageCount: locationRow.imageCount || 0,
imageRate: locationRow.imageRate || 0,
lng: locationRow.lng,
lat: locationRow.lat,
}
})
.filter((row) => row.village && row.recordCount > 0)
return {
...payload,
rows,
}
}
function getEpidemicVillageMatchKeys(value) {
const text = String(value || "").trim()
const withoutSuffix = text.replace(/(村|社区|牧委会)$/, "")
return [...new Set([text, withoutSuffix].filter(Boolean))]
}
function toFiniteNumber(value) {
if (value === undefined || value === null || value === "") return NaN
const number = Number(String(value).replace(/,/g, "").replace(/%/g, ""))
@ -2646,7 +2887,13 @@ function displayTradeCertScope(row = {}) {
}
async function loadRemoteRows() {
const [townResult, forecastResult] = await Promise.allSettled([getYakTownRows(), getYakForecastRows()])
structureLoading.value = true
structureError.value = ""
const [townResult, forecastResult, structureResult] = await Promise.allSettled([
getYakTownRows(),
getYakForecastRows(),
getYakStructureRows(),
])
if (townResult.status === "fulfilled" && townResult.value?.length) {
apiTownRows.value = aggregateTownRows(townResult.value
.map((item) => ({
@ -2664,6 +2911,16 @@ async function loadRemoteRows() {
}))
.filter((item) => item.month && Number.isFinite(item.count))
}
if (structureResult.status === "fulfilled") {
apiStructureRows.value = normalizeStructureApiRows(structureResult.value)
if (!apiStructureRows.value.length) {
structureError.value = "结构接口暂无数据"
}
} else {
structureError.value = formatLoadError(structureResult.reason, "结构接口异常")
apiStructureRows.value = []
}
structureLoading.value = false
}
async function loadTradeData(preferredYear = activeTradeYear.value) {
@ -2726,6 +2983,15 @@ function handleTradeYearSelect(year) {
loadTradeData(nextYear)
}
function handleEpidemicYearSelect(year) {
const nextYear = resolveEpidemicYear(year)
if (nextYear === Number(activeEpidemicYear.value)) return
activeEpidemicYear.value = nextYear
epidemicLedgerPage.value = 1
epidemicLedgerFilters.value = createEpidemicLedgerFilters()
loadEpidemicData(nextYear)
}
function searchTradeRecords() {
tradeRecordQueryKeyword.value = tradeRecordKeyword.value.trim()
loadTradeRecords(1)
@ -2812,13 +3078,16 @@ function locateTradeRecord(row) {
})
}
async function loadEpidemicData() {
async function loadEpidemicData(preferredYear = activeEpidemicYear.value) {
const targetYear = resolveEpidemicYear(preferredYear)
const yearRange = getEpidemicYearRange(targetYear)
activeEpidemicYear.value = targetYear
epidemicLoading.value = true
epidemicError.value = ""
const [analysisResult, recordsResult, statsResult, villageStatsResult] = await Promise.allSettled([
getYakEpidemicAnalysis(),
getYakEpidemicRecords({ page: 1, pageSize: 8 }),
getYakEpidemicStats(),
getYakEpidemicAnalysis(yearRange),
getYakEpidemicRecords({ page: 1, pageSize: 8, ...yearRange }),
getYakEpidemicStats({ year: targetYear }),
getYakEpidemicVillageStats(),
])
@ -2846,7 +3115,7 @@ async function loadEpidemicData() {
}
if (villageStatsResult.status === "fulfilled") {
epidemicVillageStats.value = villageStatsResult.value || { rows: [] }
epidemicVillageStats.value = buildYearScopedEpidemicVillageStats(epidemicAnalysis.value, villageStatsResult.value)
} else {
epidemicVillageStats.value = { rows: [] }
}
@ -2873,13 +3142,7 @@ function searchEpidemicLedger() {
}
function resetEpidemicLedgerFilters() {
epidemicLedgerFilters.value = {
keyword: "",
town: "",
medicine: "",
startDate: "",
endDate: "",
}
epidemicLedgerFilters.value = createEpidemicLedgerFilters()
loadEpidemicLedgerRows(1)
}
@ -2890,8 +3153,14 @@ function changeEpidemicLedgerPage(page) {
async function loadEpidemicLedgerRows(page = epidemicLedgerPage.value, preferredRow = null) {
epidemicLedgerLoading.value = true
epidemicLedgerError.value = ""
const filters = epidemicLedgerFilters.value
const yearRange = getEpidemicYearRange(activeEpidemicYear.value)
const params = {
...epidemicLedgerFilters.value,
keyword: filters.keyword.trim(),
town: filters.town,
medicine: filters.medicine,
startDate: filters.startDate || yearRange.startDate,
endDate: filters.endDate || yearRange.endDate,
page,
pageSize: epidemicLedgerPageSize,
}
@ -2984,6 +3253,29 @@ async function loadYakMapOnce(attempt = 0) {
mapLoadStarted = false
}
function handleYakLayerStatus(status = {}) {
topicLayerStatus.value = {
type: status.type || "idle",
text: status.text || "",
}
}
function handleYakOlReady() {
olMapReady.value = true
}
function handleYakTileStatus(status = {}) {
tileStatus.value = {
type: status.type || "idle",
text: status.text || "",
loaded: status.loaded,
failed: status.failed,
total: status.total,
zoom: status.zoom,
tileUrlTemplate: status.tileUrlTemplate,
}
}
function emitModuleStats() {
if (activeView.value === "epidemic") {
emit("module-stats", {
@ -3159,6 +3451,73 @@ function buildLineSeries(name, data, color, yAxisIndex = 0, area = false) {
}
}
function getForecastGranularity(key) {
return forecastGranularityOptions.find((item) => item.key === key) || forecastGranularityOptions[2]
}
function buildForecastSeriesByGranularity(period, granularityKey = "month") {
if (granularityKey === "month") return buildForecastSeries(period)
const dayCount = Math.max(1, Math.round(Number(period.months || 0) * 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}`
})
const stock = labels.map((_, index) => {
const day = Math.min(dayCount, index * stepDays)
const ratio = day / dayCount
const seasonal = Math.sin(index / (granularityKey === "week" ? 1.8 : 8.5)) * 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) => {
const ratio = Math.min(dayCount, index * stepDays) / dayCount
return Math.round(period.births * ratio)
})
const outflow = labels.map((_, index) => {
const ratio = Math.min(dayCount, index * stepDays) / dayCount
return Math.round(period.outflow * ratio)
})
return { labels, stock, births, outflow }
}
function getForecastAxisInterval(count, granularityKey) {
if (granularityKey === "day") return Math.max(10, Math.ceil(count / 6))
if (granularityKey === "week") return Math.max(2, Math.ceil(count / 7))
return count > 24 ? 4 : count > 14 ? 2 : 1
}
function getForecastBarWidth(count, granularityKey) {
if (granularityKey === "day") return count > 700 ? 2 : count > 360 ? 3 : 4
if (granularityKey === "week") return count > 110 ? 4 : 6
return count > 24 ? 7 : 10
}
function buildForecastKLineRows(series) {
return (series.labels || []).map((label, index) => {
const open = Number(series.stock?.[Math.max(index - 1, 0)] || 0)
const close = Number(series.stock?.[index] || open)
const birth = Math.max(0, Number(series.births?.[index] || 0) - Number(series.births?.[Math.max(index - 1, 0)] || 0))
const outflow = Math.max(0, Number(series.outflow?.[index] || 0) - Number(series.outflow?.[Math.max(index - 1, 0)] || 0))
const wickPadding = Math.max(120, Math.round((birth + outflow) * 0.18))
const high = Math.max(open, close) + Math.max(wickPadding, Math.round(birth * 0.32))
const low = Math.max(0, Math.min(open, close) - Math.max(wickPadding, Math.round(outflow * 0.32)))
return {
label,
open,
close,
low,
high,
birth,
outflow,
value: [open, close, low, high],
}
})
}
function buildCenterMapRows(rows, valueKey) {
const rowMap = new Map()
rows.forEach((row) => {
@ -3199,6 +3558,55 @@ function chartTooltip(formatter) {
}
}
function normalizeStructureApiRows(rows = []) {
return rows
.map((item, index) => {
const name = String(item.name || item.label || item.type || `结构${index + 1}`).trim()
const count = toFiniteCount(item.value ?? item.count ?? item.number)
return {
key: item.key || item.id || `${name}-${index}`,
name,
count,
ratio: Number(item.ratio ?? item.percent ?? 0),
source: item.source || "接口同步",
}
})
.filter((item) => item.name && item.count > 0)
}
function normalizeStructureChartRows(apiRows = [], fallbackRows = []) {
const sourceRows = apiRows.length ? apiRows : fallbackRows
const rows = sourceRows
.map((item, index) => {
const name = String(item.name || item.label || `结构${index + 1}`).trim()
const count = toFiniteCount(item.count ?? item.value ?? item.number)
return {
...item,
key: item.key || `${name}-${index}`,
name,
count,
}
})
.filter((item) => item.name && item.count > 0)
const total = rows.reduce((sum, item) => sum + item.count, 0)
return rows.map((item) => ({
...item,
ratio: total > 0 ? Number(((item.count / total) * 100).toFixed(1)) : Number(item.ratio || 0),
}))
}
function toFiniteCount(value) {
const number = Number(String(value ?? "").replace(/,/g, "").replace(/%/g, ""))
return Number.isFinite(number) && number > 0 ? Math.round(number) : 0
}
function formatLoadError(error, fallback) {
const message = String(error?.message || error || "")
if (/未登录|401|HTTP_401/.test(message)) return "结构接口未登录"
if (/超时|timeout/i.test(message)) return "结构接口超时"
return fallback
}
function parseExtraNumber(value) {
const match = String(value || "").match(/-?\d+(?:\.\d+)?/)
return match ? Number(match[0]) : 0
@ -3278,6 +3686,12 @@ function formatSignedWan(value) {
}
}
.yak-ol-map-layer {
position: absolute;
inset: 0;
z-index: 1;
}
.yak-management-ui {
position: absolute;
inset: 0;
@ -3294,6 +3708,13 @@ function formatSignedWan(value) {
radial-gradient(circle at 50% 52%, transparent 0%, transparent 42%, rgba(1, 9, 15, 0.22) 74%, rgba(1, 8, 13, 0.58) 100%),
linear-gradient(90deg, rgba(0, 12, 20, 0.36), transparent 24%, transparent 76%, rgba(0, 12, 20, 0.36)),
linear-gradient(180deg, rgba(2, 16, 27, 0.02), rgba(2, 13, 22, 0.22));
&.is-ol-map {
background:
radial-gradient(circle at 50% 52%, transparent 0%, transparent 58%, rgba(1, 9, 15, 0.16) 82%, rgba(1, 8, 13, 0.42) 100%),
linear-gradient(90deg, rgba(0, 12, 20, 0.24), transparent 22%, transparent 78%, rgba(0, 12, 20, 0.24)),
linear-gradient(180deg, rgba(2, 16, 27, 0.01), rgba(2, 13, 22, 0.12));
}
}
.yak-left,
@ -3385,6 +3806,81 @@ function formatSignedWan(value) {
box-sizing: border-box;
}
.forecast-trend-card {
position: relative;
width: 100%;
height: 100%;
}
.structure-chart-card {
position: relative;
width: 100%;
height: 100%;
}
.yak-data-status {
position: absolute;
left: 12px;
bottom: 6px;
z-index: 2;
max-width: calc(100% - 24px);
padding: 2px 8px;
border: 1px solid rgba(48, 220, 255, 0.24);
border-radius: 3px;
color: rgba(222, 250, 255, 0.74);
font-size: 10px;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: rgba(4, 24, 37, 0.72);
box-shadow: inset 0 0 10px rgba(48, 220, 255, 0.08);
&.is-warning {
border-color: rgba(255, 215, 108, 0.28);
color: rgba(255, 229, 146, 0.86);
background: rgba(48, 34, 12, 0.58);
}
}
.forecast-kline-chart {
padding-top: 16px;
}
.forecast-density-switcher {
position: absolute;
top: 0;
left: 4px;
z-index: 3;
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid rgba(48, 220, 255, 0.22);
border-radius: 3px;
background: rgba(4, 24, 37, 0.72);
box-shadow: inset 0 0 10px rgba(48, 220, 255, 0.08);
button {
min-width: 24px;
height: 18px;
padding: 0 6px;
border: 0;
border-radius: 2px;
color: rgba(226, 250, 255, 0.66);
font-size: 10px;
line-height: 18px;
font-weight: 800;
background: transparent;
cursor: pointer;
&.active {
color: #041c2b;
background: linear-gradient(180deg, #7efbf6, #30dcff);
box-shadow: 0 0 10px rgba(48, 220, 255, 0.42);
}
}
}
.yak-map-loading {
position: absolute;
left: 50%;
@ -3405,7 +3901,8 @@ function formatSignedWan(value) {
}
.trade-year-switcher,
.forecast-year-switcher {
.forecast-year-switcher,
.epidemic-year-switcher {
position: absolute;
left: 462px;
top: 218px;
@ -3475,6 +3972,23 @@ function formatSignedWan(value) {
}
}
.epidemic-year-switcher {
border-color: rgba(39, 240, 192, 0.28);
box-shadow:
0 0 18px rgba(39, 240, 192, 0.12),
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
button {
&.active,
&:hover {
border-color: rgba(39, 240, 192, 0.72);
background: rgba(39, 240, 192, 0.16);
color: #27f0c0;
box-shadow: 0 0 14px rgba(39, 240, 192, 0.14);
}
}
}
.estimate-summary-card,
.forecast-summary-card {
height: 100%;

@ -25,6 +25,8 @@ export default ({ mode }) => {
const jsyApiTarget = env.VITE_JSY_API_PROXY_TARGET || openApiTarget;
const geoserverTarget = env.VITE_GEOSERVER_PROXY_TARGET || 'http://127.0.0.1:8080';
const ezvizTarget = env.VITE_EZVIZ_PROXY_TARGET || 'https://open.ys7.com';
const hyResultTileTarget = env.VITE_HY_RESULT_TILE_PROXY_TARGET || 'https://screen3.qklny.cn';
const hyResultTileCookie = env.VITE_HY_RESULT_TILE_COOKIE || '';
return defineConfig({
base: './', //env.VITE_APP_BASE_URL, // 动态改变base值
assetsInclude: [
@ -114,6 +116,18 @@ export default ({ mode }) => {
target: geoserverTarget,
changeOrigin: true,
},
'/hy-result': {
target: hyResultTileTarget,
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
const cookie = hyResultTileCookie || req.headers.cookie;
proxyReq.setHeader('Referer', 'https://screen3.qklny.cn/');
proxyReq.setHeader('Origin', 'https://screen3.qklny.cn');
if (cookie) proxyReq.setHeader('Cookie', cookie);
});
},
},
'/ezviz': {
target: ezvizTarget,
changeOrigin: true,

Loading…
Cancel
Save