feat: update screen map interactions

V2.0
M先生 3 weeks ago
parent 6c7dfee3ca
commit a264afc214
  1. 128
      design-qa.md
  2. 0
      docs/assets/AlibabaPuHuiTi-14435b65-balabala-1783957353180.woff2
  3. 0
      docs/assets/AlimamaShuHeiTi-Bold-6401fd0d-balabala-1783957353180.ttf
  4. 0
      docs/assets/AlimamaShuHeiTi-Bold-a95023f2-balabala-1783957353180.woff
  5. 0
      docs/assets/AlimamaShuHeiTi-Bold-fa4a258d-balabala-1783957353180.woff2
  6. 4
      docs/assets/SmartChartRenderer-55310556-1783957353180.js
  7. 0
      docs/assets/SmartChartRenderer-7a3a96b9-balabala-1783957353180.css
  8. 0
      docs/assets/area-b57940eb-balabala-1783957353180.png
  9. 0
      docs/assets/bg-3acb94e3-balabala-1783957353180.png
  10. 0
      docs/assets/bottom-menu-arrow-big-180b7071-balabala-1783957353180.svg
  11. 0
      docs/assets/bottom-menu-arrow-small-19304179-balabala-1783957353180.svg
  12. 0
      docs/assets/bottom-menu-bg-b465793d-balabala-1783957353180.png
  13. 0
      docs/assets/bottom-menu-btn-ee5c8064-balabala-1783957353180.png
  14. 0
      docs/assets/bottom-menu-btn-hover-df33e514-balabala-1783957353180.png
  15. 332
      docs/assets/chartTheme-1b75dfa1-1783957353180.js
  16. 0
      docs/assets/chartTheme-73f6d8d0-balabala-1783957353180.css
  17. 0
      docs/assets/chinaBlurLine-1a2cbc0f-balabala-1783957353180.png
  18. 0
      docs/assets/content-bottom-left-arrow-3a8a61f3-balabala-1783957353180.svg
  19. 0
      docs/assets/content-bottom-right-arrow-a3c5270d-balabala-1783957353180.svg
  20. 0
      docs/assets/content-middle-line-162bc0d5-balabala-1783957353180.svg
  21. 0
      docs/assets/focus_bg-b6803e2d-balabala-1783957353180.png
  22. 0
      docs/assets/focus_move_bg-cf41417a-balabala-1783957353180.png
  23. 0
      docs/assets/header-bg-65b38012-balabala-1783957353180.png
  24. 0
      docs/assets/huiguang-3c5dfda5-balabala-1783957353180.png
  25. 0
      docs/assets/icon1-6f7c222f-balabala-1783957353180.png
  26. 0
      docs/assets/icon2-4f77ccec-balabala-1783957353180.png
  27. 1
      docs/assets/index-104f35b4-balabala-1783905032331.css
  28. 2
      docs/assets/index-151bcf82-balabala-1783957353180.css
  29. 70
      docs/assets/index-1783957353180.js
  30. 2
      docs/assets/index-45757d30-1783905032331.js
  31. 2
      docs/assets/index-4ccdd147-balabala-1783957353180.css
  32. 2
      docs/assets/index-5b72bdc9-1783957353180.js
  33. 18158
      docs/assets/index-5e598b65-1783957353180.js
  34. 0
      docs/assets/index-8d752fbe-balabala-1783957353180.css
  35. 2
      docs/assets/index-b410abf2-1783957353180.js
  36. 17858
      docs/assets/index-ca1a1f57-1783905032331.js
  37. 1
      docs/assets/index-f35293d5-balabala-1783957353180.css
  38. 0
      docs/assets/leaf-7be9efef-balabala-1783957353180.png
  39. 0
      docs/assets/left-kuang-5d6bd1ae-balabala-1783957353180.svg
  40. 0
      docs/assets/live-scene-1-786cdd0a-balabala-1783957353180.png
  41. 0
      docs/assets/menu-btn-91a961cf-balabala-1783957353180.png
  42. 0
      docs/assets/menu-btn-hover-62eba0bb-balabala-1783957353180.png
  43. 0
      docs/assets/ocean-bg-8c848b8f-balabala-1783957353180.png
  44. 0
      docs/assets/park-ee0533e0-balabala-1783957353180.png
  45. 0
      docs/assets/pathLine2-d4134d4b-balabala-1783957353180.png
  46. 0
      docs/assets/right-kuang-b1937caf-balabala-1783957353180.svg
  47. 0
      docs/assets/ring2-a1b91808-balabala-1783957353180.png
  48. 0
      docs/assets/ring3-fe0f60d7-balabala-1783957353180.png
  49. 0
      docs/assets/ring4-f2e324d6-balabala-1783957353180.png
  50. 0
      docs/assets/rotationBorder1-2ce8e0ef-balabala-1783957353180.png
  51. 0
      docs/assets/rotationBorder2-7496b968-balabala-1783957353180.png
  52. 0
      docs/assets/saoguang-ccf84a88-balabala-1783957353180.svg
  53. 0
      docs/assets/stack-7a426057-balabala-1783957353180.png
  54. 0
      docs/assets/title-zs1-c15a5ee8-balabala-1783957353180.svg
  55. 0
      docs/assets/town-f8c0c9cc-balabala-1783957353180.png
  56. 0
      docs/assets/xiangfa-breeding-base-03c7f2cb-balabala-1783957353180.png
  57. 0
      docs/assets/yield-66e9af79-balabala-1783957353180.png
  58. 4
      docs/index.html
  59. 1052
      src/components/CesiumMap/index.vue
  60. 3
      src/config/townshipBoundaries.js
  61. 142
      src/services/homeVideoApi.js
  62. 34
      src/services/openApi.js
  63. 44
      src/services/pastureApi.js
  64. 801
      src/services/smartArtifactApi.js
  65. 2
      src/services/smartChartApi.js
  66. 98
      src/services/smartContextBuilder.js
  67. 242
      src/views/ecologicalProtection/components/EngineeringAnalysisPanel.vue
  68. 17
      src/views/ecologicalProtection/data.js
  69. 405
      src/views/ecologicalProtection/index.vue
  70. 38
      src/views/forestGrassWet/components/BusinessPanelRenderer.vue
  71. 71
      src/views/forestGrassWet/components/FeatureInfoPanel.vue
  72. 388
      src/views/forestGrassWet/index.vue
  73. 64
      src/views/forestGrassWet/map.js
  74. 7
      src/views/forestGrassWet/map.vue
  75. 1166
      src/views/home/index.vue
  76. 560
      src/views/pastureManagement/index.vue
  77. 123
      src/views/platform/index.vue
  78. 91
      src/views/wisdomManagement/index.vue
  79. 1
      src/views/yakIndustryChain/index.vue
  80. 232
      src/views/yakManagement/index.vue

@ -137,6 +137,65 @@ final result: passed
final result: passed final result: passed
## 2026-07-13 Platform Loading And Yak/Pasture Recheck
**Scope**
- Pages:
- `http://127.0.0.1:5174/#/platform?module=yak-industry-chain`
- `http://127.0.0.1:5174/#/platform?module=pasture-management`
- `http://127.0.0.1:5174/#/platform?module=yak-management&view=epidemic`
- File changed: `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/views/platform/index.vue`
- Screenshot evidence:
- `/tmp/screen2-standard-audit/audit-yak-industry-chain-loading-fixed-20260713.jpg`
- `/tmp/screen2-standard-audit/audit-pasture-management-loading-fixed-20260713.jpg`
- `/tmp/screen2-standard-audit/audit-yak-management-epidemic-clean-20260713.jpg`
**Fix**
- Added a platform startup-loading safety timeout:
- Homepage keeps up to `12s`.
- Other platform-loading modules keep up to `8s`.
- This prevents Yak Industry Chain and Pasture Management from remaining covered by a `100%` platform loading layer when map initialization is slow or background-tab rendering is throttled.
**Validation**
- Yak Industry Chain: loading node removed; top metrics render with real values; console error check passed.
- Pasture Management: loading node removed; top metrics render with real values; yellow yak-recognition layer remains.
- Yak Management / Epidemic: clean reload shows active `2026`, top metric `涉及村数 10 个`, and no console errors.
- `npm run build` passed; remaining warnings are existing Cesium/Sass/chunk-size warnings.
final result: passed
## 2026-07-13 Forest-Grass-Wet / National Park Follow-Up QA
**Scope**
- Pages:
- `http://127.0.0.1:5174/#/platform?module=forest-grass-wet`
- `http://127.0.0.1:5174/#/platform?module=ecological-protection&view=national-park`
- Files changed:
- `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/views/platform/index.vue`
- `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/services/openApi.js`
- `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/views/ecologicalProtection/data.js`
- `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/views/forestGrassWet/components/BusinessPanelRenderer.vue`
- Screenshot evidence:
- `/tmp/screen2-standard-audit/forest-grass-wet-overview-final-20260713.jpg`
- `/tmp/screen2-standard-audit/forest-grass-wet-woodland-click-final-20260713.jpg`
- `/tmp/screen2-standard-audit/national-park-final-20260713.jpg`
**Fix**
- Four-item top metric layout now has more horizontal room and smaller four-card-specific icon/value/unit sizing, so long values such as `林草湿面积 1226.11 万亩` no longer visually clip.
- Platform startup loading now only remains for modules that actively report platform-level loading progress; direct links to Forest-Grass-Wet, Ecological Protection, Yak Management and Smart Management no longer stay covered by a 100% loading mask.
- Woodland `优势树种面积` now uses the compact no-wrap rank-bar treatment instead of the larger default bar style.
- National Park `公益岗位分布` uses the homepage-like thin-column style, matching the restrained chart direction.
**Validation**
- Browser check: bottom `林地资源` was clicked through the real UI state, not a guessed URL query.
- Forest-Grass-Wet overview: no platform loading mask; top metric text is present in DOM; left/right cards keep the current restrained cyan-blue style.
- Woodland: `林地面积分布` and `优势树种面积` both present; right-side `优势树种面积` text remains readable and no longer fills the entire card width.
- National Park: grass/wetland stacked columns and job distribution keep weaker glow and clearer labels.
- Browser console error check: no errors.
- `npm run build` passed; remaining warnings are existing Cesium/Sass/chunk-size warnings.
final result: passed
## 2026-07-13 Yak Management Trade Right Rhythm ## 2026-07-13 Yak Management Trade Right Rhythm
**Scope** **Scope**
@ -156,6 +215,30 @@ final result: passed
final result: passed final result: passed
## 2026-07-13 Ecological Protection National Park Chart Cleanup
**Scope**
- Page: `http://127.0.0.1:5174/#/platform?module=ecological-protection&view=national-park`
- Files changed:
- `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/views/ecologicalProtection/data.js`
- `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/views/forestGrassWet/components/BusinessPanelRenderer.vue`
- Screenshot evidence: `/tmp/screen2-standard-audit/national-park-chart-style-optimized-v2-20260713.png`
**Fix**
- Kept the park business dimensions unchanged: grass/wetland area, grass/wetland productivity, township yak count, balance, zoning, households, jobs, capacity.
- National park grass/wetland stack columns now use a restrained slim style: thin body, light grid, weak glow, values above the bars.
- `乡镇牦牛数量` now uses a single lightweight horizontal distribution style.
- `公益岗位分布` now uses the homepage-like `plain` thin-column treatment instead of the stronger glowing column style.
**Validation**
- Browser check at `1920x1080`.
- Left side keeps four cards: `246px + 227px + 227px + 246px`.
- Right side keeps four cards: `239px + 227px + 227px + 239px`.
- Browser console error check: no errors.
- `npm run build` passed; remaining warnings are existing Cesium/Sass/chunk-size warnings.
final result: passed
## 2026-07-13 Cross-Module Four-Card Continuation ## 2026-07-13 Cross-Module Four-Card Continuation
**Scope** **Scope**
@ -188,6 +271,51 @@ final result: passed
- Engineering Analysis is an independent workspace rather than a side-card dashboard; it should be reviewed with its own workflow standard instead of forcing four side cards. - Engineering Analysis is an independent workspace rather than a side-card dashboard; it should be reviewed with its own workflow standard instead of forcing four side cards.
- Full production deployment was not performed in this pass. - Full production deployment was not performed in this pass.
## 2026-07-13 Cesium Map Label Crispness
**Scope**
- Shared component: `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/components/CesiumMap/index.vue`
- Affected modules: Forest-Grass-Wet, Ecological Protection, Pasture Management, Yak Management, Yak Industry Chain.
- Viewport: `1920x1080`.
**Issue**
- Township badges on the 3D map looked soft/blurry.
- Cause: the badges are rendered as canvas images and then displayed as Cesium billboards inside a WebGL scene. When Cesium renders at browser-recommended 1x resolution and FXAA is enabled, the whole scene is post-processed, so text textures become softened.
**Fix**
- Enabled high-DPI Cesium rendering with `useBrowserRecommendedResolution: false`.
- Set `viewer.resolutionScale` to the current device pixel ratio, capped at `2`.
- Disabled Cesium FXAA post-process for this shared map to avoid blurring billboard text.
- Regenerated township badge cache (`v14-crisp-map-badge`) with 3x-4x canvas backing pixels.
- Removed glow blur from the badge text and kept a thinner dark outline for readable but sharper labels.
**Validation**
- Screenshot evidence: `/tmp/screen2-standard-audit/cesium-label-crisp-20260713.png`
- Browser route verified: `http://127.0.0.1:5174/#/platform?module=forest-grass-wet`
- Browser console: only external resource 404s during local map loading; no Vue/runtime errors.
final result: passed
## 2026-07-13 Ecological Restoration Chart Fixes
**Scope**
- Page: `http://127.0.0.1:5174/#/platform?module=ecological-protection&view=restoration`
- Source files:
- `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/views/ecologicalProtection/data.js`
- `/Users/mocker/Documents/code/hy-screen/screenV2.0/src/views/forestGrassWet/components/BusinessPanelRenderer.vue`
- Viewport: `1920x1080`.
**Fixes**
- `治理面积分布`, `年度工程长度`, and `年度项目数量` now use horizontal year labels instead of vertical split digits.
- `乡镇治理面积分布` changed from a crowded horizontal bar list to the same water-column style used by `湿地面积`, with horizontal township labels and no horizontal scrollbar.
**Validation**
- Year-axis screenshot evidence: `/tmp/screen2-standard-audit/ecology-restoration-year-axis-fixed-20260713.png`
- Township water-column screenshot evidence: `/tmp/screen2-standard-audit/ecology-restoration-town-column-20260713.png`
- Automated DOM check confirmed the target township panel renders as `business-columns is-variant-water is-dense has-unit-label`.
final result: passed
## 2026-07-12 Homepage Chart Variety Pass ## 2026-07-12 Homepage Chart Variety Pass
**Scope** **Scope**

@ -1,2 +1,2 @@
var W=Object.defineProperty,D=Object.defineProperties;var R=Object.getOwnPropertyDescriptors;var z=Object.getOwnPropertySymbols;var H=Object.prototype.hasOwnProperty,P=Object.prototype.propertyIsEnumerable;var L=(a,r,o)=>r in a?W(a,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):a[r]=o,m=(a,r)=>{for(var o in r||(r={}))H.call(r,o)&&L(a,o,r[o]);if(z)for(var o of z(r))P.call(r,o)&&L(a,o,r[o]);return a},d=(a,r)=>D(a,R(r));import{e as p,j as Z,k as F,L as b,o as g,c as h,a as y,t as x,g as _,b as V,u as E,n as j}from"./index-1783905032331.js";import{aW as q}from"./chartTheme-798f6f49-1783905032331.js";const G={class:"smart-chart-header"},I={key:0},M={key:0},k="口径",Q={__name:"SmartChartRenderer",props:{chart:{type:Object,required:!0}},emits:["ready"],setup(a,{emit:r}){const o=r,i=a,l=["#30dcff","#27f0c0","#ffd76c","#7aa7ff","#ff8f6b","#a6f06d"],A=p(()=>{const t=(i.chart.rows||[]).filter(e=>Number.isFinite(Number(e.value)));return i.chart.type==="pie"?T(t):i.chart.type==="line"?$(t):C(t)}),w=p(()=>{const t=i.chart.rows||[];return i.chart.type==="bar"&&v(t)}),S=p(()=>B(i.chart.subtitle));Z(()=>{F(()=>{window.requestAnimationFrame(()=>o("ready"))})});function C(t){return v(t)?N(t):{animationDuration:700,grid:{left:52,right:26,top:28,bottom:54},tooltip:c(),xAxis:{type:"category",data:t.map(e=>e.name),axisTick:{show:!1},axisLine:{lineStyle:{color:"rgba(48, 220, 255, 0.28)"}},axisLabel:{color:"rgba(223, 250, 255, 0.76)",fontSize:11,interval:0,rotate:t.length>6?28:0}},yAxis:{type:"value",axisLine:{show:!1},axisTick:{show:!1},splitLine:{lineStyle:{color:"rgba(48, 220, 255, 0.1)",type:"dashed"}},axisLabel:{color:"rgba(223, 250, 255, 0.58)",fontSize:11}},series:[{name:i.chart.title,type:"bar",barWidth:t.length>7?16:22,data:t.map((e,n)=>({value:e.value,name:e.name,itemStyle:{borderRadius:[8,8,0,0],color:new b(0,0,0,1,[{offset:0,color:l[n%l.length]},{offset:1,color:"rgba(48, 220, 255, 0.08)"}])}})),label:{show:!0,position:"top",color:"#ffffff",fontSize:11,formatter:({value:e})=>u(e)}}]}}function N(t){return{animationDuration:700,grid:{left:78,right:72,top:22,bottom:20},tooltip:d(m({},c()),{trigger:"axis",axisPointer:{type:"shadow"}}),xAxis:{type:"value",axisLine:{show:!1},axisTick:{show:!1},splitLine:{lineStyle:{color:"rgba(48, 220, 255, 0.1)",type:"dashed"}},axisLabel:{color:"rgba(223, 250, 255, 0.58)",fontSize:11}},yAxis:{type:"category",inverse:!0,data:t.map(e=>e.name),axisTick:{show:!1},axisLine:{lineStyle:{color:"rgba(48, 220, 255, 0.22)"}},axisLabel:{color:"rgba(223, 250, 255, 0.82)",fontSize:12}},series:[{name:i.chart.title,type:"bar",barWidth:13,data:t.map((e,n)=>({value:e.value,name:e.name,itemStyle:{borderRadius:[0,8,8,0],color:new b(0,0,1,0,[{offset:0,color:"rgba(48, 220, 255, 0.16)"},{offset:1,color:l[n%l.length]}])}})),label:{show:!0,position:"right",color:"#ffffff",fontSize:11,formatter:({value:e})=>u(e)}}]}}function T(t){return{animationDuration:700,tooltip:c(),legend:{orient:"vertical",right:10,top:"middle",itemWidth:10,itemHeight:10,textStyle:{color:"rgba(223, 250, 255, 0.78)",fontSize:11}},series:[{name:i.chart.title,type:"pie",radius:["48%","68%"],center:["35%","52%"],avoidLabelOverlap:!0,label:{color:"#fff",fontSize:11,formatter:`{b} var D=Object.defineProperty,R=Object.defineProperties;var W=Object.getOwnPropertyDescriptors;var z=Object.getOwnPropertySymbols;var H=Object.prototype.hasOwnProperty,P=Object.prototype.propertyIsEnumerable;var L=(a,r,o)=>r in a?D(a,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):a[r]=o,m=(a,r)=>{for(var o in r||(r={}))H.call(r,o)&&L(a,o,r[o]);if(z)for(var o of z(r))P.call(r,o)&&L(a,o,r[o]);return a},d=(a,r)=>R(a,W(r));import{e as p,h as Z,j as F,L as b,o as h,c as g,a as y,t as x,g as _,b as V,u as E,n as j}from"./index-1783957353180.js";import{aK as q}from"./chartTheme-1b75dfa1-1783957353180.js";const G={class:"smart-chart-header"},I={key:0},K={key:0},A="口径",Q={__name:"SmartChartRenderer",props:{chart:{type:Object,required:!0}},emits:["ready"],setup(a,{emit:r}){const o=r,i=a,l=["#30dcff","#27f0c0","#ffd76c","#7aa7ff","#ff8f6b","#a6f06d"],k=p(()=>{const t=(i.chart.rows||[]).filter(e=>Number.isFinite(Number(e.value)));return i.chart.type==="pie"?T(t):i.chart.type==="line"?$(t):C(t)}),w=p(()=>{const t=i.chart.rows||[];return i.chart.type==="bar"&&v(t)}),S=p(()=>B(i.chart.subtitle));Z(()=>{F(()=>{window.requestAnimationFrame(()=>o("ready"))})});function C(t){return v(t)?N(t):{animationDuration:700,grid:{left:52,right:26,top:28,bottom:54},tooltip:c(),xAxis:{type:"category",data:t.map(e=>e.name),axisTick:{show:!1},axisLine:{lineStyle:{color:"rgba(48, 220, 255, 0.28)"}},axisLabel:{color:"rgba(223, 250, 255, 0.76)",fontSize:11,interval:0,rotate:t.length>6?28:0}},yAxis:{type:"value",axisLine:{show:!1},axisTick:{show:!1},splitLine:{lineStyle:{color:"rgba(48, 220, 255, 0.1)",type:"dashed"}},axisLabel:{color:"rgba(223, 250, 255, 0.58)",fontSize:11}},series:[{name:i.chart.title,type:"bar",barWidth:t.length>7?16:22,data:t.map((e,n)=>({value:e.value,name:e.name,itemStyle:{borderRadius:[8,8,0,0],color:new b(0,0,0,1,[{offset:0,color:l[n%l.length]},{offset:1,color:"rgba(48, 220, 255, 0.08)"}])}})),label:{show:!0,position:"top",color:"#ffffff",fontSize:11,formatter:({value:e})=>u(e)}}]}}function N(t){return{animationDuration:700,grid:{left:78,right:72,top:22,bottom:20},tooltip:d(m({},c()),{trigger:"axis",axisPointer:{type:"shadow"}}),xAxis:{type:"value",axisLine:{show:!1},axisTick:{show:!1},splitLine:{lineStyle:{color:"rgba(48, 220, 255, 0.1)",type:"dashed"}},axisLabel:{color:"rgba(223, 250, 255, 0.58)",fontSize:11}},yAxis:{type:"category",inverse:!0,data:t.map(e=>e.name),axisTick:{show:!1},axisLine:{lineStyle:{color:"rgba(48, 220, 255, 0.22)"}},axisLabel:{color:"rgba(223, 250, 255, 0.82)",fontSize:12}},series:[{name:i.chart.title,type:"bar",barWidth:13,data:t.map((e,n)=>({value:e.value,name:e.name,itemStyle:{borderRadius:[0,8,8,0],color:new b(0,0,1,0,[{offset:0,color:"rgba(48, 220, 255, 0.16)"},{offset:1,color:l[n%l.length]}])}})),label:{show:!0,position:"right",color:"#ffffff",fontSize:11,formatter:({value:e})=>u(e)}}]}}function T(t){return{animationDuration:700,tooltip:c(),legend:{orient:"vertical",right:10,top:"middle",itemWidth:10,itemHeight:10,textStyle:{color:"rgba(223, 250, 255, 0.78)",fontSize:11}},series:[{name:i.chart.title,type:"pie",radius:["48%","68%"],center:["35%","52%"],avoidLabelOverlap:!0,label:{color:"#fff",fontSize:11,formatter:`{b}
{d}%`},labelLine:{lineStyle:{color:"rgba(223, 250, 255, 0.42)"}},itemStyle:{borderColor:"rgba(3, 18, 31, 0.92)",borderWidth:3},data:t.map((e,n)=>({name:e.name,value:e.value,itemStyle:{color:l[n%l.length]}}))}]}}function $(t){return{animationDuration:760,grid:{left:52,right:28,top:30,bottom:48},tooltip:d(m({},c()),{trigger:"axis",axisPointer:{type:"line",lineStyle:{color:"rgba(255, 215, 108, 0.52)"}}}),xAxis:{type:"category",boundaryGap:!1,data:t.map(e=>e.name),axisTick:{show:!1},axisLine:{lineStyle:{color:"rgba(48, 220, 255, 0.28)"}},axisLabel:{color:"rgba(223, 250, 255, 0.76)",fontSize:11,interval:0}},yAxis:{type:"value",axisLine:{show:!1},axisTick:{show:!1},splitLine:{lineStyle:{color:"rgba(48, 220, 255, 0.1)",type:"dashed"}},axisLabel:{color:"rgba(223, 250, 255, 0.58)",fontSize:11}},series:[{name:i.chart.title,type:"line",smooth:!0,symbol:"circle",symbolSize:8,lineStyle:{width:3,color:"#30dcff"},itemStyle:{color:"#ffd76c",borderColor:"#041522",borderWidth:2},areaStyle:{color:new b(0,0,0,1,[{offset:0,color:"rgba(48, 220, 255, 0.24)"},{offset:1,color:"rgba(48, 220, 255, 0.02)"}])},label:{show:!0,position:"top",color:"#ffffff",fontSize:11,formatter:({value:e})=>u(e)},data:t.map(e=>({value:e.value,name:e.name}))}]}}function c(){return{trigger:"item",backgroundColor:"rgba(2, 13, 22, 0.94)",borderColor:"rgba(48, 220, 255, 0.34)",borderWidth:1,textStyle:{color:"#fff",fontSize:12},formatter:t=>{const e=(i.chart.rows||[]).find(s=>s.name===t.name),n=(e==null?void 0:e.unit)||i.chart.unit||"",f=e!=null&&e.extra?`<br/><span style="color:rgba(223,250,255,.62)">${e.extra}</span>`:"";return`${t.marker}${t.name}: ${u(t.value)}${n}${f}`}}}function v(t=[]){return t.length>6||t.some(e=>String(e.name||"").length>4)}function B(t){const e=String(t||"").trim();if(!e||/^(数据)?来源[:]/.test(e)||e.includes("固定展示"))return"";const n=new RegExp(k,"g"),f=new RegExp(`统计${k}`,"g"),s=e.replace(f,"统计数据").replace(n,"数据").replace(/来源[::]\s*openApi\s*统计库\s*[a-zA-Z0-9_./:-]*[,,、]?\s*/gi,"").replace(/openApi\s*统计库\s*/gi,"").replace(/\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+){2,}\b/g,"").replace(/[,,、]\s*[,,、]+/g,",").replace(/^[\s,,、::]+|[\s,,、]+$/g,"");return!s||/^来源[::]?$/.test(s)||O(s)?"":s}function O(t){return/^[a-zA-Z0-9_./:-]+$/.test(t)&&(t.includes("_")||t.length>24)}function u(t){const e=Number(t);return Number.isFinite(e)?Number.isInteger(e)?e.toLocaleString("zh-CN"):e.toLocaleString("zh-CN",{maximumFractionDigits:2}):"--"}return(t,e)=>(g(),h("div",{class:j(["smart-chart-card",{"is-horizontal":w.value}])},[y("div",G,[y("div",null,[y("strong",null,x(a.chart.title),1),S.value?(g(),h("span",I,x(S.value),1)):_("",!0)]),a.chart.unit?(g(),h("em",M,x(a.chart.unit),1)):_("",!0)]),V(E(q),{class:"smart-chart",option:A.value,autoresize:""},null,8,["option"])],2))}};export{Q as default}; {d}%`},labelLine:{lineStyle:{color:"rgba(223, 250, 255, 0.42)"}},itemStyle:{borderColor:"rgba(3, 18, 31, 0.92)",borderWidth:3},data:t.map((e,n)=>({name:e.name,value:e.value,itemStyle:{color:l[n%l.length]}}))}]}}function $(t){return{animationDuration:760,grid:{left:52,right:28,top:30,bottom:48},tooltip:d(m({},c()),{trigger:"axis",axisPointer:{type:"line",lineStyle:{color:"rgba(255, 215, 108, 0.52)"}}}),xAxis:{type:"category",boundaryGap:!1,data:t.map(e=>e.name),axisTick:{show:!1},axisLine:{lineStyle:{color:"rgba(48, 220, 255, 0.28)"}},axisLabel:{color:"rgba(223, 250, 255, 0.76)",fontSize:11,interval:0}},yAxis:{type:"value",axisLine:{show:!1},axisTick:{show:!1},splitLine:{lineStyle:{color:"rgba(48, 220, 255, 0.1)",type:"dashed"}},axisLabel:{color:"rgba(223, 250, 255, 0.58)",fontSize:11}},series:[{name:i.chart.title,type:"line",smooth:!0,symbol:"circle",symbolSize:8,lineStyle:{width:3,color:"#30dcff"},itemStyle:{color:"#ffd76c",borderColor:"#041522",borderWidth:2},areaStyle:{color:new b(0,0,0,1,[{offset:0,color:"rgba(48, 220, 255, 0.24)"},{offset:1,color:"rgba(48, 220, 255, 0.02)"}])},label:{show:!0,position:"top",color:"#ffffff",fontSize:11,formatter:({value:e})=>u(e)},data:t.map(e=>({value:e.value,name:e.name}))}]}}function c(){return{trigger:"item",backgroundColor:"rgba(2, 13, 22, 0.94)",borderColor:"rgba(48, 220, 255, 0.34)",borderWidth:1,textStyle:{color:"#fff",fontSize:12},formatter:t=>{const e=(i.chart.rows||[]).find(s=>s.name===t.name),n=(e==null?void 0:e.unit)||i.chart.unit||"",f=e!=null&&e.extra?`<br/><span style="color:rgba(223,250,255,.62)">${e.extra}</span>`:"";return`${t.marker}${t.name}: ${u(t.value)}${n}${f}`}}}function v(t=[]){return t.length>6||t.some(e=>String(e.name||"").length>4)}function B(t){const e=String(t||"").trim();if(!e||/^(数据)?来源[:]/.test(e)||e.includes("固定展示"))return"";const n=new RegExp(A,"g"),f=new RegExp(`统计${A}`,"g"),s=e.replace(f,"统计数据").replace(n,"数据").replace(/来源[::]\s*openApi\s*统计库\s*[a-zA-Z0-9_./:-]*[,,、]?\s*/gi,"").replace(/openApi\s*统计库\s*/gi,"").replace(/\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+){2,}\b/g,"").replace(/[,,、]\s*[,,、]+/g,",").replace(/^[\s,,、::]+|[\s,,、]+$/g,"");return!s||/^来源[::]?$/.test(s)||O(s)?"":s}function O(t){return/^[a-zA-Z0-9_./:-]+$/.test(t)&&(t.includes("_")||t.length>24)}function u(t){const e=Number(t);return Number.isFinite(e)?Number.isInteger(e)?e.toLocaleString("zh-CN"):e.toLocaleString("zh-CN",{maximumFractionDigits:2}):"--"}return(t,e)=>(h(),g("div",{class:j(["smart-chart-card",{"is-horizontal":w.value}])},[y("div",G,[y("div",null,[y("strong",null,x(a.chart.title),1),S.value?(h(),g("span",I,x(S.value),1)):_("",!0)]),a.chart.unit?(h(),g("em",K,x(a.chart.unit),1)):_("",!0)]),V(E(q),{class:"smart-chart",option:k.value,autoresize:""},null,8,["option"])],2))}};export{Q as default};

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because one or more lines are too long

@ -1 +1 @@
.login-screen[data-v-8ebd0d6e]{position:fixed;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;overflow:hidden;background:linear-gradient(180deg,rgba(4,16,38,.1),rgba(2,8,22,.62)),url(./bg-3acb94e3-balabala-1783905032331.png) center/cover no-repeat,#05142f;color:#fff}.login-screen[data-v-8ebd0d6e]:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(90deg,rgba(0,0,0,.46),transparent 42%,rgba(0,0,0,.35)),radial-gradient(circle at 52% 46%,rgba(48,220,255,.16),transparent 32%)}.login-shell[data-v-8ebd0d6e]{position:relative;z-index:1;width:min(980px,100vw - 48px);display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:56px;align-items:center}.login-brand[data-v-8ebd0d6e]{display:flex;flex-direction:column;gap:14px;min-width:0;text-shadow:0 0 20px rgba(48,220,255,.36)}.login-brand span[data-v-8ebd0d6e]{font-size:42px;line-height:1.2;font-weight:700}.login-brand small[data-v-8ebd0d6e]{color:#c4f3feb8;font-size:13px;letter-spacing:0}.login-panel[data-v-8ebd0d6e]{width:360px;padding:30px 28px 28px;border:1px solid rgba(48,220,255,.34);background:rgba(3,14,32,.82);box-shadow:0 24px 70px #00000057,inset 0 0 28px #30dcff14;box-sizing:border-box}.login-panel-title[data-v-8ebd0d6e]{margin-bottom:26px;color:#fff;font-size:22px;font-weight:700;text-align:center}.login-field[data-v-8ebd0d6e]{display:block;margin-bottom:18px}.login-field span[data-v-8ebd0d6e]{display:block;margin-bottom:8px;color:#c4f3fec7;font-size:13px}.login-field input[data-v-8ebd0d6e]{width:100%;height:42px;padding:0 12px;border:1px solid rgba(48,220,255,.26);outline:none;background:rgba(0,10,24,.62);color:#fff;font-size:15px;box-sizing:border-box}.login-field input[data-v-8ebd0d6e]:focus{border-color:#30dcffb8;box-shadow:0 0 0 2px #30dcff1f}.login-error[data-v-8ebd0d6e]{min-height:20px;margin:-4px 0 12px;color:#ffcf8a;font-size:13px;line-height:20px}.login-submit[data-v-8ebd0d6e]{width:100%;height:44px;border:1px solid rgba(48,220,255,.52);background:linear-gradient(90deg,rgba(24,140,196,.92),rgba(37,204,218,.92));color:#fff;cursor:pointer;font-size:16px;font-weight:700}.login-submit[data-v-8ebd0d6e]:disabled{cursor:not-allowed;opacity:.68}@media (max-width: 820px){.login-shell[data-v-8ebd0d6e]{grid-template-columns:1fr;gap:28px}.login-brand span[data-v-8ebd0d6e]{font-size:30px}.login-panel[data-v-8ebd0d6e]{width:100%}} .login-screen[data-v-8ebd0d6e]{position:fixed;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;overflow:hidden;background:linear-gradient(180deg,rgba(4,16,38,.1),rgba(2,8,22,.62)),url(./bg-3acb94e3-balabala-1783957353180.png) center/cover no-repeat,#05142f;color:#fff}.login-screen[data-v-8ebd0d6e]:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(90deg,rgba(0,0,0,.46),transparent 42%,rgba(0,0,0,.35)),radial-gradient(circle at 52% 46%,rgba(48,220,255,.16),transparent 32%)}.login-shell[data-v-8ebd0d6e]{position:relative;z-index:1;width:min(980px,100vw - 48px);display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:56px;align-items:center}.login-brand[data-v-8ebd0d6e]{display:flex;flex-direction:column;gap:14px;min-width:0;text-shadow:0 0 20px rgba(48,220,255,.36)}.login-brand span[data-v-8ebd0d6e]{font-size:42px;line-height:1.2;font-weight:700}.login-brand small[data-v-8ebd0d6e]{color:#c4f3feb8;font-size:13px;letter-spacing:0}.login-panel[data-v-8ebd0d6e]{width:360px;padding:30px 28px 28px;border:1px solid rgba(48,220,255,.34);background:rgba(3,14,32,.82);box-shadow:0 24px 70px #00000057,inset 0 0 28px #30dcff14;box-sizing:border-box}.login-panel-title[data-v-8ebd0d6e]{margin-bottom:26px;color:#fff;font-size:22px;font-weight:700;text-align:center}.login-field[data-v-8ebd0d6e]{display:block;margin-bottom:18px}.login-field span[data-v-8ebd0d6e]{display:block;margin-bottom:8px;color:#c4f3fec7;font-size:13px}.login-field input[data-v-8ebd0d6e]{width:100%;height:42px;padding:0 12px;border:1px solid rgba(48,220,255,.26);outline:none;background:rgba(0,10,24,.62);color:#fff;font-size:15px;box-sizing:border-box}.login-field input[data-v-8ebd0d6e]:focus{border-color:#30dcffb8;box-shadow:0 0 0 2px #30dcff1f}.login-error[data-v-8ebd0d6e]{min-height:20px;margin:-4px 0 12px;color:#ffcf8a;font-size:13px;line-height:20px}.login-submit[data-v-8ebd0d6e]{width:100%;height:44px;border:1px solid rgba(48,220,255,.52);background:linear-gradient(90deg,rgba(24,140,196,.92),rgba(37,204,218,.92));color:#fff;cursor:pointer;font-size:16px;font-weight:700}.login-submit[data-v-8ebd0d6e]:disabled{cursor:not-allowed;opacity:.68}@media (max-width: 820px){.login-shell[data-v-8ebd0d6e]{grid-template-columns:1fr;gap:28px}.login-brand span[data-v-8ebd0d6e]{font-size:30px}.login-panel[data-v-8ebd0d6e]{width:100%}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +1 @@
var m=(v,d,n)=>new Promise((i,a)=>{var u=l=>{try{r(n.next(l))}catch(t){a(t)}},o=l=>{try{r(n.throw(l))}catch(t){a(t)}},r=l=>l.done?i(l.value):Promise.resolve(l.value).then(u,o);r((n=n.apply(v,d)).next())});import{_ as x,I as w,J as h,e as M,o as _,c as f,a as e,y as S,z as g,A as y,t as b,g as V,d as p,M as k}from"./index-1783905032331.js";const B={class:"login-screen"},D={class:"login-shell"},I={class:"login-field"},R={class:"login-field"},U={key:0,class:"login-error"},q=["disabled"],A={__name:"index",setup(v){const d=w(),n=h(),i=p(""),a=p(""),u=p(!1),o=p(""),r=M(()=>{const t=typeof d.query.redirect=="string"?decodeURIComponent(d.query.redirect):"/platform";return t.startsWith("/login")?"/platform":t||"/platform"});function l(){return m(this,null,function*(){if(o.value="",!i.value||!a.value){o.value="请输入账号和密码。";return}u.value=!0;try{yield k(i.value,a.value),n.replace(r.value)}catch(t){o.value=(t==null?void 0:t.message)||"登录失败,请稍后再试。"}finally{u.value=!1}})}return(t,s)=>(_(),f("div",B,[e("div",D,[s[5]||(s[5]=e("div",{class:"login-brand"},[e("span",null,"红原县数字畜牧业监管平台"),e("small",null,"Hongyuan Digital Animal Husbandry Supervision Platform")],-1)),e("form",{class:"login-panel",onSubmit:S(l,["prevent"])},[s[4]||(s[4]=e("div",{class:"login-panel-title"},"用户登录",-1)),e("label",I,[s[2]||(s[2]=e("span",null,"账号",-1)),g(e("input",{"onUpdate:modelValue":s[0]||(s[0]=c=>i.value=c),autocomplete:"username",autofocus:"",placeholder:"请输入账号"},null,512),[[y,i.value,void 0,{trim:!0}]])]),e("label",R,[s[3]||(s[3]=e("span",null,"密码",-1)),g(e("input",{"onUpdate:modelValue":s[1]||(s[1]=c=>a.value=c),autocomplete:"current-password",placeholder:"请输入密码",type:"password"},null,512),[[y,a.value]])]),o.value?(_(),f("div",U,b(o.value),1)):V("",!0),e("button",{class:"login-submit",disabled:u.value,type:"submit"},b(u.value?"登录中":"登录"),9,q)],32)])]))}},N=x(A,[["__scopeId","data-v-8ebd0d6e"]]);export{N as default}; var m=(v,d,n)=>new Promise((i,a)=>{var u=l=>{try{r(n.next(l))}catch(t){a(t)}},o=l=>{try{r(n.throw(l))}catch(t){a(t)}},r=l=>l.done?i(l.value):Promise.resolve(l.value).then(u,o);r((n=n.apply(v,d)).next())});import{_ as x,J as w,K as h,e as S,o as _,c as f,a as e,x as V,y as g,z as y,t as b,g as k,d as p,N as B}from"./index-1783957353180.js";const D={class:"login-screen"},M={class:"login-shell"},N={class:"login-field"},R={class:"login-field"},U={key:0,class:"login-error"},q=["disabled"],C={__name:"index",setup(v){const d=w(),n=h(),i=p(""),a=p(""),u=p(!1),o=p(""),r=S(()=>{const t=typeof d.query.redirect=="string"?decodeURIComponent(d.query.redirect):"/platform";return t.startsWith("/login")?"/platform":t||"/platform"});function l(){return m(this,null,function*(){if(o.value="",!i.value||!a.value){o.value="请输入账号和密码。";return}u.value=!0;try{yield B(i.value,a.value),n.replace(r.value)}catch(t){o.value=(t==null?void 0:t.message)||"登录失败,请稍后再试。"}finally{u.value=!1}})}return(t,s)=>(_(),f("div",D,[e("div",M,[s[5]||(s[5]=e("div",{class:"login-brand"},[e("span",null,"红原县数字畜牧业监管平台"),e("small",null,"Hongyuan Digital Animal Husbandry Supervision Platform")],-1)),e("form",{class:"login-panel",onSubmit:V(l,["prevent"])},[s[4]||(s[4]=e("div",{class:"login-panel-title"},"用户登录",-1)),e("label",N,[s[2]||(s[2]=e("span",null,"账号",-1)),g(e("input",{"onUpdate:modelValue":s[0]||(s[0]=c=>i.value=c),autocomplete:"username",autofocus:"",placeholder:"请输入账号"},null,512),[[y,i.value,void 0,{trim:!0}]])]),e("label",R,[s[3]||(s[3]=e("span",null,"密码",-1)),g(e("input",{"onUpdate:modelValue":s[1]||(s[1]=c=>a.value=c),autocomplete:"current-password",placeholder:"请输入密码",type:"password"},null,512),[[y,a.value]])]),o.value?(_(),f("div",U,b(o.value),1)):k("",!0),e("button",{class:"login-submit",disabled:u.value,type:"submit"},b(u.value?"登录中":"登录"),9,q)],32)])]))}},P=x(C,[["__scopeId","data-v-8ebd0d6e"]]);export{P as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -11,8 +11,8 @@
window.CESIUM_BASE_URL = "./cdn/Cesium/"; window.CESIUM_BASE_URL = "./cdn/Cesium/";
</script> </script>
<script src="./cdn/Cesium/Cesium.js"></script> <script src="./cdn/Cesium/Cesium.js"></script>
<script type="module" crossorigin src="./assets/index-1783905032331.js"></script> <script type="module" crossorigin src="./assets/index-1783957353180.js"></script>
<link rel="stylesheet" href="./assets/index-56af86f7-balabala-1783905032331.css"> <link rel="stylesheet" href="./assets/index-4ccdd147-balabala-1783957353180.css">
</head> </head>
<body> <body>

File diff suppressed because it is too large Load Diff

@ -135,6 +135,8 @@ function roundPoint(point = []) {
function getFeatureVisualCenter(feature = {}) { function getFeatureVisualCenter(feature = {}) {
const fallback = readPoint(feature) const fallback = readPoint(feature)
if (isPointInGeometry(fallback, feature.geometry)) return roundPoint(fallback)
const largestPolygon = getLargestPolygon(feature) const largestPolygon = getLargestPolygon(feature)
const outerRing = largestPolygon?.[0] || [] const outerRing = largestPolygon?.[0] || []
const centroid = getRingCentroid(outerRing) const centroid = getRingCentroid(outerRing)
@ -143,7 +145,6 @@ function getFeatureVisualCenter(feature = {}) {
const bounds = getRingBounds(outerRing) const bounds = getRingBounds(outerRing)
const boundsCenter = bounds ? [(bounds.west + bounds.east) / 2, (bounds.south + bounds.north) / 2] : null const boundsCenter = bounds ? [(bounds.west + bounds.east) / 2, (bounds.south + bounds.north) / 2] : null
if (boundsCenter && isPointInPolygon(boundsCenter, largestPolygon)) return roundPoint(boundsCenter) if (boundsCenter && isPointInPolygon(boundsCenter, largestPolygon)) return roundPoint(boundsCenter)
if (isPointInGeometry(fallback, feature.geometry)) return roundPoint(fallback)
return roundPoint(centroid || boundsCenter || fallback) return roundPoint(centroid || boundsCenter || fallback)
} }

@ -1,7 +1,6 @@
import { normalizeResponseData, requestJson } from "./request" import { normalizeResponseData, requestJson } from "./request"
const JSY_API_BASE = import.meta.env.VITE_JSY_API_BASE || "/jsyApi" const JSY_API_BASE = import.meta.env.VITE_JSY_API_BASE || "/jsyApi"
const EZVIZ_ACCESS_TOKEN = String(import.meta.env.VITE_EZVIZ_ACCESS_TOKEN || "").trim()
const HONGYUAN_TOWN_NAMES = [ const HONGYUAN_TOWN_NAMES = [
"邛溪镇", "邛溪镇",
"刷经寺镇", "刷经寺镇",
@ -42,39 +41,136 @@ function pickValue(item, keys, fallback = "") {
return fallback return fallback
} }
function pickEntry(item, keys) {
for (const key of keys) {
if (item?.[key] !== undefined && item?.[key] !== null && item?.[key] !== "") {
return { key, value: item[key] }
}
}
return null
}
function getMonitoringToken(row) { function getMonitoringToken(row) {
return pickValue(row, ["token", "accessToken", "access_token"], EZVIZ_ACCESS_TOKEN) return pickValue(row, ["token", "accessToken", "access_token"])
} }
function getMonitoringPlayUrl(row) { function getMonitoringPlayUrl(row) {
return pickValue(row, ["hdPlayUrl", "hd_play_url", "playUrl", "play_url", "url"]) return pickValue(row, ["hdPlayUrl", "hd_play_url", "playUrl", "play_url", "url"])
} }
function isMonitoringOnline(row) { function hasMonitoringStream(row) {
const status = pickValue(row, [ return Boolean(getMonitoringToken(row) && getMonitoringPlayUrl(row))
}
function getMonitoringStatusEntry(row) {
return pickEntry(row, [
"isOnline", "isOnline",
"is_online",
"online", "online",
"status", "onlineStatus",
"deviceStatus", "online_status",
"statusName", "statusName",
"status_name",
"deviceStatus",
"device_status",
"cameraStatus",
"camera_status",
"rawStatus",
"raw_status",
"status",
"state", "state",
"onlineStatus",
"deviceState", "deviceState",
"device_state",
])
}
function getMonitoringStatusInfo(row) {
const hasStream = hasMonitoringStream(row)
const entry = getMonitoringStatusEntry(row)
if (!entry) {
return {
online: hasStream,
raw: "",
reason: hasStream ? "后台未返回设备状态" : "缺少播放地址或令牌",
}
}
const value = entry.value
if (typeof value === "boolean") {
return {
online: value,
raw: value ? "在线" : "离线",
reason: `后台状态:${value ? "在线" : "离线"}`,
}
}
const text = String(value ?? "").trim().toLowerCase()
if (!text) {
return {
online: hasStream,
raw: "",
reason: hasStream ? "后台状态为空" : "缺少播放地址或令牌",
}
}
if (typeof value === "number" || /^-?\d+(\.\d+)?$/.test(text)) {
const online = Number(value) !== 0
return {
online,
raw: String(value),
reason: `后台状态:${value}`,
}
}
if (["true", "online", "on", "open", "running", "active", "enabled"].includes(text)) {
return { online: true, raw: String(value), reason: `后台状态:${value}` }
}
if (["false", "offline", "off", "closed", "disabled", "stop", "stopped"].includes(text)) {
return { online: false, raw: String(value), reason: `后台状态:${value}` }
}
if (/(离线|不在线|未在线|断线|断开|异常|故障|停用|不可用|offline|disabled|closed|stop)/i.test(text)) {
return { online: false, raw: String(value), reason: `后台状态:${value}` }
}
if (/(在线|直播中|正常|启用|可用|online|enabled|open|running|active)/i.test(text)) {
return { online: true, raw: String(value), reason: `后台状态:${value}` }
}
return {
online: hasStream,
raw: String(value),
reason: `后台状态:${value}`,
}
}
function isMonitoringOnline(row) {
return getMonitoringStatusInfo(row).online
}
function getMonitoringImage(row) {
return pickValue(row, [
"previewImgUrl",
"preview_img_url",
"latestCaptureUrl",
"latest_capture_url",
"img",
"image",
"cover",
"snapshot",
])
}
function getMonitoringLastCaptureTime(row) {
return pickValue(row, [
"lastCaptureTime",
"last_capture_time",
"captureTime",
"capture_time",
"latestCaptureTime",
"latest_capture_time",
]) ])
if (status === "") return true
if (typeof status === "boolean") return status
if (typeof status === "number") return status !== 0
const text = String(status).trim().toLowerCase()
if (!text) return true
if (["1", "true", "online", "on"].includes(text)) return true
if (["0", "false", "offline", "off"].includes(text)) return false
if (/(离线|不在线|未在线|断线|断开|异常|故障|停用|不可用|offline|disabled|closed|stop)/i.test(text)) return false
if (/(在线|直播中|正常|可用|online|enabled|open|running|active)/i.test(text)) return true
return true
} }
export function isPlayableMonitoring(row) { export function isPlayableMonitoring(row) {
return Boolean(getMonitoringToken(row) && getMonitoringPlayUrl(row) && isMonitoringOnline(row)) return Boolean(hasMonitoringStream(row) && isMonitoringOnline(row))
} }
function inferTownName(name = "") { function inferTownName(name = "") {
@ -113,7 +209,8 @@ function normalizeMonitoring(row, index, total, validTotal) {
const hdPlayUrl = pickValue(row, ["hdPlayUrl", "hd_play_url", "url"]) const hdPlayUrl = pickValue(row, ["hdPlayUrl", "hd_play_url", "url"])
const playUrl = pickValue(row, ["playUrl", "play_url"], hdPlayUrl) const playUrl = pickValue(row, ["playUrl", "play_url"], hdPlayUrl)
const token = getMonitoringToken(row) const token = getMonitoringToken(row)
const isOnline = isPlayableMonitoring(row) const statusInfo = getMonitoringStatusInfo(row)
const isOnline = Boolean(hasMonitoringStream(row) && statusInfo.online)
const onlineRate = total ? Math.round((validTotal / total) * 100) : 0 const onlineRate = total ? Math.round((validTotal / total) * 100) : 0
return { return {
@ -125,11 +222,14 @@ function normalizeMonitoring(row, index, total, validTotal) {
channel: getChannel(row, index), channel: getChannel(row, index),
cameraCount: total, cameraCount: total,
status: isOnline ? "直播中" : "离线", status: isOnline ? "直播中" : "离线",
rawStatus: statusInfo.raw,
statusReason: statusInfo.reason,
onlineRate, onlineRate,
updateTime: getUpdateTime(), updateTime: getUpdateTime(),
operator: isOnline ? "萤石云 · 在线预览" : "监控参数待同步", operator: isOnline ? "萤石云 · 在线预览" : statusInfo.reason,
progress: isOnline ? 100 : 0, progress: isOnline ? 100 : 0,
image: pickValue(row, ["img", "image", "cover", "snapshot"]), image: getMonitoringImage(row),
lastCaptureTime: getMonitoringLastCaptureTime(row),
playUrl, playUrl,
hdPlayUrl, hdPlayUrl,
token, token,

@ -38,6 +38,38 @@ function get(path, params) {
return requestJson(joinBase(path), { params }) return requestJson(joinBase(path), { params })
} }
function unwrapPointDetailPayload(data, keys = []) {
const candidates = [
...keys.map((key) => data?.[key]),
...keys.map((key) => data?.data?.[key]),
...keys.map((key) => data?.result?.[key]),
data?.data,
data?.result,
data,
]
for (const candidate of candidates) {
if (Array.isArray(candidate)) {
const row = candidate.find((item) => item && typeof item === "object")
if (row) return row
} else if (candidate && typeof candidate === "object") {
return candidate
}
}
return null
}
export async function getWoodlandClickInfoByWkt(wkt) {
if (!wkt) return null
const data = normalizeResponseData(await get("/woodland/detail", { wkt }))
return unwrapPointDetailPayload(data, ["woodland"])
}
export async function getBasicGrasslandClickInfoByWkt(wkt) {
if (!wkt) return null
const data = normalizeResponseData(await get("/basic_grassland/detail", { wkt }))
return unwrapPointDetailPayload(data, ["basicGrassland"])
}
function pickValue(item, keys) { function pickValue(item, keys) {
for (const key of keys) { for (const key of keys) {
if (item?.[key] !== undefined && item?.[key] !== null && item?.[key] !== "") { if (item?.[key] !== undefined && item?.[key] !== null && item?.[key] !== "") {
@ -1175,7 +1207,7 @@ function buildWoodlandDefaultPanels({
const typePanel = buildPanel("woodland-type", "林地类型结构", colorRowsByPalette(typeRows, WOODLAND_CHART_PALETTE), "donut", { span: 1, variant: "ring" }) const typePanel = buildPanel("woodland-type", "林地类型结构", colorRowsByPalette(typeRows, WOODLAND_CHART_PALETTE), "donut", { span: 1, variant: "ring" })
const protectionPanel = buildPanel("woodland-protection", "公益林保护等级", colorRowsByPalette(withPercentExtra(protectionRows), WOODLAND_CHART_PALETTE), "bar", { span: 1, variant: "raceBarRelaxed" }) const protectionPanel = buildPanel("woodland-protection", "公益林保护等级", colorRowsByPalette(withPercentExtra(protectionRows), WOODLAND_CHART_PALETTE), "bar", { span: 1, variant: "raceBarRelaxed" })
const ownershipPanel = buildPanel("woodland-ownership", "林地权属结构", colorRowsByPalette(withPercentExtra(ownershipRows), WOODLAND_CHART_PALETTE), "structure", { span: 1 }) const ownershipPanel = buildPanel("woodland-ownership", "林地权属结构", colorRowsByPalette(withPercentExtra(ownershipRows), WOODLAND_CHART_PALETTE), "structure", { span: 1 })
const qualityPanel = buildPanel("woodland-quality", "优势树种面积", topRows(qualityRows, 6), "bar", { span: 1 }) const qualityPanel = buildPanel("woodland-quality", "优势树种面积", topRows(qualityRows, 6), "bar", { span: 1, variant: "raceBarNoWrap" })
const originPanel = buildPanel("woodland-origin", "林地起源结构", withPercentExtra(originRows), "structure", { span: 1 }) const originPanel = buildPanel("woodland-origin", "林地起源结构", withPercentExtra(originRows), "structure", { span: 1 })
const agePanel = buildPanel("woodland-age", "林龄组结构", topRows(ageRows, 6), "column", { span: 1 }) const agePanel = buildPanel("woodland-age", "林龄组结构", topRows(ageRows, 6), "column", { span: 1 })
const canopyPanel = buildPanel("woodland-canopy", "郁闭度结构", colorRowsByPalette(topRows(canopyRows, 6), WOODLAND_CHART_PALETTE), "donut", { span: 1, fixedType: true }) const canopyPanel = buildPanel("woodland-canopy", "郁闭度结构", colorRowsByPalette(topRows(canopyRows, 6), WOODLAND_CHART_PALETTE), "donut", { span: 1, fixedType: true })

@ -67,13 +67,55 @@ export function getMuhuList(query = "", page = 1, pageSize = 80) {
} }
export function getPastureGrasslandsById(id) { export function getPastureGrasslandsById(id) {
return post("/api/home/grasslandByPastureId", { id }).then(normalizeRows) if (id === undefined || id === null || id === "") return Promise.resolve([])
return get("/api/home/grasslandByPastureId", { id }, { timeout: 20000 }).then(normalizeRows)
} }
export function getGrasslandClickInfo(params = {}) { export function getGrasslandClickInfo(params = {}) {
return get("/api/home/grasslandClickInfo", params, { timeout: 20000 }).then((data) => data?.data || data || null) return get("/api/home/grasslandClickInfo", params, { timeout: 20000 }).then((data) => data?.data || data || null)
} }
export function getPastureClickInfoByWkt(wkt) {
if (!wkt) return Promise.resolve(null)
return get("/api/pastureDetails", { wkt }, { timeout: 20000 }).then((data) => data || null)
}
function unwrapDetailPayload(data, keys = []) {
const candidates = [
...keys.map((key) => data?.[key]),
...keys.map((key) => data?.data?.[key]),
data?.data,
data?.result,
data,
]
for (const candidate of candidates) {
if (Array.isArray(candidate)) {
const row = candidate.find((item) => item && typeof item === "object")
if (row) return row
} else if (candidate && typeof candidate === "object") {
return candidate
}
}
return null
}
export function getLandClickInfo(params = {}) {
return get("/api/grasslandDetails", params, { timeout: 20000 }).then((data) => unwrapDetailPayload(data, ["grassland"]))
}
export function getWetlandClickInfo(params = {}) {
return get("/api/wetlandDetails", params, { timeout: 20000 }).then((data) => unwrapDetailPayload(data, ["wetland"]))
}
export function getProjectClickInfoByWkt(wkt) {
if (!wkt) return Promise.resolve(null)
return get("/api/map/projectDetail", { wkt }, { timeout: 20000 }).then((data) => data || null)
}
export function getProjectList(query = "", page = 1, pageSize = 10) {
return get("/api/home/project_list", { query, page, pageSize }, { timeout: 20000 }).then((data) => data || {})
}
export function getYakMarksByGrasslandIds(ids = []) { export function getYakMarksByGrasslandIds(ids = []) {
const grasslandIds = Array.from(new Set((Array.isArray(ids) ? ids : [ids]).map((id) => String(id || "").trim()).filter(Boolean))) const grasslandIds = Array.from(new Set((Array.isArray(ids) ? ids : [ids]).map((id) => String(id || "").trim()).filter(Boolean)))
if (!grasslandIds.length) return Promise.resolve([]) if (!grasslandIds.length) return Promise.resolve([])

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

@ -28,7 +28,7 @@ export async function getSmartChartsForQuestion(question, pagePath = window.loca
pagePath, pagePath,
pageName, pageName,
}, },
timeout: 30000, timeout: 12000,
}).then(unwrapResponse) }).then(unwrapResponse)
const charts = Array.isArray(data?.charts) ? data.charts : [] const charts = Array.isArray(data?.charts) ? data.charts : []

@ -1,5 +1,4 @@
import { getSmartChartsForQuestion } from "./smartChartApi" import { getSmartChartsForQuestion } from "./smartChartApi"
import { getLivestockBalance, queryBalanceTargets } from "./smartApi"
import { import {
businessMatrixRows, businessMatrixRows,
dataFlowRows, dataFlowRows,
@ -29,7 +28,6 @@ import {
BALANCE_CALCULATION_PARAMS, BALANCE_CALCULATION_PARAMS,
balanceTownRows, balanceTownRows,
countyBalanceRow, countyBalanceRow,
normalizeBalanceResult,
} from "@/views/grassLivestockBalance/data" } from "@/views/grassLivestockBalance/data"
import { import {
engineeringAnalysisTopStats, engineeringAnalysisTopStats,
@ -52,7 +50,6 @@ import {
} from "@/views/yakIndustryChain/data" } from "@/views/yakIndustryChain/data"
const PAGE_NAME = "智慧管理" const PAGE_NAME = "智慧管理"
const COUNTY_AREA_CODE = "513233"
const DATASET_ROW_LIMIT = 80 const DATASET_ROW_LIMIT = 80
const INTENT_DEFS = [ const INTENT_DEFS = [
@ -176,19 +173,12 @@ function buildYakDatasets() {
] ]
} }
async function buildBalanceDatasets(question, warnings) { function buildBalanceDatasets(question) {
const target = await resolveBalanceTarget(question, warnings) const focusRow = findBalanceContextRow(question)
const fallbackRow = findBalanceFallback(target) const focusName = focusRow?.name || "红原县"
let resultRow = fallbackRow
try {
const apiRow = await getLivestockBalance(target.params)
resultRow = normalizeBalanceResult(apiRow || {}, fallbackRow)
} catch (error) {
warnings.push(getFriendlyContextError(error, `${target.name}草畜平衡接口暂未取得,已使用展示数据`))
}
return [ return [
dataset("balance.target", `${target.name}草畜平衡`, [resultRow], target.source), dataset("balance.target", `${focusName}草畜平衡`, [focusRow], "平台业务汇总数据"),
dataset("balance.county", "全县草畜平衡", [countyBalanceRow], "平台业务汇总数据"),
dataset("balance.townRows", "乡镇草畜平衡", balanceTownRows, "平台业务汇总数据"), dataset("balance.townRows", "乡镇草畜平衡", balanceTownRows, "平台业务汇总数据"),
dataset("balance.params", "草畜平衡计算参数", [BALANCE_CALCULATION_PARAMS], "平台业务汇总数据"), dataset("balance.params", "草畜平衡计算参数", [BALANCE_CALCULATION_PARAMS], "平台业务汇总数据"),
] ]
@ -232,82 +222,8 @@ function buildIndustryDatasets() {
] ]
} }
async function resolveBalanceTarget(question, warnings) { function findBalanceContextRow(question) {
const mentionedTown = balanceTownRows.find((row) => containsName(question, row.name)) return balanceTownRows.find((row) => containsName(question, row.name)) || countyBalanceRow
if (mentionedTown) {
return {
key: "areaCode",
name: mentionedTown.name,
params: { areaCode: mentionedTown.areaCode },
source: "草畜平衡业务数据",
}
}
if (isCountyBalanceOverviewQuestion(question)) {
return countyBalanceTarget("全县草畜平衡默认按红原县汇总")
}
try {
const targets = await queryBalanceTargets(question)
const normalizedTargets = normalizeBalanceTargets(targets)
if (normalizedTargets.length) {
const target = normalizedTargets[0]
return {
key: target.key,
name: target.name,
params: getBalanceTargetParams(target),
source: "草畜平衡业务数据",
}
}
} catch (error) {
warnings.push(getFriendlyContextError(error, "草畜平衡对象识别暂未取得"))
}
return countyBalanceTarget("按红原县全县草畜平衡汇总")
}
function countyBalanceTarget(source) {
return {
key: "areaCode",
name: "红原县",
params: { areaCode: COUNTY_AREA_CODE },
source,
}
}
function findBalanceFallback(target) {
if (!target || target.name === "红原县") return countyBalanceRow
return balanceTownRows.find((row) => row.areaCode === target.params?.areaCode || row.name === target.name) || countyBalanceRow
}
function normalizeBalanceTargets(rows = []) {
return (Array.isArray(rows) ? rows : [])
.map((item, index) => ({
key: item?.key || item?.type || "area_code",
value: item?.value || item?.id || item?.code || item?.areaCode || item?.pasturesId,
name: item?.name || item?.label || item?.areaName || item?.pasturesName || `对象${index + 1}`,
}))
.filter((item) => item.value || item.name === "红原县")
}
function getBalanceTargetParams(choice) {
if (choice?.key === "pasturesId" || choice?.key === "pastures_id") {
return { pasturesId: choice.value }
}
return { areaCode: choice?.value || COUNTY_AREA_CODE }
}
function isCountyBalanceOverviewQuestion(question) {
const text = normalizeQuestionText(question).replace(/\s+/g, "")
const specificScopePattern = /(?:邛溪|刷经寺|安曲|龙日|江茸|查尔玛|瓦切|阿木|麦洼|色地|村|牧户|牧场|合作社|基地)/
if (specificScopePattern.test(text)) return false
if (/乡|镇/.test(text) && !/(全县|红原县|全域|整体|总体)/.test(text)) return false
const core = text
.replace(/红原县|全县|全域|整体|总体|当前|现在|的/g, "")
.replace(/请|帮我|帮忙|查询|查看|看一下|看|了解|分析|生成|形成|出|一下/g, "")
.replace(/情况|数据|专题|页面|报告|建议|研判|汇总|概况|总览/g, "")
return /^(草畜平衡|草畜|平衡|草地承载力|承载力|载畜|载畜压力|放牧压力|超载率)$/.test(core)
} }
function dataset(id, title, rows, sourceLabel) { function dataset(id, title, rows, sourceLabel) {

@ -1,17 +1,23 @@
<template> <template>
<div class="engineering-analysis-panel"> <div class="engineering-analysis-panel">
<div class="analysis-card analysis-workbench"> <div
class="analysis-card analysis-workbench"
:class="{
'is-running': analysisRunning,
'is-complete': analysisDone,
}"
>
<div class="analysis-card-head"> <div class="analysis-card-head">
<span>生态工程分析</span> <span>生态工程分析</span>
<strong v-if="statusText">{{ statusText }}</strong> <strong v-if="statusText">{{ statusText }}</strong>
</div> </div>
<div v-if="!drawing" class="analysis-operation" :class="{ 'has-boundary': boundaryFeature }"> <div v-if="!drawing" class="analysis-operation" :class="{ 'has-boundary': boundaryFeature }">
<label class="analysis-entry is-upload" :class="{ 'is-disabled': drawing }"> <label class="analysis-entry is-upload" :class="{ 'is-disabled': drawing || analysisRunning }">
<input type="file" accept=".zip,.json,.geojson" :disabled="drawing" @change="handleFileChange" /> <input type="file" accept=".zip,.json,.geojson" :disabled="drawing || analysisRunning" @change="handleFileChange" />
<span>{{ boundaryFeature ? "更换边界" : "上传边界" }}</span> <span>{{ boundaryFeature ? "更换边界" : "上传边界" }}</span>
<strong>SHP / GeoJSON</strong> <strong>SHP / GeoJSON</strong>
</label> </label>
<button type="button" class="analysis-entry is-draw" :disabled="drawing" @pointerup.stop.prevent="startDrawing"> <button type="button" class="analysis-entry is-draw" :disabled="drawing || analysisRunning" @pointerup.stop.prevent="startDrawing">
<span>{{ boundaryFeature ? "重新绘制" : "在线绘制" }}</span> <span>{{ boundaryFeature ? "重新绘制" : "在线绘制" }}</span>
<strong>地图圈定</strong> <strong>地图圈定</strong>
</button> </button>
@ -24,8 +30,8 @@
<em></em> <em></em>
<small>至少 3 </small> <small>至少 3 </small>
</div> </div>
<button type="button" class="analysis-control is-primary" :disabled="!canFinishDrawing" @pointerup.stop.prevent="finishDrawing"> <button type="button" class="analysis-control is-primary" :disabled="!canFinishDrawing || analysisRunning" @pointerup.stop.prevent="finishDrawing">
完成分析 完成绘制
</button> </button>
<button type="button" class="analysis-control" :disabled="!drawingPointCount" @pointerup.stop.prevent="undoPoint">撤销</button> <button type="button" class="analysis-control" :disabled="!drawingPointCount" @pointerup.stop.prevent="undoPoint">撤销</button>
<button type="button" class="analysis-control is-danger" @pointerup.stop.prevent="cancelDrawing">删除图形</button> <button type="button" class="analysis-control is-danger" @pointerup.stop.prevent="cancelDrawing">删除图形</button>
@ -33,17 +39,31 @@
<div v-else-if="boundaryFeature" class="analysis-boundary-bar"> <div v-else-if="boundaryFeature" class="analysis-boundary-bar">
<span>边界已加载</span> <span>边界已加载</span>
<strong>{{ resultRows.length ? `命中 ${resultRows.length}` : resultTitle }}</strong> <strong>{{ boundaryStatusText }}</strong>
<button type="button" @pointerup.stop.prevent="clearBoundary">删除图形</button> <button type="button" @pointerup.stop.prevent="clearBoundary">删除图形</button>
</div> </div>
</div> </div>
<div v-if="boundaryFeature" class="analysis-card analysis-result"> <div
v-if="boundaryFeature"
class="analysis-card analysis-result"
:class="{
'is-running': analysisRunning,
'is-empty': analysisDone && !resultRows.length,
}"
>
<div class="analysis-card-head"> <div class="analysis-card-head">
<span>建设记录核查</span> <span>建设记录核查</span>
<strong>{{ resultTitle }}</strong> <strong>{{ resultTitle }}</strong>
</div> </div>
<div class="analysis-conclusion" :class="analysisConclusion.levelClass"> <div v-if="analysisRunning" class="analysis-running">
<div class="analysis-running-orbit" aria-hidden="true"></div>
<div>
<strong>空间叠加分析中</strong>
<span>正在核对边界与建设项目库</span>
</div>
</div>
<div v-else class="analysis-conclusion" :class="analysisConclusion.levelClass">
<div class="analysis-conclusion-head"> <div class="analysis-conclusion-head">
<span>分析结论</span> <span>分析结论</span>
<strong>{{ analysisConclusion.verdict }}</strong> <strong>{{ analysisConclusion.verdict }}</strong>
@ -54,14 +74,15 @@
</div> </div>
<small>{{ analysisConclusion.advice }}</small> <small>{{ analysisConclusion.advice }}</small>
</div> </div>
<div v-if="!resultRows.length" class="analysis-empty is-success">该边界范围内暂未匹配到已建设项目</div> <div v-if="analysisDone && !resultRows.length" class="analysis-empty is-success">该边界范围内暂未匹配到已建设项目</div>
<div v-else class="analysis-project-list"> <div v-else-if="analysisDone" class="analysis-project-list">
<button <button
v-for="item in resultRows" v-for="item in resultRows"
:key="item.key" :key="item.key"
type="button" type="button"
class="analysis-project-row" class="analysis-project-row"
@pointerup.stop.prevent="$emit('focus-project', item)" :class="{ 'is-active': item.key === activeProjectKey }"
@pointerup.stop.prevent="selectProject(item)"
> >
<span class="analysis-project-name">{{ item.projectName }}</span> <span class="analysis-project-name">{{ item.projectName }}</span>
<div class="analysis-project-meta"> <div class="analysis-project-meta">
@ -103,7 +124,10 @@ const matchedFeatures = ref([])
const fileError = ref("") const fileError = ref("")
const projectLoadState = ref("idle") const projectLoadState = ref("idle")
const projectFeatures = ref([]) const projectFeatures = ref([])
const analysisState = ref("idle")
const activeProjectKey = ref("")
let projectFeaturePromise = null let projectFeaturePromise = null
let analysisRequestId = 0
const summary = computed(() => { const summary = computed(() => {
const featureCount = matchedFeatures.value.length const featureCount = matchedFeatures.value.length
@ -119,6 +143,7 @@ const summary = computed(() => {
const statusText = computed(() => { const statusText = computed(() => {
if (fileError.value) return fileError.value if (fileError.value) return fileError.value
if (analysisRunning.value) return "自动分析中"
if (projectLoadState.value === "loading") return "项目库加载中" if (projectLoadState.value === "loading") return "项目库加载中"
if (projectLoadState.value === "error") return "项目库加载失败" if (projectLoadState.value === "error") return "项目库加载失败"
return "" return ""
@ -126,8 +151,17 @@ const statusText = computed(() => {
const resultTitle = computed(() => { const resultTitle = computed(() => {
if (!boundaryFeature.value) return "未分析" if (!boundaryFeature.value) return "未分析"
if (analysisRunning.value) return "正在分析"
if (analysisState.value === "error") return "分析异常"
return resultRows.value.length ? "已发现建设记录" : "未发现建设记录" return resultRows.value.length ? "已发现建设记录" : "未发现建设记录"
}) })
const analysisRunning = computed(() => analysisState.value === "running")
const analysisDone = computed(() => Boolean(boundaryFeature.value) && analysisState.value === "done")
const boundaryStatusText = computed(() => {
if (analysisRunning.value) return "正在自动分析"
if (analysisState.value === "error") return "分析失败"
return resultRows.value.length ? `命中 ${resultRows.value.length}` : resultTitle.value
})
const canFinishDrawing = computed(() => drawing.value && props.drawingPointCount >= 3) const canFinishDrawing = computed(() => drawing.value && props.drawingPointCount >= 3)
const analysisConclusion = computed(() => { const analysisConclusion = computed(() => {
if (!boundaryFeature.value) { if (!boundaryFeature.value) {
@ -208,7 +242,10 @@ function normalizeBoundaryGeojson(value) {
function startDrawing() { function startDrawing() {
if (drawing.value) return if (drawing.value) return
analysisRequestId += 1
fileError.value = "" fileError.value = ""
analysisState.value = "idle"
activeProjectKey.value = ""
drawing.value = true drawing.value = true
boundaryFeature.value = null boundaryFeature.value = null
resultRows.value = [] resultRows.value = []
@ -219,7 +256,10 @@ function startDrawing() {
function cancelDrawing() { function cancelDrawing() {
if (!drawing.value) return if (!drawing.value) return
analysisRequestId += 1
drawing.value = false drawing.value = false
analysisState.value = "idle"
activeProjectKey.value = ""
emit("clear-boundary") emit("clear-boundary")
emit("analysis-result", { boundary: null, matches: [], rows: [], summary: summary.value }) emit("analysis-result", { boundary: null, matches: [], rows: [], summary: summary.value })
} }
@ -234,11 +274,14 @@ function undoPoint() {
} }
function clearBoundary() { function clearBoundary() {
analysisRequestId += 1
drawing.value = false drawing.value = false
boundaryFeature.value = null boundaryFeature.value = null
resultRows.value = [] resultRows.value = []
matchedFeatures.value = [] matchedFeatures.value = []
fileError.value = "" fileError.value = ""
analysisState.value = "idle"
activeProjectKey.value = ""
emit("clear-boundary") emit("clear-boundary")
emit("analysis-result", { boundary: null, matches: [], rows: [], summary: summary.value }) emit("analysis-result", { boundary: null, matches: [], rows: [], summary: summary.value })
} }
@ -250,18 +293,33 @@ function applyDrawnBoundary(feature) {
} }
async function applyBoundary(feature) { async function applyBoundary(feature) {
const requestId = ++analysisRequestId
boundaryFeature.value = feature boundaryFeature.value = feature
matchedFeatures.value = [] matchedFeatures.value = []
resultRows.value = [] resultRows.value = []
activeProjectKey.value = ""
fileError.value = ""
analysisState.value = "running"
let matches = [] let matches = []
emit("analysis-result", {
boundary: feature,
matches: [],
rows: [],
summary: summary.value,
})
try { try {
const features = await ensureProjectFeatures() const features = await ensureProjectFeatures()
if (requestId !== analysisRequestId) return
matches = findProjectMatches(feature, features) matches = findProjectMatches(feature, features)
matchedFeatures.value = matches matchedFeatures.value = matches
resultRows.value = buildProjectRows(matches) resultRows.value = buildProjectRows(matches)
analysisState.value = "done"
} catch (error) { } catch (error) {
if (requestId !== analysisRequestId) return
fileError.value = "项目库加载失败" fileError.value = "项目库加载失败"
analysisState.value = "error"
} }
if (requestId !== analysisRequestId) return
emit("analysis-result", { emit("analysis-result", {
boundary: feature, boundary: feature,
matches, matches,
@ -270,6 +328,14 @@ async function applyBoundary(feature) {
}) })
} }
function selectProject(item) {
activeProjectKey.value = item?.key || ""
emit("focus-project", {
...item,
boundary: boundaryFeature.value,
})
}
async function ensureProjectFeatures() { async function ensureProjectFeatures() {
if (projectFeatures.value.length) return projectFeatures.value if (projectFeatures.value.length) return projectFeatures.value
if (projectFeaturePromise) return projectFeaturePromise if (projectFeaturePromise) return projectFeaturePromise
@ -634,7 +700,7 @@ defineExpose({
z-index: 8; z-index: 8;
left: 32px; left: 32px;
top: 188px; top: 188px;
width: 430px; width: 454px;
display: grid; display: grid;
grid-template-rows: auto auto; grid-template-rows: auto auto;
gap: 12px; gap: 12px;
@ -656,6 +722,7 @@ defineExpose({
inset 0 1px 0 rgba(180, 252, 255, 0.16), inset 0 1px 0 rgba(180, 252, 255, 0.16),
inset 0 0 26px rgba(48, 220, 255, 0.12), inset 0 0 26px rgba(48, 220, 255, 0.12),
0 16px 30px rgba(0, 0, 0, 0.32); 0 16px 30px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(4px);
box-sizing: border-box; box-sizing: border-box;
&::before { &::before {
@ -683,6 +750,22 @@ defineExpose({
} }
} }
.analysis-workbench {
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&.is-running {
border-color: rgba(126, 251, 246, 0.5);
box-shadow:
inset 0 1px 0 rgba(180, 252, 255, 0.18),
inset 0 0 30px rgba(48, 220, 255, 0.16),
0 18px 32px rgba(0, 0, 0, 0.36);
}
&.is-complete {
border-color: rgba(36, 246, 196, 0.42);
}
}
.analysis-card-head { .analysis-card-head {
position: relative; position: relative;
display: flex; display: flex;
@ -755,6 +838,7 @@ defineExpose({
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
box-shadow: inset 0 0 18px rgba(48, 220, 255, 0.08); box-shadow: inset 0 0 18px rgba(48, 220, 255, 0.08);
transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
&::before { &::before {
content: ""; content: "";
@ -843,6 +927,15 @@ defineExpose({
background: background:
linear-gradient(90deg, rgba(36, 246, 196, 0.2), rgba(48, 220, 255, 0.06)), linear-gradient(90deg, rgba(36, 246, 196, 0.2), rgba(48, 220, 255, 0.06)),
rgba(4, 23, 34, 0.56); rgba(4, 23, 34, 0.56);
box-shadow:
inset 0 0 20px rgba(36, 246, 196, 0.1),
0 0 16px rgba(36, 246, 196, 0.12);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid rgba(139, 247, 255, 0.8);
outline-offset: 2px;
} }
} }
@ -903,6 +996,12 @@ defineExpose({
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease;
&:hover {
border-color: rgba(139, 247, 255, 0.72);
background: rgba(48, 220, 255, 0.18);
}
} }
} }
@ -950,6 +1049,7 @@ defineExpose({
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
&.is-primary { &.is-primary {
border-color: rgba(36, 246, 196, 0.78); border-color: rgba(36, 246, 196, 0.78);
@ -968,6 +1068,12 @@ defineExpose({
opacity: 0.42; opacity: 0.42;
cursor: not-allowed; cursor: not-allowed;
} }
&:not(:disabled):hover {
border-color: rgba(139, 247, 255, 0.62);
background: rgba(48, 220, 255, 0.16);
transform: translateY(-1px);
}
} }
.analysis-result { .analysis-result {
@ -977,6 +1083,84 @@ defineExpose({
height: clamp(500px, 56vh, 620px); height: clamp(500px, 56vh, 620px);
min-height: 0; min-height: 0;
max-height: calc(100vh - 350px); max-height: calc(100vh - 350px);
&.is-running {
min-height: 214px;
height: auto;
}
&.is-empty {
min-height: 286px;
height: auto;
}
}
.analysis-running {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: center;
gap: 12px;
min-height: 104px;
padding: 14px;
border: 1px solid rgba(126, 251, 246, 0.24);
background:
radial-gradient(circle at 22px 22px, rgba(126, 251, 246, 0.18), transparent 46px),
linear-gradient(135deg, rgba(48, 220, 255, 0.09), rgba(4, 23, 34, 0.5));
box-sizing: border-box;
strong,
span {
display: block;
min-width: 0;
}
strong {
color: #ffffff;
font-size: 14px;
line-height: 22px;
}
span {
margin-top: 4px;
color: rgba(232, 252, 255, 0.72);
font-size: 12px;
line-height: 18px;
}
}
.analysis-running-orbit {
position: relative;
width: 42px;
height: 42px;
border: 1px solid rgba(126, 251, 246, 0.42);
border-radius: 50%;
box-shadow:
inset 0 0 12px rgba(48, 220, 255, 0.18),
0 0 18px rgba(36, 246, 196, 0.16);
&::before {
content: "";
position: absolute;
inset: 5px;
border: 2px solid transparent;
border-top-color: #24f6c4;
border-right-color: rgba(48, 220, 255, 0.84);
border-radius: 50%;
animation: analysisSpin 1.1s linear infinite;
}
&::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 7px;
height: 7px;
border-radius: 50%;
background: #eaffff;
box-shadow: 0 0 12px rgba(126, 251, 246, 0.8);
transform: translate(-50%, -50%);
}
} }
.analysis-empty { .analysis-empty {
@ -1116,6 +1300,7 @@ defineExpose({
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
box-shadow: inset 0 0 16px rgba(48, 220, 255, 0.05); box-shadow: inset 0 0 16px rgba(48, 220, 255, 0.05);
transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
.analysis-project-name, .analysis-project-name,
b { b {
@ -1184,6 +1369,37 @@ defineExpose({
background: background:
linear-gradient(90deg, rgba(36, 246, 196, 0.14), rgba(48, 220, 255, 0.06)), linear-gradient(90deg, rgba(36, 246, 196, 0.14), rgba(48, 220, 255, 0.06)),
rgba(4, 23, 34, 0.62); rgba(4, 23, 34, 0.62);
box-shadow:
inset 0 0 18px rgba(48, 220, 255, 0.08),
0 0 14px rgba(36, 246, 196, 0.08);
transform: translateX(2px);
}
&.is-active {
border-color: rgba(36, 246, 196, 0.72);
border-left-color: #24f6c4;
background:
linear-gradient(90deg, rgba(36, 246, 196, 0.18), rgba(48, 220, 255, 0.08)),
rgba(4, 28, 38, 0.7);
box-shadow:
inset 3px 0 0 rgba(36, 246, 196, 0.9),
inset 0 0 18px rgba(36, 246, 196, 0.08),
0 0 18px rgba(36, 246, 196, 0.12);
}
&:focus-visible {
outline: 2px solid rgba(139, 247, 255, 0.78);
outline-offset: 2px;
}
}
@keyframes analysisSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
} }
} }
</style> </style>

@ -1117,15 +1117,9 @@ export const parkPanels = {
key: "park-town-ecology-job", key: "park-town-ecology-job",
title: "公益岗位分布", title: "公益岗位分布",
type: "column", type: "column",
variant: "slim", variant: "plain",
rows: parkTownEcologyJobRows, rows: parkTownEcologyJobRows,
}, },
{
key: "park-balance-capacity",
title: "草畜承载能力",
type: "metric",
rows: parkBalanceCapacityRows,
},
], ],
} }
@ -1207,6 +1201,7 @@ export const restorationPanels = {
key: "restoration-year-area", key: "restoration-year-area",
title: "治理面积分布", title: "治理面积分布",
type: "line", type: "line",
labelMode: "horizontal",
rows: (restorationSummary.years || []).map((item) => ({ rows: (restorationSummary.years || []).map((item) => ({
key: item.name, key: item.name,
name: `${item.name}`, name: `${item.name}`,
@ -1218,8 +1213,10 @@ export const restorationPanels = {
{ {
key: "restoration-town-area", key: "restoration-town-area",
title: "乡镇治理面积分布", title: "乡镇治理面积分布",
type: "bar", type: "column",
variant: "distribution", variant: "water",
labelMode: "horizontal",
scrollable: false,
rows: validTownRows.map((item) => ({ rows: validTownRows.map((item) => ({
key: item.name, key: item.name,
name: item.name, name: item.name,
@ -1245,6 +1242,7 @@ export const restorationPanels = {
title: "年度工程长度", title: "年度工程长度",
type: "column", type: "column",
variant: "slim", variant: "slim",
labelMode: "horizontal",
rows: (restorationSummary.years || []) rows: (restorationSummary.years || [])
.filter((item) => Number(item.lengthKm) > 0) .filter((item) => Number(item.lengthKm) > 0)
.map((item) => ({ .map((item) => ({
@ -1274,6 +1272,7 @@ export const restorationPanels = {
title: "年度项目数量", title: "年度项目数量",
type: "column", type: "column",
variant: "projectYear", variant: "projectYear",
labelMode: "horizontal",
rows: (restorationSummary.years || []) rows: (restorationSummary.years || [])
.filter((item) => item.name !== "未知") .filter((item) => item.name !== "未知")
.map((item) => ({ .map((item) => ({

@ -5,8 +5,10 @@
:active-layer="activeLayer" :active-layer="activeLayer"
:map-ranking="mapRanking" :map-ranking="mapRanking"
:admin-boundary-line-scale="2.4" :admin-boundary-line-scale="2.4"
:auto-apply-layer-map-view="false"
@feature-loading="featureLoading = $event" @feature-loading="featureLoading = $event"
@feature-info="handleFeatureInfo" @feature-info="handleFeatureInfo"
@map-click="handleMapClick"
@layer-status="handleLayerStatus" @layer-status="handleLayerStatus"
@draw-boundary="handleDrawBoundary" @draw-boundary="handleDrawBoundary"
/> />
@ -132,6 +134,7 @@ import {
getLayersByTopic, getLayersByTopic,
} from "@/config/layers" } from "@/config/layers"
import { getEcologicalScopeStats } from "@/services/ecologicalProtectionApi" import { getEcologicalScopeStats } from "@/services/ecologicalProtectionApi"
import { getProjectClickInfoByWkt, getProjectList } from "@/services/pastureApi"
import { Assets } from "@/views/forestGrassWet/assets" import { Assets } from "@/views/forestGrassWet/assets"
import { SCREEN2_LAYOUT, getScreen2SideCardHeight, getScreen2SpanHeight } from "@/utils/screen2Standard" import { SCREEN2_LAYOUT, getScreen2SideCardHeight, getScreen2SpanHeight } from "@/utils/screen2Standard"
import MapScene from "@/components/CesiumMap/index.vue" import MapScene from "@/components/CesiumMap/index.vue"
@ -191,12 +194,41 @@ let mapAssetsReady = false
let mapLoadStarted = false let mapLoadStarted = false
let mapLoadTimer = null let mapLoadTimer = null
let scopeStatsRequestId = 0 let scopeStatsRequestId = 0
let featureQueryRequestId = 0
let projectListRequestId = 0
const scopeStatsCache = new Map() const scopeStatsCache = new Map()
const projectListRows = ref(null)
const nonBusinessFeatureKeys = new Set([
"code",
"msg",
"message",
"success",
"data",
"result",
"project",
"progress",
"dycolumn",
"geom",
"wkt",
"geometry",
"the_geom",
"raw_properties",
"gid",
"id",
"objectid",
"object_id",
"fid",
"key_uid",
"bsm",
"ysdm",
])
const currentView = computed(() => const currentView = computed(() =>
ecologicalProtectionTopics.some((item) => item.key === props.activeView) ? props.activeView : "ecological-redline" ecologicalProtectionTopics.some((item) => item.key === props.activeView) ? props.activeView : "ecological-redline"
) )
const isEngineeringAnalysis = computed(() => currentView.value === "engineering-analysis") const isEngineeringAnalysis = computed(() => currentView.value === "engineering-analysis")
const isProjectDetailView = computed(() => ["restoration", "engineering-analysis"].includes(currentView.value))
const isEcologicalScopeView = computed(() => ecologicalScopeViewKeys.includes(currentView.value)) const isEcologicalScopeView = computed(() => ecologicalScopeViewKeys.includes(currentView.value))
const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(currentView.value, activeLayerKey.value) : null) const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(currentView.value, activeLayerKey.value) : null)
const selectedLegend = computed(() => const selectedLegend = computed(() =>
@ -230,7 +262,7 @@ const activeLayer = computed(() => {
}) })
const currentPanels = computed(() => { const currentPanels = computed(() => {
if (isEcologicalScopeView.value) return scopePanels.value if (isEcologicalScopeView.value) return scopePanels.value
if (currentView.value === "restoration") return restorationPanels if (currentView.value === "restoration") return buildRestorationPanels(projectListRows.value)
if (currentView.value === "engineering-analysis") return engineeringAnalysisPanels if (currentView.value === "engineering-analysis") return engineeringAnalysisPanels
return parkPanels return parkPanels
}) })
@ -252,9 +284,10 @@ const featurePanelStyle = computed(() => {
const panelHeight = featurePanelHeight.value const panelHeight = featurePanelHeight.value
const margin = 24 const margin = 24
const point = toScreenLocalPoint(featurePanelPoint.value) const point = toScreenLocalPoint(featurePanelPoint.value)
const xOffset = point.x > 1420 ? -panelWidth - 28 : 28 const topLimit = 104
const x = Math.min(Math.max(point.x + xOffset, margin), 1920 - panelWidth - margin) const verticalGap = 36
const y = Math.min(Math.max(point.y - panelHeight - 72, 188), 1080 - panelHeight - 132) const x = Math.min(Math.max(point.x - panelWidth / 2, margin), 1920 - panelWidth - margin)
const y = Math.min(Math.max(point.y - panelHeight - verticalGap, topLimit), 1080 - panelHeight - margin)
return { return {
left: `${x}px`, left: `${x}px`,
top: `${y}px`, top: `${y}px`,
@ -263,6 +296,18 @@ const featurePanelStyle = computed(() => {
} }
}) })
function buildRestorationPanels(rows) {
if (!Array.isArray(rows) || !rows.length) return restorationPanels
return {
...restorationPanels,
right: (restorationPanels.right || []).map((panel) =>
panel?.key === "restoration-projects"
? { ...panel, rows }
: panel
),
}
}
onMounted(() => { onMounted(() => {
updateScreenViewportHeight() updateScreenViewportHeight()
window.addEventListener("resize", updateScreenViewportHeight) window.addEventListener("resize", updateScreenViewportHeight)
@ -318,6 +363,9 @@ function syncView(viewKey) {
scopePanels.value = buildEcologicalScopePanels({}, viewKey) scopePanels.value = buildEcologicalScopePanels({}, viewKey)
loadEcologicalScopeStats(viewKey) loadEcologicalScopeStats(viewKey)
} }
if (viewKey === "restoration") {
loadRestorationProjectList()
}
emit("module-stats", { emit("module-stats", {
moduleKey: "ecological-protection", moduleKey: "ecological-protection",
viewKey, viewKey,
@ -327,6 +375,110 @@ function syncView(viewKey) {
}) })
} }
async function loadRestorationProjectList() {
const requestId = ++projectListRequestId
try {
const payload = await getProjectList("", 1, 200)
if (requestId !== projectListRequestId || currentView.value !== "restoration" || destroyed) return
const rows = normalizeProjectListRows(payload)
projectListRows.value = rows.length ? rows : null
} catch (error) {
if (requestId !== projectListRequestId || currentView.value !== "restoration" || destroyed) return
projectListRows.value = null
console.warn("Failed to load restoration project list", error)
}
}
function normalizeProjectListRows(payload) {
return normalizeProjectListSource(payload)
.map((item, index) => normalizeProjectRow(item, index))
.filter((item) => item.name)
}
function normalizeProjectListSource(value) {
if (Array.isArray(value)) return value
if (!value || typeof value !== "object") return []
const candidates = [
value.rows,
value.list,
value.records,
value.items,
value.data?.rows,
value.data?.list,
value.data?.records,
value.result?.rows,
value.result?.list,
value.result?.records,
]
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate
}
return []
}
function normalizeProjectRow(item = {}, index = 0) {
const name = pickProjectValue(item, ["projectName", "project_name", "name", "project", "title"])
const category = pickProjectValue(item, ["projectCategory", "project_category", "category", "type", "project_type"])
const year = pickProjectValue(item, ["year", "projectYear", "project_year", "buildYear", "build_year"]) || "未知"
const areaWanMu = toProjectNumber(pickProjectValue(item, ["areaWanMu", "area_wan_mu", "area_mu", "acreage", "area", "mj"]))
const lengthKm = normalizeProjectLength(pickProjectValue(item, ["lengthKm", "length_km", "length_meter", "length", "project_length"]))
const measure = normalizeProjectTextList(pickProjectValue(item, ["measure", "measureTypes", "measure_types", "measure_type", "method"]))
const townships = normalizeProjectTextList(pickProjectValue(item, ["townships", "township", "town", "xzqmc", "full_name"]))
const subjectFeatureCount = toProjectNumber(pickProjectValue(item, ["subjectFeatureCount", "subject_feature_count", "featureCount", "feature_count", "count"]))
const hasArea = areaWanMu > 0
const hasLength = lengthKm > 0
const value = hasArea ? roundProjectNumber(areaWanMu, 2) : hasLength ? roundProjectNumber(lengthKm, 2) : subjectFeatureCount || 0
const unit = hasArea ? "万亩" : hasLength ? "公里" : "个"
const metric = hasArea ? "治理面积" : hasLength ? "工程长度" : "主体要素"
return {
key: item.id || item.project_id || name || `project-${index + 1}`,
index: index + 1,
name,
projectName: name,
category,
year,
areaWanMu: roundProjectNumber(areaWanMu, 4),
lengthKm: roundProjectNumber(lengthKm, 2),
measure,
townships,
subjectFeatureCount,
value,
unit,
metric,
extra: [year, measure].filter(Boolean).join(" / "),
searchText: [name, category, year, measure, townships].filter(Boolean).join(" "),
}
}
function pickProjectValue(item, keys) {
for (const key of keys) {
const value = item?.[key]
if (value !== undefined && value !== null && value !== "") return value
}
return undefined
}
function normalizeProjectTextList(value) {
if (Array.isArray(value)) return value.filter(Boolean).join("、")
return String(value || "").trim()
}
function toProjectNumber(value) {
if (value === undefined || value === null || value === "") return 0
const number = Number(String(value).replace(/[^\d.-]/g, ""))
return Number.isFinite(number) ? number : 0
}
function normalizeProjectLength(value) {
const number = toProjectNumber(value)
if (!number) return 0
return number > 10000 ? number / 1000 : number
}
function roundProjectNumber(value, digits = 2) {
return Number(Number(value || 0).toFixed(digits))
}
function handleViewSelect(viewKey) { function handleViewSelect(viewKey) {
const nextKey = ecologicalProtectionTopics.some((item) => item.key === viewKey) ? viewKey : "ecological-redline" const nextKey = ecologicalProtectionTopics.some((item) => item.key === viewKey) ? viewKey : "ecological-redline"
if (nextKey === currentView.value) return if (nextKey === currentView.value) return
@ -483,19 +635,203 @@ function getPanelHeight(panel) {
function estimateFeatureRowCount(feature) { function estimateFeatureRowCount(feature) {
const properties = feature?.properties || {} const properties = feature?.properties || {}
return Object.keys(properties).filter((key) => { return Object.keys(properties).filter((key) => isDisplayableFeatureProperty(key, properties[key])).length
const value = properties[key] }
if (value === undefined || value === null || value === "") return false
return !["geom", "wkt", "geometry", "the_geom", "raw_properties"].includes(String(key).toLowerCase()) function isDisplayableFeatureProperty(key, value) {
}).length if (value === undefined || value === null || value === "") return false
if (Array.isArray(value) || typeof value === "object") return false
return !nonBusinessFeatureKeys.has(String(key || "").toLowerCase())
}
function buildPointWkt(lngLat = {}) {
const lng = formatWktCoordinate(lngLat.lng)
const lat = formatWktCoordinate(lngLat.lat)
if (lng === "" || lat === "") return ""
return `POINT(${lng} ${lat})`
}
function formatWktCoordinate(value) {
const number = Number(value)
if (!Number.isFinite(number)) return ""
return number.toFixed(8).replace(/\.?0+$/, "")
}
async function handleMapClick(payload = {}) {
if (!isProjectDetailView.value) return
if (isVectorProjectLayer(payload.overlayLayer || activeLayer.value || baseActiveLayer.value)) {
closeFeaturePanel()
return
}
const lng = Number(payload?.lngLat?.lng)
const lat = Number(payload?.lngLat?.lat)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
closeFeaturePanel()
return
}
const requestId = ++featureQueryRequestId
const clickScreen = payload.screen || { x: 960, y: 540 }
featurePanelPoint.value = null
selectedFeature.value = null
featureEmpty.value = false
featureLoading.value = true
try {
const detail = await getProjectClickInfoByWkt(buildPointWkt({ lng, lat }))
if (requestId !== featureQueryRequestId || destroyed) return
const feature = normalizeProjectClickFeature(detail, payload)
if (!feature || estimateFeatureRowCount(feature) <= 0) {
closeFeaturePanel()
return
}
selectedFeature.value = feature
featurePanelPoint.value = mapSceneRef.value?.getVectorFeatureAnchorScreen?.(feature) || clickScreen
featureEmpty.value = false
mapSceneRef.value?.highlightFeature?.(feature)
} catch (error) {
if (requestId !== featureQueryRequestId) return
closeFeaturePanel()
} finally {
if (requestId === featureQueryRequestId) {
featureLoading.value = false
}
}
}
function isVectorProjectLayer(layer = activeLayer.value) {
const sourceType = String(layer?.sourceType || "").toLowerCase()
return ["geojson", "wkt-api", "wfs"].includes(sourceType)
|| String(layer?.dataPath || "").includes("ecological_restoration_projects")
}
function normalizeProjectClickFeature(payload, clickPayload = {}) {
const project = pickFirstObject(payload?.project || payload?.data?.project || payload?.data || payload)
if (!hasProjectDetail(project)) return null
const properties = normalizeProjectProperties(project, payload)
const geometry = project.geom || project.geometry || project.wkt || null
return {
id: project.id || properties.id || properties.project_name || `project-${clickPayload?.lngLat?.lng}-${clickPayload?.lngLat?.lat}`,
properties,
feature: {
type: "Feature",
properties,
geometry,
},
geometry,
layer: clickPayload.overlayLayer || activeLayer.value || baseActiveLayer.value,
lngLat: clickPayload.lngLat,
}
}
function normalizePickedProjectFeature(payload, clickPayload = {}) {
const source = payload?.feature && payload.feature.type === "Feature"
? {
...payload.feature,
id: payload.id || payload.feature.id,
layer: payload.layer,
lngLat: payload.lngLat,
}
: payload
const sourceFeature = source?.type === "Feature" ? source : source?.feature?.type === "Feature" ? source.feature : null
const rawProperties = source?.properties || sourceFeature?.properties || {}
const geometry = source?.geometry
|| sourceFeature?.geometry
|| rawProperties.geometry
|| rawProperties.geom
|| rawProperties.wkt
|| null
const properties = normalizeProjectProperties(rawProperties, clickPayload)
const id = source?.id
|| sourceFeature?.id
|| properties.id
|| properties.gid
|| properties.project_id
|| properties.project_name
|| `project-${clickPayload?.lngLat?.lng || ""}-${clickPayload?.lngLat?.lat || ""}`
return {
id,
properties,
feature: {
type: "Feature",
id,
properties,
geometry,
},
geometry,
layer: source?.layer || clickPayload.overlayLayer || activeLayer.value || baseActiveLayer.value,
lngLat: source?.lngLat || clickPayload.lngLat,
}
}
function pickFirstObject(value) {
if (Array.isArray(value)) return value.find((item) => item && typeof item === "object") || null
return value && typeof value === "object" ? value : null
}
function hasProjectDetail(detail) {
if (!detail || typeof detail !== "object") return false
if (Array.isArray(detail.dyColumn) && detail.dyColumn.some(hasProjectColumnValue)) return true
return Object.keys(detail).some((key) => isDisplayableFeatureProperty(key, detail[key]))
}
function hasProjectColumnValue(column = {}) {
const value = column?.value
if (value === undefined || value === null || value === "") return false
return Boolean(String(column?.field || column?.title || "").trim())
}
function normalizeProjectProperties(project = {}, payload = {}) {
const properties = { ...project }
const dyColumns = Array.isArray(project.dyColumn) ? project.dyColumn : []
dyColumns.forEach((column) => {
const value = column?.value
if (value === undefined || value === null || value === "") return
const fieldKey = String(column?.field || "").trim()
const keys = [fieldKey, camelToSnake(fieldKey)].filter(Boolean)
keys.forEach((key) => {
if (properties[key] === undefined || properties[key] === null || properties[key] === "") {
properties[key] = value
}
})
})
const projectName = properties.project_name || properties.projectName || properties.name
const acreage = properties.area_mu || properties.areaMu || properties.acreage
const projectStatus = properties.project_status || properties.projectStatus
return {
...properties,
project_name: projectName,
name: projectName || properties.name,
area_mu: acreage,
project_status: projectStatus,
progress_count: Array.isArray(payload?.progress) ? payload.progress.length : undefined,
}
}
function camelToSnake(value) {
return String(value || "").replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`)
} }
function handleFeatureInfo(payload) { function handleFeatureInfo(payload) {
const feature = payload?.feature ?? payload ?? null const feature = payload?.feature ?? payload ?? null
if (!feature) { if (!feature || estimateFeatureRowCount(feature) <= 0) {
closeFeaturePanel() closeFeaturePanel()
return return
} }
if (isProjectDetailView.value) {
featureQueryRequestId += 1
const projectFeature = normalizePickedProjectFeature(feature, payload)
if (!projectFeature || estimateFeatureRowCount(projectFeature) <= 0) {
closeFeaturePanel()
return
}
selectedFeature.value = projectFeature
featureEmpty.value = false
featureLoading.value = false
featurePanelPoint.value = mapSceneRef.value?.getVectorFeatureAnchorScreen?.(projectFeature) || payload?.screen || null
return
}
selectedFeature.value = feature selectedFeature.value = feature
featureEmpty.value = false featureEmpty.value = false
if (payload?.screen) { if (payload?.screen) {
@ -522,7 +858,51 @@ function handlePanelRowSelect(panel, row) {
const projectName = row?.projectName || row?.name const projectName = row?.projectName || row?.name
if (!projectName) return if (!projectName) return
closeFeaturePanel() closeFeaturePanel()
mapSceneRef.value?.focusProject?.(projectName) const focus = () => scheduleRestorationProjectFocus(row)
if (selectedLegendKey.value) {
selectedLegendKey.value = ""
nextTick(() => window.setTimeout(focus, 80))
return
}
focus()
}
function scheduleRestorationProjectFocus(row, attempt = 0) {
if (currentView.value !== "restoration") return
const projectName = row?.projectName || row?.name
if (!projectName) return
const result = mapSceneRef.value?.focusProject?.({
id: row?.id,
key: row?.key,
name: projectName,
projectName,
project_name: projectName,
highlightTone: "white",
})
if (!result?.feature) {
if (attempt < 10) {
window.setTimeout(() => scheduleRestorationProjectFocus(row, attempt + 1), 120)
return
}
layerStatus.value = {
type: "warning",
text: "当前项目图斑尚未加载",
}
return
}
const projectFeature = normalizePickedProjectFeature({
id: result.feature.id,
properties: result.feature.properties || {},
geometry: result.feature.geometry,
feature: result.feature,
layer: activeLayer.value || baseActiveLayer.value,
}, {})
selectedFeature.value = projectFeature
featureEmpty.value = false
featureLoading.value = false
featurePanelPoint.value = result.screen
|| mapSceneRef.value?.getVectorFeaturesAnchorScreen?.(result.highlightFeatures?.length ? result.highlightFeatures : result.features)
|| mapSceneRef.value?.getVectorFeatureAnchorScreen?.(projectFeature)
} }
function handleDrawingChange(enabled) { function handleDrawingChange(enabled) {
@ -565,13 +945,16 @@ function handleAnalysisResult(payload = {}) {
function handleAnalysisProjectFocus(item) { function handleAnalysisProjectFocus(item) {
const matches = (item?.features || []).filter(Boolean) const matches = (item?.features || []).filter(Boolean)
mapSceneRef.value?.showAnalysisResult?.({ mapSceneRef.value?.showAnalysisResult?.({
boundary: item?.boundary,
matches, matches,
}) })
} }
function closeFeaturePanel() { function closeFeaturePanel() {
featureQueryRequestId += 1
selectedFeature.value = null selectedFeature.value = null
featureEmpty.value = false featureEmpty.value = false
featureLoading.value = false
featurePanelPoint.value = null featurePanelPoint.value = null
mapSceneRef.value?.clearSelectedFeature?.() mapSceneRef.value?.clearSelectedFeature?.()
} }

@ -6100,7 +6100,7 @@ function columnGradient(index, item = {}) {
&.is-variant-parkSlim { &.is-variant-parkSlim {
grid-template-rows: 24px minmax(0, 1fr); grid-template-rows: 24px minmax(0, 1fr);
gap: 2px; gap: 2px;
padding: 7px 22px 8px; padding: 5px 20px 6px;
.business-stack-legend { .business-stack-legend {
justify-content: center; justify-content: center;
@ -6132,8 +6132,8 @@ function columnGradient(index, item = {}) {
.business-stack-chart { .business-stack-chart {
position: relative; position: relative;
grid-template-columns: repeat(auto-fit, minmax(82px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(82px, 1fr));
gap: 28px; gap: 30px;
padding: 4px 10px 0; padding: 1px 10px 0;
&:before { &:before {
content: ""; content: "";
@ -6141,7 +6141,7 @@ function columnGradient(index, item = {}) {
left: 8px; left: 8px;
right: 8px; right: 8px;
top: 26px; top: 26px;
bottom: 43px; bottom: 41px;
background: background:
repeating-linear-gradient( repeating-linear-gradient(
to bottom, to bottom,
@ -6155,7 +6155,7 @@ function columnGradient(index, item = {}) {
.business-stack-item { .business-stack-item {
position: relative; position: relative;
z-index: 1; z-index: 1;
grid-template-rows: 18px minmax(70px, 1fr) 42px; grid-template-rows: 18px minmax(86px, 1fr) 40px;
min-width: 0; min-width: 0;
overflow: visible; overflow: visible;
} }
@ -6185,8 +6185,8 @@ function columnGradient(index, item = {}) {
align-self: end; align-self: end;
width: 18px; width: 18px;
height: 100%; height: 100%;
min-height: 66px; min-height: 78px;
max-height: 100px; max-height: 118px;
overflow: visible; overflow: visible;
border: 0; border: 0;
background: linear-gradient(180deg, rgba(48, 220, 255, 0.1), rgba(48, 220, 255, 0.035)); background: linear-gradient(180deg, rgba(48, 220, 255, 0.1), rgba(48, 220, 255, 0.035));
@ -6565,16 +6565,31 @@ function columnGradient(index, item = {}) {
padding-bottom: 9px; padding-bottom: 9px;
} }
.business-columns.is-variant-slim,
.business-columns.is-variant-projectYear {
padding-bottom: 14px;
}
.business-columns.is-variant-projectYear {
grid-template-columns: repeat(auto-fit, minmax(42px, 1fr));
gap: 8px;
}
.business-column-item, .business-column-item,
.business-columns.is-variant-plain .business-column-item { .business-columns.is-variant-plain .business-column-item {
grid-template-rows: minmax(70px, 1fr) 28px; grid-template-rows: minmax(70px, 1fr) 28px;
} }
.business-columns.is-variant-projectYear .business-column-item,
.business-columns.is-variant-slim .business-column-item {
grid-template-rows: minmax(76px, 1fr) 24px;
}
.business-column-item span, .business-column-item span,
.business-line-labels span { .business-line-labels span {
width: auto; width: auto;
height: auto; height: auto;
max-width: 58px; max-width: 68px;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
font-size: 12px; font-size: 12px;
line-height: 14px; line-height: 14px;
@ -6584,6 +6599,13 @@ function columnGradient(index, item = {}) {
overflow: visible; overflow: visible;
text-overflow: clip; text-overflow: clip;
} }
.business-columns.is-variant-projectYear .business-column-item span,
.business-columns.is-variant-slim .business-column-item span {
max-width: 72px;
font-size: 12px;
line-height: 15px;
}
} }
.business-peaks { .business-peaks {

@ -22,7 +22,7 @@
<script setup> <script setup>
import { computed } from "vue" import { computed } from "vue"
import mCard from "@/components/mCard/index.vue" import mCard from "@/components/mCard/index.vue"
import { getHongyuanTownshipNameByCode } from "@/config/townshipBoundaries" import { getHongyuanTownshipNameByCode, hongyuanTownshipDisplayOrder } from "@/config/townshipBoundaries"
const props = defineProps({ const props = defineProps({
feature: { feature: {
@ -179,12 +179,18 @@ const topicBusinessFields = {
], ],
ecological: [ ecological: [
[["type"], "管控分区"], [["type"], "管控分区"],
[["project_name"], "项目名称"], [["project_name", "projectName", "name"], "项目名称"],
[["project_category"], "项目类型"], [["project_category", "projectCategory"], "项目类型"],
[["company"], "实施单位"],
[["leader"], "负责人"],
[["project_status", "projectStatus", "status"], "项目状态"],
[["full_name", "fullName"], "所在区域"],
[["address"], "项目地址"],
[["measure_type"], "措施类型"], [["measure_type"], "措施类型"],
[["feature_role"], "要素类型"], [["feature_role"], "要素类型"],
[["township"], "涉及乡镇"], [["township"], "涉及乡镇"],
[["year"], "建设年份"], [["year"], "建设年份"],
[["progress_count", "progressCount"], "进度记录"],
[["totalAcreage"], "总面积"], [["totalAcreage"], "总面积"],
[["cdAcreage"], "草地面积"], [["cdAcreage"], "草地面积"],
[["sdAcreage"], "湿地面积"], [["sdAcreage"], "湿地面积"],
@ -447,13 +453,27 @@ const fieldLabels = {
sf_tbq: "是否特别区", sf_tbq: "是否特别区",
td_th_lx: "土地退化类型", td_th_lx: "土地退化类型",
project_name: "项目名称", project_name: "项目名称",
projectName: "项目名称",
project_category: "项目类型", project_category: "项目类型",
projectCategory: "项目类型",
company: "实施单位",
leader: "负责人",
project_status: "项目状态",
projectStatus: "项目状态",
address: "项目地址",
conclusion: "工程结论",
progress_count: "进度记录",
progressCount: "进度记录",
measure_type: "措施类型", measure_type: "措施类型",
measureType: "措施类型",
feature_role: "要素类型", feature_role: "要素类型",
featureRole: "要素类型",
township: "涉及乡镇", township: "涉及乡镇",
year: "建设年份", year: "建设年份",
length_meter: "工程长度", length_meter: "工程长度",
lengthMeter: "工程长度",
geometry_type: "几何类型", geometry_type: "几何类型",
geometryType: "几何类型",
totalAcreage: "总面积", totalAcreage: "总面积",
cdAcreage: "草地面积", cdAcreage: "草地面积",
sdAcreage: "湿地面积", sdAcreage: "湿地面积",
@ -497,6 +517,24 @@ const duplicatedFallbackKeys = new Set([
"remarks", "remarks",
]) ])
const locationFieldKeys = new Set([
"full_name",
"fullName",
"townName",
"town_name",
"xiangName",
"xiang_name",
"xiang",
"xzqmc",
"xzqdm",
"town",
"town_code",
"areaCode",
"area_code",
])
const townshipNamesByLength = [...hongyuanTownshipDisplayOrder].sort((a, b) => b.length - a.length)
const propsData = computed(() => props.feature?.properties || {}) const propsData = computed(() => props.feature?.properties || {})
const featureGeometry = computed(() => props.feature?.feature?.geometry || props.feature?.geometry || null) const featureGeometry = computed(() => props.feature?.feature?.geometry || props.feature?.geometry || null)
@ -677,7 +715,7 @@ function hasValue(value) {
function formatValue(key, value) { function formatValue(key, value) {
if (!hasValue(value)) return "" if (!hasValue(value)) return ""
if (codeNameMaps[key]?.[String(value)]) return codeNameMaps[key][String(value)] if (codeNameMaps[key]?.[String(value)]) return codeNameMaps[key][String(value)]
if (["xiang", "xzqdm", "town_code", "area_code", "areaCode"].includes(key)) return getTownNameFromCode(value) if (locationFieldKeys.has(key)) return formatLocationValue(key, value)
if (["cun", "village_code"].includes(key) && isCodeOnly(value)) return "" if (["cun", "village_code"].includes(key) && isCodeOnly(value)) return ""
if (["grassland_level", "grasslandLevel", "level"].includes(key) && isNumberLike(value)) { if (["grassland_level", "grasslandLevel", "level"].includes(key) && isNumberLike(value)) {
return activeTopicKey.value === "wetland" ? `${Number(value)}级湿地` : `${Number(value)}级草原` return activeTopicKey.value === "wetland" ? `${Number(value)}级湿地` : `${Number(value)}级草原`
@ -712,6 +750,31 @@ function formatValue(key, value) {
return text return text
} }
function formatLocationValue(key, value) {
if (["xiang", "xzqdm", "town_code", "area_code", "areaCode"].includes(key)) {
const townName = getTownNameFromCode(value)
if (townName) return townName
}
return trimLocationToTown(value)
}
function trimLocationToTown(value) {
const text = String(value || "").trim()
if (!text) return ""
const compact = text.replace(/\s+/g, "")
const townName = getHongyuanTownshipNameByCode(compact)
if (townName) return townName
const matchedTown = townshipNamesByLength.find((name) => compact.includes(name))
if (matchedTown) return compact.slice(compact.indexOf(matchedTown))
const countyIndex = compact.indexOf("红原县")
if (countyIndex >= 0) {
const afterCounty = compact.slice(countyIndex + "红原县".length)
return afterCounty || compact
}
const countyMatch = compact.match(/县(.+)$/)
return countyMatch?.[1] || text
}
function getTownNameFromCode(value) { function getTownNameFromCode(value) {
const text = String(value || "").trim() const text = String(value || "").trim()
if (!text) return "" if (!text) return ""

@ -5,8 +5,13 @@
:active-layer="activeLayer" :active-layer="activeLayer"
:map-ranking="mapRanking" :map-ranking="mapRanking"
:admin-boundary-line-scale="2.4" :admin-boundary-line-scale="2.4"
:initial-zoom="9.55"
hide-town-label-stats
:enable-imagery-feature-pick="false"
:enable-town-polygon-pick="false"
@feature-loading="featureLoading = $event" @feature-loading="featureLoading = $event"
@feature-info="handleFeatureInfo" @feature-info="handleFeatureInfo"
@map-click="handleMapClick"
@layer-status="handleLayerStatus" @layer-status="handleLayerStatus"
/> />
@ -44,15 +49,17 @@
</div> </div>
<div <div
v-if="activeTopicKey !== 'overview'" v-if="layerPanelVisible"
class="fgw-map-layer-tools" class="fgw-map-layer-tools"
:style="{ height: `${layerPanelHeight}px` }" :style="{ height: `${layerPanelHeight}px` }"
> >
<layer-panel <layer-panel
v-model="activeLayerKey" v-model="layerPanelValue"
v-model:selected-legend-key="selectedLegendKey" v-model:selected-legend-key="selectedLegendKey"
:topic-key="activeTopicKey" :topic-key="activeTopicKey"
:layers="layerPanelLayers"
:legends="activeLayerLegends" :legends="activeLayerLegends"
:multiple="activeTopicKey === 'overview'"
:width="248" :width="248"
:height="layerPanelHeight" :height="layerPanelHeight"
:compact="layerPanelCompact" :compact="layerPanelCompact"
@ -110,7 +117,8 @@ import {
getLayersByTopic, getLayersByTopic,
getTopicConfig, getTopicConfig,
} from "@/config/layers" } from "@/config/layers"
import { getLayerTownRanking, getTopicBusinessStats } from "@/services/openApi" import { getBasicGrasslandClickInfoByWkt, getLayerTownRanking, getTopicBusinessStats, getWoodlandClickInfoByWkt } from "@/services/openApi"
import { getLandClickInfo, getWetlandClickInfo } from "@/services/pastureApi"
import { getGrasslandNdviTimeline } from "@/services/ndviTimeline" import { getGrasslandNdviTimeline } from "@/services/ndviTimeline"
import { SCREEN2_LAYOUT, getScreen2SideCardHeight, getScreen2SpanHeight } from "@/utils/screen2Standard" import { SCREEN2_LAYOUT, getScreen2SideCardHeight, getScreen2SpanHeight } from "@/utils/screen2Standard"
import MapScene from "@/components/CesiumMap/index.vue" import MapScene from "@/components/CesiumMap/index.vue"
@ -136,6 +144,7 @@ const emit = defineEmits(["topic-stats"])
const mapSceneRef = ref(null) const mapSceneRef = ref(null)
const activeTopicKey = ref(forestGrassWetTopics[0].key) const activeTopicKey = ref(forestGrassWetTopics[0].key)
const activeLayerKey = ref("") const activeLayerKey = ref("")
const overviewActiveLayerKeys = ref([])
const coreStats = ref([]) const coreStats = ref([])
const coreStatus = ref("idle") const coreStatus = ref("idle")
const coreLoading = ref(false) const coreLoading = ref(false)
@ -167,9 +176,54 @@ let mapLoadTimer = null
let rankingRequestId = 0 let rankingRequestId = 0
let businessRequestId = 0 let businessRequestId = 0
let ndviTimelineRequestId = 0 let ndviTimelineRequestId = 0
let featureQueryRequestId = 0
let resettingTopicLayer = false let resettingTopicLayer = false
let resettingLegend = false let resettingLegend = false
const magicDetailTitleFieldMap = {
图斑编号: "patch_no",
图斑号: "patch_no",
湿地编号: "land_number",
草地编号: "land_number",
林地类型: "dlmc",
地类: "landUseName",
地类名称: "landUseName",
所在乡镇: "townName",
所在区域: "full_name",
面积: "acreage",
图斑面积: "acreage",
湿地面积: "acreage",
草场面积: "acreage",
草原类: "grassTypeName",
草原型: "grassMinType",
草原等级: "grasslandLevel",
草原类型: "basic_type_name",
小斑干草产量: "xbGcYield",
小斑鲜草产量: "xbXcYield",
鲜草产量: "xcYield",
干草产量: "gcYield",
海拔: "elevation",
地貌: "landformName",
权属单位: "qsdwmc",
坐落单位: "zldwmc",
林班: "lin_ban",
小班: "xiao_ban",
森林类别: "sen_lin_lb",
林种: "lin_zhong",
公益林等级: "gjgyl_bhdj",
保护等级: "bh_dj",
质量等级: "zl_dj",
优势树种: "you_shi_sz",
起源: "qi_yuan",
龄组: "ling_zu",
郁闭度: "yu_bi_du",
林地权属: "ld_qs",
土地使用权属: "tdsyqs",
林木权属: "lmqs",
生态区位: "stqwmc",
坡度等级: "slope",
}
const mapRankingLayerKeys = new Set([ const mapRankingLayerKeys = new Set([
"grassland-distribution", "grassland-distribution",
"wetland-distribution", "wetland-distribution",
@ -180,8 +234,63 @@ const mapRankingLayerKeys = new Set([
"woodland-ownership", "woodland-ownership",
]) ])
const overviewLayerDefinitions = [
{ topicKey: "wetland", layerKey: "wetland-distribution", name: "湿地分布", legendName: "湿地资源" },
{ topicKey: "grassland", layerKey: "grassland-distribution", name: "草地分布", legendName: "草地资源" },
{ topicKey: "woodland", layerKey: "woodland-distribution", name: "林地分布", legendName: "林地资源" },
]
const nonBusinessFeatureKeys = new Set([
"code",
"msg",
"message",
"success",
"data",
"result",
"grassland",
"wetland",
"woodland",
"basicGrassland",
"dyColumn",
"geom",
"wkt",
"geometry",
"the_geom",
"theGeom",
"shape",
"shape_leng",
"shape_length",
"length",
"perimeter",
"gid",
"id",
"objectid",
"object_id",
"fid",
"key_uid",
"bsm",
"ysdm",
].map((key) => key.toLowerCase()))
const activeTopic = computed(() => getTopicConfig(activeTopicKey.value)) const activeTopic = computed(() => getTopicConfig(activeTopicKey.value))
const activeTopicLayers = computed(() => getLayersByTopic(activeTopicKey.value)) const activeTopicLayers = computed(() => getLayersByTopic(activeTopicKey.value))
const overviewLayerOptions = computed(() => overviewLayerDefinitions
.map((item) => createOverviewLayerOption(item))
.filter(Boolean)
)
const layerPanelLayers = computed(() => activeTopicKey.value === "overview" ? overviewLayerOptions.value : activeTopicLayers.value)
const layerPanelValue = computed({
get() {
return activeTopicKey.value === "overview" ? overviewActiveLayerKeys.value : activeLayerKey.value
},
set(value) {
if (activeTopicKey.value === "overview") {
overviewActiveLayerKeys.value = Array.isArray(value) ? value.map(String).filter(Boolean) : []
return
}
activeLayerKey.value = String(value || "")
},
})
const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(activeTopicKey.value, activeLayerKey.value) : null) const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(activeTopicKey.value, activeLayerKey.value) : null)
const ndviTimelineActive = computed(() => activeTopicKey.value === "grassland" && activeLayerKey.value === "grassland-health") const ndviTimelineActive = computed(() => activeTopicKey.value === "grassland" && activeLayerKey.value === "grassland-health")
const ndviTimelineVisible = computed(() => ndviTimelineActive.value) const ndviTimelineVisible = computed(() => ndviTimelineActive.value)
@ -191,21 +300,27 @@ const selectedNdviTimelineItem = computed(() =>
const selectedLegend = computed(() => const selectedLegend = computed(() =>
(baseActiveLayer.value?.legends || []).find((item) => item.key === selectedLegendKey.value) || null (baseActiveLayer.value?.legends || []).find((item) => item.key === selectedLegendKey.value) || null
) )
const activeLayerLegends = computed(() => baseActiveLayer.value?.legends || []) const overviewLayerLegends = computed(() => overviewLayerOptions.value.flatMap((layer) => layer.legends || []))
const activeLayerLegends = computed(() => activeTopicKey.value === "overview" ? overviewLayerLegends.value : baseActiveLayer.value?.legends || [])
const layerPanelVisible = computed(() => layerPanelLayers.value.length > 0)
const layerPanelCompact = computed(() => const layerPanelCompact = computed(() =>
activeTopicKey.value !== "overview" layerPanelLayers.value.length <= 6
&& activeTopicLayers.value.length <= 6
&& activeLayerLegends.value.length <= 8 && activeLayerLegends.value.length <= 8
) )
const layerPanelHeight = computed(() => { const layerPanelHeight = computed(() => {
if (!layerPanelCompact.value) return 430 if (!layerPanelCompact.value) return 430
const layerCount = Math.max(1, activeTopicLayers.value.length) const layerCount = Math.max(1, layerPanelLayers.value.length)
const legendRows = Math.max(1, Math.ceil(activeLayerLegends.value.length / 2)) const legendRows = Math.max(1, Math.ceil(activeLayerLegends.value.length / 2))
const layerArea = layerCount * 32 const layerArea = layerCount * 32
const legendArea = 24 + legendRows * 28 const legendArea = 24 + legendRows * 28
return Math.max(228, Math.min(390, 48 + layerArea + legendArea)) return Math.max(228, Math.min(390, 48 + layerArea + legendArea))
}) })
const activeLayer = computed(() => { const activeLayer = computed(() => {
if (activeTopicKey.value === "overview") {
const selectedKeys = new Set(overviewActiveLayerKeys.value)
const selectedLayers = overviewLayerOptions.value.filter((layer) => selectedKeys.has(layer.key))
return createLayerStack(selectedLayers)
}
const layer = baseActiveLayer.value const layer = baseActiveLayer.value
if (!layer) return null if (!layer) return null
let visibleLayer = selectedLegend.value?.cqlFilter let visibleLayer = selectedLegend.value?.cqlFilter
@ -245,9 +360,10 @@ const featurePanelStyle = computed(() => {
const panelHeight = featurePanelHeight.value const panelHeight = featurePanelHeight.value
const margin = 24 const margin = 24
const point = toScreenLocalPoint(featurePanelPoint.value) const point = toScreenLocalPoint(featurePanelPoint.value)
const xOffset = point.x > 1420 ? -panelWidth - 28 : 28 const topLimit = 104
const x = Math.min(Math.max(point.x + xOffset, margin), 1920 - panelWidth - margin) const verticalGap = 36
const y = Math.min(Math.max(point.y - panelHeight - 72, 188), 1080 - panelHeight - 132) const x = Math.min(Math.max(point.x - panelWidth / 2, margin), 1920 - panelWidth - margin)
const y = Math.min(Math.max(point.y - panelHeight - verticalGap, topLimit), 1080 - panelHeight - margin)
return { return {
left: `${x}px`, left: `${x}px`,
top: `${y}px`, top: `${y}px`,
@ -270,6 +386,35 @@ function updateScreenViewportHeight() {
screenViewportHeight.value = typeof window === "undefined" ? 1080 : window.innerHeight screenViewportHeight.value = typeof window === "undefined" ? 1080 : window.innerHeight
} }
function createOverviewLayerOption({ topicKey, layerKey, name, legendName }) {
const layer = getLayerByKey(topicKey, layerKey)
if (!layer || layer.key !== layerKey) return null
const firstLegend = layer.legends?.[0] || {}
return {
...layer,
name,
overviewTopicKey: topicKey,
legends: [
{
...firstLegend,
key: `overview-${layerKey}`,
name: legendName,
selectable: false,
},
],
}
}
function createLayerStack(layers = []) {
const validLayers = layers.filter(Boolean)
if (!validLayers.length) return null
const [primaryLayer, ...overlayLayers] = validLayers
return {
...primaryLayer,
overlayLayers,
}
}
onMounted(() => { onMounted(() => {
updateScreenViewportHeight() updateScreenViewportHeight()
window.addEventListener("resize", updateScreenViewportHeight) window.addEventListener("resize", updateScreenViewportHeight)
@ -290,6 +435,7 @@ watch(activeTopicKey, (topicKey) => {
resettingTopicLayer = true resettingTopicLayer = true
selectedLegendKey.value = "" selectedLegendKey.value = ""
activeLayerKey.value = "" activeLayerKey.value = ""
if (topicKey === "overview") overviewActiveLayerKeys.value = []
closeFeaturePanel() closeFeaturePanel()
layerStatus.value = { layerStatus.value = {
type: "idle", type: "idle",
@ -320,6 +466,17 @@ watch(activeLayerKey, () => {
}) })
}) })
watch(overviewActiveLayerKeys, () => {
if (activeTopicKey.value !== "overview") return
selectedLegendKey.value = ""
closeFeaturePanel()
layerStatus.value = {
type: overviewActiveLayerKeys.value.length ? "loading" : "idle",
text: overviewActiveLayerKeys.value.length ? "正在加载 GeoServer 图层" : "等待 GeoServer 图层加载",
}
mapRanking.value = createEmptyMapRanking("overview", overviewActiveLayerKeys.value.join(","))
}, { deep: true })
watch(selectedLegendKey, () => { watch(selectedLegendKey, () => {
if (resettingTopicLayer || resettingLegend) return if (resettingTopicLayer || resettingLegend) return
closeFeaturePanel() closeFeaturePanel()
@ -542,16 +699,199 @@ function getPanelHeight(panel) {
function estimateFeatureRowCount(feature) { function estimateFeatureRowCount(feature) {
const properties = feature?.properties || {} const properties = feature?.properties || {}
const keys = Object.keys(properties).filter((key) => { const keys = Object.keys(properties).filter((key) => isDisplayableFeatureProperty(key, properties[key]))
const value = properties[key] return keys.length
if (value === undefined || value === null || value === "") return false }
const normalized = key.toLowerCase()
if (["geom", "wkt", "geometry", "the_geom", "shape_leng", "shape_length", "length", "perimeter"].includes(normalized)) return false function isDisplayableFeatureProperty(key, value) {
if (["gid", "id", "objectid", "object_id", "fid", "key_uid", "bsm", "ysdm"].includes(normalized)) return false if (value === undefined || value === null || value === "") return false
return true if (Array.isArray(value) || typeof value === "object") return false
return !nonBusinessFeatureKeys.has(String(key || "").toLowerCase())
}
function getPointQueryLayers() {
if (activeTopicKey.value === "overview") {
const selectedKeys = new Set(overviewActiveLayerKeys.value)
return overviewLayerOptions.value
.filter((layer) => selectedKeys.has(layer.key))
.slice()
.reverse()
}
return baseActiveLayer.value ? [baseActiveLayer.value] : []
}
function getPointQueryType(layer = {}) {
const topicKey = String(layer.overviewTopicKey || activeTopicKey.value || "").toLowerCase()
const signal = `${layer.key || ""} ${layer.layerName || ""} ${topicKey}`.toLowerCase()
if (signal.includes("basic-grassland") || signal.includes("t_basic_grassland")) return "basicGrassland"
if (topicKey === "woodland" || signal.includes("woodland") || signal.includes("t_woodland")) return "woodland"
if (topicKey === "wetland" || signal.includes("wetland") || signal.includes("shidi")) return "wetland"
if (topicKey === "grassland" || signal.includes("grassland") || signal.includes("caodi")) return "grassland"
return ""
}
function buildPointWkt(lngLat = {}) {
const lng = formatWktCoordinate(lngLat.lng)
const lat = formatWktCoordinate(lngLat.lat)
if (lng === "" || lat === "") return ""
return `POINT(${lng} ${lat})`
}
function formatWktCoordinate(value) {
const number = Number(value)
if (!Number.isFinite(number)) return ""
return number.toFixed(8).replace(/\.?0+$/, "")
}
async function handleMapClick(payload = {}) {
const lng = Number(payload?.lngLat?.lng)
const lat = Number(payload?.lngLat?.lat)
const queryLayers = getPointQueryLayers()
if (!Number.isFinite(lng) || !Number.isFinite(lat) || !queryLayers.length) {
closeFeaturePanel()
return
}
const requestId = ++featureQueryRequestId
const clickScreen = payload.screen || { x: 960, y: 540 }
featurePanelPoint.value = null
selectedFeature.value = null
featureEmpty.value = false
featureLoading.value = true
mapSceneRef.value?.clearSelectedFeature?.()
try {
for (const layer of queryLayers) {
const feature = await queryMagicPointFeature(layer, { lng, lat })
if (requestId !== featureQueryRequestId || destroyed) return
if (feature) {
selectedFeature.value = feature
featurePanelPoint.value = clickScreen
featureEmpty.value = false
mapSceneRef.value?.highlightFeature?.(feature)
return
}
}
if (requestId === featureQueryRequestId) closeFeaturePanel()
} catch (error) {
if (requestId === featureQueryRequestId) closeFeaturePanel()
} finally {
if (requestId === featureQueryRequestId) {
featureLoading.value = false
}
}
}
async function queryMagicPointFeature(layer, lngLat) {
const queryType = getPointQueryType(layer)
if (!queryType) return null
const wkt = buildPointWkt(lngLat)
let detail = null
if (queryType === "grassland") {
detail = await getLandClickInfo({ wkt })
} else if (queryType === "wetland") {
detail = await getWetlandClickInfo({ wkt })
} else if (queryType === "woodland") {
detail = await getWoodlandClickInfoByWkt(wkt)
} else if (queryType === "basicGrassland") {
detail = await getBasicGrasslandClickInfoByWkt(wkt)
}
const item = pickMagicPointDetail(detail, queryType)
if (!hasMagicPointDetail(item)) return null
const feature = normalizeMagicPointFeature(item, layer, lngLat, queryType)
return estimateFeatureRowCount(feature) > 0 ? feature : null
}
function pickMagicPointDetail(payload, queryType) {
const candidates = [
payload?.[queryType],
payload?.grassland,
payload?.wetland,
payload?.woodland,
payload?.basicGrassland,
payload?.data?.[queryType],
payload?.data?.grassland,
payload?.data?.wetland,
payload?.data?.woodland,
payload?.data?.basicGrassland,
payload?.data,
payload?.result,
payload,
]
for (const candidate of candidates) {
const item = pickFirstObject(candidate)
if (item) return item
}
return null
}
function pickFirstObject(value) {
if (Array.isArray(value)) return value.find((item) => item && typeof item === "object") || null
return value && typeof value === "object" ? value : null
}
function hasMagicPointDetail(detail) {
if (!detail || typeof detail !== "object") return false
if (Array.isArray(detail.dyColumn) && detail.dyColumn.some(hasMagicColumnValue)) return true
return Object.keys(detail).some((key) => isDisplayableFeatureProperty(key, detail[key]))
}
function hasMagicColumnValue(column = {}) {
const value = column?.value
if (value === undefined || value === null || value === "") return false
return Boolean(String(column?.field || column?.title || "").trim())
}
function normalizeMagicPointFeature(detail, layer, lngLat, queryType) {
const properties = normalizeMagicPointProperties(detail)
const geometry = pickMagicGeometry(detail)
return {
id: detail.id || detail.patchNo || detail.patch_no || detail.landNumber || detail.land_number || `${queryType}-${lngLat.lng}-${lngLat.lat}`,
properties,
feature: {
type: "Feature",
properties,
geometry,
},
geometry,
layer,
lngLat,
}
}
function pickMagicGeometry(detail = {}) {
return detail.geometry
|| detail.geom
|| detail.wkt
|| detail.the_geom
|| detail.theGeom
|| detail.geo
|| detail.geometryWkt
|| detail.geometry_wkt
|| detail.shape
|| null
}
function normalizeMagicPointProperties(detail = {}) {
const properties = { ...detail }
const dyColumns = Array.isArray(detail.dyColumn) ? detail.dyColumn : []
dyColumns.forEach((column) => {
const value = column?.value
if (value === undefined || value === null || value === "") return
const titleKey = magicDetailTitleFieldMap[String(column?.title || "").trim()]
const fieldKey = String(column?.field || "").trim()
const keys = [fieldKey, camelToSnake(fieldKey), titleKey].filter(Boolean)
keys.forEach((key) => {
if (properties[key] === undefined || properties[key] === null || properties[key] === "") {
properties[key] = value
}
})
}) })
const hasGeometry = Boolean(feature?.feature?.geometry || feature?.geometry) return properties
return keys.length + (hasGeometry ? 1 : 0) }
function camelToSnake(value) {
return String(value || "").replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`)
} }
async function loadMapRanking(topicKey, layerKey, legend = selectedLegend.value) { async function loadMapRanking(topicKey, layerKey, legend = selectedLegend.value) {
@ -564,7 +904,10 @@ async function loadMapRanking(topicKey, layerKey, legend = selectedLegend.value)
if (destroyed || requestId !== rankingRequestId) return if (destroyed || requestId !== rankingRequestId) return
if (topicKey !== activeTopicKey.value || layerKey !== activeLayerKey.value) return if (topicKey !== activeTopicKey.value || layerKey !== activeLayerKey.value) return
if ((legend?.key || "") !== selectedLegendKey.value) return if ((legend?.key || "") !== selectedLegendKey.value) return
mapRanking.value = result mapRanking.value = {
...result,
hideTownLabelStats: true,
}
} }
function shouldShowMapRanking(layerKey, legend = null) { function shouldShowMapRanking(layerKey, legend = null) {
@ -578,13 +921,14 @@ function createEmptyMapRanking(topicKey, layerKey) {
layerKey, layerKey,
title: "", title: "",
unit: "", unit: "",
hideTownLabelStats: true,
rows: [], rows: [],
} }
} }
function handleFeatureInfo(payload) { function handleFeatureInfo(payload) {
const feature = payload?.feature ?? payload ?? null const feature = payload?.feature ?? payload ?? null
if (!feature) { if (!feature || estimateFeatureRowCount(feature) <= 0) {
closeFeaturePanel() closeFeaturePanel()
return return
} }
@ -600,8 +944,10 @@ function handleLayerStatus(status) {
} }
function closeFeaturePanel() { function closeFeaturePanel() {
featureQueryRequestId += 1
selectedFeature.value = null selectedFeature.value = null
featureEmpty.value = false featureEmpty.value = false
featureLoading.value = false
featurePanelPoint.value = null featurePanelPoint.value = null
mapSceneRef.value?.clearSelectedFeature?.() mapSceneRef.value?.clearSelectedFeature?.()
} }

@ -61,7 +61,7 @@ import gsap from "gsap"
import { InteractionManager } from "three.interactive" import { InteractionManager } from "three.interactive"
import { applyMapTextureUv, createHongyuanSatelliteTexture, getHongyuanSatelliteTileRange } from "./satelliteTiles" import { applyMapTextureUv, createHongyuanSatelliteTexture, getHongyuanSatelliteTileRange } from "./satelliteTiles"
import { ecologicalProtectionLayers, forestGrassWetLayers, HONGYUAN_BOUNDS, SATELLITE_TEXTURE_ZOOM, WMS_IMAGE_SIZE } from "@/config/layers" import { ecologicalProtectionLayers, forestGrassWetLayers, HONGYUAN_BOUNDS, SATELLITE_TEXTURE_ZOOM, WMS_IMAGE_SIZE } from "@/config/layers"
import { buildWmsGetMapUrl, buildWmtsGetTileUrl, getFeatureInfo, getWfsFeatureProperties, normalizeFeatureInfo } from "@/services/geoserver" import { buildWmsGetMapUrl, buildWmtsGetTileUrl, getFeatureInfo, getWfsFeatureProperties } from "@/services/geoserver"
const DEFAULT_MAP_CAMERA_POSITION = new Vector3(-0.17427287762525134, 13.678992786206543, 20.688611202093714) const DEFAULT_MAP_CAMERA_POSITION = new Vector3(-0.17427287762525134, 13.678992786206543, 20.688611202093714)
const DEFAULT_MAP_TARGET = new Vector3(0, 0, 0) const DEFAULT_MAP_TARGET = new Vector3(0, 0, 0)
const INTRO_MAP_CAMERA_POSITION = new Vector3(-13.767695123014105, 12.990152163077308, 39.28228164159694) const INTRO_MAP_CAMERA_POSITION = new Vector3(-13.767695123014105, 12.990152163077308, 39.28228164159694)
@ -274,6 +274,7 @@ export class World extends Mini3d {
this.useSatelliteBase = callbacks.useSatelliteBase !== false this.useSatelliteBase = callbacks.useSatelliteBase !== false
this.showNationalBackdrop = callbacks.showNationalBackdrop === true this.showNationalBackdrop = callbacks.showNationalBackdrop === true
this.topicLayerPresentation = callbacks.topicLayerPresentation || "default" this.topicLayerPresentation = callbacks.topicLayerPresentation || "default"
this.hideTownLabelStatsByDefault = callbacks.hideTownLabelStats === true
this.tileUrlTemplate = callbacks.tileUrlTemplate || "" this.tileUrlTemplate = callbacks.tileUrlTemplate || ""
this.usesCustomSatelliteTiles = this.useSatelliteBase && Boolean(this.tileUrlTemplate) this.usesCustomSatelliteTiles = this.useSatelliteBase && Boolean(this.tileUrlTemplate)
const satelliteTextureZoom = Math.round(Number(callbacks.satelliteTextureZoom)) const satelliteTextureZoom = Math.round(Number(callbacks.satelliteTextureZoom))
@ -981,7 +982,7 @@ export class World extends Mini3d {
color: this.useSatelliteBase ? 0xffffff : 0xc8ffff, color: this.useSatelliteBase ? 0xffffff : 0xc8ffff,
map: satellite?.texture || null, map: satellite?.texture || null,
transparent: true, transparent: true,
opacity: this.useSatelliteBase ? (this.usesCustomSatelliteTiles ? 1 : 0.94) : 0.88, opacity: this.useSatelliteBase ? (this.usesCustomSatelliteTiles ? 1 : 0.94) : (this.isHomeTopicPresentation() ? 0.58 : 0.88),
roughness: this.useSatelliteBase ? (this.usesCustomSatelliteTiles ? 0.7 : 0.82) : 0.66, roughness: this.useSatelliteBase ? (this.usesCustomSatelliteTiles ? 0.7 : 0.82) : 0.66,
metalness: this.useSatelliteBase ? 0 : 0.08, metalness: this.useSatelliteBase ? 0 : 0.08,
// fog: false, // fog: false,
@ -990,8 +991,8 @@ export class World extends Mini3d {
new GradientShader(faceMaterial, { new GradientShader(faceMaterial, {
// uColor1: 0x2a6e92, // uColor1: 0x2a6e92,
// uColor2: 0x102736, // uColor2: 0x102736,
uColor1: this.useSatelliteBase ? 0x18dce2 : 0x12bbe0, uColor1: this.useSatelliteBase ? 0x18dce2 : (this.isHomeTopicPresentation() ? 0x0a6f82 : 0x12bbe0),
uColor2: this.useSatelliteBase ? 0x0b8fa9 : 0x0094b5, uColor2: this.useSatelliteBase ? 0x0b8fa9 : (this.isHomeTopicPresentation() ? 0x06324a : 0x0094b5),
size: this.useSatelliteBase ? 15 : 8.6, size: this.useSatelliteBase ? 15 : 8.6,
}) })
} }
@ -1397,6 +1398,7 @@ export class World extends Mini3d {
const context = this.topicLayerContext const context = this.topicLayerContext
if (!canvas || !context) return if (!canvas || !context) return
if (!layer.softMask) { if (!layer.softMask) {
this.drawHomeTopicRasterGlow(image, layer)
context.drawImage(image, 0, 0, canvas.width, canvas.height) context.drawImage(image, 0, 0, canvas.width, canvas.height)
return return
} }
@ -1422,6 +1424,21 @@ export class World extends Mini3d {
context.drawImage(softCanvas, 0, 0, canvas.width, canvas.height) context.drawImage(softCanvas, 0, 0, canvas.width, canvas.height)
context.restore() context.restore()
} }
drawHomeTopicRasterGlow(image, layer = {}) {
if (!this.isHomeTopicPresentation()) return
const canvas = this.topicLayerCanvas
const context = this.topicLayerContext
if (!canvas || !context) return
const glowOpacity = clampNumber(layer.homeGlowOpacity ?? 0, 0, 0.8)
const glowBlur = clampNumber(layer.homeGlowBlur ?? 0, 0, 32)
if (!glowOpacity || !glowBlur) return
const glowColor = layer.homeGlowColor || "#E5FCFF"
context.save()
context.globalAlpha = glowOpacity
context.filter = `drop-shadow(0 0 ${glowBlur}px ${glowColor})`
context.drawImage(image, 0, 0, canvas.width, canvas.height)
context.restore()
}
applyLayerMapView(layer, options = {}) { applyLayerMapView(layer, options = {}) {
if (!this.camera?.instance || !this.camera?.controls) return if (!this.camera?.instance || !this.camera?.controls) return
if (!this.mapAnimationComplete && !options.force) return if (!this.mapAnimationComplete && !options.force) return
@ -2133,6 +2150,14 @@ export class World extends Mini3d {
return true return true
} }
getVisibleVectorFeatures(layer, features) { getVisibleVectorFeatures(layer, features) {
const legends = Array.isArray(layer?.activeLegends)
? layer.activeLegends.filter((legend) => legend?.matches?.length || legend?.match?.field)
: []
if (legends.length) {
return (features || []).filter((feature) =>
legends.some((legend) => this.featureMatchesLegend(feature?.properties || {}, legend))
)
}
const legend = layer?.activeLegend const legend = layer?.activeLegend
if (legend?.matches?.length || legend?.match?.field) { if (legend?.matches?.length || legend?.match?.field) {
return (features || []).filter((feature) => this.featureMatchesLegend(feature?.properties || {}, legend)) return (features || []).filter((feature) => this.featureMatchesLegend(feature?.properties || {}, legend))
@ -3153,6 +3178,7 @@ export class World extends Mini3d {
note: ranking?.note || "", note: ranking?.note || "",
hideTownNames: shouldHideTownNames(ranking), hideTownNames: shouldHideTownNames(ranking),
hideMapLabels: shouldHideMapLabels(ranking), hideMapLabels: shouldHideMapLabels(ranking),
hideTownLabelStats: ranking?.hideTownLabelStats ?? this.hideTownLabelStatsByDefault,
calloutMode, calloutMode,
showBars: ranking?.showBars ?? false, showBars: ranking?.showBars ?? false,
showPointMarkers: ranking?.showPointMarkers ?? calloutMode, showPointMarkers: ranking?.showPointMarkers ?? calloutMode,
@ -3817,6 +3843,16 @@ export class World extends Mini3d {
if (!this.townNameLabels?.length) return if (!this.townNameLabels?.length) return
const rows = this.getTownRankingRows(ranking, Number.POSITIVE_INFINITY, false) const rows = this.getTownRankingRows(ranking, Number.POSITIVE_INFINITY, false)
const meta = this.getTownRankingMeta(ranking) const meta = this.getTownRankingMeta(ranking)
if (meta.hideTownLabelStats) {
this.townNameLabels.forEach((label) => {
const townName = label.userData?.townName || label.userData?.name
const name = label.element.querySelector(".town-name")
if (name) name.innerText = townName || ""
this.clearTownNameLabelStat(label)
label.visible = !meta.hideTownNames
})
return
}
const rowByName = new Map(rows.map((row) => [normalizeTownName(row.name), row])) const rowByName = new Map(rows.map((row) => [normalizeTownName(row.name), row]))
const canShowMissingStat = Boolean(meta.showMissingTownStats && ranking && rows.length && (meta.metricLabel || meta.unit || meta.title)) const canShowMissingStat = Boolean(meta.showMissingTownStats && ranking && rows.length && (meta.metricLabel || meta.unit || meta.title))
this.townNameLabels.forEach((label) => { this.townNameLabels.forEach((label) => {
@ -4698,7 +4734,7 @@ export class World extends Mini3d {
this.callbacks.onFeatureLoading?.(true) this.callbacks.onFeatureLoading?.(true)
try { try {
const response = await getFeatureInfo(this.currentTopicLayer, pixel, { size: WMS_IMAGE_SIZE, buffer: 10 }) const response = await getFeatureInfo(this.currentTopicLayer, pixel, { size: WMS_IMAGE_SIZE, buffer: 10 })
const feature = normalizeFeatureInfo(response) const feature = this.normalizeFeatureInfoAtPoint(response, lngLat)
const fallbackFeature = feature ? null : await this.queryFallbackFeatureInfo(lngLat) const fallbackFeature = feature ? null : await this.queryFallbackFeatureInfo(lngLat)
const hitFeature = feature || fallbackFeature?.feature const hitFeature = feature || fallbackFeature?.feature
if (hitFeature) { if (hitFeature) {
@ -4804,7 +4840,7 @@ export class World extends Mini3d {
} }
async enrichFeatureInfo(feature, pixel, lngLat) { async enrichFeatureInfo(feature, pixel, lngLat) {
const topicKey = this.getLayerTopicKey(this.currentTopicLayer) const topicKey = this.getLayerTopicKey(this.currentTopicLayer)
if (!["grassland", "woodland"].includes(topicKey)) return feature if (!["grassland", "wetland", "woodland"].includes(topicKey)) return feature
const layers = forestGrassWetLayers[topicKey] || [] const layers = forestGrassWetLayers[topicKey] || []
const baseLayer = layers.find((layer) => layer.key === `${topicKey}-distribution`) const baseLayer = layers.find((layer) => layer.key === `${topicKey}-distribution`)
const visibleLayers = (this.visibleTopicLayers || []) const visibleLayers = (this.visibleTopicLayers || [])
@ -4819,7 +4855,7 @@ export class World extends Mini3d {
.filter((layer) => this.getLayerQueryKey(layer) !== this.getLayerQueryKey(this.currentTopicLayer)) .filter((layer) => this.getLayerQueryKey(layer) !== this.getLayerQueryKey(this.currentTopicLayer))
.map(async (layer) => { .map(async (layer) => {
const response = await getFeatureInfo(layer, pixel, { size: WMS_IMAGE_SIZE, buffer: 10 }) const response = await getFeatureInfo(layer, pixel, { size: WMS_IMAGE_SIZE, buffer: 10 })
const extraFeature = normalizeFeatureInfo(response) const extraFeature = this.normalizeFeatureInfoAtPoint(response, lngLat)
return extraFeature ? { layer, feature: extraFeature, source: "wms" } : null return extraFeature ? { layer, feature: extraFeature, source: "wms" } : null
}), }),
...queryLayers.map(async (layer) => { ...queryLayers.map(async (layer) => {
@ -4846,7 +4882,7 @@ export class World extends Mini3d {
} }
async queryFallbackFeatureInfo(lngLat) { async queryFallbackFeatureInfo(lngLat) {
const topicKey = this.getLayerTopicKey(this.currentTopicLayer) const topicKey = this.getLayerTopicKey(this.currentTopicLayer)
if (!["grassland", "woodland"].includes(topicKey)) return null if (!["grassland", "wetland", "woodland"].includes(topicKey)) return null
const layers = forestGrassWetLayers[topicKey] || [] const layers = forestGrassWetLayers[topicKey] || []
const preferredLayers = [ const preferredLayers = [
layers.find((layer) => layer.key === `${topicKey}-distribution`), layers.find((layer) => layer.key === `${topicKey}-distribution`),
@ -4859,6 +4895,18 @@ export class World extends Mini3d {
} }
return null return null
} }
normalizeFeatureInfoAtPoint(response, lngLat) {
const features = Array.isArray(response?.features) ? response.features : []
if (!features.length) return null
const pickedFeature = this.pickFeatureAtPoint(features, lngLat, { requireContainment: true })
if (!pickedFeature) return null
const properties = pickedFeature.properties || {}
return {
id: pickedFeature.id || properties.gid || properties.objectid || properties.OBJECTID,
properties,
feature: pickedFeature,
}
}
async queryLayerFeatureByPoint(layer, lngLat, options = {}) { async queryLayerFeatureByPoint(layer, lngLat, options = {}) {
if (!layer?.layerName || !Number.isFinite(lngLat?.lng) || !Number.isFinite(lngLat?.lat)) return null if (!layer?.layerName || !Number.isFinite(lngLat?.lng) || !Number.isFinite(lngLat?.lat)) return null
const tolerance = layer.pointQueryTolerance || 0.0015 const tolerance = layer.pointQueryTolerance || 0.0015

@ -43,6 +43,10 @@ const props = defineProps({
type: String, type: String,
default: "default", default: "default",
}, },
hideTownLabelStats: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(["feature-loading", "feature-info", "layer-status", "tile-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 canvasRef = ref(null);
@ -77,6 +81,7 @@ function loadMap(assets) {
useSatelliteBase: props.useSatelliteBase, useSatelliteBase: props.useSatelliteBase,
showNationalBackdrop: props.showNationalBackdrop, showNationalBackdrop: props.showNationalBackdrop,
topicLayerPresentation: props.topicLayerPresentation, topicLayerPresentation: props.topicLayerPresentation,
hideTownLabelStats: props.hideTownLabelStats,
}); });
canvasMap.value.time.pause(); canvasMap.value.time.pause();
activeLayerSignature = getLayerSignature(props.activeLayer); activeLayerSignature = getLayerSignature(props.activeLayer);
@ -341,6 +346,7 @@ function getLayerSignature(layer) {
item.sldBody || "", item.sldBody || "",
item.cqlFilter || "", item.cqlFilter || "",
item.activeLegend?.key || "", item.activeLegend?.key || "",
Array.isArray(item.activeLegends) ? item.activeLegends.map((legend) => legend?.key || "").join(",") : "",
item.timelineDate || "", item.timelineDate || "",
item.timelineItem?.key || "", item.timelineItem?.key || "",
item.sourceType || "", item.sourceType || "",
@ -370,6 +376,7 @@ function getMapRankingSignature(ranking) {
ranking.showBars ?? "", ranking.showBars ?? "",
ranking.showTownNames ?? "", ranking.showTownNames ?? "",
ranking.showMapLabels ?? "", ranking.showMapLabels ?? "",
ranking.hideTownLabelStats ?? "",
ranking.showFlowLines ?? "", ranking.showFlowLines ?? "",
ranking.onlyRankedTownLabels ?? "", ranking.onlyRankedTownLabels ?? "",
ranking.showMissingTownStats ?? "", ranking.showMissingTownStats ?? "",

File diff suppressed because it is too large Load Diff

@ -8,6 +8,7 @@
:tile-url-template="pastureMapTileUrlTemplate" :tile-url-template="pastureMapTileUrlTemplate"
:center="pastureOlCenter" :center="pastureOlCenter"
:initial-zoom="9" :initial-zoom="9"
:auto-apply-layer-map-view="false"
:max-zoom="21" :max-zoom="21"
:overlay-layer="pastureMapLayer" :overlay-layer="pastureMapLayer"
:overlay-layers="pastureMapLayers" :overlay-layers="pastureMapLayers"
@ -140,7 +141,7 @@ import BusinessPanelRenderer from "@/views/forestGrassWet/components/BusinessPan
import FeatureInfoPanel from "@/views/forestGrassWet/components/FeatureInfoPanel.vue" import FeatureInfoPanel from "@/views/forestGrassWet/components/FeatureInfoPanel.vue"
import { getHongyuanTownshipNameByCode, hongyuanTownshipDisplayOrder, hongyuanTownshipsGeoJsonRaw, isHongyuanTownshipName } from "@/config/townshipBoundaries" import { getHongyuanTownshipNameByCode, hongyuanTownshipDisplayOrder, hongyuanTownshipsGeoJsonRaw, isHongyuanTownshipName } from "@/config/townshipBoundaries"
import { getHomeMonitorings } from "@/services/homeVideoApi" import { getHomeMonitorings } from "@/services/homeVideoApi"
import { getGrasslandClickInfo, getPastureGrasslandsById, getPastureProfile, getPasturesDetailsById } from "@/services/pastureApi" import { getPastureClickInfoByWkt, getPastureDetailsById, getPastureGrasslandsById, getPastureProfile, getPasturesDetailsById } from "@/services/pastureApi"
import { SCREEN2_LAYOUT, getScreen2SideCardHeight, getScreen2SpanHeight } from "@/utils/screen2Standard" import { SCREEN2_LAYOUT, getScreen2SideCardHeight, getScreen2SpanHeight } from "@/utils/screen2Standard"
import liveSceneImage from "@/assets/images/gd-live/live-scene-1.png" import liveSceneImage from "@/assets/images/gd-live/live-scene-1.png"
import { import {
@ -187,9 +188,31 @@ const pastureTopStatNames = ["牧户数量", "户均牦牛头数", "户均草地
const pasturePalette = ["#30dcff", "#24f6c4", "#8bf7ff", "#48cfff", "#16b6e8", "#b9fbff", "#67ffe4"] const pasturePalette = ["#30dcff", "#24f6c4", "#8bf7ff", "#48cfff", "#16b6e8", "#b9fbff", "#67ffe4"]
const pastureVideoElementId = `pasture-video-player-${Math.random().toString(36).slice(2)}` const pastureVideoElementId = `pasture-video-player-${Math.random().toString(36).slice(2)}`
const pastureVideoLoadTimeoutMs = 12000 const pastureVideoLoadTimeoutMs = 12000
const nonBusinessGrasslandFeatureKeys = new Set([
"code",
"msg",
"message",
"success",
"data",
"result",
"dycolumn",
"geom",
"wkt",
"geometry",
"the_geom",
"raw_properties",
"gid",
"id",
"objectid",
"object_id",
"fid",
"key_uid",
"bsm",
"ysdm",
])
const pastureMapTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png" const pastureMapTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png"
const pastureOlCenter = [102.58, 32.72] const pastureOlCenter = [102.58, 32.72]
const pastureBoundaryColor = "#FFE35A" const pastureBoundaryColor = "#30DCFF"
const pastureBoundarySldBody = createPastureBoundarySld("ne:t_pasture_grassland", pastureBoundaryColor) const pastureBoundarySldBody = createPastureBoundarySld("ne:t_pasture_grassland", pastureBoundaryColor)
const pastureYakRecognitionLayer = { const pastureYakRecognitionLayer = {
key: pastureYakLayerKey, key: pastureYakLayerKey,
@ -206,7 +229,7 @@ const pastureYakRecognitionLayer = {
fillColor: "rgba(255, 203, 69, 0)", fillColor: "rgba(255, 203, 69, 0)",
strokeColor: "#FFCB45", strokeColor: "#FFCB45",
glowColor: "rgba(255, 203, 69, 0.28)", glowColor: "rgba(255, 203, 69, 0.28)",
opacity: 0.96, opacity: 0.22,
lineWidth: 1.4, lineWidth: 1.4,
zIndex: 46, zIndex: 46,
legends: [ legends: [
@ -310,11 +333,11 @@ const pastureLayerOptions = computed(() => [
cqlFilter: "gov_show = true", cqlFilter: "gov_show = true",
sldBody: pastureBoundarySldBody, sldBody: pastureBoundarySldBody,
color: pastureBoundaryColor, color: pastureBoundaryColor,
fillColor: "rgba(255, 227, 90, 0.08)", fillColor: "rgba(48, 220, 255, 0.035)",
strokeColor: pastureBoundaryColor, strokeColor: pastureBoundaryColor,
glowColor: "rgba(255, 227, 90, 0.28)", glowColor: "rgba(48, 220, 255, 0.2)",
zIndex: 35, zIndex: 35,
opacity: 0.86, opacity: 0.72,
queryBuffer: 8, queryBuffer: 8,
mapView: { mapView: {
targetLngLat: pastureOlCenter, targetLngLat: pastureOlCenter,
@ -372,9 +395,10 @@ const grasslandFeaturePanelStyle = computed(() => {
const panelHeight = grasslandFeaturePanelHeight.value const panelHeight = grasslandFeaturePanelHeight.value
const margin = 24 const margin = 24
const point = toPastureScreenLocalPoint(grasslandFeaturePoint.value) const point = toPastureScreenLocalPoint(grasslandFeaturePoint.value)
const xOffset = point.x > 1420 ? -panelWidth - 28 : 28 const topLimit = 104
const x = Math.min(Math.max(point.x + xOffset, margin), 1920 - panelWidth - margin) const verticalGap = 36
const y = Math.min(Math.max(point.y - panelHeight - 72, 188), 1080 - panelHeight - 132) const x = Math.min(Math.max(point.x - panelWidth / 2, margin), 1920 - panelWidth - margin)
const y = Math.min(Math.max(point.y - panelHeight - verticalGap, topLimit), 1080 - panelHeight - margin)
return { return {
left: `${x}px`, left: `${x}px`,
top: `${y}px`, top: `${y}px`,
@ -485,7 +509,7 @@ const pastureListPanel = computed(() => ({
columns: [ columns: [
{ key: "name", label: "牧户名称", field: "name", width: "42%" }, { key: "name", label: "牧户名称", field: "name", width: "42%" },
{ key: "township", label: "区域", field: "township", width: "36%" }, { key: "township", label: "区域", field: "township", width: "36%" },
{ key: "locate", label: "定位", field: "locateText", width: "22%", align: "center", fallback: "定位", icon: "locate" }, { key: "locate", label: "定位", field: "locateText", width: "22%", align: "center", fallback: "定位", icon: "locate", action: "locate" },
], ],
rows: pastureRows.value, rows: pastureRows.value,
})) }))
@ -594,17 +618,14 @@ function buildPastureVideoPreviewRows(rows = [], activeIndex = 0, emptyRow = emp
if (!sourceRows.length) { if (!sourceRows.length) {
return [{ key: `empty-${emptyRow.key || "video"}`, index: 0, live: emptyRow, isActive: true }] return [{ key: `empty-${emptyRow.key || "video"}`, index: 0, live: emptyRow, isActive: true }]
} }
const count = Math.min(2, sourceRows.length) const index = (activeIndex + sourceRows.length) % sourceRows.length
return Array.from({ length: count }, (_, offset) => { const live = sourceRows[index] || emptyRow
const index = (activeIndex + offset + sourceRows.length) % sourceRows.length return [{
const live = sourceRows[index] || emptyRow key: `${live.key || live.title || "video"}-${index}`,
return { index,
key: `${live.key || live.title || "video"}-${index}`, live,
index, isActive: true,
live, }]
isActive: index === activeIndex,
}
})
} }
function pastureVideoScreenStyle(live, isActive = true) { function pastureVideoScreenStyle(live, isActive = true) {
@ -612,7 +633,7 @@ function pastureVideoScreenStyle(live, isActive = true) {
return pastureVideoPlaying.value && isActive return pastureVideoPlaying.value && isActive
? undefined ? undefined
: { : {
backgroundImage: `linear-gradient(180deg, rgba(0, 16, 26, 0.08), rgba(0, 16, 26, 0.32)), url(${image})`, backgroundImage: `url(${image})`,
} }
} }
@ -691,6 +712,8 @@ async function playCurrentPastureVideo() {
pastureVideoError.value = "" pastureVideoError.value = ""
pastureVideoPlayerReady = true pastureVideoPlayerReady = true
pastureVideoLoading.value = false pastureVideoLoading.value = false
fitPastureVideoPlayerToScreen()
keepPastureEzvizControlsVisible()
}, },
fullScreenChangeCallBack() {}, fullScreenChangeCallBack() {},
}) })
@ -701,6 +724,10 @@ async function playCurrentPastureVideo() {
} }
pastureVideoPlayer = player pastureVideoPlayer = player
pastureVideoPlaying.value = true pastureVideoPlaying.value = true
void nextTick(() => {
fitPastureVideoPlayerToScreen()
keepPastureEzvizControlsVisible()
})
} catch (error) { } catch (error) {
clearPastureVideoLoadTimeout() clearPastureVideoLoadTimeout()
console.warn("Pasture EZUIKit init error", error) console.warn("Pasture EZUIKit init error", error)
@ -709,6 +736,107 @@ async function playCurrentPastureVideo() {
} }
} }
function keepPastureEzvizControlsVisible() {
if (typeof document === "undefined") return
const footer = document.getElementById(`${pastureVideoElementId}-ez-iframe-footer-container`)
const controls = document.getElementById(`${pastureVideoElementId}-audioControls`)
if (footer) {
Object.assign(footer.style, {
position: "absolute",
left: "0",
right: "0",
bottom: "0",
zIndex: "20",
width: "100%",
marginTop: "0",
pointerEvents: "auto",
visibility: "visible",
opacity: "1",
})
}
if (!controls) return
Object.assign(controls.style, {
opacity: "1",
pointerEvents: "all",
visibility: "visible",
})
}
function fitPastureVideoPlayerToScreen() {
if (typeof document === "undefined") return
const screen = pastureVideoScreenRef.value
const wrap = document.getElementById(`${pastureVideoElementId}-wrap`)
const player = document.getElementById(pastureVideoElementId)
const root = wrap || player
if (!screen || !root) return
const rect = screen.getBoundingClientRect?.()
const width = Math.max(1, Math.floor(rect?.width || screen.clientWidth || 1))
const height = Math.max(1, Math.floor(rect?.height || screen.clientHeight || 1))
try {
if (pastureVideoPlayer && typeof pastureVideoPlayer.reSize === "function") {
pastureVideoPlayer.reSize(width, height)
} else if (pastureVideoPlayer && typeof pastureVideoPlayer.autoResize === "function") {
pastureVideoPlayer.autoResize()
}
} catch (error) {
console.warn("Pasture EZUIKit resize failed", error)
}
Object.assign(root.style, {
position: "absolute",
left: "0",
top: "0",
right: "0",
bottom: "0",
display: "block",
width: `${width}px`,
height: `${height}px`,
verticalAlign: "top",
})
if (wrap && player) {
Object.assign(player.style, {
position: "relative",
left: "auto",
top: "auto",
right: "auto",
bottom: "auto",
display: "block",
width: "100%",
height: "100%",
maxWidth: "none",
maxHeight: "none",
overflow: "hidden",
verticalAlign: "top",
})
}
const iframe = document.getElementById(`EZUIKitPlayer-${pastureVideoElementId}`)
if (iframe) {
iframe.setAttribute("width", String(width))
iframe.setAttribute("height", String(height))
}
const nodes = [
...Array.from(player?.children || []),
...Array.from(player?.querySelectorAll?.(".play-window, video, canvas, iframe") || []),
iframe,
].filter(Boolean)
nodes.forEach((node) => {
Object.assign(node.style, {
position: "absolute",
left: "0",
top: "0",
width: "100%",
height: "100%",
maxWidth: "none",
maxHeight: "none",
})
})
}
function stopPastureVideoPlayer(options = {}) { function stopPastureVideoPlayer(options = {}) {
const { clearElement = true, resetError = false } = options const { clearElement = true, resetError = false } = options
clearPastureVideoLoadTimeout() clearPastureVideoLoadTimeout()
@ -752,6 +880,14 @@ function stopPastureEzvizPlayer(player) {
function clearPastureVideoElement() { function clearPastureVideoElement() {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const wrap = document.getElementById(`${pastureVideoElementId}-wrap`)
if (wrap) {
const container = document.createElement("div")
container.id = pastureVideoElementId
container.className = "pasture-video-player"
wrap.replaceWith(container)
return
}
const container = document.getElementById(pastureVideoElementId) const container = document.getElementById(pastureVideoElementId)
if (container) { if (container) {
container.replaceChildren() container.replaceChildren()
@ -760,6 +896,7 @@ function clearPastureVideoElement() {
function buildPastureEzvizThemeData() { function buildPastureEzvizThemeData() {
return { return {
autoFocus: 0,
header: { header: {
color: "#30dcff", color: "#30dcff",
activeColor: "#ffffff", activeColor: "#ffffff",
@ -830,8 +967,8 @@ async function enrichPastureRowsWithGrasslands(rows = []) {
let detail = null let detail = null
let township = getPastureTownship(row, rowGrasslands) || getPastureFallbackTownship(row, row.name, pastureId) let township = getPastureTownship(row, rowGrasslands) || getPastureFallbackTownship(row, row.name, pastureId)
try { try {
if (pastureId && (!township || !grasslands.length)) { if (!township || !grasslands.some(hasGrasslandGeometry)) {
grasslands = normalizeGrasslandDetailRows(await getPastureGrasslandsById(pastureId)) grasslands = await loadPastureGrasslandsForRow(row)
} }
} catch (error) { } catch (error) {
grasslands = rowGrasslands grasslands = rowGrasslands
@ -848,7 +985,12 @@ async function enrichPastureRowsWithGrasslands(rows = []) {
if (detailGrasslands.length && !grasslands.length) grasslands = detailGrasslands if (detailGrasslands.length && !grasslands.length) grasslands = detailGrasslands
} }
township = township || getPastureFallbackTownship(row, row.name, pastureId) township = township || getPastureFallbackTownship(row, row.name, pastureId)
const canLocate = Boolean(pastureId || grasslands.some(hasGrasslandGeometry) || hasGrasslandGeometry(row)) const canLocate = Boolean(
pastureId
|| hasPastureGrasslandBoundary(row)
|| grasslands.some(hasGrasslandGeometry)
|| hasGrasslandGeometry(row)
)
if (!township && !grasslands.length && canLocate === row.canLocate) return null if (!township && !grasslands.length && canLocate === row.canLocate) return null
const result = { const result = {
key: row.key, key: row.key,
@ -857,6 +999,7 @@ async function enrichPastureRowsWithGrasslands(rows = []) {
name: row.name, name: row.name,
township: township || row.township, township: township || row.township,
grasslands: grasslands.length ? grasslands : row.grasslands, grasslands: grasslands.length ? grasslands : row.grasslands,
grasslandArea: grasslands.length ? sumPastureGrasslandArea(grasslands) : pickPastureArea(row),
canLocate, canLocate,
locateText: canLocate ? "定位" : "--", locateText: canLocate ? "定位" : "--",
} }
@ -876,6 +1019,8 @@ async function enrichPastureRowsWithGrasslands(rows = []) {
function shouldEnrichPastureTownship(row = {}) { function shouldEnrichPastureTownship(row = {}) {
const township = String(row?.township || "").trim() const township = String(row?.township || "").trim()
const hasGeometry = hasGrasslandGeometry(row) || normalizeRowGrasslands(row).some(hasGrasslandGeometry)
if (!hasGeometry && getPastureCandidateIds(row).length) return true
if (township && township !== "--") return false if (township && township !== "--") return false
return pickPastureArea(row) > 0 || isPastureLocatable(row) return pickPastureArea(row) > 0 || isPastureLocatable(row)
} }
@ -898,10 +1043,16 @@ function applyPastureEnrichmentResults(resultMap) {
.find(Boolean) .find(Boolean)
if (!result) return row if (!result) return row
const township = result.township && result.township !== "--" ? result.township : row.township const township = result.township && result.township !== "--" ? result.township : row.township
const grasslandArea = Number(result.grasslandArea) > 0 ? Number(result.grasslandArea) : pickPastureArea(row)
return { return {
...row, ...row,
township, township,
grasslands: result.grasslands, grasslands: result.grasslands,
grasslandArea,
acreage: grasslandArea,
value: grasslandArea,
grasslandCount: pickGrasslandBoundaryCount(result, result.grasslands),
hasGrasslandBoundary: result.grasslands?.some?.(hasGrasslandGeometry) || row.hasGrasslandBoundary,
canLocate: result.canLocate, canLocate: result.canLocate,
locateText: result.locateText, locateText: result.locateText,
searchText: [row.name, township, row.id].filter(Boolean).join(" "), searchText: [row.name, township, row.id].filter(Boolean).join(" "),
@ -911,11 +1062,28 @@ function applyPastureEnrichmentResults(resultMap) {
function normalizeGrasslandDetailRows(rows = []) { function normalizeGrasslandDetailRows(rows = []) {
const normalized = [] const normalized = []
;(Array.isArray(rows) ? rows : []).forEach((item) => { const visit = (item) => {
if (!item) return
if (Array.isArray(item)) {
item.forEach(visit)
return
}
if (typeof item !== "object") return
const childRows = normalizeRowGrasslands(item) const childRows = normalizeRowGrasslands(item)
if (childRows.length) normalized.push(...childRows) if (childRows.length) normalized.push(...childRows)
else if (item && typeof item === "object") normalized.push(item) else {
}) let visitedNested = false
;["rows", "list", "data", "result", "grassland", "pasture", "features"].forEach((key) => {
const value = item?.[key]
if (value && (Array.isArray(value) || typeof value === "object")) {
visitedNested = true
visit(value)
}
})
if (!visitedNested) normalized.push(item)
}
}
visit(rows)
return normalized return normalized
} }
@ -961,37 +1129,61 @@ async function mapWithConcurrency(items, limit, worker) {
return results return results
} }
async function loadPastureGrasslandsForRow(row = {}) {
const rowGrasslands = normalizeGrasslandDetailRows(normalizeRowGrasslands(row))
const apiGrasslands = []
for (const pastureId of getPastureCandidateIds(row)) {
try {
const rows = normalizeGrasslandDetailRows(await getPastureGrasslandsById(pastureId))
if (rows.length) apiGrasslands.push(...rows)
if (rows.some(hasGrasslandGeometry)) break
} catch (error) {
// Try the next real id alias from the same row.
}
}
return enrichGrasslandGeometryRows(uniqueGrasslandRows([...apiGrasslands, ...rowGrasslands]))
}
async function enrichGrasslandGeometryRows(rows = []) {
const sourceRows = uniqueGrasslandRows(rows)
if (!sourceRows.length) return []
const enrichedRows = await mapWithConcurrency(sourceRows, 5, async (row) => {
if (hasGrasslandGeometry(row)) return row
const grasslandId = getGrasslandDetailId(row)
if (!grasslandId) return row
try {
const detail = await getPastureDetailsById(grasslandId)
const detailRows = normalizeGrasslandDetailRows([detail])
const detailRow = detailRows.find(hasGrasslandGeometry)
return detailRow ? mergeGrasslandDetailRow(row, detailRow) : row
} catch (error) {
return row
}
})
return uniqueGrasslandRows(enrichedRows)
}
async function handlePastureSelect(row) { async function handlePastureSelect(row) {
const requestId = ++pastureFocusRequestId const requestId = ++pastureFocusRequestId
clearGrasslandFeaturePanel() clearGrasslandFeaturePanel()
mapSceneRef.value?.clearFloatingOverlays?.() mapSceneRef.value?.clearFloatingOverlays?.()
if (!isPastureLocatable(row)) {
cancelPastureFocusSelection()
mapSceneRef.value?.focusPasture?.({ name: row?.name })
return
}
const pastureId = getRealPastureId(row)
let grasslands = [] let grasslands = []
if (pastureId) { try {
try { grasslands = await loadPastureGrasslandsForRow(row)
grasslands = await getPastureGrasslandsById(pastureId) } catch (error) {
} catch (error) { grasslands = normalizeRowGrasslands(row)
grasslands = []
}
} }
if (requestId !== pastureFocusRequestId) return if (requestId !== pastureFocusRequestId) return
const rowGrasslands = normalizeRowGrasslands(row) const pastureGrasslands = grasslands.filter(hasGrasslandGeometry)
const geometryGrasslands = grasslands.filter(hasGrasslandGeometry)
const pastureGrasslands = geometryGrasslands.length ? geometryGrasslands : rowGrasslands
if (!pastureGrasslands.length) { if (!pastureGrasslands.length) {
cancelPastureFocusSelection() cancelPastureFocusSelection()
mapSceneRef.value?.focusPasture?.({ name: row?.name })
return return
} }
if (requestId !== pastureFocusRequestId) return if (requestId !== pastureFocusRequestId) return
const focused = mapSceneRef.value?.focusPasture?.({ const focused = mapSceneRef.value?.focusPasture?.({
name: row?.name, name: row?.name,
grasslands: pastureGrasslands, grasslands: pastureGrasslands,
highlightTone: "white",
}) })
if (!focused) cancelPastureFocusSelection() if (!focused) cancelPastureFocusSelection()
} }
@ -1018,30 +1210,102 @@ function toPastureScreenLocalPoint(point = {}) {
const rect = screen?.getBoundingClientRect() const rect = screen?.getBoundingClientRect()
if (!rect) return { x: Number(point.x) || 960, y: Number(point.y) || 540 } if (!rect) return { x: Number(point.x) || 960, y: Number(point.y) || 540 }
return { return {
x: (Number(point.x) || rect.left + rect.width / 2) - rect.left, x: (((Number(point.x) || rect.left + rect.width / 2) - rect.left) / Math.max(1, rect.width)) * 1920,
y: (Number(point.y) || rect.top + rect.height / 2) - rect.top, y: (((Number(point.y) || rect.top + rect.height / 2) - rect.top) / Math.max(1, rect.height)) * 1080,
} }
} }
function estimateGrasslandFeatureRowCount(feature) { function estimateGrasslandFeatureRowCount(feature) {
const properties = feature?.properties || {} const properties = feature?.properties || {}
return Math.min(10, Object.entries(properties).filter(([, value]) => value !== undefined && value !== null && value !== "").length) return Math.min(10, Object.entries(properties).filter(([key, value]) => isDisplayableGrasslandFeatureProperty(key, value)).length)
}
function isDisplayableGrasslandFeatureProperty(key, value) {
if (value === undefined || value === null || value === "") return false
if (Array.isArray(value) || typeof value === "object") return false
return !nonBusinessGrasslandFeatureKeys.has(String(key || "").toLowerCase())
}
function buildPointWkt(lngLat = {}) {
const lng = formatWktCoordinate(lngLat.lng)
const lat = formatWktCoordinate(lngLat.lat)
if (lng === "" || lat === "") return ""
return `POINT(${lng} ${lat})`
}
function formatWktCoordinate(value) {
const number = Number(value)
if (!Number.isFinite(number)) return ""
return number.toFixed(8).replace(/\.?0+$/, "")
} }
function normalizeGrasslandClickFeature(detail = {}, payload = {}) { function normalizeGrasslandClickFeature(detail = {}, payload = {}) {
const properties = normalizePastureClickProperties(detail)
if (!properties || !Object.keys(properties).length) return null
const geometry = pickPastureClickGeometry(detail, properties)
return { return {
id: detail.id, id: properties.id || properties.grasslandId || properties.grassland_id || `${payload?.lngLat?.lng}-${payload?.lngLat?.lat}`,
properties: detail, properties,
feature: { feature: {
type: "Feature", type: "Feature",
properties: detail, properties,
geometry: null, geometry,
}, },
geometry,
layer: pastureMapLayer.value || basePastureMapLayer.value, layer: pastureMapLayer.value || basePastureMapLayer.value,
lngLat: payload.lngLat, lngLat: payload.lngLat,
} }
} }
function normalizePastureClickProperties(payload = {}) {
const data = pickPasturePayloadObject(payload)
const grassland = pickFirstObject(data?.grassland || data?.data?.grassland)
const pasture = pickFirstObject(data?.pasture || data?.data?.pasture)
const balance = pickFirstObject(data?.pasture_balance || data?.pastureBalance || data?.data?.pasture_balance)
const source = grassland || pickFirstObject(data?.data) || pickFirstObject(data?.result) || pickFirstObject(data)
if (!source && !pasture && !balance) return null
return {
...(source || {}),
...(balance || {}),
...flattenDynamicColumns(source),
...flattenDynamicColumns(pasture),
...flattenDynamicColumns(balance),
pastureName: pickValue(pasture || {}, ["name", "pasturesName", "pastures_name", "pastureName", "pasture_name"]) || pickValue(source || {}, ["pastureName", "pasture_name"]),
yakCount: pickValue(pasture || {}, ["yakCount", "yak_count", "yakNumber", "yak_number"]) || pickValue(balance || {}, ["yakCount", "yak_count", "yakNumber", "yak_number"]),
}
}
function flattenDynamicColumns(row = {}) {
const fields = Array.isArray(row?.dyColumn) ? row.dyColumn : Array.isArray(row?.fields) ? row.fields : []
return fields.reduce((result, column) => {
const key = String(column?.field || column?.key || column?.title || column?.label || "").trim()
const value = column?.value ?? column?.text ?? column?.content
if (key && value !== undefined && value !== null && value !== "") result[key] = value
return result
}, {})
}
function pickPasturePayloadObject(payload) {
if (!payload || typeof payload !== "object") return {}
return payload.data && typeof payload.data === "object" ? payload.data : payload.result && typeof payload.result === "object" ? payload.result : payload
}
function pickPastureClickGeometry(payload = {}, properties = {}) {
const data = pickPasturePayloadObject(payload)
const grassland = pickFirstObject(data?.grassland || data?.data?.grassland)
const pasture = pickFirstObject(data?.pasture || data?.data?.pasture)
return pickValue(grassland || {}, ["geometry", "geom", "wkt", "geo", "the_geom", "geometryWkt", "geometry_wkt", "shape"])
|| pickValue(pasture || {}, ["geometry", "geom", "wkt", "geo", "the_geom", "geometryWkt", "geometry_wkt", "shape"])
|| pickValue(properties, ["geometry", "geom", "wkt", "geo", "the_geom", "geometryWkt", "geometry_wkt", "shape"])
|| pickValue(data, ["geometry", "geom", "wkt", "geo", "the_geom", "geometryWkt", "geometry_wkt", "shape"])
|| null
}
function pickFirstObject(value) {
if (Array.isArray(value)) return value.find((item) => item && typeof item === "object") || null
return value && typeof value === "object" ? value : null
}
function closeGrasslandFeaturePanel() { function closeGrasslandFeaturePanel() {
selectedGrasslandFeature.value = null selectedGrasslandFeature.value = null
grasslandFeaturePoint.value = null grasslandFeaturePoint.value = null
@ -1052,6 +1316,7 @@ function closeGrasslandFeaturePanel() {
function clearGrasslandFeaturePanel() { function clearGrasslandFeaturePanel() {
grasslandFeatureRequestId += 1 grasslandFeatureRequestId += 1
closeGrasslandFeaturePanel() closeGrasslandFeaturePanel()
mapSceneRef.value?.clearSelectedFeature?.()
} }
function cancelPastureFocusSelection() { function cancelPastureFocusSelection() {
@ -1060,28 +1325,39 @@ function cancelPastureFocusSelection() {
} }
function createPastureBoundarySld(layerName, color) { function createPastureBoundarySld(layerName, color) {
return `<?xml version="1.0" encoding="UTF-8"?><StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><NamedLayer><Name>${layerName}</Name><UserStyle><Title>${layerName}</Title><FeatureTypeStyle><Rule><PolygonSymbolizer><Fill><CssParameter name="fill">${color}</CssParameter><CssParameter name="fill-opacity">0.05</CssParameter></Fill><Stroke><CssParameter name="stroke">${color}</CssParameter><CssParameter name="stroke-width">1.1</CssParameter><CssParameter name="stroke-opacity">0.92</CssParameter></Stroke></PolygonSymbolizer></Rule></FeatureTypeStyle></UserStyle></NamedLayer></StyledLayerDescriptor>` return `<?xml version="1.0" encoding="UTF-8"?><StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><NamedLayer><Name>${layerName}</Name><UserStyle><Title>${layerName}</Title><FeatureTypeStyle><Rule><PolygonSymbolizer><Fill><CssParameter name="fill">${color}</CssParameter><CssParameter name="fill-opacity">0.025</CssParameter></Fill><Stroke><CssParameter name="stroke">${color}</CssParameter><CssParameter name="stroke-width">0.9</CssParameter><CssParameter name="stroke-opacity">0.74</CssParameter></Stroke></PolygonSymbolizer></Rule></FeatureTypeStyle></UserStyle></NamedLayer></StyledLayerDescriptor>`
} }
async function handlePastureMapClick(payload = {}) { async function handlePastureMapClick(payload = {}) {
if (!isPastureGrasslandLayer(payload.overlayLayer || pastureMapLayer.value)) return if (!isPastureGrasslandLayer(payload.overlayLayer || pastureMapLayer.value)) {
clearGrasslandFeaturePanel()
return
}
const lng = Number(payload?.lngLat?.lng) const lng = Number(payload?.lngLat?.lng)
const lat = Number(payload?.lngLat?.lat) const lat = Number(payload?.lngLat?.lat)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
clearGrasslandFeaturePanel()
return
}
const requestId = ++grasslandFeatureRequestId const requestId = ++grasslandFeatureRequestId
grasslandFeaturePoint.value = payload.screen || { x: 960, y: 540 } const clickScreen = payload.screen || { x: 960, y: 540 }
grasslandFeaturePoint.value = null
selectedGrasslandFeature.value = null selectedGrasslandFeature.value = null
grasslandFeatureEmpty.value = false grasslandFeatureEmpty.value = false
grasslandFeatureLoading.value = true grasslandFeatureLoading.value = true
mapSceneRef.value?.clearSelectedFeature?.()
try { try {
const detail = await getGrasslandClickInfo({ lng, lat }) const detail = await getPastureClickInfoByWkt(buildPointWkt({ lng, lat }))
if (requestId !== grasslandFeatureRequestId) return if (requestId !== grasslandFeatureRequestId) return
if (!detail?.id) { const feature = normalizeGrasslandClickFeature(detail, payload)
if (!feature || estimateGrasslandFeatureRowCount(feature) <= 0) {
closeGrasslandFeaturePanel() closeGrasslandFeaturePanel()
return return
} }
selectedGrasslandFeature.value = normalizeGrasslandClickFeature(detail, payload) selectedGrasslandFeature.value = feature
grasslandFeaturePoint.value = clickScreen
grasslandFeatureEmpty.value = false grasslandFeatureEmpty.value = false
if (feature.geometry) mapSceneRef.value?.highlightFeature?.(feature)
} catch (error) { } catch (error) {
if (requestId !== grasslandFeatureRequestId) return if (requestId !== grasslandFeatureRequestId) return
closeGrasslandFeaturePanel() closeGrasslandFeaturePanel()
@ -1354,7 +1630,12 @@ function normalizePastureRows(rows = []) {
hasGrasslandBoundary: grasslandCount > 0, hasGrasslandBoundary: grasslandCount > 0,
grasslands, grasslands,
} }
normalized.canLocate = Boolean(realId || grasslands.some(hasGrasslandGeometry) || hasGrasslandGeometry(item)) normalized.canLocate = Boolean(
realId
|| hasPastureGrasslandBoundary(item)
|| grasslands.some(hasGrasslandGeometry)
|| hasGrasslandGeometry(item)
)
normalized.locateText = normalized.canLocate ? "定位" : "--" normalized.locateText = normalized.canLocate ? "定位" : "--"
normalized.value = normalized.grasslandArea normalized.value = normalized.grasslandArea
normalized.unit = "亩" normalized.unit = "亩"
@ -1456,9 +1737,12 @@ function pickGrasslandBoundaryCount(item = {}, grasslands = item?.grasslands) {
function pickPastureArea(item = {}) { function pickPastureArea(item = {}) {
const keys = [ const keys = [
"acreage",
"grasslandArea", "grasslandArea",
"grassland_area", "grassland_area",
"grasslandAcreage",
"grassland_acreage",
"assignedGrasslandAcreage",
"assigned_grassland_acreage",
"area", "area",
"areaRange", "areaRange",
"area_range", "area_range",
@ -1466,10 +1750,9 @@ function pickPastureArea(item = {}) {
"cd_acreage", "cd_acreage",
"totalAcreage", "totalAcreage",
"total_acreage", "total_acreage",
"grasslandAcreage",
"grassland_acreage",
"pastureArea", "pastureArea",
"pasture_area", "pasture_area",
"acreage",
] ]
const values = keys.map((key) => parsePastureArea(item?.[key])) const values = keys.map((key) => parsePastureArea(item?.[key]))
const positive = values.find((value) => value > 0) const positive = values.find((value) => value > 0)
@ -1543,14 +1826,82 @@ function hasGrasslandGeometry(item = {}) {
return Boolean(pickValue(item, ["wkt", "geo", "geom", "geometryWkt", "geometry_wkt", "shape", "location"])) return Boolean(pickValue(item, ["wkt", "geo", "geom", "geometryWkt", "geometry_wkt", "shape", "location"]))
} }
function mergeGrasslandDetailRow(row = {}, detail = {}) {
const merged = {
...row,
...detail,
id: row?.id ?? detail?.id,
grasslandId: row?.grasslandId ?? row?.grassland_id ?? detail?.grasslandId ?? detail?.grassland_id ?? detail?.id,
}
const detailWkt = pickValue(detail, ["wkt", "geo", "geom", "geometryWkt", "geometry_wkt", "shape", "location"])
const rowWkt = pickValue(row, ["wkt", "geo", "geom", "geometryWkt", "geometry_wkt", "shape", "location"])
if (detailWkt || rowWkt) merged.wkt = detailWkt || rowWkt
merged.geometry = detail?.geometry || row?.geometry
return merged
}
function uniqueGrasslandRows(rows = []) {
const seen = new Set()
return (Array.isArray(rows) ? rows : []).filter((row) => {
if (!row) return false
const key = getGrasslandRowUniqueKey(row)
if (key && seen.has(key)) return false
if (key) seen.add(key)
return true
})
}
function getGrasslandRowUniqueKey(row = {}) {
const geometryText = pickValue(row, ["wkt", "geo", "geom", "geometryWkt", "geometry_wkt", "shape", "location"])
if (geometryText) return `g:${String(geometryText).slice(0, 240)}`
if (row?.geometry) {
try {
return `j:${JSON.stringify(row.geometry).slice(0, 240)}`
} catch (error) {
return ""
}
}
const id = getGrasslandDetailId(row)
return id ? `id:${id}` : ""
}
function getGrasslandDetailId(row = {}) {
return normalizeIdValue(pickValue(row, [
"id",
"grasslandId",
"grassland_id",
"landId",
"land_id",
"gid",
"objectid",
"object_id",
"fid",
]))
}
function getPastureCandidateIds(row = {}) {
return [
row?.realPastureId,
row?.pastureId,
row?.pasturesId,
row?.pastures_id,
row?.muhuId,
row?.muhu_id,
row?.id,
].map(normalizeIdValue).filter(Boolean).filter((value, index, array) => array.indexOf(value) === index)
}
function normalizeIdValue(value) {
const text = String(value ?? "").trim()
return text && text !== "--" ? text : ""
}
function getRealPastureId(row = {}) { function getRealPastureId(row = {}) {
const id = row?.realPastureId || row?.pastureId || row?.pasturesId || row?.pastures_id || row?.muhuId || row?.id return getPastureCandidateIds(row)[0] || null
return id === undefined || id === null || id === "" ? null : id
} }
function isPastureLocatable(row = {}) { function isPastureLocatable(row = {}) {
if (row?.canLocate) return true if (row?.canLocate) return true
if (getRealPastureId(row)) return true
if (hasGrasslandGeometry(row)) return true if (hasGrasslandGeometry(row)) return true
return normalizeRowGrasslands(row).some(hasGrasslandGeometry) return normalizeRowGrasslands(row).some(hasGrasslandGeometry)
} }
@ -2159,8 +2510,8 @@ function isLngLatInPolygon(point, polygon) {
.pasture-video-grid { .pasture-video-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
gap: 8px; gap: 0;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
@ -2197,22 +2548,64 @@ function isLngLatInPolygon(point, polygon) {
background-image: none !important; background-image: none !important;
} }
.pasture-video-player, .pasture-video-screen > .pasture-video-player,
.pasture-video-player > div, .pasture-video-screen [id$="-wrap"],
.pasture-video-player video, .pasture-video-screen [id$="-wrap"] > .pasture-video-player > div,
.pasture-video-player canvas, .pasture-video-screen [id$="-wrap"] > .pasture-video-player .play-window,
.pasture-video-player iframe { .pasture-video-screen [id$="-wrap"] > .pasture-video-player video,
.pasture-video-screen [id$="-wrap"] > .pasture-video-player canvas,
.pasture-video-screen [id$="-wrap"] > .pasture-video-player iframe {
position: absolute !important; position: absolute !important;
inset: 0 !important; inset: 0 !important;
z-index: 1;
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
} }
.pasture-video-player { .pasture-video-screen > .pasture-video-player,
.pasture-video-screen [id$="-wrap"] {
z-index: 3 !important;
overflow: hidden;
pointer-events: auto;
}
.pasture-video-screen [id$="-wrap"] > .pasture-video-player {
position: relative !important;
inset: auto !important;
z-index: 1;
display: block !important;
width: 100% !important;
height: 100% !important;
overflow: hidden;
pointer-events: auto; pointer-events: auto;
} }
.pasture-video-screen [id$="-wrap"] > .pasture-video-player > div,
.pasture-video-screen [id$="-wrap"] > .pasture-video-player .play-window,
.pasture-video-screen [id$="-wrap"] > .pasture-video-player video,
.pasture-video-screen [id$="-wrap"] > .pasture-video-player canvas,
.pasture-video-screen [id$="-wrap"] > .pasture-video-player iframe {
z-index: 1;
}
.pasture-video-screen [id$="-ez-iframe-footer-container"] {
position: absolute !important;
right: 0 !important;
bottom: 0 !important;
left: 0 !important;
z-index: 20 !important;
width: 100% !important;
margin-top: 0 !important;
pointer-events: auto !important;
visibility: visible !important;
opacity: 1 !important;
}
.pasture-video-screen [id$="-audioControls"] {
pointer-events: auto !important;
visibility: visible !important;
opacity: 1 !important;
}
.pasture-video-player video { .pasture-video-player video {
object-fit: cover; object-fit: cover;
} }
@ -2255,18 +2648,8 @@ function isLngLatInPolygon(point, polygon) {
} }
.pasture-video-screen::before { .pasture-video-screen::before {
position: absolute; display: none;
inset: 0; content: none;
z-index: 2;
content: "";
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 34%, rgba(0, 0, 0, 0.44)),
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.04) 0, rgba(255, 255, 255, 0.04) 1px, transparent 1px, transparent 5px);
}
.pasture-video-screen.is-playing::before {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.36), transparent 28%, rgba(0, 0, 0, 0.24));
} }
.pasture-video-play { .pasture-video-play {
@ -2313,6 +2696,11 @@ function isLngLatInPolygon(point, polygon) {
background: rgba(0, 0, 0, 0.42); background: rgba(0, 0, 0, 0.42);
} }
.pasture-video-screen.is-playing .pasture-video-point-name {
z-index: 2;
opacity: 0;
}
.pasture-video-loading, .pasture-video-loading,
.pasture-video-error { .pasture-video-error {
position: absolute; position: absolute;

@ -317,11 +317,18 @@ let screenAutofit = null
let clockTimer = null let clockTimer = null
let weatherTimer = null let weatherTimer = null
let loadingHideStarted = false let loadingHideStarted = false
let loadingSafetyTimer = null
let pendingModuleRenderTimer = null let pendingModuleRenderTimer = null
let pendingModuleRenderFrame = null let pendingModuleRenderFrame = null
const loadingStartedAt = Date.now() const loadingStartedAt = Date.now()
const minLoadingVisibleMs = 1200 const minLoadingVisibleMs = 1200
const loadingTweenState = { progress: 0 } const loadingTweenState = { progress: 0 }
const platformLoadingModuleKeys = new Set([
"home",
"grass-livestock-balance",
"pasture-management",
"yak-industry-chain",
])
const topStatIconPool = [ const topStatIconPool = [
"town", "town",
@ -416,11 +423,19 @@ onMounted(() => {
bindPlatformFullscreenEvents() bindPlatformFullscreenEvents()
syncPlatformFullscreenState() syncPlatformFullscreenState()
resetRootScroll() resetRootScroll()
if (!usesPlatformStartupLoading(activeModuleKey.value)) {
loadingHideStarted = true
loadingProgress.value = 100
loadingVisible.value = false
} else {
scheduleLoadingSafetyHide(activeModuleKey.value)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (clockTimer) window.clearInterval(clockTimer) if (clockTimer) window.clearInterval(clockTimer)
if (weatherTimer) window.clearInterval(weatherTimer) if (weatherTimer) window.clearInterval(weatherTimer)
clearLoadingSafetyTimer()
screenAutofit?.off?.("#large-screen") screenAutofit?.off?.("#large-screen")
unbindPlatformFullscreenEvents() unbindPlatformFullscreenEvents()
if (pendingModuleRenderTimer) window.clearTimeout(pendingModuleRenderTimer) if (pendingModuleRenderTimer) window.clearTimeout(pendingModuleRenderTimer)
@ -556,6 +571,7 @@ function handleHomeLoadingProgress(payload = {}) {
} }
function handleHomeMapReady() { function handleHomeMapReady() {
clearLoadingSafetyTimer()
gsap.killTweensOf(loadingTweenState) gsap.killTweensOf(loadingTweenState)
loadingTweenState.progress = 100 loadingTweenState.progress = 100
loadingProgress.value = 100 loadingProgress.value = 100
@ -580,6 +596,7 @@ function updateLoadingProgress(progress) {
function hidePlatformLoading() { function hidePlatformLoading() {
if (!loadingVisible.value || loadingHideStarted) return if (!loadingVisible.value || loadingHideStarted) return
clearLoadingSafetyTimer()
loadingHideStarted = true loadingHideStarted = true
const tl = gsap.timeline({ const tl = gsap.timeline({
onComplete: () => { onComplete: () => {
@ -605,6 +622,28 @@ function hidePlatformLoading() {
) )
} }
function usesPlatformStartupLoading(moduleKey) {
return platformLoadingModuleKeys.has(moduleKey)
}
function scheduleLoadingSafetyHide(moduleKey) {
clearLoadingSafetyTimer()
const timeout = moduleKey === "home" ? 12000 : 8000
loadingSafetyTimer = window.setTimeout(() => {
if (!loadingVisible.value || loadingHideStarted) return
gsap.killTweensOf(loadingTweenState)
loadingTweenState.progress = 100
loadingProgress.value = 100
window.setTimeout(hidePlatformLoading, 120)
}, timeout)
}
function clearLoadingSafetyTimer() {
if (!loadingSafetyTimer) return
window.clearTimeout(loadingSafetyTimer)
loadingSafetyTimer = null
}
function buildTopCountItems(items = []) { function buildTopCountItems(items = []) {
const usedIcons = new Set() const usedIcons = new Set()
return items.slice(0, 10).map((item, index) => { return items.slice(0, 10).map((item, index) => {
@ -946,6 +985,14 @@ function handleLogout() {
} }
} }
&:has(.home-more-modal) {
.platform-map-tools {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
}
.platform-map-tool-icon { .platform-map-tool-icon {
position: relative; position: relative;
flex: 0 0 15px; flex: 0 0 15px;
@ -1294,36 +1341,39 @@ function handleLogout() {
} }
&.is-four { &.is-four {
left: 450px; left: 420px;
right: 450px; right: 420px;
top: 102px; top: 102px;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 10px; column-gap: 8px;
.count-card-icon { .count-card-icon {
width: 72px; width: 64px;
height: 86px; height: 78px;
flex-basis: 72px; flex-basis: 64px;
margin-right: 2px; margin-right: 0;
} }
.count-card-right { .count-card-right {
padding-left: 6px; padding-left: 4px;
} }
.count-card-right .value { .count-card-right .value {
min-height: 34px; min-height: 34px;
font-size: var(--screen-kpi-value-size, 31px); font-size: var(--screen-kpi-four-value-size, 29px);
line-height: 34px; line-height: 34px;
margin-right: 4px;
} }
.count-card-right .unit { .count-card-right .unit {
padding-top: 9px; padding-top: 9px;
font-size: var(--screen-kpi-unit-size, 11px); font-size: var(--screen-kpi-four-unit-size, 10px);
} }
.count-card-title .title-zh { .count-card-title .title-zh {
font-size: var(--screen-kpi-label-size, 14px); font-size: var(--screen-kpi-four-label-size, 13px);
line-height: 17px;
white-space: nowrap;
} }
.count-card-title .title-en { .count-card-title .title-en {
@ -1473,6 +1523,57 @@ function handleLogout() {
} }
&.is-home {
.top-count-card.is-home-five {
left: 432px;
right: 442px;
top: 82px;
display: grid;
grid-template-columns: 208px 220px 242px 168px 168px;
column-gap: 10px;
justify-items: start;
align-items: center;
& > div {
width: 100%;
min-width: 0;
justify-content: flex-start;
}
.count-card {
width: 100%;
min-height: 74px;
justify-content: flex-start;
}
.count-card-icon {
width: 58px;
height: 72px;
flex-basis: 58px;
margin-right: 2px;
}
.count-card-right {
padding-left: 3px;
}
.count-card-right .value {
font-size: 28px;
line-height: 31px;
}
.count-card-right .unit {
padding-top: 8px;
font-size: 10px;
}
.count-card-title .title-zh {
font-size: 13px;
line-height: 17px;
}
}
}
&.is-ecology-national-park { &.is-ecology-national-park {
.top-count-card.is-home-five { .top-count-card.is-home-five {
left: 390px; left: 390px;

@ -217,7 +217,7 @@
<script setup> <script setup>
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue" import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import { buildSmartContextAnswer, buildSmartQuestionContext } from "@/services/smartContextBuilder" import { buildSmartContextAnswer, buildSmartQuestionContext } from "@/services/smartContextBuilder"
import { generateSmartArtifact } from "@/services/smartArtifactApi" import { buildLocalSmartArtifact, generateSmartArtifact } from "@/services/smartArtifactApi"
import { secureArtifactHtml } from "./artifactRuntime" import { secureArtifactHtml } from "./artifactRuntime"
const SmartChartRenderer = defineAsyncComponent({ const SmartChartRenderer = defineAsyncComponent({
@ -255,6 +255,7 @@ const voiceSupported = ref(false)
const voiceListening = ref(false) const voiceListening = ref(false)
const voiceError = ref("") const voiceError = ref("")
const welcomeText = "请输入需要了解的业务事项,系统将依据平台汇总数据形成要点分析和专题页面。" const welcomeText = "请输入需要了解的业务事项,系统将依据平台汇总数据形成要点分析和专题页面。"
const ARTIFACT_SOFT_TIMEOUT = 28000
let streamTimer = null let streamTimer = null
let speechRecognition = null let speechRecognition = null
let artifactRequestSerial = 0 let artifactRequestSerial = 0
@ -298,6 +299,7 @@ function createEmptyArtifact() {
html: "", html: "",
error: "", error: "",
model: "", model: "",
fallback: false,
renderKey: 0, renderKey: 0,
} }
} }
@ -318,7 +320,7 @@ function buildArtifactTitle(question) {
return normalized.length > 28 ? `${normalized.slice(0, 28)}...` : normalized || "动态分析" return normalized.length > 28 ? `${normalized.slice(0, 28)}...` : normalized || "动态分析"
} }
async function launchArtifactGeneration(payload, assistantId) { async function launchArtifactGeneration(payload) {
const normalizedPayload = { const normalizedPayload = {
question: payload?.question || artifact.value.question, question: payload?.question || artifact.value.question,
answer: payload?.answer || "", answer: payload?.answer || "",
@ -338,9 +340,10 @@ async function launchArtifactGeneration(payload, assistantId) {
html: "", html: "",
error: "", error: "",
model: "", model: "",
fallback: false,
} }
try { try {
const result = await generateSmartArtifact(normalizedPayload) const result = await withArtifactSoftTimeout(generateSmartArtifact(normalizedPayload))
if (requestId !== artifactRequestSerial) return { stale: true } if (requestId !== artifactRequestSerial) return { stale: true }
const securedHtml = secureArtifactHtml(result.html) const securedHtml = secureArtifactHtml(result.html)
if (!securedHtml) throw new Error("生成页面未通过安全检查") if (!securedHtml) throw new Error("生成页面未通过安全检查")
@ -350,6 +353,7 @@ async function launchArtifactGeneration(payload, assistantId) {
html: securedHtml, html: securedHtml,
error: "", error: "",
model: result.model || "DeepSeek", model: result.model || "DeepSeek",
fallback: false,
renderKey: Date.now(), renderKey: Date.now(),
} }
return { ok: true, result } return { ok: true, result }
@ -357,18 +361,54 @@ async function launchArtifactGeneration(payload, assistantId) {
if (requestId !== artifactRequestSerial) return { stale: true } if (requestId !== artifactRequestSerial) return { stale: true }
const errorMessage = getFriendlyErrorMessage(error, "专题分析页面生成暂时不可用,请稍后重试。") const errorMessage = getFriendlyErrorMessage(error, "专题分析页面生成暂时不可用,请稍后重试。")
console.error("[智慧管理] DeepSeek HTML生成失败", error) console.error("[智慧管理] DeepSeek HTML生成失败", error)
const fallbackHtml = buildLocalArtifactHtml(normalizedPayload, errorMessage)
if (fallbackHtml) {
artifact.value = {
...artifact.value,
status: "ready",
html: fallbackHtml,
error: errorMessage,
model: "本地兜底",
fallback: true,
renderKey: Date.now(),
}
return { ok: false, fallback: true, message: errorMessage, error }
}
artifact.value = { artifact.value = {
...artifact.value, ...artifact.value,
status: "error", status: "error",
html: "", html: "",
error: errorMessage, error: errorMessage,
model: "", model: "",
fallback: false,
renderKey: Date.now(), renderKey: Date.now(),
} }
return { ok: false, message: errorMessage, error } return { ok: false, message: errorMessage, error }
} }
} }
function withArtifactSoftTimeout(promise) {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
const error = new Error("专题分析页面生成服务响应时间较长,已切换本地分析页面")
error.code = "ARTIFACT_SOFT_TIMEOUT"
reject(error)
}, ARTIFACT_SOFT_TIMEOUT)
promise.then(resolve, reject).finally(() => window.clearTimeout(timer))
})
}
function buildLocalArtifactHtml(payload, reason) {
try {
const html = buildLocalSmartArtifact({ ...payload, reason })
return secureArtifactHtml(html)
} catch (error) {
console.error("[智慧管理] 本地专题页面生成失败", error)
return ""
}
}
function retryArtifactGeneration() { function retryArtifactGeneration() {
if (!lastArtifactPayload.value || artifactGenerating.value) return if (!lastArtifactPayload.value || artifactGenerating.value) return
launchArtifactGeneration(lastArtifactPayload.value) launchArtifactGeneration(lastArtifactPayload.value)
@ -381,6 +421,7 @@ function failArtifactPreparation(message) {
status: "error", status: "error",
html: "", html: "",
error: message, error: message,
fallback: false,
renderKey: Date.now(), renderKey: Date.now(),
} }
} }
@ -398,6 +439,7 @@ function handleArtifactMessage(event) {
status: "error", status: "error",
html: "", html: "",
error: event.data.message || "生成界面运行异常", error: event.data.message || "生成界面运行异常",
fallback: false,
renderKey: Date.now(), renderKey: Date.now(),
} }
} }
@ -514,8 +556,6 @@ async function handleUnifiedQuestion(question, assistantId) {
const pagePath = window.location.hash || window.location.pathname const pagePath = window.location.hash || window.location.pathname
const context = await buildSmartQuestionContext(question, { pagePath }) const context = await buildSmartQuestionContext(question, { pagePath })
const answer = buildSmartContextAnswer(context) const answer = buildSmartContextAnswer(context)
const datasetCount = context.datasets?.length || 0
const chartCount = context.charts?.length || 0
const insights = context.insights?.length ? context.insights : buildChartInsights(context.charts || []) const insights = context.insights?.length ? context.insights : buildChartInsights(context.charts || [])
patchAssistant(assistantId, { patchAssistant(assistantId, {
@ -532,18 +572,14 @@ async function handleUnifiedQuestion(question, assistantId) {
insights, insights,
suggestions: [], suggestions: [],
}) })
const artifactResult = await launchArtifactGeneration({ finishAssistant(assistantId, buildArtifactPreparingMessage())
launchArtifactGeneration({
question, question,
answer, answer,
charts: context.charts || [], charts: context.charts || [],
datasets: context.datasets || [], datasets: context.datasets || [],
context, context,
}, assistantId) })
if (artifactResult?.stale) return
finishAssistant(
assistantId,
artifactResult?.ok ? buildArtifactReadyMessage(context) : buildArtifactFailureMessage(artifactResult?.message),
)
} catch (error) { } catch (error) {
const errorText = getFriendlyErrorMessage(error, "上下文数据整理失败,请稍后重试。") const errorText = getFriendlyErrorMessage(error, "上下文数据整理失败,请稍后重试。")
updateDynamicPanels({ question, answer: errorText, charts: [], insights: [], suggestions: [] }) updateDynamicPanels({ question, answer: errorText, charts: [], insights: [], suggestions: [] })
@ -552,16 +588,10 @@ async function handleUnifiedQuestion(question, assistantId) {
} }
} }
function buildArtifactReadyMessage() { function buildArtifactPreparingMessage() {
return "已生成。还有其他问题可以继续问我。"
}
function buildArtifactFailureMessage(message) {
return [ return [
"**生成失败**", "专题分析页面正在生成,完成后会自动显示在右侧。",
"右侧专题分析页面未生成。", "你可以继续提出其他问题。",
"",
message || "专题分析页面生成暂时不可用,请稍后重试。",
].join("\n") ].join("\n")
} }
@ -1436,7 +1466,7 @@ async function scrollToBottom() {
.wisdom-empty-state p { .wisdom-empty-state p {
margin: 0; margin: 0;
max-width: 420px; max-width: 420px;
color: rgba(223, 250, 255, 0.68); color: rgba(223, 250, 255, 0.82);
font-size: 15px; font-size: 15px;
line-height: 1.8; line-height: 1.8;
} }
@ -1643,6 +1673,14 @@ async function scrollToBottom() {
box-sizing: border-box; box-sizing: border-box;
} }
.wisdom-input-shell:focus-within {
border-color: rgba(126, 251, 246, 0.78);
box-shadow:
inset 0 0 24px rgba(48, 220, 255, 0.1),
0 0 0 2px rgba(126, 251, 246, 0.12),
0 0 30px rgba(48, 220, 255, 0.14);
}
.wisdom-input-icon { .wisdom-input-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -1675,7 +1713,7 @@ async function scrollToBottom() {
} }
.wisdom-input-shell input::placeholder { .wisdom-input-shell input::placeholder {
color: rgba(223, 250, 255, 0.42); color: rgba(223, 250, 255, 0.62);
} }
.wisdom-submit-button { .wisdom-submit-button {
@ -1759,6 +1797,13 @@ async function scrollToBottom() {
opacity: 0.62; opacity: 0.62;
} }
.wisdom-submit-button:focus-visible,
.wisdom-voice-button:focus-visible,
.wisdom-suggestion-list button:focus-visible {
outline: 2px solid #7efbf6;
outline-offset: 2px;
}
.wisdom-button-loading { .wisdom-button-loading {
width: 12px; width: 12px;
height: 12px; height: 12px;

@ -6,6 +6,7 @@
:active-layer="industryMapLayer" :active-layer="industryMapLayer"
:map-ranking="mapRanking" :map-ranking="mapRanking"
:admin-boundary-line-scale="2.4" :admin-boundary-line-scale="2.4"
:auto-apply-layer-map-view="false"
@feature-info="handleMapFeatureInfo" @feature-info="handleMapFeatureInfo"
/> />
<div class="industry-map-scrim"></div> <div class="industry-map-scrim"></div>

@ -21,6 +21,9 @@
:active="props.active && isYakOlMapView" :active="props.active && isYakOlMapView"
:tile-url-template="yakMapTileUrlTemplate" :tile-url-template="yakMapTileUrlTemplate"
:center="yakOlMapCenter" :center="yakOlMapCenter"
:initial-zoom="yakOlMapInitialZoom"
:county-view-target-offset="yakOlMapTargetOffset"
:auto-apply-layer-map-view="false"
:max-zoom="21" :max-zoom="21"
:overlay-layer="activeYakMapLayer" :overlay-layer="activeYakMapLayer"
@ready="handleYakOlReady" @ready="handleYakOlReady"
@ -354,14 +357,6 @@
</template> </template>
<template v-else-if="activeView === 'epidemic'"> <template v-else-if="activeView === 'epidemic'">
<m-card class="yak-side-card right-card" title="乡镇覆盖均衡" :width="sideCardWidth" :height="sideCardHeight">
<v-chart class="yak-chart" :option="epidemicTownBalanceOption" :autoresize="true" />
</m-card>
<m-card class="yak-side-card right-card" title="村级覆盖进度" :width="sideCardWidth" :height="sideCardHeight">
<v-chart class="yak-chart" :option="epidemicVillageCoverageOption" :autoresize="true" />
</m-card>
<m-card class="yak-side-card right-card epidemic-ledger-card" title="防疫记录台账" :width="sideCardWidth" :height="epidemicLedgerCardHeight"> <m-card class="yak-side-card right-card epidemic-ledger-card" title="防疫记录台账" :width="sideCardWidth" :height="epidemicLedgerCardHeight">
<div class="epidemic-record-table is-full"> <div class="epidemic-record-table is-full">
<div class="epidemic-record-toolbar"> <div class="epidemic-record-toolbar">
@ -461,7 +456,7 @@
v-for="row in tradeLedgerRows" v-for="row in tradeLedgerRows"
:key="row.certId" :key="row.certId"
type="button" type="button"
@click="locateTradeRecord(row)" @click="openTradeLedgerModal(row)"
> >
<span>{{ displayValue(row.ownerName) }}</span> <span>{{ displayValue(row.ownerName) }}</span>
<span>{{ displayValue(row.transportNumber) }}</span> <span>{{ displayValue(row.transportNumber) }}</span>
@ -787,8 +782,11 @@ const YAK_CHART_AXIS_COLOR = SCREEN2_COLORS.textMuted
const YAK_CHART_GRID_COLOR = SCREEN2_COLORS.chartGridLine const YAK_CHART_GRID_COLOR = SCREEN2_COLORS.chartGridLine
const epidemicImageBaseUrl = "https://qkl-hy.oss-cn-chengdu.aliyuncs.com/yak-vaccine-images" const epidemicImageBaseUrl = "https://qkl-hy.oss-cn-chengdu.aliyuncs.com/yak-vaccine-images"
const yakHighResolutionTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png" const yakHighResolutionTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png"
const yakStructureMapCenter = [103.07984, 33.029058] const yakDefaultMapCenter = [102.58, 32.72]
const yakForecastMapCenter = [102.682809, 33.131561] const yakStructureMapCenter = yakDefaultMapCenter
const yakForecastMapCenter = yakDefaultMapCenter
const yakOlMapInitialZoom = 8.85
const yakOlMapTargetOffset = { lng: 0, lat: 0 }
const yakTradeMapView = { const yakTradeMapView = {
targetLngLat: [102.6, 32.59], targetLngLat: [102.6, 32.59],
targetLocalZ: 0.72, targetLocalZ: 0.72,
@ -796,9 +794,9 @@ const yakTradeMapView = {
duration: 0.75, duration: 0.75,
} }
const yakMapSceneRevision = "oss-satellite-dynamic-zoom21-v5" const yakMapSceneRevision = "oss-satellite-dynamic-zoom21-v5"
const palette = ["#30dcff", "#27f0c0", "#8bf7ff", "#48cfff", "#48cfff", "#24f6c4"] const palette = ["#30dcff", "#8bf7ff", "#58d9ff", "#28e8d6", "#7bdfff", "#9efcff"]
const epidemicPieColors = ["#24f6c4", "#32dcff", "#8bf7ff", "#24f6c4", "#48cfff", "#48cfff"] const epidemicPieColors = ["#30dcff", "#8bf7ff", "#58d9ff", "#28e8d6", "#7bdfff", "#9efcff"]
const tradePalette = ["#30dcff", "#8bf7ff", "#27f0c0", "#48cfff", "#48cfff", "#24f6c4", "#48cfff"] const tradePalette = ["#30dcff", "#8bf7ff", "#58d9ff", "#28e8d6", "#7bdfff", "#9efcff", "#48cfff"]
const yakTradeMarket = { const yakTradeMarket = {
name: "红原牦牛交易市场", name: "红原牦牛交易市场",
lng: 102.562157, lng: 102.562157,
@ -808,7 +806,7 @@ const yakTradeMarket = {
const viewTopics = [ const viewTopics = [
{ key: "structure", name: "畜群结构", color: "#30dcff" }, { key: "structure", name: "畜群结构", color: "#30dcff" },
{ key: "forecast", name: "畜群预测", color: "#8bf7ff" }, { key: "forecast", name: "畜群预测", color: "#8bf7ff" },
{ key: "epidemic", name: "牦牛防疫", color: "#27f0c0" }, { key: "epidemic", name: "牦牛防疫", color: "#8bf7ff" },
{ key: "trade", name: "牦牛交易", color: "#63d7ff" }, { key: "trade", name: "牦牛交易", color: "#63d7ff" },
] ]
@ -1245,7 +1243,7 @@ const yakRecognitionLayer = computed(() => ({
url: "/geoserver/gwc/service/wmts", url: "/geoserver/gwc/service/wmts",
cqlFilter: "gov_show = true", cqlFilter: "gov_show = true",
name: "牦牛识别标记", name: "牦牛识别标记",
opacity: 0.96, opacity: 0.28,
queryBuffer: 10, queryBuffer: 10,
geometryField: "geom", geometryField: "geom",
fillColor: "rgba(255, 203, 69, 0)", fillColor: "rgba(255, 203, 69, 0)",
@ -1253,7 +1251,7 @@ const yakRecognitionLayer = computed(() => ({
glowColor: "rgba(255, 203, 69, 0.28)", glowColor: "rgba(255, 203, 69, 0.28)",
lineWidth: 1.4, lineWidth: 1.4,
mapView: { mapView: {
targetLngLat: [102.58, 32.72], targetLngLat: yakDefaultMapCenter,
distanceScale: 0.82, distanceScale: 0.82,
targetLocalZ: 0.76, targetLocalZ: 0.76,
duration: 0.85, duration: 0.85,
@ -1846,7 +1844,7 @@ const forecastFactorOption = computed(() => ({
splitLine: { lineStyle: { color: "rgba(48, 220, 255, 0.22)" } }, splitLine: { lineStyle: { color: "rgba(48, 220, 255, 0.22)" } },
splitArea: { splitArea: {
areaStyle: { areaStyle: {
color: ["rgba(48, 220, 255, 0.04)", "rgba(39, 240, 192, 0.02)"], color: ["rgba(48, 220, 255, 0.04)", "rgba(139, 247, 255, 0.018)"],
}, },
}, },
axisLine: { lineStyle: { color: "rgba(48, 220, 255, 0.18)" } }, axisLine: { lineStyle: { color: "rgba(48, 220, 255, 0.18)" } },
@ -1976,9 +1974,9 @@ function forecastHeatStyle(item) {
const positive = Number(item?.change || 0) >= 0 const positive = Number(item?.change || 0) >= 0
return { return {
"--heat-width": `${item.width}%`, "--heat-width": `${item.width}%`,
"--heat-color": positive ? "#24f6c4" : "#48cfff", "--heat-color": positive ? "#30dcff" : "#8bf7ff",
"--heat-color-soft": positive ? "rgba(36, 246, 196, 0.28)" : "rgba(72, 207, 255, 0.26)", "--heat-color-soft": positive ? "rgba(48, 220, 255, 0.24)" : "rgba(139, 247, 255, 0.22)",
"--heat-color-dim": positive ? "rgba(36, 246, 196, 0.08)" : "rgba(72, 207, 255, 0.08)", "--heat-color-dim": positive ? "rgba(48, 220, 255, 0.07)" : "rgba(139, 247, 255, 0.07)",
} }
} }
@ -1991,11 +1989,11 @@ const epidemicTrendOption = computed(() => {
const values = rows.map((item) => item.value) const values = rows.map((item) => item.value)
const maxValue = Math.max(...values, 1) const maxValue = Math.max(...values, 1)
return { return {
color: ["#27f0c0", "#8bf7ff"], color: ["#30dcff", "#8bf7ff"],
tooltip: { tooltip: {
...chartTooltip(), ...chartTooltip(),
trigger: "axis", trigger: "axis",
axisPointer: { type: "line", lineStyle: { color: "rgba(39, 240, 192, 0.34)" } }, axisPointer: { type: "line", lineStyle: { color: "rgba(48, 220, 255, 0.28)" } },
formatter(params) { formatter(params) {
const item = params[0] const item = params[0]
return `${item.name}<br/>防疫记录:${formatNumber(item.value)}` return `${item.name}<br/>防疫记录:${formatNumber(item.value)}`
@ -2006,7 +2004,7 @@ const epidemicTrendOption = computed(() => {
type: "category", type: "category",
data: rows.map((item) => item.name), data: rows.map((item) => item.name),
axisLabel: { color: YAK_CHART_AXIS_COLOR, fontSize: 10, interval: Math.max(Math.floor(rows.length / 7), 0) }, axisLabel: { color: YAK_CHART_AXIS_COLOR, fontSize: 10, interval: Math.max(Math.floor(rows.length / 7), 0) },
axisLine: { lineStyle: { color: "rgba(39, 240, 192, 0.22)" } }, axisLine: { lineStyle: { color: "rgba(48, 220, 255, 0.22)" } },
axisTick: { show: false }, axisTick: { show: false },
}, },
yAxis: { yAxis: {
@ -2023,8 +2021,9 @@ const epidemicTrendOption = computed(() => {
itemStyle: { itemStyle: {
borderRadius: [7, 7, 0, 0], borderRadius: [7, 7, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#27f0c0" }, { offset: 0, color: "#8bf7ff" },
{ offset: 1, color: "rgba(39, 240, 192, 0.08)" }, { offset: 0.45, color: "#30dcff" },
{ offset: 1, color: "rgba(48, 220, 255, 0.08)" },
]), ]),
}, },
label: { label: {
@ -2058,7 +2057,7 @@ const epidemicOverviewOption = computed(() => {
const trimTownName = (name) => String(name || "").replace(/(镇|乡|村)$/, "") const trimTownName = (name) => String(name || "").replace(/(镇|乡|村)$/, "")
return { return {
color: ["#27f0c0", "#8bf7ff"], color: ["#30dcff", "#8bf7ff"],
title: [ title: [
{ {
text: "作业节奏", text: "作业节奏",
@ -2101,7 +2100,7 @@ const epidemicOverviewOption = computed(() => {
interval: 0, interval: 0,
formatter: (value) => String(value || "").replace(/^(\d{1,2})\D+(\d{1,2}).*$/, "$1/$2"), formatter: (value) => String(value || "").replace(/^(\d{1,2})\D+(\d{1,2}).*$/, "$1/$2"),
}, },
axisLine: { lineStyle: { color: "rgba(39, 240, 192, 0.22)" } }, axisLine: { lineStyle: { color: "rgba(48, 220, 255, 0.22)" } },
axisTick: { show: false }, axisTick: { show: false },
}, },
{ {
@ -2143,8 +2142,9 @@ const epidemicOverviewOption = computed(() => {
itemStyle: { itemStyle: {
borderRadius: [6, 6, 0, 0], borderRadius: [6, 6, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#27f0c0" }, { offset: 0, color: "#8bf7ff" },
{ offset: 1, color: "rgba(39, 240, 192, 0.1)" }, { offset: 0.45, color: "#30dcff" },
{ offset: 1, color: "rgba(48, 220, 255, 0.1)" },
]), ]),
}, },
label: { label: {
@ -2200,7 +2200,7 @@ const epidemicTownBalanceOption = computed(() => {
const displayMax = Math.ceil(maxValue * 1.18) const displayMax = Math.ceil(maxValue * 1.18)
return { return {
color: ["#27f0c0", "#8bf7ff"], color: ["#30dcff", "#8bf7ff"],
tooltip: { tooltip: {
...chartTooltip(), ...chartTooltip(),
trigger: "axis", trigger: "axis",
@ -2260,12 +2260,12 @@ const epidemicTownBalanceOption = computed(() => {
itemStyle: { itemStyle: {
borderRadius: [0, 8, 8, 0], borderRadius: [0, 8, 8, 0],
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: item.isMissing ? "rgba(72, 207, 255, 0.24)" : "rgba(39, 240, 192, 0.16)" }, { offset: 0, color: item.isMissing ? "rgba(72, 207, 255, 0.2)" : "rgba(48, 220, 255, 0.14)" },
{ offset: 0.72, color: item.isMissing ? "#48cfff" : "#27f0c0" }, { offset: 0.72, color: item.isMissing ? "#48cfff" : "#30dcff" },
{ offset: 1, color: item.isMissing ? "#8bf7ff" : "#8fffe9" }, { offset: 1, color: item.isMissing ? "#8bf7ff" : "#9efcff" },
]), ]),
shadowBlur: item.isMissing ? 8 : 10, shadowBlur: item.isMissing ? 4 : 5,
shadowColor: item.isMissing ? "rgba(72, 207, 255, 0.36)" : "rgba(39, 240, 192, 0.34)", shadowColor: item.isMissing ? "rgba(72, 207, 255, 0.18)" : "rgba(48, 220, 255, 0.16)",
}, },
} }
}), }),
@ -2300,7 +2300,7 @@ const epidemicTownBalanceOption = computed(() => {
const epidemicVillageCoverageOption = computed(() => { const epidemicVillageCoverageOption = computed(() => {
const rows = epidemicVillageCoverageRows.value const rows = epidemicVillageCoverageRows.value
const colors = ["#27f0c0", "#30dcff", "#8bf7ff", "#48cfff", "#24f6c4", "#48cfff", "#67ffe4", "#40b8ff", "#8bf7ff"] const colors = ["#30dcff", "#8bf7ff", "#58d9ff", "#28e8d6", "#7bdfff", "#9efcff", "#48cfff", "#40b8ff", "#8bf7ff"]
const data = rows.length const data = rows.length
? rows.map((item, index) => ({ ? rows.map((item, index) => ({
name: String(item.name || "").replace(/(村|社区|牧委会)$/, ""), name: String(item.name || "").replace(/(村|社区|牧委会)$/, ""),
@ -2356,7 +2356,7 @@ const epidemicVillageCoverageOption = computed(() => {
}, },
emphasis: { emphasis: {
label: { color: "#ffffff" }, label: { color: "#ffffff" },
itemStyle: { shadowBlur: 14, shadowColor: "rgba(39, 240, 192, 0.4)" }, itemStyle: { shadowBlur: 6, shadowColor: "rgba(48, 220, 255, 0.18)" },
}, },
levels: [ levels: [
{ {
@ -2598,7 +2598,7 @@ const epidemicOwnerOption = computed(() => {
borderRadius: [0, 8, 8, 0], borderRadius: [0, 8, 8, 0],
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: "rgba(48, 220, 255, 0.12)" }, { offset: 0, color: "rgba(48, 220, 255, 0.12)" },
{ offset: 0.62, color: ["#27f0c0", "#30dcff", "#8bf7ff"][index % 3] }, { offset: 0.62, color: ["#30dcff", "#8bf7ff", "#58d9ff"][index % 3] },
{ offset: 1, color: "#ffffff" }, { offset: 1, color: "#ffffff" },
]), ]),
}, },
@ -3502,7 +3502,7 @@ function buildEpidemicOverviewRows() {
value: epidemicLatestDate.value, value: epidemicLatestDate.value,
desc: "最后作业时间", desc: "最后作业时间",
level: epidemicLatestDate.value === "-" ? "watch" : "stable", level: epidemicLatestDate.value === "-" ? "watch" : "stable",
color: "#27f0c0", color: "#8bf7ff",
}, },
{ {
key: "top-town", key: "top-town",
@ -3796,7 +3796,7 @@ function changeTradeRecordPage(page) {
function openTradeLedgerModal(row = null) { function openTradeLedgerModal(row = null) {
tradeLedgerModalVisible.value = true tradeLedgerModalVisible.value = true
if (row) tradeLedgerModalSelectedRecord.value = row if (row) tradeLedgerModalSelectedRecord.value = row
loadTradeLedgerModalRows(1, row) loadTradeLedgerModalRows(row ? tradeRecordPage.value : 1, row)
} }
function closeTradeLedgerModal() { function closeTradeLedgerModal() {
@ -3863,9 +3863,13 @@ function selectTradeLedgerRecord(row) {
function locateTradeRecord(row) { function locateTradeRecord(row) {
if (!row?.startTown) return if (!row?.startTown) return
activeView.value = "trade" activeView.value = "trade"
mapSceneRef.value?.focusTown?.(normalizeEpidemicTownName(row.startTown), { nextTick(() => {
distanceScale: 0.78, mapSceneRef.value?.resume?.()
duration: 0.8, mapSceneRef.value?.refreshView?.()
mapSceneRef.value?.focusTown?.(normalizeEpidemicTownName(row.startTown), {
distanceScale: 0.78,
duration: 0.8,
})
}) })
} }
@ -4165,7 +4169,7 @@ function buildHorizontalBarOption(rows, valueKey, unit, options = {}) {
const isDense = rows.length > 8 const isDense = rows.length > 8
const isLadder = options.style === "ladder" const isLadder = options.style === "ladder"
const barWidth = isDense ? (isLadder ? 7 : 6) : rows.length > 6 ? 11 : 13 const barWidth = isDense ? (isLadder ? 7 : 6) : rows.length > 6 ? 11 : 13
const rankColors = ["#30dcff", "#24f6c4", "#8bf7ff", "#48cfff", "#16b6e8", "#b9fbff"] const rankColors = ["#30dcff", "#58d9ff", "#8bf7ff", "#48cfff", "#16b6e8", "#b9fbff"]
const axisTextColor = options.axisTextColor || YAK_CHART_TEXT_COLOR const axisTextColor = options.axisTextColor || YAK_CHART_TEXT_COLOR
const dimAxisTextColor = options.dimAxisTextColor || YAK_CHART_AXIS_COLOR const dimAxisTextColor = options.dimAxisTextColor || YAK_CHART_AXIS_COLOR
return { return {
@ -4544,7 +4548,7 @@ function getStructureCategoryDefinitions() {
{ {
key: "fertileCow", key: "fertileCow",
name: "适龄能繁母牛", name: "适龄能繁母牛",
color: "#27f0c0", color: "#30dcff",
matcher: (key, name) => key === "fertileCow" || name.includes("适龄能繁母牛"), matcher: (key, name) => key === "fertileCow" || name.includes("适龄能繁母牛"),
}, },
{ {
@ -4556,7 +4560,7 @@ function getStructureCategoryDefinitions() {
{ {
key: "breedingBull", key: "breedingBull",
name: "种公牛", name: "种公牛",
color: "#24f6c4", color: "#58d9ff",
matcher: (key, name) => key === "breedingBull" || name.includes("种公牛"), matcher: (key, name) => key === "breedingBull" || name.includes("种公牛"),
}, },
{ {
@ -4587,7 +4591,7 @@ function buildStructureSummaryRows(rows = []) {
key: "female", key: "female",
group: "性别", group: "性别",
name: "母牛合计", name: "母牛合计",
color: "#27f0c0", color: "#8bf7ff",
matcher: (key, name) => ["fertileCow", "femaleCalf", "overageCow"].includes(key) || name.includes("母"), matcher: (key, name) => ["fertileCow", "femaleCalf", "overageCow"].includes(key) || name.includes("母"),
}, },
{ {
@ -4677,7 +4681,7 @@ function buildBreedingHealthRows(rows = [], fallbackRows = []) {
buildMetric( buildMetric(
"fertile-cow", "fertile-cow",
"适龄能繁母牛", "适龄能繁母牛",
"#27f0c0", "#30dcff",
(key, name) => key === "fertileCow" || name.includes("适龄能繁母牛"), (key, name) => key === "fertileCow" || name.includes("适龄能繁母牛"),
"繁殖基础", "繁殖基础",
), ),
@ -4765,7 +4769,7 @@ function structureSummaryBarWidth(item) {
function buildForecastWaterfallRows(period = {}) { function buildForecastWaterfallRows(period = {}) {
const startStock = estimatedStock const startStock = estimatedStock
const deltas = [ const deltas = [
{ key: "births", name: "新生牛犊", delta: Number(period.births || 0), color: "#27f0c0" }, { key: "births", name: "新生牛犊", delta: Number(period.births || 0), color: "#30dcff" },
{ key: "outflow", name: "出栏流出", delta: -Number(period.outflow || 0), color: "#48cfff" }, { key: "outflow", name: "出栏流出", delta: -Number(period.outflow || 0), color: "#48cfff" },
{ key: "correction", name: "修正损耗", delta: Number(period.correction || 0), color: "#8bf7ff" }, { key: "correction", name: "修正损耗", delta: Number(period.correction || 0), color: "#8bf7ff" },
] ]
@ -4996,10 +5000,10 @@ function formatSignedWan(value) {
align-items: center; align-items: center;
min-width: 0; min-width: 0;
padding: 0 12px; padding: 0 12px;
border-left: 3px solid rgba(39, 240, 192, 0.82); border-left: 3px solid rgba(48, 220, 255, 0.72);
background: background:
radial-gradient(circle at 18% 50%, rgba(39, 240, 192, 0.18), transparent 48%), radial-gradient(circle at 18% 50%, rgba(48, 220, 255, 0.12), transparent 48%),
linear-gradient(90deg, rgba(39, 240, 192, 0.14), rgba(48, 220, 255, 0.04)); linear-gradient(90deg, rgba(48, 220, 255, 0.12), rgba(48, 220, 255, 0.035));
box-sizing: border-box; box-sizing: border-box;
strong { strong {
@ -5012,7 +5016,7 @@ function formatSignedWan(value) {
font-size: 16px; font-size: 16px;
line-height: 20px; line-height: 20px;
font-weight: 900; font-weight: 900;
text-shadow: 0 0 12px rgba(39, 240, 192, 0.22); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.62);
} }
} }
@ -5081,7 +5085,7 @@ function formatSignedWan(value) {
width: var(--health-width); width: var(--health-width);
height: 100%; height: 100%;
background: var(--health-color); background: var(--health-color);
box-shadow: 0 0 10px color-mix(in srgb, var(--health-color) 70%, transparent); box-shadow: 0 0 5px color-mix(in srgb, var(--health-color) 34%, transparent);
} }
} }
@ -5165,10 +5169,10 @@ function formatSignedWan(value) {
z-index: 1; z-index: 1;
width: 9px; width: 9px;
height: 9px; height: 9px;
border: 1px solid #24f6c4; border: 1px solid #30dcff;
border-radius: 50%; border-radius: 50%;
box-sizing: border-box; box-sizing: border-box;
box-shadow: 0 0 10px currentColor; box-shadow: none;
} }
span { span {
@ -5179,11 +5183,11 @@ function formatSignedWan(value) {
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
color: var(--row-color); color: rgba(235, 253, 255, 0.9);
font-size: 13px; font-size: 13px;
line-height: 15px; line-height: 15px;
font-weight: 800; font-weight: 800;
text-shadow: 0 0 9px color-mix(in srgb, var(--row-color) 42%, transparent); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.62);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -5200,13 +5204,13 @@ function formatSignedWan(value) {
strong { strong {
position: relative; position: relative;
z-index: 1; z-index: 1;
color: var(--row-color); color: rgba(255, 255, 255, 0.94);
font-family: var(--screen-font-number, D-DIN, Arial, sans-serif); font-family: var(--screen-font-number, D-DIN, Arial, sans-serif);
font-size: 18px; font-size: 18px;
font-weight: 800; font-weight: 800;
line-height: 20px; line-height: 20px;
text-align: right; text-align: right;
text-shadow: 0 0 10px color-mix(in srgb, var(--row-color) 42%, transparent); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.62);
white-space: nowrap; white-space: nowrap;
} }
@ -5235,22 +5239,22 @@ function formatSignedWan(value) {
transition: opacity 0.3s ease, filter 0.3s ease, transform 0.3s ease; transition: opacity 0.3s ease, filter 0.3s ease, transform 0.3s ease;
filter: filter:
drop-shadow(0 2px 2px rgba(0, 10, 18, 0.34)) drop-shadow(0 2px 2px rgba(0, 10, 18, 0.34))
drop-shadow(0 0 5px rgba(48, 220, 255, 0.18)); drop-shadow(0 0 3px rgba(48, 220, 255, 0.12));
&.is-active { &.is-active {
opacity: 1; opacity: 1;
transform: scale(1.035); transform: scale(1.035);
filter: filter:
drop-shadow(0 3px 5px rgba(0, 10, 18, 0.42)) drop-shadow(0 3px 5px rgba(0, 10, 18, 0.42))
drop-shadow(0 0 10px rgba(36, 246, 196, 0.42)); drop-shadow(0 0 5px rgba(48, 220, 255, 0.22));
} }
} }
.structure-rose-glow { .structure-rose-glow {
fill: rgba(36, 246, 196, 0.1); fill: rgba(48, 220, 255, 0.07);
stroke: rgba(48, 220, 255, 0.24); stroke: rgba(48, 220, 255, 0.18);
stroke-width: 1; stroke-width: 1;
filter: drop-shadow(0 0 8px rgba(36, 246, 196, 0.26)); filter: none;
} }
.structure-rose-hole { .structure-rose-hole {
@ -5303,11 +5307,11 @@ function formatSignedWan(value) {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
overflow: hidden; overflow: hidden;
color: var(--structure-color); color: rgba(235, 253, 255, 0.9);
font-size: 13px; font-size: 13px;
line-height: 17px; line-height: 17px;
font-weight: 800; font-weight: 800;
text-shadow: 0 0 9px color-mix(in srgb, var(--structure-color) 38%, transparent); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.62);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@ -5318,19 +5322,19 @@ function formatSignedWan(value) {
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 2px; border-radius: 2px;
background: var(--structure-color); background: var(--structure-color);
box-shadow: 0 0 10px var(--structure-color); box-shadow: none;
} }
strong { strong {
grid-column: 3; grid-column: 3;
grid-row: 1; grid-row: 1;
color: var(--structure-color); color: rgba(255, 255, 255, 0.94);
font-family: var(--screen-font-number, D-DIN, Arial, sans-serif); font-family: var(--screen-font-number, D-DIN, Arial, sans-serif);
font-size: 14px; font-size: 14px;
line-height: 15px; line-height: 15px;
font-weight: 800; font-weight: 800;
text-align: right; text-align: right;
text-shadow: 0 0 10px color-mix(in srgb, var(--structure-color) 38%, transparent); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.62);
white-space: nowrap; white-space: nowrap;
em { em {
@ -5360,7 +5364,7 @@ function formatSignedWan(value) {
width: var(--structure-width); width: var(--structure-width);
height: 100%; height: 100%;
background: linear-gradient(90deg, color-mix(in srgb, var(--structure-color) 20%, transparent), var(--structure-color)); background: linear-gradient(90deg, color-mix(in srgb, var(--structure-color) 20%, transparent), var(--structure-color));
box-shadow: 0 0 12px var(--structure-color); box-shadow: 0 0 5px color-mix(in srgb, var(--structure-color) 28%, transparent);
} }
small { small {
@ -5434,7 +5438,7 @@ function formatSignedWan(value) {
&.active { &.active {
color: #041c2b; color: #041c2b;
background: linear-gradient(180deg, #7efbf6, #30dcff); background: linear-gradient(180deg, #7efbf6, #30dcff);
box-shadow: 0 0 10px rgba(48, 220, 255, 0.42); box-shadow: 0 0 6px rgba(48, 220, 255, 0.18);
} }
} }
} }
@ -5475,7 +5479,7 @@ function formatSignedWan(value) {
linear-gradient(180deg, rgba(9, 42, 55, 0.72), rgba(4, 18, 30, 0.82)), linear-gradient(180deg, rgba(9, 42, 55, 0.72), rgba(4, 18, 30, 0.82)),
rgba(3, 18, 31, 0.62); rgba(3, 18, 31, 0.62);
box-shadow: box-shadow:
0 0 18px rgba(139, 247, 255, 0.12), 0 0 10px rgba(48, 220, 255, 0.08),
inset 0 0 0 1px rgba(255, 255, 255, 0.05); inset 0 0 0 1px rgba(255, 255, 255, 0.05);
pointer-events: all; pointer-events: all;
transform: perspective(620px) rotateY(6deg); transform: perspective(620px) rotateY(6deg);
@ -5508,7 +5512,7 @@ function formatSignedWan(value) {
border-color: rgba(139, 247, 255, 0.7); border-color: rgba(139, 247, 255, 0.7);
background: rgba(139, 247, 255, 0.18); background: rgba(139, 247, 255, 0.18);
color: #8bf7ff; color: #8bf7ff;
box-shadow: 0 0 14px rgba(139, 247, 255, 0.12); box-shadow: 0 0 7px rgba(48, 220, 255, 0.12);
} }
} }
} }
@ -5516,7 +5520,7 @@ function formatSignedWan(value) {
.forecast-year-switcher { .forecast-year-switcher {
border-color: rgba(48, 220, 255, 0.28); border-color: rgba(48, 220, 255, 0.28);
box-shadow: box-shadow:
0 0 18px rgba(48, 220, 255, 0.12), 0 0 10px rgba(48, 220, 255, 0.08),
inset 0 0 0 1px rgba(255, 255, 255, 0.05); inset 0 0 0 1px rgba(255, 255, 255, 0.05);
button { button {
@ -5525,24 +5529,24 @@ function formatSignedWan(value) {
border-color: rgba(48, 220, 255, 0.7); border-color: rgba(48, 220, 255, 0.7);
background: rgba(48, 220, 255, 0.16); background: rgba(48, 220, 255, 0.16);
color: #30dcff; color: #30dcff;
box-shadow: 0 0 14px rgba(48, 220, 255, 0.14); box-shadow: 0 0 7px rgba(48, 220, 255, 0.12);
} }
} }
} }
.epidemic-year-switcher { .epidemic-year-switcher {
border-color: rgba(39, 240, 192, 0.28); border-color: rgba(48, 220, 255, 0.26);
box-shadow: box-shadow:
0 0 18px rgba(39, 240, 192, 0.12), 0 0 10px rgba(48, 220, 255, 0.08),
inset 0 0 0 1px rgba(255, 255, 255, 0.05); inset 0 0 0 1px rgba(255, 255, 255, 0.05);
button { button {
&.active, &.active,
&:hover { &:hover {
border-color: rgba(39, 240, 192, 0.72); border-color: rgba(48, 220, 255, 0.68);
background: rgba(39, 240, 192, 0.16); background: rgba(48, 220, 255, 0.16);
color: #27f0c0; color: #8bf7ff;
box-shadow: 0 0 14px rgba(39, 240, 192, 0.14); box-shadow: 0 0 7px rgba(48, 220, 255, 0.12);
} }
} }
} }
@ -5675,7 +5679,7 @@ function formatSignedWan(value) {
width: var(--waterfall-width); width: var(--waterfall-width);
height: 10px; height: 10px;
background: linear-gradient(90deg, color-mix(in srgb, var(--waterfall-color) 22%, transparent), var(--waterfall-color)); background: linear-gradient(90deg, color-mix(in srgb, var(--waterfall-color) 22%, transparent), var(--waterfall-color));
box-shadow: 0 0 10px color-mix(in srgb, var(--waterfall-color) 72%, transparent); box-shadow: 0 0 5px color-mix(in srgb, var(--waterfall-color) 34%, transparent);
} }
} }
@ -5694,7 +5698,7 @@ function formatSignedWan(value) {
} }
&.positive strong { &.positive strong {
color: #27f0c0; color: #8bf7ff;
} }
&.negative strong { &.negative strong {
@ -6071,8 +6075,8 @@ function formatSignedWan(value) {
top: 6px; top: 6px;
bottom: 6px; bottom: 6px;
width: 2px; width: 2px;
background: var(--heat-color); background: rgba(48, 220, 255, 0.82);
box-shadow: 0 0 10px var(--heat-color); box-shadow: none;
opacity: 0.78; opacity: 0.78;
} }
@ -6085,15 +6089,13 @@ function formatSignedWan(value) {
} }
span { span {
color: #effdff; color: rgba(236, 253, 255, 0.9);
font-size: 12px; font-size: 12px;
line-height: 14px; line-height: 14px;
font-weight: 900; font-weight: 900;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-shadow: text-shadow: 0 1px 2px rgba(0, 0, 0, 0.66);
0 1px 2px rgba(0, 0, 0, 0.72),
0 0 8px rgba(48, 220, 255, 0.18);
} }
b { b {
@ -6117,9 +6119,7 @@ function formatSignedWan(value) {
background: background:
linear-gradient(90deg, rgba(255, 255, 255, 0.86), var(--heat-color) 16%, rgba(48, 220, 255, 0.72) 78%, rgba(255, 255, 255, 0.9)), linear-gradient(90deg, rgba(255, 255, 255, 0.86), var(--heat-color) 16%, rgba(48, 220, 255, 0.72) 78%, rgba(255, 255, 255, 0.9)),
linear-gradient(90deg, var(--heat-color-soft), transparent); linear-gradient(90deg, var(--heat-color-soft), transparent);
box-shadow: box-shadow: 0 0 5px rgba(48, 220, 255, 0.14);
0 0 10px var(--heat-color-soft),
0 0 18px var(--heat-color-dim);
overflow: visible; overflow: visible;
&::after { &::after {
@ -6131,9 +6131,7 @@ function formatSignedWan(value) {
height: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: #ffffff; background: #ffffff;
box-shadow: box-shadow: 0 0 5px rgba(255, 255, 255, 0.32);
0 0 8px #ffffff,
0 0 14px var(--heat-color);
transform: translateY(-50%); transform: translateY(-50%);
} }
@ -6154,9 +6152,7 @@ function formatSignedWan(value) {
line-height: 17px; line-height: 17px;
font-weight: 900; font-weight: 900;
text-align: right; text-align: right;
text-shadow: text-shadow: 0 1px 2px rgba(0, 0, 0, 0.66);
0 1px 2px rgba(0, 0, 0, 0.72),
0 0 10px var(--heat-color-soft);
} }
&.is-negative { &.is-negative {
@ -6223,7 +6219,7 @@ function formatSignedWan(value) {
strong { strong {
margin-top: 5px; margin-top: 5px;
color: #27f0c0; color: #8bf7ff;
font-family: var(--screen-font-number, D-DIN, Arial, sans-serif); font-family: var(--screen-font-number, D-DIN, Arial, sans-serif);
font-size: 15px; font-size: 15px;
font-weight: 900; font-weight: 900;
@ -6262,8 +6258,8 @@ function formatSignedWan(value) {
justify-content: space-between; justify-content: space-between;
height: 28px; height: 28px;
padding: 0 8px; padding: 0 8px;
border: 1px solid rgba(39, 240, 192, 0.14); border: 1px solid rgba(48, 220, 255, 0.14);
background: rgba(39, 240, 192, 0.04); background: rgba(48, 220, 255, 0.04);
} }
em { em {
@ -6362,7 +6358,7 @@ function formatSignedWan(value) {
i { i {
width: 8px; width: 8px;
height: 8px; height: 8px;
border: 2px solid #24f6c4; border: 2px solid #30dcff;
border-radius: 2px; border-radius: 2px;
box-sizing: border-box; box-sizing: border-box;
background: rgba(3, 20, 31, 0.84); background: rgba(3, 20, 31, 0.84);
@ -6582,7 +6578,7 @@ function formatSignedWan(value) {
span { span {
overflow: hidden; overflow: hidden;
color: #27f0c0; color: #8bf7ff;
font-family: var(--screen-font-number, D-DIN, Arial, sans-serif); font-family: var(--screen-font-number, D-DIN, Arial, sans-serif);
font-size: 15px; font-size: 15px;
font-weight: 900; font-weight: 900;
@ -6868,8 +6864,8 @@ function formatSignedWan(value) {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
padding: 14px; padding: 14px;
border: 1px solid rgba(39, 240, 192, 0.15); border: 1px solid rgba(48, 220, 255, 0.15);
background: rgba(39, 240, 192, 0.04); background: rgba(48, 220, 255, 0.04);
box-sizing: border-box; box-sizing: border-box;
} }
@ -6891,7 +6887,7 @@ function formatSignedWan(value) {
display: flex; display: flex;
align-items: center; align-items: center;
height: 18px; height: 18px;
color: #27f0c0; color: #8bf7ff;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
line-height: 18px; line-height: 18px;
@ -6910,14 +6906,14 @@ function formatSignedWan(value) {
height: 96px; height: 96px;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(39, 240, 192, 0.24); border: 1px solid rgba(48, 220, 255, 0.24);
background: rgba(39, 240, 192, 0.04); background: rgba(48, 220, 255, 0.04);
cursor: zoom-in; cursor: zoom-in;
box-sizing: border-box; box-sizing: border-box;
&:hover { &:hover {
border-color: rgba(139, 247, 255, 0.58); border-color: rgba(139, 247, 255, 0.58);
box-shadow: 0 0 14px rgba(139, 247, 255, 0.16); box-shadow: 0 0 7px rgba(139, 247, 255, 0.12);
} }
&::after { &::after {
@ -7013,7 +7009,7 @@ function formatSignedWan(value) {
strong { strong {
margin-top: 3px; margin-top: 3px;
color: rgba(39, 240, 192, 0.82); color: rgba(139, 247, 255, 0.82);
font-family: var(--screen-font-number, D-DIN, Arial, sans-serif); font-family: var(--screen-font-number, D-DIN, Arial, sans-serif);
font-size: 12px; font-size: 12px;
} }
@ -7267,7 +7263,7 @@ function formatSignedWan(value) {
.epidemic-ledger-record-detail { .epidemic-ledger-record-detail {
.trade-detail-head em { .trade-detail-head em {
color: #27f0c0; color: #8bf7ff;
} }
.epidemic-detail-flat-grid { .epidemic-detail-flat-grid {
@ -7364,7 +7360,7 @@ function formatSignedWan(value) {
.epidemic-detail-photo-title { .epidemic-detail-photo-title {
height: 18px; height: 18px;
color: #27f0c0; color: #8bf7ff;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
line-height: 18px; line-height: 18px;
@ -7405,7 +7401,7 @@ function formatSignedWan(value) {
background: background:
linear-gradient(90deg, rgba(139, 247, 255, 0.16), rgba(48, 220, 255, 0.11)), linear-gradient(90deg, rgba(139, 247, 255, 0.16), rgba(48, 220, 255, 0.11)),
rgba(8, 36, 48, 0.84); rgba(8, 36, 48, 0.84);
box-shadow: 0 0 14px rgba(139, 247, 255, 0.12); box-shadow: 0 0 7px rgba(139, 247, 255, 0.1);
} }
&:disabled { &:disabled {
@ -7576,8 +7572,8 @@ function formatSignedWan(value) {
top: 50%; top: 50%;
width: 2px; width: 2px;
height: 22px; height: 22px;
background: var(--risk-color); background: rgba(48, 220, 255, 0.78);
box-shadow: 0 0 10px var(--risk-color); box-shadow: none;
transform: translateY(-50%); transform: translateY(-50%);
} }
@ -7588,7 +7584,7 @@ function formatSignedWan(value) {
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--risk-color); background: var(--risk-color);
box-shadow: 0 0 9px var(--risk-color); box-shadow: none;
} }
span { span {

Loading…
Cancel
Save