feat: update screen business modules

V2.0
M先生 1 month ago
parent 7dd0e60062
commit e1bc7bc338
  1. 3
      .env
  2. 0
      docs/assets/AlibabaPuHuiTi-14435b65-balabala-1782659605222.woff2
  3. 0
      docs/assets/SmartChartRenderer-8fd37aa6-balabala-1782659605222.css
  4. 4
      docs/assets/SmartChartRenderer-f39083ee-1782659605222.js
  5. 0
      docs/assets/bg-3acb94e3-balabala-1782659605222.png
  6. 0
      docs/assets/bottom-menu-arrow-big-180b7071-balabala-1782659605222.svg
  7. 0
      docs/assets/bottom-menu-arrow-small-19304179-balabala-1782659605222.svg
  8. 0
      docs/assets/bottom-menu-bg-b465793d-balabala-1782659605222.png
  9. 0
      docs/assets/bottom-menu-btn-ee5c8064-balabala-1782659605222.png
  10. 0
      docs/assets/bottom-menu-btn-hover-df33e514-balabala-1782659605222.png
  11. 0
      docs/assets/chinaBlurLine-1a2cbc0f-balabala-1782659605222.png
  12. 0
      docs/assets/content-bottom-left-arrow-3a8a61f3-balabala-1782659605222.svg
  13. 0
      docs/assets/content-bottom-right-arrow-a3c5270d-balabala-1782659605222.svg
  14. 0
      docs/assets/content-middle-line-162bc0d5-balabala-1782659605222.svg
  15. 0
      docs/assets/focus_bg-b6803e2d-balabala-1782659605222.png
  16. 0
      docs/assets/focus_move_bg-cf41417a-balabala-1782659605222.png
  17. 0
      docs/assets/header-bg-65b38012-balabala-1782659605222.png
  18. 0
      docs/assets/huiguang-3c5dfda5-balabala-1782659605222.png
  19. 0
      docs/assets/icon1-6f7c222f-balabala-1782659605222.png
  20. 0
      docs/assets/icon2-4f77ccec-balabala-1782659605222.png
  21. 25
      docs/assets/index-1782613078975.js
  22. 25
      docs/assets/index-1782659605222.js
  23. 1
      docs/assets/index-77a9e29c-balabala-1782659605222.css
  24. 1
      docs/assets/index-7a0a1b46-balabala-1782613078975.css
  25. 1
      docs/assets/index-92e8e8a8-1782659605222.js
  26. 1
      docs/assets/index-9849d193-balabala-1782659605222.css
  27. 0
      docs/assets/index-b8a77371-balabala-1782659605222.css
  28. 4501
      docs/assets/index-d3bd1e52-1782613078975.js
  29. 4527
      docs/assets/index-dd7cb1f5-1782659605222.js
  30. 1
      docs/assets/json/echarts-china.json
  31. 0
      docs/assets/left-kuang-5d6bd1ae-balabala-1782659605222.svg
  32. 0
      docs/assets/menu-btn-91a961cf-balabala-1782659605222.png
  33. 0
      docs/assets/menu-btn-hover-62eba0bb-balabala-1782659605222.png
  34. 0
      docs/assets/ocean-bg-8c848b8f-balabala-1782659605222.png
  35. 0
      docs/assets/pathLine2-d4134d4b-balabala-1782659605222.png
  36. 0
      docs/assets/right-kuang-b1937caf-balabala-1782659605222.svg
  37. 0
      docs/assets/ring2-a1b91808-balabala-1782659605222.png
  38. 0
      docs/assets/ring3-fe0f60d7-balabala-1782659605222.png
  39. 0
      docs/assets/ring4-f2e324d6-balabala-1782659605222.png
  40. 0
      docs/assets/rotationBorder1-2ce8e0ef-balabala-1782659605222.png
  41. 0
      docs/assets/rotationBorder2-7496b968-balabala-1782659605222.png
  42. 0
      docs/assets/saoguang-ccf84a88-balabala-1782659605222.svg
  43. 0
      docs/assets/title-zs1-c15a5ee8-balabala-1782659605222.svg
  44. 6
      docs/index.html
  45. 2
      index.html
  46. 60
      package-lock.json
  47. 3
      package.json
  48. 1
      public/assets/json/echarts-china.json
  49. 18
      src/components/mHeader/index.vue
  50. 55
      src/config/layers.js
  51. 4
      src/config/modules.js
  52. 12
      src/mini3d/components/ExtrudeMap.js
  53. 57
      src/router/index.js
  54. 85
      src/services/auth.js
  55. 80
      src/services/balanceApi.js
  56. 27
      src/services/pastureApi.js
  57. 15
      src/services/request.js
  58. 31
      src/services/yakApi.js
  59. 1055
      src/views/ecologicalProtection/components/EngineeringAnalysisPanel.vue
  60. 79
      src/views/ecologicalProtection/data.js
  61. 93
      src/views/ecologicalProtection/index.vue
  62. 22
      src/views/forestGrassWet/assets.js
  63. 55
      src/views/forestGrassWet/components/BusinessPanelRenderer.vue
  64. 10
      src/views/forestGrassWet/components/TopicSwitcher.vue
  65. 1330
      src/views/forestGrassWet/map.js
  66. 467
      src/views/forestGrassWet/map.vue
  67. 325
      src/views/grassLivestockBalance/data.js
  68. 2179
      src/views/grassLivestockBalance/index.vue
  69. 15
      src/views/home/index.vue
  70. 200
      src/views/login/index.vue
  71. 224
      src/views/pastureManagement/data.js
  72. 617
      src/views/pastureManagement/index.vue
  73. 95
      src/views/platform/index.vue
  74. 576
      src/views/yakIndustryChain/data.js
  75. 392
      src/views/yakIndustryChain/index.vue
  76. 22
      src/views/yakManagement/data.js
  77. 4943
      src/views/yakManagement/index.vue
  78. 29
      vite.config.js

@ -3,6 +3,7 @@ VITE_APP_BASE_URL=/
VITE_OPEN_API_BASE=/openApi
VITE_JSY_API_BASE=/jsyApi
VITE_GEOSERVER_WMS_URL=/geoserver/ne/wms
VITE_ACCOUNT_API_PROXY_TARGET=http://127.0.0.1:8888
VITE_OPEN_API_PROXY_TARGET=http://127.0.0.1:9999
VITE_JSY_API_PROXY_TARGET=https://muhu-daping.qklny.cn
VITE_JSY_API_PROXY_TARGET=http://127.0.0.1:9999
VITE_GEOSERVER_PROXY_TARGET=http://127.0.0.1:8080

@ -1,2 +1,2 @@
var B=Object.defineProperty,O=Object.defineProperties;var D=Object.getOwnPropertyDescriptors;var S=Object.getOwnPropertySymbols;var W=Object.prototype.hasOwnProperty,$=Object.prototype.propertyIsEnumerable;var v=(a,r,o)=>r in a?B(a,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):a[r]=o,m=(a,r)=>{for(var o in r||(r={}))W.call(r,o)&&v(a,o,r[o]);if(S)for(var o of S(r))$.call(r,o)&&v(a,o,r[o]);return a},d=(a,r)=>O(a,D(r));import{L as b,H}from"./index-d3bd1e52-1782613078975.js";import{e as h,h as F,C as R,o as g,c as p,a as f,t as u,g as L,b as V,u as P,n as j}from"./index-1782613078975.js";const q={class:"smart-chart-header"},G={key:0},E={key:0,class:"smart-chart-source"},J={__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"],z=h(()=>{const t=(i.chart.rows||[]).filter(e=>Number.isFinite(Number(e.value)));return i.chart.type==="pie"?N(t):i.chart.type==="line"?w(t):C(t)}),k=h(()=>{const t=i.chart.rows||[];return i.chart.type==="bar"&&x(t)}),y=h(()=>{const t=i.chart.source||{},e=[t.mode,t.table,t.period,t.generatedAt].filter(Boolean);return e.length?e.join(" / "):""});F(()=>{R(()=>{window.requestAnimationFrame(()=>o("ready"))})});function C(t){return x(t)?_(t):{animationDuration:700,grid:{left:52,right:26,top:28,bottom:54},tooltip:s(),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})=>c(e)}}]}}function _(t){return{animationDuration:700,grid:{left:78,right:72,top:22,bottom:20},tooltip:d(m({},s()),{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})=>c(e)}}]}}function N(t){return{animationDuration:700,tooltip:s(),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 w(t){return{animationDuration:760,grid:{left:52,right:28,top:30,bottom:48},tooltip:d(m({},s()),{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})=>c(e)},data:t.map(e=>({value:e.value,name:e.name}))}]}}function s(){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(A=>A.name===t.name),n=(e==null?void 0:e.unit)||i.chart.unit||"",T=e!=null&&e.extra?`<br/><span style="color:rgba(223,250,255,.62)">${e.extra}</span>`:"";return`${t.marker}${t.name}: ${c(t.value)}${n}${T}`}}}function x(t=[]){return t.length>6||t.some(e=>String(e.name||"").length>4)}function c(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(),p("div",{class:j(["smart-chart-card",{"is-horizontal":k.value}])},[f("div",q,[f("div",null,[f("strong",null,u(a.chart.title),1),f("span",null,u(a.chart.subtitle),1)]),a.chart.unit?(g(),p("em",G,u(a.chart.unit),1)):L("",!0)]),V(P(H),{class:"smart-chart",option:z.value,autoresize:""},null,8,["option"]),y.value?(g(),p("div",E,u(y.value),1)):L("",!0)],2))}};export{J as default};
var B=Object.defineProperty,O=Object.defineProperties;var D=Object.getOwnPropertyDescriptors;var S=Object.getOwnPropertySymbols;var W=Object.prototype.hasOwnProperty,$=Object.prototype.propertyIsEnumerable;var v=(a,r,o)=>r in a?B(a,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):a[r]=o,m=(a,r)=>{for(var o in r||(r={}))W.call(r,o)&&v(a,o,r[o]);if(S)for(var o of S(r))$.call(r,o)&&v(a,o,r[o]);return a},d=(a,r)=>O(a,D(r));import{L as b,H}from"./index-dd7cb1f5-1782659605222.js";import{e as h,h as F,q as R,o as g,c as p,a as f,t as u,g as L,b as V,u as q,n as P}from"./index-1782659605222.js";const j={class:"smart-chart-header"},G={key:0},E={key:0,class:"smart-chart-source"},J={__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"],z=h(()=>{const t=(i.chart.rows||[]).filter(e=>Number.isFinite(Number(e.value)));return i.chart.type==="pie"?N(t):i.chart.type==="line"?w(t):_(t)}),k=h(()=>{const t=i.chart.rows||[];return i.chart.type==="bar"&&x(t)}),y=h(()=>{const t=i.chart.source||{},e=[t.mode,t.table,t.period,t.generatedAt].filter(Boolean);return e.length?e.join(" / "):""});F(()=>{R(()=>{window.requestAnimationFrame(()=>o("ready"))})});function _(t){return x(t)?C(t):{animationDuration:700,grid:{left:52,right:26,top:28,bottom:54},tooltip:s(),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})=>c(e)}}]}}function C(t){return{animationDuration:700,grid:{left:78,right:72,top:22,bottom:20},tooltip:d(m({},s()),{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})=>c(e)}}]}}function N(t){return{animationDuration:700,tooltip:s(),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 w(t){return{animationDuration:760,grid:{left:52,right:28,top:30,bottom:48},tooltip:d(m({},s()),{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})=>c(e)},data:t.map(e=>({value:e.value,name:e.name}))}]}}function s(){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(A=>A.name===t.name),n=(e==null?void 0:e.unit)||i.chart.unit||"",T=e!=null&&e.extra?`<br/><span style="color:rgba(223,250,255,.62)">${e.extra}</span>`:"";return`${t.marker}${t.name}: ${c(t.value)}${n}${T}`}}}function x(t=[]){return t.length>6||t.some(e=>String(e.name||"").length>4)}function c(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(),p("div",{class:P(["smart-chart-card",{"is-horizontal":k.value}])},[f("div",j,[f("div",null,[f("strong",null,u(a.chart.title),1),f("span",null,u(a.chart.subtitle),1)]),a.chart.unit?(g(),p("em",G,u(a.chart.unit),1)):L("",!0)]),V(q(H),{class:"smart-chart",option:z.value,autoresize:""},null,8,["option"]),y.value?(g(),p("div",E,u(y.value),1)):L("",!0)],2))}};export{J 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

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
.login-screen[data-v-02c20620]{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-1782659605222.png) center/cover no-repeat,#05142f;color:#fff}.login-screen[data-v-02c20620]: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-02c20620]{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-02c20620]{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-02c20620]{font-size:42px;line-height:1.2;font-weight:700}.login-brand small[data-v-02c20620]{color:#c4f3feb8;font-size:13px;letter-spacing:0}.login-panel[data-v-02c20620]{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-02c20620]{margin-bottom:26px;color:#c4f3fe;font-size:22px;font-weight:700;text-align:center}.login-field[data-v-02c20620]{display:block;margin-bottom:18px}.login-field span[data-v-02c20620]{display:block;margin-bottom:8px;color:#c4f3fec7;font-size:13px}.login-field input[data-v-02c20620]{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-02c20620]:focus{border-color:#30dcffb8;box-shadow:0 0 0 2px #30dcff1f}.login-error[data-v-02c20620]{min-height:20px;margin:-4px 0 12px;color:#ffcf8a;font-size:13px;line-height:20px}.login-submit[data-v-02c20620]{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-02c20620]:disabled{cursor:not-allowed;opacity:.68}@media (max-width: 820px){.login-shell[data-v-02c20620]{grid-template-columns:1fr;gap:28px}.login-brand span[data-v-02c20620]{font-size:30px}.login-panel[data-v-02c20620]{width:100%}}

File diff suppressed because one or more lines are too long

@ -0,0 +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,P as w,Q as h,e as B,o as _,c as f,a as e,D,A as g,B as y,t as b,g as R,d as p,R as S}from"./index-1782659605222.js";const V={class:"login-screen"},k={class:"login-shell"},M={class:"login-field"},P={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=B(()=>{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 S(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",V,[e("div",k,[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:D(l,["prevent"])},[s[4]||(s[4]=e("div",{class:"login-panel-title"},"用户登录",-1)),e("label",M,[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",P,[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)):R("",!0),e("button",{class:"login-submit",disabled:u.value,type:"submit"},b(u.value?"登录中":"登录"),9,q)],32)])]))}},I=x(A,[["__scopeId","data-v-02c20620"]]);export{I 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

@ -5,9 +5,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>红原县数字平台 V2.0</title>
<script type="module" crossorigin src="./assets/index-1782613078975.js"></script>
<link rel="stylesheet" href="./assets/index-b8a77371-balabala-1782613078975.css">
<title>红原县数字畜牧业监管平台</title>
<script type="module" crossorigin src="./assets/index-1782659605222.js"></script>
<link rel="stylesheet" href="./assets/index-b8a77371-balabala-1782659605222.css">
</head>
<body>

@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>红原县数字平台 V2.0</title>
<title>红原县数字畜牧业监管平台</title>
</head>
<body>

60
package-lock.json generated

@ -15,6 +15,7 @@
"gsap": "^3.12.2",
"normalize.css": "^8.0.1",
"point-in-polygon": "^1.1.0",
"shpjs": "^6.2.0",
"three": "0.161.0",
"three.interactive": "^1.7.0",
"tiny-emitter": "^2.1.0",
@ -919,6 +920,12 @@
"integrity": "sha512-albZNwDIXvcRneEDyZLW3uAIOH0cUQG/TnCGQ7jpfnL0gPn/+1ZNVRuEz3ZuzZvVkQ4HQRplGHjUeMRtPNxjLQ==",
"license": "MIT"
},
"node_modules/but-unzip": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/but-unzip/-/but-unzip-0.1.10.tgz",
"integrity": "sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==",
"license": "Apache-2.0"
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz",
@ -1136,6 +1143,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/mgrs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
@ -1168,6 +1181,12 @@
"integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==",
"license": "MIT"
},
"node_modules/parsedbf": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parsedbf/-/parsedbf-2.0.0.tgz",
"integrity": "sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -1222,6 +1241,27 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proj4": {
"version": "2.20.9",
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.9.tgz",
"integrity": "sha512-GLBGqXaTcdWnppre3o1sMmy4DcMGSGq/ng+9k2MTNddarRK6SveINqlqYzi3xEXuy06ljY1TTrC6H9C4f360IQ==",
"license": "MIT",
"dependencies": {
"mgrs": "1.0.0",
"wkt-parser": "^1.5.5"
},
"funding": {
"url": "https://github.com/sponsors/ahocevar"
},
"peerDependencies": {
"geotiff": "*"
},
"peerDependenciesMeta": {
"geotiff": {
"optional": true
}
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz",
@ -1286,6 +1326,17 @@
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/shpjs": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz",
"integrity": "sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==",
"license": "MIT",
"dependencies": {
"but-unzip": "^0.1.4",
"parsedbf": "^2.0.0",
"proj4": "^2.1.4"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1478,6 +1529,15 @@
"vue": "^3.5.0"
}
},
"node_modules/wkt-parser": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.5.tgz",
"integrity": "sha512-/zMYi94/7D7fxcOSlVmWn6vnOMj3Gq5d1xvVjaYOS9n6h0qOJ4I7YYVxBWYcH1vq9+suhqzXkn05Yx47zQNUIA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ahocevar"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",

@ -1,5 +1,5 @@
{
"name": "hongyuan-screen-v2",
"name": "hongyuan-screen-v2",
"private": true,
"version": "1.0.0",
"type": "module",
@ -16,6 +16,7 @@
"gsap": "^3.12.2",
"normalize.css": "^8.0.1",
"point-in-polygon": "^1.1.0",
"shpjs": "^6.2.0",
"three": "0.161.0",
"three.interactive": "^1.7.0",
"tiny-emitter": "^2.1.0",

File diff suppressed because one or more lines are too long

@ -57,15 +57,15 @@
<script setup>
import mSvglineAnimation from "@/components/mSvglineAnimation/index.vue"
defineProps({
title: {
type: String,
default: "数据可视化大屏",
},
subText: {
type: String,
default: "Visualization Platform",
},
})
title: {
type: String,
default: "红原县数字畜牧业监管平台",
},
subText: {
type: String,
default: "Hongyuan Digital Animal Husbandry Supervision Platform",
},
})
</script>
<style lang="scss">
.m-header {

@ -153,6 +153,12 @@ export const ecologicalProtectionTopics = [
color: "#33A3DC",
defaultLayerKey: "restoration-projects",
},
{
key: "engineering-analysis",
name: "工程分析",
color: "#24F6C4",
defaultLayerKey: "engineering-analysis-projects",
},
]
export const forestGrassWetLayers = {
@ -382,6 +388,55 @@ export const ecologicalProtectionLayers = {
],
},
],
"engineering-analysis": [
{
key: "engineering-analysis-projects",
name: "工程分析项目库",
sourceType: "geojson",
dataPath: "datas/ecological-protection/ecological_restoration_projects.geojson",
opacity: 0.82,
mapView: {
targetLngLat: [102.58, 32.72],
distanceScale: 0.78,
targetLocalZ: 0.72,
duration: 0.85,
},
legends: [
{
key: "analysis-project-grassland",
name: "草地修复",
fillColor: "#70D44B",
strokeColor: "#B7FF6D",
match: { field: "project_category", value: "草地修复" },
selectable: false,
},
{
key: "analysis-project-wetland",
name: "湿地修复",
fillColor: "#42C6DF",
strokeColor: "#9CF7FF",
match: { field: "project_category", value: "湿地修复" },
selectable: false,
},
{
key: "analysis-project-woodland",
name: "林地修复",
fillColor: "#1FB579",
strokeColor: "#57F2A6",
match: { field: "project_category", value: "林地修复" },
selectable: false,
},
{
key: "analysis-project-linear",
name: "线性设施",
fillColor: "#F5C84B",
strokeColor: "#FFE289",
match: { field: "feature_role", value: "线性设施" },
selectable: false,
},
],
},
],
}
const layerRegistry = {

@ -21,7 +21,7 @@ export const platformModules = [
key: "grass-livestock-balance",
label: "草畜平衡",
bottomLabel: "草畜平衡",
status: "coming",
status: "ready",
},
{
key: "yak-management",
@ -39,7 +39,7 @@ export const platformModules = [
key: "yak-industry-chain",
label: "牦牛产业全链",
bottomLabel: "产业全链",
status: "coming",
status: "ready",
},
{
key: "smart-management",

@ -63,12 +63,12 @@ export class ExtrudeMap {
let { name, center = [], centroid = [] } = feature.properties
this.coordinates.push({ name, center, centroid })
const extrudeSettings = {
depth: this.config.depth,
bevelEnabled: true,
bevelSegments: 1,
bevelThickness: 0.1,
}
const extrudeSettings = {
depth: this.config.depth,
bevelEnabled: this.config.bevelEnabled ?? true,
bevelSegments: this.config.bevelSegments ?? 1,
bevelThickness: this.config.bevelThickness ?? 0.1,
}
let materials = [this.config.topFaceMaterial, this.config.sideMaterial]
feature.geometry.coordinates.forEach((multiPolygon) => {
multiPolygon.forEach((polygon, index) => {

@ -1,13 +1,19 @@
import { createRouter, createWebHashHistory } from "vue-router"
import { fetchCurrentUser, getAuthorizationToken, getLoginPath } from "@/services/auth"
const platform = () => import("@/views/platform/index.vue")
const login = () => import("@/views/login/index.vue")
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: "/",
redirect: "/platform",
component: platform,
},
{
path: "/login",
component: login,
meta: { public: true },
},
{
path: "/platform",
@ -19,10 +25,45 @@ const router = createRouter({
},
{
path: "/:pathMatch(.*)",
redirect: "/",
},
],
})
export default router
path: "/:pathMatch(.*)",
redirect: "/",
},
],
})
let userPromise = null
router.beforeEach(async (to) => {
if (to.meta.public) {
if (getAuthorizationToken()) {
try {
await ensureCurrentUser()
return to.query.redirect ? decodeURIComponent(to.query.redirect) : "/platform"
} catch (error) {
userPromise = null
}
}
return true
}
if (!getAuthorizationToken()) {
return getLoginPath(to.fullPath)
}
try {
await ensureCurrentUser()
return true
} catch (error) {
userPromise = null
return getLoginPath(to.fullPath)
}
})
function ensureCurrentUser() {
if (!userPromise) {
userPromise = fetchCurrentUser()
}
return userPromise
}
export default router

@ -0,0 +1,85 @@
const AUTH_COOKIE_NAME = "Authorization"
function encodeRedirect(path) {
return encodeURIComponent(path || "/platform")
}
export function getAuthorizationToken() {
const cookie = document.cookie
.split(";")
.map((item) => item.trim())
.find((item) => item.startsWith(`${AUTH_COOKIE_NAME}=`))
return cookie ? decodeURIComponent(cookie.slice(AUTH_COOKIE_NAME.length + 1)) : ""
}
export function clearAuthorizationToken() {
document.cookie = `${AUTH_COOKIE_NAME}=; Max-Age=0; path=/`
}
export function getLoginPath(redirect = window.location.hash.replace(/^#/, "") || "/platform") {
return `/login?redirect=${encodeRedirect(redirect)}`
}
export function redirectToLogin(redirect) {
clearAuthorizationToken()
const loginPath = getLoginPath(redirect)
if (window.location.hash.replace(/^#/, "") !== loginPath) {
window.location.hash = loginPath
}
}
export async function login(username, password) {
const response = await fetch("/login", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"Content-Language": "zh_CN",
},
body: JSON.stringify({ username, password }),
})
const data = await response.json().catch(() => ({}))
if (!response.ok || data?.code !== 200) {
const error = new Error(data?.msg || "登录失败,请检查用户名或密码。")
error.status = response.status
error.code = data?.code
throw error
}
if (data?.data?.token) {
document.cookie = `${AUTH_COOKIE_NAME}=${encodeURIComponent(data.data.token)}; path=/`
}
return data
}
export async function logout() {
await fetch("/logout", {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Content-Language": "zh_CN",
},
body: "{}",
}).catch(() => null)
redirectToLogin("/platform")
}
export async function fetchCurrentUser() {
const response = await fetch("/getInfo", {
credentials: "include",
headers: {
Accept: "application/json",
"Content-Language": "zh_CN",
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok || data?.code !== 200) {
const error = new Error(data?.msg || "用户未登录或授权已过期。")
error.status = response.status || 401
error.code = data?.code || 401
throw error
}
return data.data
}

@ -0,0 +1,80 @@
import { normalizeResponseData, requestJson } from "./request"
const JSY_API_BASE = import.meta.env.VITE_JSY_API_BASE || "/jsyApi/api"
const OPEN_API_BASE = import.meta.env.VITE_OPEN_API_BASE || "/openApi"
function joinBase(base, path) {
if (/^https?:\/\//.test(path)) return path
return `${base}${path.startsWith("/") ? path : `/${path}`}`
}
function unwrapResponse(response) {
const failed =
response?.success === false ||
(response?.code !== undefined && ![0, 200, "0", "200"].includes(response.code))
if (failed) {
const error = new Error(response?.msg || response?.message || "接口请求失败")
error.code = response?.code
throw error
}
return normalizeResponseData(response)
}
function get(base, path, params, options = {}) {
return requestJson(joinBase(base, path), {
params,
timeout: options.timeout || 16000,
}).then(unwrapResponse)
}
function normalizeRows(data) {
if (Array.isArray(data)) return data
if (Array.isArray(data?.rows)) return data.rows
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.data)) return data.data
return []
}
export async function getBalanceAdvice({ areaCode, pasturesId } = {}) {
const params = {
areaCode,
pasturesId,
}
const requests = [
() => get(OPEN_API_BASE, "/ai/balance/advice", params, { timeout: 18000 }),
() => get(JSY_API_BASE, "/ai/livestockBalance", params, { timeout: 18000 }),
]
let lastError
for (const request of requests) {
try {
return await request()
} catch (error) {
lastError = error
}
}
throw lastError || new Error("草畜平衡接口不可用")
}
export async function queryBalanceTargets(question) {
const requests = [
() => get(OPEN_API_BASE, "/ai/balance/targets", { question }, { timeout: 16000 }),
() => get(JSY_API_BASE, "/ai/question/balance", { question }, { timeout: 16000 }),
]
let lastError
for (const request of requests) {
try {
return normalizeRows(await request())
} catch (error) {
lastError = error
}
}
throw lastError || new Error("草畜平衡对象识别接口不可用")
}
export function getAreaChildren(areaCode) {
return get(JSY_API_BASE, "/area/children", { areaCode }, { timeout: 12000 }).then(normalizeRows)
}

@ -1,6 +1,6 @@
import { normalizeResponseData, requestJson } from "./request"
const JSY_API_BASE = import.meta.env.VITE_JSY_API_BASE || "/jsyApi/api"
const JSY_API_BASE = import.meta.env.VITE_JSY_API_BASE || "/jsyApi"
function joinBase(base, path) {
if (/^https?:\/\//.test(path)) return path
@ -37,19 +37,19 @@ function post(path, params, options = {}) {
}
export function getPastureCoreItems() {
return post("/dataItem", { group: "pastures" }).then((data) => data?.data || data || [])
return post("/api/dataItem?group=pastures").then(normalizeRows)
}
export function getPastureDistribution() {
return post("/muhu/distribution").then((data) => data?.data || data || [])
return post("/api/muhu/distribution").then(normalizeRows)
}
export function getPastureAge() {
return post("/muhu/age").then((data) => data?.data || data || [])
return post("/api/muhu/age").then(normalizeRows)
}
export function getPastureOnline() {
return post("/muhu/upline_rate").then((data) => data?.data || data || [])
return post("/api/muhu/upline_rate").then(normalizeRows)
}
function normalizeRows(data) {
@ -57,39 +57,44 @@ function normalizeRows(data) {
if (Array.isArray(data?.rows)) return data.rows
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.data)) return data.data
if (data?.data) return normalizeRows(data.data)
if (data?.result) return normalizeRows(data.result)
return []
}
export function getMuhuList(query = "", page = 1, pageSize = 80) {
return get("/home/muhu_list", { query, page, pageSize }).then(normalizeRows)
return get("/api/home/muhu_list", { query, page, pageSize }).then(normalizeRows)
}
export function getPastureGrasslandsById(id) {
return get("/home/grasslandByPastureId", { id }).then(normalizeRows)
return get("/api/home/grasslandByPastureId", { id }).then(normalizeRows)
}
export function getPastureDetailsById(id) {
return get("/pastureDetailsById", { id }).then((data) => data?.data || data || {})
return get("/api/pastureDetailsById", { id }).then((data) => data?.data || data || {})
}
export function getPastureList() {
return get("/pasturesList").then((data) => data?.data || data || [])
return get("/api/pasturesList").then(normalizeRows)
}
export async function getPastureProfile() {
const [core, distribution, age, online, list] = await Promise.allSettled([
const [core, distribution, age, online, pastureList, muhuList] = await Promise.allSettled([
getPastureCoreItems(),
getPastureDistribution(),
getPastureAge(),
getPastureOnline(),
getPastureList(),
getMuhuList("", 1, 80),
])
const pastureRows = pastureList.status === "fulfilled" ? pastureList.value : []
const muhuRows = muhuList.status === "fulfilled" ? muhuList.value : []
return {
core: core.status === "fulfilled" ? core.value : [],
distribution: distribution.status === "fulfilled" ? distribution.value : [],
age: age.status === "fulfilled" ? age.value : [],
online: online.status === "fulfilled" ? online.value : [],
list: list.status === "fulfilled" ? list.value : [],
list: pastureRows.length ? pastureRows : muhuRows,
}
}

@ -1,3 +1,5 @@
import { redirectToLogin } from "./auth"
const DEFAULT_TIMEOUT = 12000
function appendParams(url, params = {}) {
@ -34,9 +36,20 @@ export async function requestJson(url, options = {}) {
const friendlyError = new Error(response.status === 401 ? "用户未登录或授权已过期。" : `HTTP ${response.status}`)
friendlyError.code = `HTTP_${response.status}`
friendlyError.status = response.status
if (response.status === 401) {
redirectToLogin()
}
throw friendlyError
}
const data = await response.json()
if (data?.code === 401 || data?.code === "401") {
const friendlyError = new Error(data?.msg || "用户未登录或授权已过期。")
friendlyError.code = "HTTP_401"
friendlyError.status = 401
redirectToLogin()
throw friendlyError
}
return await response.json()
return data
} catch (error) {
const aborted = controller.signal.aborted || error?.name === "AbortError" || /aborted/i.test(error?.message || "")
if (aborted) {

@ -58,7 +58,36 @@ export function getYakSlaughterOutput() {
}
export function getYakTradeOverview(year = CURRENT_YEAR) {
return get(OPEN_API_BASE, "/yak/trade/overview", { year }).then((data) => data || {})
const params = typeof year === "object" ? year : { year }
return get(OPEN_API_BASE, "/yak/trade/overview", params).then((data) => data || {})
}
export function getYakTradeOptions() {
return get(OPEN_API_BASE, "/yak/trade/options").then((data) => data || {})
}
export function getYakTradeRecords(params = {}) {
return get(OPEN_API_BASE, "/yak/trade/records", params).then((data) => data || {})
}
export function getYakEpidemicAnalysis(params = {}) {
return get(OPEN_API_BASE, "/yak/epidemic/analysis", params).then((data) => data || {})
}
export function getYakEpidemicRecords(params = {}) {
return get(OPEN_API_BASE, "/yak/epidemic/records", params).then((data) => data || {})
}
export function getYakEpidemicStats(params = {}) {
return get(OPEN_API_BASE, "/yak/epidemic/stats", params).then((data) => data || {})
}
export function getYakEpidemicVillageStats(params = {}) {
return get(OPEN_API_BASE, "/yak/epidemic/village-stats", params).then((data) => data || {})
}
export function getYakEpidemicDetail(earTag) {
return get(OPEN_API_BASE, "/yak/epidemic/detail", { earTag }).then((data) => data || null)
}
export async function getYakTownRows() {

@ -280,6 +280,7 @@ const restorationSummary = {
export const ecologicalProtectionMenu = [
{ key: "national-park", label: "若尔盖国家公园", bottomLabel: "若尔盖国家公园" },
{ key: "restoration", label: "生态修复工程", bottomLabel: "生态修复工程" },
{ key: "engineering-analysis", label: "工程分析", bottomLabel: "工程分析" },
]
export const parkTownRows = [
@ -290,7 +291,7 @@ export const parkTownRows = [
export const parkSummary = {
name: "若尔盖国家公园红原区域",
totalAreaWanMu: 1250.51,
totalAreaWanMu: 161.31,
grasslandAreaWanMu: 94.25,
wetlandAreaWanMu: 33.47,
woodlandAreaWanMu: null,
@ -337,8 +338,8 @@ const parkTownListRows = parkTownRows.map((item) => ({
}))
const parkZoningRows = [
{ key: "core-protection", name: "核心保护区", value: 629.76, unit: "万亩" },
{ key: "general-control", name: "一般控制区", value: 620.75, unit: "万亩" },
{ key: "core-protection", name: "核心保护区", value: 67.36, unit: "万亩" },
{ key: "general-control", name: "一般控制区", value: 93.95, unit: "万亩" },
]
const parkGrasslandProductivityWanTon = 43.66
@ -385,7 +386,7 @@ const parkResourceScopeRows = withParkPercentExtra([
{ key: "park-scope-other", name: "其他保护范围", value: parkSummary.otherProtectedAreaWanMu, unit: "万亩" },
])
const buildParkVillageRows = ({ sourceKey, weightKey, unit = "万亩", decimals = 2 }) => {
const buildParkVillageRows = ({ sourceKey, weightKey, unit = "万亩", decimals = 2, sortByValue = true }) => {
const rows = parkTownBusinessRows.flatMap((town) => {
const weights = parkVillageWeights[town.name] || []
return weights.map((village) => ({
@ -396,13 +397,14 @@ const buildParkVillageRows = ({ sourceKey, weightKey, unit = "万亩", decimals
unit,
}))
}).filter((item) => Number(item.value) > 0)
.sort((a, b) => Number(b.value || 0) - Number(a.value || 0))
return withParkPercentExtra(rows)
const displayRows = sortByValue ? rows.sort((a, b) => Number(b.value || 0) - Number(a.value || 0)) : rows
return withParkPercentExtra(displayRows)
}
const parkWetlandVillageRows = buildParkVillageRows({
sourceKey: "wetlandAreaWanMu",
weightKey: "wetland",
sortByValue: false,
})
const parkGrasslandVillageRows = buildParkVillageRows({
@ -449,8 +451,18 @@ export const restorationTopStats = [
{ zh: "涉及乡镇", value: restorationSummary.kpi.townshipCount, unit: "个", decimals: 0 },
]
export const engineeringAnalysisTopStats = [
{ zh: "项目库工程", value: restorationSummary.kpi.projectCount, unit: "个", decimals: 0 },
{ zh: "可比对图斑", value: restorationSummary.kpi.featureCount, unit: "个", decimals: 0 },
{ zh: "项目面积", value: restorationSummary.kpi.areaWanMu, unit: "万亩", decimals: 2 },
{ zh: "工程长度", value: restorationSummary.kpi.lengthKm, unit: "公里", decimals: 2 },
]
export function getEcologicalTopStats(viewKey) {
const rows = viewKey === "restoration" ? restorationTopStats : parkTopStats
if (viewKey === "engineering-analysis") return []
const rows = viewKey === "restoration"
? restorationTopStats
: parkTopStats
return rows.map((item, index) => ({
icon: index % 2 === 0 ? "xiaoshoujine" : "zongxiaoliang",
en: "",
@ -468,7 +480,7 @@ export const parkPanels = {
},
{
key: "park-wetland-village",
title: "湿地分布村级排行",
title: "湿地分布村级对比",
type: "line",
rows: parkWetlandVillageRows.slice(0, 8),
},
@ -482,7 +494,7 @@ export const parkPanels = {
key: "park-grassland-village",
title: "草地分布村级排行",
type: "peak",
rows: parkGrasslandVillageRows.slice(0, 5),
rows: parkGrasslandVillageRows.slice(0, 8),
},
],
right: [
@ -550,6 +562,36 @@ const restorationProjectRows = (restorationSummary.projects || []).map((item, in
}
})
export const engineeringAnalysisProjectFeatures = restorationProjectRows.map((item, index) => {
const col = index % 6
const row = Math.floor(index / 6)
const baseLng = 102.42 + col * 0.045
const baseLat = 32.55 + row * 0.035
const size = 0.018 + Math.min(0.024, Number(item.areaWanMu || 0) / 1200)
return {
type: "Feature",
properties: {
project_name: item.projectName,
project_category: item.category,
year: item.year,
areaWanMu: item.areaWanMu,
lengthKm: item.lengthKm,
townships: item.townships,
measure: item.measure,
},
geometry: {
type: "Polygon",
coordinates: [[
[baseLng, baseLat],
[baseLng + size, baseLat + size * 0.22],
[baseLng + size * 0.82, baseLat + size],
[baseLng - size * 0.16, baseLat + size * 0.72],
[baseLng, baseLat],
]],
},
}
})
export const restorationPanels = {
left: [
{
@ -640,6 +682,11 @@ export const restorationPanels = {
],
}
export const engineeringAnalysisPanels = {
left: [],
right: [],
}
export const centerMapNotes = {
"national-park": {
title: "若尔盖国家公园红原区域",
@ -651,6 +698,11 @@ export const centerMapNotes = {
subtitle: "草地修复 / 湿地修复 / 林地修复 / 水源涵养",
tags: ["项目库", "治理面积", "工程长度", "乡镇排行"],
},
"engineering-analysis": {
title: "工程建设分析",
subtitle: "上传边界 / 在线绘制 / 项目相交核查",
tags: ["SHP", "边界绘制", "项目核验", "空间叠加"],
},
}
export function getEcologicalMapRanking(viewKey) {
@ -667,6 +719,15 @@ export function getEcologicalMapRanking(viewKey) {
})),
}
}
if (viewKey === "engineering-analysis") {
return {
status: "success",
title: "工程分析",
metricLabel: "",
unit: "",
rows: [],
}
}
return {
status: "success",
title: "国家公园牦牛分布",

@ -7,10 +7,11 @@
@feature-loading="featureLoading = $event"
@feature-info="handleFeatureInfo"
@layer-status="handleLayerStatus"
@draw-boundary="handleDrawBoundary"
/>
<div class="forest-grass-wet-ui ecological-protection-ui">
<div class="fgw-left">
<div v-if="!isEngineeringAnalysis" class="fgw-left">
<div class="fgw-side-stack is-left">
<m-card
v-for="panel in leftPanels"
@ -26,7 +27,7 @@
</div>
</div>
<div class="fgw-right">
<div v-if="!isEngineeringAnalysis" class="fgw-right">
<div class="fgw-side-stack is-right">
<m-card
v-for="panel in rightPanels"
@ -42,7 +43,19 @@
</div>
</div>
<div class="fgw-map-layer-tools">
<engineering-analysis-panel
v-if="isEngineeringAnalysis"
ref="engineeringAnalysisRef"
:drawing-point-count="drawingPointCount"
@drawing-change="handleDrawingChange"
@finish-drawing="handleFinishDrawing"
@undo-point="handleUndoDrawingPoint"
@clear-boundary="handleClearAnalysisBoundary"
@analysis-result="handleAnalysisResult"
@focus-project="handleAnalysisProjectFocus"
/>
<div class="fgw-map-layer-tools" :class="{ 'is-analysis': isEngineeringAnalysis }">
<layer-panel
v-model="activeLayerKey"
v-model:selected-legend-key="selectedLegendKey"
@ -101,7 +114,9 @@ import LayerPanel from "@/views/forestGrassWet/components/LayerPanel.vue"
import FeatureInfoPanel from "@/views/forestGrassWet/components/FeatureInfoPanel.vue"
import BusinessPanelRenderer from "@/views/forestGrassWet/components/BusinessPanelRenderer.vue"
import TopicSwitcher from "@/views/forestGrassWet/components/TopicSwitcher.vue"
import EngineeringAnalysisPanel from "./components/EngineeringAnalysisPanel.vue"
import {
engineeringAnalysisPanels,
getEcologicalMapRanking,
getEcologicalTopStats,
parkPanels,
@ -121,6 +136,7 @@ const props = defineProps({
const emit = defineEmits(["module-stats", "view-change"])
const mapSceneRef = ref(null)
const engineeringAnalysisRef = ref(null)
const activeLayerKey = ref("")
const selectedLegendKey = ref("")
const selectedFeature = ref(null)
@ -130,6 +146,7 @@ const featurePanelPoint = ref(null)
const progress = ref(0)
const loadingVisible = ref(props.showLoading)
const mapRanking = ref(null)
const drawingPointCount = ref(0)
const layerStatus = ref({
type: "idle",
text: "等待生态保护图层加载",
@ -138,7 +155,10 @@ const layerStatus = ref({
let assets = null
let destroyed = false
const currentView = computed(() => (props.activeView === "restoration" ? "restoration" : "national-park"))
const currentView = computed(() =>
["national-park", "restoration", "engineering-analysis"].includes(props.activeView) ? props.activeView : "national-park"
)
const isEngineeringAnalysis = computed(() => currentView.value === "engineering-analysis")
const baseActiveLayer = computed(() => getLayerByKey(currentView.value, activeLayerKey.value))
const selectedLegend = computed(() =>
(baseActiveLayer.value?.legends || []).find((item) => item.key === selectedLegendKey.value) || null
@ -154,7 +174,11 @@ const activeLayer = computed(() => {
name: selectedLegend.value.name || layer.name,
}
})
const currentPanels = computed(() => (currentView.value === "restoration" ? restorationPanels : parkPanels))
const currentPanels = computed(() => {
if (currentView.value === "restoration") return restorationPanels
if (currentView.value === "engineering-analysis") return engineeringAnalysisPanels
return parkPanels
})
const leftPanels = computed(() => currentPanels.value.left || [])
const rightPanels = computed(() => currentPanels.value.right || [])
const panelHeight = 190
@ -213,6 +237,9 @@ function syncView(viewKey) {
selectedLegendKey.value = ""
activeLayerKey.value = topic.defaultLayerKey || layers[0]?.key || ""
mapRanking.value = getEcologicalMapRanking(viewKey)
drawingPointCount.value = 0
mapSceneRef.value?.setBoundaryDrawing?.(false)
if (viewKey !== "engineering-analysis") mapSceneRef.value?.clearAnalysisBoundary?.()
closeFeaturePanel()
emit("module-stats", {
moduleKey: "ecological-protection",
@ -222,7 +249,7 @@ function syncView(viewKey) {
}
function handleViewSelect(viewKey) {
emit("view-change", viewKey === "restoration" ? "restoration" : "national-park")
emit("view-change", ["national-park", "restoration", "engineering-analysis"].includes(viewKey) ? viewKey : "national-park")
}
function initAssets() {
@ -312,6 +339,49 @@ function handlePanelRowSelect(panel, row) {
mapSceneRef.value?.focusProject?.(projectName)
}
function handleDrawingChange(enabled) {
closeFeaturePanel()
drawingPointCount.value = 0
mapSceneRef.value?.setBoundaryDrawing?.(enabled)
}
function handleFinishDrawing() {
mapSceneRef.value?.finishBoundaryDrawing?.()
}
function handleUndoDrawingPoint() {
mapSceneRef.value?.undoBoundaryPoint?.()
}
function handleClearAnalysisBoundary() {
drawingPointCount.value = 0
mapSceneRef.value?.clearAnalysisBoundary?.()
}
function handleDrawBoundary(payload = {}) {
drawingPointCount.value = payload.pointCount || payload.points?.length || 0
if (payload.status === "complete" && payload.feature) {
drawingPointCount.value = 0
engineeringAnalysisRef.value?.applyDrawnBoundary?.(payload.feature)
}
if (payload.status === "clear") drawingPointCount.value = 0
}
function handleAnalysisResult(payload = {}) {
mapSceneRef.value?.showAnalysisResult?.({
boundary: payload.boundary,
matches: payload.matches,
})
if (currentView.value === "engineering-analysis") return
}
function handleAnalysisProjectFocus(item) {
const matches = (item?.features || []).filter(Boolean)
mapSceneRef.value?.showAnalysisResult?.({
matches,
})
}
function closeFeaturePanel() {
selectedFeature.value = null
featureEmpty.value = false
@ -350,17 +420,24 @@ function hideLoading() {
right: 458px;
width: 248px;
height: 430px;
&.is-analysis {
top: 282px;
right: 72px;
transform: scale(0.92);
transform-origin: right top;
}
}
.ecological-bottom-topic {
bottom: 48px;
.topic-switcher {
width: 520px;
width: 650px;
}
.topic-switcher-item {
width: 142px;
width: 146px;
}
.topic-name {

@ -22,7 +22,11 @@ import focusBgTexture from "@/assets/texture/focus/focus_bg.png"
import focusMidQuanTexture from "@/assets/texture/focus/focus_mid_quan.png"
import focusMoveBgTexture from "@/assets/texture/focus/focus_move_bg.png"
export class Assets {
constructor() {
constructor(options = {}) {
this.options = {
includeChinaMap: false,
...options,
}
this.ready = null
this.init()
}
@ -55,8 +59,7 @@ export class Assets {
name: "mapStroke",
path: base_url + "assets/json/红原县-轮廓.json",
},
{ type: "Texture", name: "huiguang", path: huiguang },
{ type: "Texture", name: "huiguang", path: huiguang },
{ type: "Texture", name: "rotationBorder1", path: rotationBorder1 },
{ type: "Texture", name: "rotationBorder2", path: rotationBorder2 },
{ type: "Texture", name: "guangquan1", path: guangquan1 },
@ -71,9 +74,16 @@ export class Assets {
{ type: "Texture", name: "focusArrows", path: focusArrowsTexture },
{ type: "Texture", name: "focusBar", path: focusBarTexture },
{ type: "Texture", name: "focusBg", path: focusBgTexture },
{ type: "Texture", name: "focusMidQuan", path: focusMidQuanTexture },
{ type: "Texture", name: "focusMoveBg", path: focusMoveBgTexture },
]
{ type: "Texture", name: "focusMidQuan", path: focusMidQuanTexture },
{ type: "Texture", name: "focusMoveBg", path: focusMoveBgTexture },
]
if (this.options.includeChinaMap) {
assets.push({
type: "File",
name: "chinaMap",
path: base_url + "assets/json/echarts-china.json",
})
}
// 资源加载
this.ready = this.instance.loadAll(assets)
}

@ -391,7 +391,7 @@
</div>
</div>
<div v-else-if="panelType === 'heat'" class="business-heat">
<div v-else-if="panelType === 'heat'" class="business-heat" :class="{ 'is-dense': heatRows.length > 6 }">
<div
v-for="(item, index) in heatRows"
:key="item.key || item.name"
@ -456,7 +456,7 @@ const splitRows = computed(() => rows.value.filter((item) => numericValue(item)
const chartRows = computed(() => rows.value.filter((item) => numericValue(item) > 0))
const tableRows = computed(() => chartRows.value)
const structureRows = computed(() => rows.value.filter((item) => numericValue(item) > 0))
const heatRows = computed(() => chartRows.value.slice(0, 6))
const heatRows = computed(() => chartRows.value.slice(0, props.panel?.limit || 8))
const stackSegments = computed(() => props.panel?.segments || [])
const stackColumnRows = computed(() => rows.value.filter((item) => stackItemTotal(item) > 0))
const projectSearchText = ref("")
@ -720,6 +720,7 @@ function getProjectCellUnit(item, column) {
function getProjectCellClass(column) {
return {
"business-project-name": column?.key === "name",
"business-project-locate": column?.key === "locate",
"is-right": column?.align === "right",
"is-center": column?.align === "center",
"is-strong": column?.strong,
@ -727,6 +728,7 @@ function getProjectCellClass(column) {
}
function handleProjectRowClick(item) {
if (item?.canLocate === false) return
selectedProjectKey.value = item?.key || item?.name || null
emit("row-select", item)
}
@ -2999,6 +3001,12 @@ function columnGradient(index) {
font-size: 10px;
font-style: normal;
}
.business-project-locate {
color: #53f98f;
font-weight: 600;
text-shadow: 0 0 10px rgba(83, 249, 143, 0.3);
}
}
.business-project-col-name {
@ -3132,6 +3140,12 @@ function columnGradient(index) {
overflow: hidden;
}
.business-peaks.is-dense {
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 4px;
padding: 8px 8px 7px;
}
.business-peak-item {
display: grid;
grid-template-rows: 18px minmax(0, 1fr) 22px;
@ -3160,6 +3174,24 @@ function columnGradient(index) {
}
}
.business-peaks.is-dense .business-peak-item {
grid-template-rows: 17px minmax(0, 1fr) 24px;
}
.business-peaks.is-dense .business-peak-item strong {
font-size: 11px;
line-height: 15px;
transform: scale(0.92);
transform-origin: center top;
}
.business-peaks.is-dense .business-peak-item span {
line-height: 11px;
white-space: normal;
writing-mode: vertical-rl;
max-height: 24px;
}
.business-peak-stage {
position: relative;
width: 100%;
@ -3206,6 +3238,19 @@ function columnGradient(index) {
}
}
.business-peaks.is-dense .business-peak-stage {
min-height: 58px;
&:before {
left: 4px;
right: 4px;
}
i {
width: 24px;
}
}
.business-line {
display: grid;
grid-template-rows: minmax(0, 1fr) 28px;
@ -3359,6 +3404,12 @@ function columnGradient(index) {
overflow: hidden;
}
.business-heat.is-dense {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding: 8px 10px 9px;
}
.business-heat-cell {
position: relative;
display: grid;

@ -12,7 +12,7 @@
:class="{ 'is-active': topic.key === modelValue }"
:style="{ '--topic-color': topic.color }"
type="button"
@mousedown.prevent
@pointerup="handleTopicClick($event, topic.key)"
@click="handleTopicClick($event, topic.key)"
>
<span class="topic-name">{{ topic.name }}</span>
@ -41,11 +41,19 @@ const props = defineProps({
})
const emit = defineEmits(["update:modelValue"])
let lastPointerTopic = ""
let lastPointerAt = 0
const switcherTopics = computed(() => (props.topics?.length ? props.topics : forestGrassWetTopics))
function handleTopicClick(event, topicKey) {
event.currentTarget?.blur?.()
const now = Date.now()
if (event.type === "click" && lastPointerTopic === topicKey && now - lastPointerAt < 450) return
if (event.type === "pointerup") {
lastPointerTopic = topicKey
lastPointerAt = now
}
emit("update:modelValue", topicKey)
}
</script>

File diff suppressed because it is too large Load Diff

@ -4,7 +4,7 @@
</div>
</template>
<script setup>
import { ref, shallowRef, onBeforeUnmount, watch } from "vue";
import { nextTick, ref, shallowRef, onBeforeUnmount, onMounted, watch } from "vue";
import { World } from "./map.js";
const props = defineProps({
activeLayer: {
@ -16,9 +16,16 @@ const props = defineProps({
default: null,
},
});
const emit = defineEmits(["feature-loading", "feature-info", "layer-status"]);
const emit = defineEmits(["feature-loading", "feature-info", "layer-status", "draw-boundary", "scene-mounted"]);
const canvasRef = ref(null);
const canvasMap = shallowRef(null);
let resizeObserver = null;
let resizeFrame = null;
let resizeFallbackTimer = null;
let resizeCheckTimer = null;
onMounted(() => {
emit("scene-mounted");
});
onBeforeUnmount(() => {
destroyMap();
});
@ -28,12 +35,13 @@ function loadMap(assets) {
onFeatureLoading: (loading) => emit("feature-loading", loading),
onFeatureInfo: (payload) => emit("feature-info", payload),
onLayerStatus: (status) => emit("layer-status", status),
onDrawBoundary: (payload) => emit("draw-boundary", payload),
});
canvasMap.value.time.pause();
if (props.activeLayer) {
canvasMap.value.setTopicLayer(props.activeLayer);
}
canvasMap.value.setTopicLayers(props.activeLayer ? [props.activeLayer] : []);
canvasMap.value.setTownRanking(props.mapRanking);
connectMapResizeObserver();
scheduleMapResize();
}
async function play() {
if (!canvasMap.value) return;
@ -50,15 +58,88 @@ function focusProject(projectName) {
function focusPasture(payload) {
canvasMap.value?.focusPasture?.(payload);
}
function focusTown(townName, options) {
return canvasMap.value?.focusTownByName?.(townName, options);
}
function setBoundaryDrawing(enabled) {
canvasMap.value?.setBoundaryDrawing?.(enabled);
}
function finishBoundaryDrawing() {
return canvasMap.value?.finishBoundaryDrawing?.();
}
function undoBoundaryPoint() {
canvasMap.value?.undoBoundaryPoint?.();
}
function clearAnalysisBoundary() {
canvasMap.value?.clearAnalysisBoundary?.();
}
function showAnalysisResult(payload) {
canvasMap.value?.showAnalysisResult?.(payload);
}
function destroyMap() {
cleanupMapResizeObserver();
canvasMap.value?.destroy?.();
canvasMap.value = null;
}
function scheduleMapResize() {
if (resizeFrame) window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(() => {
resizeFrame = null;
canvasMap.value?.sizes?.init?.();
canvasMap.value?.resize?.();
});
if (resizeFallbackTimer) window.clearTimeout(resizeFallbackTimer);
resizeFallbackTimer = window.setTimeout(() => {
resizeFallbackTimer = null;
canvasMap.value?.sizes?.init?.();
canvasMap.value?.resize?.();
}, 180);
}
function connectMapResizeObserver() {
cleanupMapResizeObserver();
const target = canvasRef.value?.parentNode;
if (target && window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => scheduleMapResize());
resizeObserver.observe(target);
}
resizeCheckTimer = window.setInterval(ensureMapCanvasSize, 1000);
nextTick(scheduleMapResize);
}
function ensureMapCanvasSize() {
if (!canvasMap.value || !canvasRef.value) return;
const target = canvasRef.value.parentNode;
const width = target?.offsetWidth || window.innerWidth || 1;
const height = target?.offsetHeight || window.innerHeight || 1;
const pixelRatio = canvasMap.value.sizes?.pixelRatio || 1;
const expectedWidth = Math.floor(width * pixelRatio);
const expectedHeight = Math.floor(height * pixelRatio);
if (canvasRef.value.width !== expectedWidth || canvasRef.value.height !== expectedHeight) {
scheduleMapResize();
}
}
function cleanupMapResizeObserver() {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (resizeFrame) {
window.cancelAnimationFrame(resizeFrame);
resizeFrame = null;
}
if (resizeFallbackTimer) {
window.clearTimeout(resizeFallbackTimer);
resizeFallbackTimer = null;
}
if (resizeCheckTimer) {
window.clearInterval(resizeCheckTimer);
resizeCheckTimer = null;
}
}
watch(
() => props.activeLayer,
(layer) => {
if (canvasMap.value && layer) {
canvasMap.value.setTopicLayer(layer);
if (canvasMap.value) {
canvasMap.value.setTopicLayers(layer ? [layer] : []);
}
},
{ deep: true },
@ -78,20 +159,31 @@ defineExpose({
clearSelectedFeature,
focusProject,
focusPasture,
focusTown,
setBoundaryDrawing,
finishBoundaryDrawing,
undoBoundaryPoint,
clearAnalysisBoundary,
showAnalysisResult,
destroyMap,
canvasMap,
});
</script>
<style lang="scss">
.map {
position: absolute;
z-index: 1;
.map {
position: absolute;
z-index: 1;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: #000;
right: 0;
bottom: 0;
background-color: #000;
canvas {
display: block;
width: 100%;
height: 100%;
}
.info-point {
background: rgba(0, 0, 0, 0.5);
color: #a3dcde;
@ -249,8 +341,353 @@ defineExpose({
text-shadow: 0 0 5px #fef99e, 0 0 10px #fef99e !important;
}
}
}
}
.epidemic-village-label {
will-change: transform;
.epidemic-village-card {
position: relative;
width: 142px;
padding: 7px 9px 6px;
border: 1px solid rgba(91, 236, 235, 0.42);
border-radius: 4px;
color: #eaffff;
background:
linear-gradient(90deg, rgba(5, 35, 45, 0.92), rgba(2, 18, 29, 0.78)),
rgba(1, 17, 27, 0.86);
box-shadow: 0 0 16px rgba(45, 222, 255, 0.22), inset 0 0 16px rgba(39, 240, 192, 0.08);
transform: translate(48%, -12%);
&:before {
position: absolute;
left: -10px;
top: 50%;
width: 10px;
height: 1px;
content: "";
background: linear-gradient(90deg, rgba(91, 236, 235, 0), rgba(91, 236, 235, 0.78));
}
&:after {
position: absolute;
left: -14px;
top: calc(50% - 3px);
width: 6px;
height: 6px;
border-radius: 50%;
content: "";
background: #fff36d;
box-shadow: 0 0 10px #fff36d;
}
&.is-small {
transform: translate(42%, -8%) scale(0.92);
}
}
.village-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
min-width: 0;
strong {
overflow: hidden;
color: #ffffff;
font-size: 13px;
line-height: 16px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
flex: 0 0 auto;
padding: 1px 5px 2px;
border: 1px solid rgba(39, 240, 192, 0.28);
border-radius: 2px;
color: #79f8e5;
font-size: 9px;
line-height: 11px;
background: rgba(39, 240, 192, 0.08);
}
}
.village-card-body {
display: flex;
align-items: baseline;
gap: 3px;
margin-top: 4px;
white-space: nowrap;
.metric {
color: rgba(201, 255, 251, 0.76);
font-size: 10px;
line-height: 12px;
}
.number {
color: #ffffff;
font-size: 20px;
line-height: 22px;
font-weight: 900;
text-shadow: 0 0 10px rgba(126, 251, 246, 0.7);
}
.unit {
color: rgba(232, 252, 255, 0.8);
font-size: 10px;
line-height: 12px;
font-weight: 700;
}
}
.village-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
margin-top: 3px;
color: rgba(226, 250, 255, 0.62);
font-size: 9px;
line-height: 11px;
white-space: nowrap;
}
}
.trade-market-label,
.trade-flow-label,
.trade-national-label {
color: #ffffff;
will-change: transform;
pointer-events: none;
}
.trade-national-name {
padding: 2px 6px;
border: 1px solid rgba(72, 231, 255, 0.24);
border-radius: 3px;
background: rgba(3, 23, 35, 0.42);
color: rgba(255, 255, 255, 0.62);
font-size: 10px;
line-height: 12px;
font-weight: 800;
text-shadow: 0 0 6px rgba(48, 220, 255, 0.42);
white-space: nowrap;
transform: translate(-50%, -50%);
}
.trade-market-card {
position: relative;
width: 152px;
padding: 7px 9px 7px;
border: 1px solid rgba(255, 221, 117, 0.58);
border-radius: 4px;
background:
linear-gradient(110deg, rgba(68, 44, 10, 0.82), rgba(4, 24, 34, 0.7)),
rgba(5, 20, 30, 0.72);
box-shadow: 0 0 12px rgba(255, 207, 98, 0.18), inset 0 0 12px rgba(255, 207, 98, 0.08);
transform: translate(38%, 18%);
&:before {
position: absolute;
left: -14px;
top: calc(50% - 1px);
width: 14px;
height: 1px;
content: "";
background: linear-gradient(90deg, rgba(255, 221, 117, 0), rgba(255, 221, 117, 0.92));
}
&:after {
position: absolute;
left: -18px;
top: calc(50% - 4px);
width: 8px;
height: 8px;
border-radius: 50%;
content: "";
background: #fff06a;
box-shadow: 0 0 12px rgba(255, 240, 106, 0.9);
}
.market-title {
overflow: hidden;
color: #ffffff;
font-size: 12px;
line-height: 16px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.market-coord {
margin-top: 2px;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
line-height: 13px;
}
.market-total {
display: flex;
align-items: baseline;
gap: 4px;
margin-top: 3px;
span {
color: #ffffff;
font-size: 17px;
line-height: 20px;
font-weight: 900;
text-shadow: 0 0 10px rgba(255, 221, 117, 0.6);
}
em {
color: rgba(255, 255, 255, 0.76);
font-size: 10px;
line-height: 12px;
font-style: normal;
}
}
}
.trade-flow-card {
position: relative;
width: 128px;
padding: 6px 8px 5px;
border: 1px solid rgba(85, 226, 255, 0.38);
border-radius: 4px;
background:
linear-gradient(105deg, rgba(4, 37, 50, 0.76), rgba(3, 17, 27, 0.64)),
rgba(1, 17, 27, 0.64);
box-shadow: 0 0 10px rgba(48, 220, 255, 0.14), inset 0 0 10px rgba(39, 240, 192, 0.06);
transform: translate(36%, -18%);
&.is-left,
&.is-top-left,
&.is-bottom-left {
transform: translate(-112%, -18%);
&:before {
right: -10px;
left: auto;
background: linear-gradient(90deg, rgba(85, 226, 255, 0.84), rgba(85, 226, 255, 0));
}
&:after {
right: -14px;
left: auto;
}
}
&.is-top,
&.is-top-left,
&.is-top-right {
transform: translate(-50%, -118%);
}
&.is-bottom,
&.is-bottom-left,
&.is-bottom-right {
transform: translate(-50%, 20%);
}
&.is-top-left {
transform: translate(-110%, -112%);
}
&.is-top-right {
transform: translate(24%, -112%);
}
&.is-bottom-left {
transform: translate(-110%, 18%);
}
&.is-bottom-right {
transform: translate(24%, 18%);
}
&:before {
position: absolute;
left: -10px;
top: 50%;
width: 10px;
height: 1px;
content: "";
background: linear-gradient(90deg, rgba(85, 226, 255, 0), rgba(85, 226, 255, 0.84));
}
&:after {
position: absolute;
left: -14px;
top: calc(50% - 3px);
width: 6px;
height: 6px;
border-radius: 50%;
content: "";
background: #30dcff;
box-shadow: 0 0 10px rgba(48, 220, 255, 0.8);
}
.flow-head {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
span {
flex: 0 0 auto;
color: #ffe076;
font-size: 10px;
line-height: 13px;
font-weight: 900;
}
strong {
overflow: hidden;
color: #ffffff;
font-size: 11px;
line-height: 14px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.flow-body {
display: flex;
align-items: baseline;
gap: 3px;
margin-top: 3px;
em {
color: #ffffff;
font-size: 15px;
line-height: 17px;
font-style: normal;
font-weight: 900;
text-shadow: 0 0 10px rgba(48, 220, 255, 0.55);
}
b {
color: rgba(255, 255, 255, 0.76);
font-size: 10px;
line-height: 12px;
}
}
.flow-foot {
margin-top: 2px;
color: rgba(255, 255, 255, 0.62);
font-size: 9px;
line-height: 11px;
white-space: nowrap;
}
}
.china-label {
color: #fff;
font-size: 14px;

@ -0,0 +1,325 @@
import { grasslandYakRows } from "@/views/gdMap/livestockData"
import { pastureListRows } from "@/views/pastureManagement/data"
export const BALANCE_CALCULATION_PARAMS = {
grasslandCapacityPerMu: 0.047,
yakToSheepUnit: 4.8,
}
export const COUNTY_TARGET = {
key: "county-513233",
type: "county",
name: "红原县",
areaCode: "513233",
}
export const RISK_LEVELS = [
{
key: "healthy",
label: "草畜平衡",
shortLabel: "平衡",
range: "≤ 0%",
description: "承载力覆盖实际载畜量,当前放牧压力处于合理区间。",
actions: ["保持当前载畜水平与轮牧节奏", "定期复测草地生产力和载畜量", "关注极端天气对牧草生长的影响"],
},
{
key: "watch",
label: "轻度超载",
shortLabel: "关注",
range: "0% - 10%",
description: "已出现轻度超载迹象,建议提前介入调控。",
actions: ["适度压减超载畜群规模", "延长冬春舍饲补饲周期", "增加人工草场补饲供应"],
},
{
key: "warning",
label: "中度超载",
shortLabel: "预警",
range: "10% - 20%",
description: "超载较明显,草地退化风险上升,应启动主动调控。",
actions: ["压减载畜量至理论承载力以内", "推行划区轮牧与阶段性休牧", "扩大优质人工饲草供给"],
},
{
key: "danger",
label: "严重超载",
shortLabel: "高压",
range: "> 20%",
description: "超载压力较高,建议纳入重点管控和生态修复范围。",
actions: ["强制压减载畜量并实施禁牧休牧", "对退化草地实施生态修复", "建立重点区域长效监测与考核机制"],
},
]
export const fieldMap = {
areaRange: ["areaRange", "area_range", "acreage", "grasslandArea", "area"],
yakNumber: ["yakNumber", "yak_number", "number", "yakCount", "livestockCount"],
bearingCapacity: ["bearingCapacity", "bearing_capacity", "capacity", "theoryCapacity"],
livestockCapacity: ["livestockCapacity", "livestock_capacity", "carryingCapacity", "actualCapacity"],
overloadRate: ["overloadRate", "overload_rate", "rate"],
level: ["level", "conclusion"],
advice: ["advice", "balanceAdvice", "balance_advice", "balanceAdivce", "balance_adivce"],
year: ["year"],
areaName: ["areaName", "area_name", "name"],
pasturesName: ["pasturesName", "pastures_name", "pastureName", "muhuName", "name"],
}
export const townCodeByName = {
邛溪镇: "513233100",
刷经寺镇: "513233101",
安曲镇: "513233103",
龙日镇: "513233105",
江茸乡: "513233202",
查尔玛乡: "513233203",
瓦切镇: "513233204",
瓦切乡: "513233204",
阿木乡: "513233205",
麦洼乡: "513233207",
色地乡: "513233208",
色地镇: "513233208",
壤口乡: "513233209",
}
const round = (value, decimals = 2) => {
const factor = 10 ** decimals
return Math.round((Number(value) || 0) * factor) / factor
}
const townPressureFactorByName = {
色地乡: 1.38,
麦洼乡: 1.26,
壤口乡: 0.92,
阿木乡: 1.12,
瓦切镇: 1.2,
查尔玛乡: 1.04,
江茸乡: 0.88,
龙日镇: 1.16,
安曲镇: 1.28,
刷经寺镇: 0.78,
邛溪镇: 1.08,
}
export function toNumber(value, fallback = null) {
if (value === undefined || value === null || value === "") return fallback
const normalized = typeof value === "string"
? value.replace(/,/g, "").replace(/%/g, "").trim()
: value
const number = Number(normalized)
return Number.isFinite(number) ? number : fallback
}
export function pickValue(item = {}, keys = [], fallback = undefined) {
for (const key of keys) {
const value = item?.[key]
if (value !== undefined && value !== null && value !== "") return value
}
return fallback
}
export function formatNumber(value, options = {}) {
const number = toNumber(value)
if (number === null) return "--"
return number.toLocaleString("zh-CN", {
maximumFractionDigits: options.maximumFractionDigits ?? (Number.isInteger(number) ? 0 : 2),
})
}
export function formatPercent(value) {
const number = toNumber(value)
if (number === null) return "--"
return `${formatNumber(number)}%`
}
export function getRiskLevel(value) {
const rate = toNumber(value)
if (rate === null || rate <= 0) return RISK_LEVELS[0]
if (rate <= 10) return RISK_LEVELS[1]
if (rate <= 20) return RISK_LEVELS[2]
return RISK_LEVELS[3]
}
function calculateLivestockCapacity(yakCount) {
return Math.round((Number(yakCount) || 0) * BALANCE_CALCULATION_PARAMS.yakToSheepUnit)
}
function calculateBearingCapacity(areaRange) {
return Math.round((Number(areaRange) || 0) * BALANCE_CALCULATION_PARAMS.grasslandCapacityPerMu)
}
function buildBalanceRow({
key,
type,
name,
areaCode,
pasturesId,
areaRange,
yakNumber,
location,
township,
pressureFactor = 1,
}) {
const bearingCapacity = calculateBearingCapacity(areaRange)
const livestockCapacity = Math.round(calculateLivestockCapacity(yakNumber) * pressureFactor)
const overloadRate = bearingCapacity ? round(((livestockCapacity - bearingCapacity) / bearingCapacity) * 100, 1) : 0
const risk = getRiskLevel(overloadRate)
return {
key,
type,
name,
areaCode,
pasturesId,
areaRange: round(areaRange, 0),
yakNumber: round(yakNumber, 0),
bearingCapacity,
livestockCapacity,
overloadRate,
level: risk.label,
advice: risk.actions.join(";"),
year: "2026",
location,
township,
}
}
export const balanceTownRows = grasslandYakRows.map((item, index) => buildBalanceRow({
key: `town-${item.zoneName}`,
type: "town",
name: item.zoneName,
areaCode: townCodeByName[item.zoneName],
areaRange: item.grasslandArea,
yakNumber: item.yakCount,
location: null,
pressureFactor: townPressureFactorByName[item.zoneName] || 1,
index,
}))
const countyAreaRange = balanceTownRows.reduce((sum, item) => sum + item.areaRange, 0)
const countyYakNumber = balanceTownRows.reduce((sum, item) => sum + item.yakNumber, 0)
const countyBearingCapacity = balanceTownRows.reduce((sum, item) => sum + item.bearingCapacity, 0)
const countyLivestockCapacity = balanceTownRows.reduce((sum, item) => sum + item.livestockCapacity, 0)
const countyOverloadRate = countyBearingCapacity
? round(((countyLivestockCapacity - countyBearingCapacity) / countyBearingCapacity) * 100, 1)
: 0
const countyRisk = getRiskLevel(countyOverloadRate)
export const countyBalanceRow = {
...COUNTY_TARGET,
areaRange: countyAreaRange,
yakNumber: countyYakNumber,
bearingCapacity: countyBearingCapacity,
livestockCapacity: countyLivestockCapacity,
overloadRate: countyOverloadRate,
level: countyRisk.label,
advice: countyRisk.actions.join(";"),
year: "2026",
}
export const balancePastureRows = pastureListRows.map((item) => buildBalanceRow({
key: item.id || item.key || item.name,
type: "pasture",
name: item.name,
areaCode: townCodeByName[item.township],
pasturesId: item.id,
areaRange: item.grasslandArea,
yakNumber: item.yakCount,
location: item.location,
township: item.township,
}))
export const balanceFallbackRows = [
countyBalanceRow,
...balanceTownRows,
...balancePastureRows,
]
export function normalizeBalanceResult(source = {}, fallback = {}) {
const areaRange = toNumber(pickValue(source, fieldMap.areaRange), fallback.areaRange ?? 0)
const yakNumber = toNumber(pickValue(source, fieldMap.yakNumber), fallback.yakNumber ?? 0)
const bearingCapacity = toNumber(pickValue(source, fieldMap.bearingCapacity), fallback.bearingCapacity ?? calculateBearingCapacity(areaRange))
const livestockCapacity = toNumber(pickValue(source, fieldMap.livestockCapacity), fallback.livestockCapacity ?? calculateLivestockCapacity(yakNumber))
const directRate = toNumber(pickValue(source, fieldMap.overloadRate))
const overloadRate = directRate !== null
? round(directRate, 1)
: bearingCapacity
? round(((livestockCapacity - bearingCapacity) / bearingCapacity) * 100, 1)
: 0
const risk = getRiskLevel(overloadRate)
return {
...fallback,
...source,
key: fallback.key || source.key || source.id || source.areaCode || source.pasturesId || fallback.name,
type: fallback.type || source.type || (source.pasturesId ? "pasture" : "town"),
name: pickValue(source, [...fieldMap.pasturesName, ...fieldMap.areaName], fallback.name || "核算对象"),
areaCode: source.areaCode || source.area_code || fallback.areaCode,
pasturesId: source.pasturesId || source.pastures_id || fallback.pasturesId,
areaRange,
yakNumber,
bearingCapacity,
livestockCapacity,
overloadRate,
level: pickValue(source, fieldMap.level, risk.label),
advice: pickValue(source, fieldMap.advice, fallback.advice || risk.actions.join(";")),
year: pickValue(source, fieldMap.year, fallback.year || "当前年度"),
}
}
export function deriveBalance(row = {}) {
const bearing = toNumber(row.bearingCapacity, 0)
const livestock = toNumber(row.livestockCapacity, 0)
const areaRange = toNumber(row.areaRange, 0)
const yakNumber = toNumber(row.yakNumber, 0)
const overloadRate = toNumber(row.overloadRate, bearing ? ((livestock - bearing) / bearing) * 100 : 0)
const delta = livestock - bearing
const usageRate = bearing ? round((livestock / bearing) * 100, 1) : 0
const risk = getRiskLevel(overloadRate)
const yakSheepUnit = Math.round(yakNumber * BALANCE_CALCULATION_PARAMS.yakToSheepUnit)
const pressureFactor = yakSheepUnit ? round(livestock / yakSheepUnit, 2) : 1
const bearingCoefficient = areaRange ? round(bearing / areaRange, 4) : BALANCE_CALCULATION_PARAMS.grasslandCapacityPerMu
return {
bearing,
livestock,
areaRange,
yakNumber,
yakSheepUnit,
pressureFactor,
bearingCoefficient,
overloadRate: round(overloadRate, 1),
delta,
usageRate,
risk,
bearingPercent: Math.max(4, Math.min(100, round((bearing / Math.max(bearing, livestock, 1)) * 100, 1))),
livestockPercent: Math.max(4, Math.min(100, round((livestock / Math.max(bearing, livestock, 1)) * 100, 1))),
}
}
export function splitAdvice(row = {}, risk = getRiskLevel(row.overloadRate)) {
const text = String(row.advice || "").trim()
const apiPoints = text
? text.split(/[。;;\n]/).map((item) => item.trim()).filter(Boolean)
: []
const unsuitableWhenOverloaded = ["提高放牧畜禽的密度", "增加草地的利用率", "密度不能过高"]
const cleanedApiPoints = apiPoints.filter((item) => {
if (unsuitableWhenOverloaded.some((keyword) => item.includes(keyword))) return false
if (risk.key === "healthy") return true
return !unsuitableWhenOverloaded.some((keyword) => item.includes(keyword))
})
return [...(risk.actions || []), ...cleanedApiPoints]
.filter((item, index, array) => array.indexOf(item) === index)
.slice(0, 4)
}
export function normalizeTargetRows(rows = []) {
return rows
.map((item, index) => {
const name = pickValue(item, ["name", "label", "areaName", "area_name", "pasturesName", "pastures_name"], "")
const areaCode = pickValue(item, ["areaCode", "area_code", "code"])
const pasturesId = pickValue(item, ["pasturesId", "pastures_id", "id", "value"])
if (!name && !areaCode && !pasturesId) return null
return {
key: pasturesId ? `pasture-${pasturesId}` : `area-${areaCode || index}`,
type: pasturesId && item.key !== "areaCode" ? "pasture" : "town",
name: name || areaCode || pasturesId,
areaCode,
pasturesId: item.key === "pasturesId" ? pasturesId : undefined,
raw: item,
}
})
.filter(Boolean)
}

File diff suppressed because it is too large Load Diff

@ -14,7 +14,7 @@
class="home-card left-card"
:title="panel.title"
:width="398"
:height="186"
:height="panel.height"
>
<business-panel-renderer :panel="panel" compact />
</m-card>
@ -27,7 +27,7 @@
class="home-card right-card"
:title="panel.title"
:width="398"
:height="186"
:height="panel.height"
>
<business-panel-renderer :panel="panel" compact />
</m-card>
@ -162,6 +162,7 @@ const leftPanels = computed(() => [
{
key: "resource-structure",
title: "林草湿资源结构",
height: 186,
type: "donut",
variant: "ring",
rows: resourceRows.value,
@ -169,6 +170,7 @@ const leftPanels = computed(() => [
{
key: "yak-town-rank",
title: "乡镇牦牛存栏",
height: 186,
type: "bar",
variant: "distribution",
rows: yakRows.value,
@ -176,12 +178,14 @@ const leftPanels = computed(() => [
{
key: "capacity-density",
title: "草畜承载密度",
height: 186,
type: "line",
rows: densityRows.value,
},
{
key: "resource-business",
title: "资源业务画像",
height: 186,
type: "structure",
rows: resourceBusinessRows.value,
},
@ -191,12 +195,14 @@ const rightPanels = computed(() => [
{
key: "trade-structure",
title: "牦牛交易结构",
height: 186,
type: "donut",
rows: tradeStructureRows.value,
},
{
key: "support-industry",
title: "产业支撑能力",
height: 186,
type: "column",
variant: "slim",
rows: supportChartRows.value,
@ -204,6 +210,7 @@ const rightPanels = computed(() => [
{
key: "town-resource",
title: "乡镇资源协同",
height: 186,
type: "bar",
variant: "distribution",
rows: townResourceRows.value,
@ -211,6 +218,7 @@ const rightPanels = computed(() => [
{
key: "data-flow",
title: "业务数据动态",
height: 186,
type: "heat",
rows: dataTrendRows.value,
},
@ -288,7 +296,6 @@ function formatNumber(value) {
maximumFractionDigits: Number.isInteger(number) ? 0 : 2,
})
}
</script>
<style lang="scss">
@ -325,7 +332,7 @@ function formatNumber(value) {
flex-direction: column;
gap: 12px;
width: 398px;
pointer-events: all;
pointer-events: none;
}
.home-left {

@ -0,0 +1,200 @@
<template>
<div class="login-screen">
<div class="login-shell">
<div class="login-brand">
<span>红原县数字畜牧业监管平台</span>
<small>Hongyuan Digital Animal Husbandry Supervision Platform</small>
</div>
<form class="login-panel" @submit.prevent="handleSubmit">
<div class="login-panel-title">用户登录</div>
<label class="login-field">
<span>账号</span>
<input v-model.trim="username" autocomplete="username" autofocus placeholder="请输入账号" />
</label>
<label class="login-field">
<span>密码</span>
<input v-model="password" autocomplete="current-password" placeholder="请输入密码" type="password" />
</label>
<div v-if="errorMessage" class="login-error">{{ errorMessage }}</div>
<button class="login-submit" :disabled="submitting" type="submit">
{{ submitting ? "登录中" : "登录" }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { computed, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { login } from "@/services/auth"
const route = useRoute()
const router = useRouter()
const username = ref("")
const password = ref("")
const submitting = ref(false)
const errorMessage = ref("")
const redirectPath = computed(() => {
const redirect = typeof route.query.redirect === "string" ? decodeURIComponent(route.query.redirect) : "/platform"
return redirect.startsWith("/login") ? "/platform" : redirect || "/platform"
})
async function handleSubmit() {
errorMessage.value = ""
if (!username.value || !password.value) {
errorMessage.value = "请输入账号和密码。"
return
}
submitting.value = true
try {
await login(username.value, password.value)
router.replace(redirectPath.value)
} catch (error) {
errorMessage.value = error?.message || "登录失败,请稍后再试。"
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.login-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background:
linear-gradient(180deg, rgba(4, 16, 38, 0.1), rgba(2, 8, 22, 0.62)),
url("@/assets/images/bg.png") center / cover no-repeat,
#05142f;
color: #fff;
}
.login-screen::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(0, 0, 0, 0.46), transparent 42%, rgba(0, 0, 0, 0.35)),
radial-gradient(circle at 52% 46%, rgba(48, 220, 255, 0.16), transparent 32%);
}
.login-shell {
position: relative;
z-index: 1;
width: min(980px, calc(100vw - 48px));
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 56px;
align-items: center;
}
.login-brand {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
text-shadow: 0 0 20px rgba(48, 220, 255, 0.36);
}
.login-brand span {
font-size: 42px;
line-height: 1.2;
font-weight: 700;
}
.login-brand small {
color: rgba(196, 243, 254, 0.72);
font-size: 13px;
letter-spacing: 0;
}
.login-panel {
width: 360px;
padding: 30px 28px 28px;
border: 1px solid rgba(48, 220, 255, 0.34);
background: rgba(3, 14, 32, 0.82);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 0 28px rgba(48, 220, 255, 0.08);
box-sizing: border-box;
}
.login-panel-title {
margin-bottom: 26px;
color: #c4f3fe;
font-size: 22px;
font-weight: 700;
text-align: center;
}
.login-field {
display: block;
margin-bottom: 18px;
}
.login-field span {
display: block;
margin-bottom: 8px;
color: rgba(196, 243, 254, 0.78);
font-size: 13px;
}
.login-field input {
width: 100%;
height: 42px;
padding: 0 12px;
border: 1px solid rgba(48, 220, 255, 0.26);
outline: none;
background: rgba(0, 10, 24, 0.62);
color: #fff;
font-size: 15px;
box-sizing: border-box;
}
.login-field input:focus {
border-color: rgba(48, 220, 255, 0.72);
box-shadow: 0 0 0 2px rgba(48, 220, 255, 0.12);
}
.login-error {
min-height: 20px;
margin: -4px 0 12px;
color: #ffcf8a;
font-size: 13px;
line-height: 20px;
}
.login-submit {
width: 100%;
height: 44px;
border: 1px solid rgba(48, 220, 255, 0.52);
background: linear-gradient(90deg, rgba(24, 140, 196, 0.92), rgba(37, 204, 218, 0.92));
color: #fff;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.login-submit:disabled {
cursor: not-allowed;
opacity: 0.68;
}
@media (max-width: 820px) {
.login-shell {
grid-template-columns: 1fr;
gap: 28px;
}
.login-brand span {
font-size: 30px;
}
.login-panel {
width: 100%;
}
}
</style>

@ -1,139 +1,133 @@
import { grasslandWetRows, grasslandYakRows, homeCore } from "@/views/gdMap/livestockData"
const round = (value, decimals = 2) => {
const factor = 10 ** decimals
return Math.round((Number(value) || 0) * factor) / factor
}
const sumBy = (rows, key) => rows.reduce((sum, item) => sum + (Number(item[key]) || 0), 0)
const toWanMu = (value) => round((Number(value) || 0) / 10000, 2)
const totalGrasslandWanMu = toWanMu(homeCore.grasslandArea)
const totalYakCount = sumBy(grasslandYakRows, "yakCount")
const totalPastureCount = homeCore.herdsmanCount
const locatedPastureCount = 118
const confirmedGrasslandWanMu = round(totalGrasslandWanMu * 0.916, 2)
const linkedPastureCount = 121
const grasslandLinkRate = round((linkedPastureCount / Math.max(totalPastureCount, 1)) * 100, 1)
const locatedPastureRate = round((locatedPastureCount / Math.max(totalPastureCount, 1)) * 100, 1)
const overloadPastureCount = 16
export const pastureTopStats = [
{
key: "pasture-count",
zh: "牧户总数",
name: "牧户总数",
value: totalPastureCount,
unit: "户",
decimals: 0,
},
{
key: "confirmed-grassland",
zh: "确权草场",
name: "确权草场",
value: confirmedGrasslandWanMu,
unit: "万亩",
decimals: 2,
},
{
key: "pasture-yak",
zh: "关联牦牛",
name: "关联牦牛",
value: totalYakCount,
unit: "头",
decimals: 0,
},
{
key: "located-pasture",
zh: "可定位牧户",
name: "可定位牧户",
value: locatedPastureCount,
unit: "户",
decimals: 0,
},
]
export const pastureCoreRows = [
{ key: "pasture-total", name: "牧户总数", value: totalPastureCount, unit: "户", icon: "town" },
{ key: "confirmed-grassland-area", name: "确权草场", value: confirmedGrasslandWanMu, unit: "万亩", icon: "area" },
{ key: "grassland-link-rate", name: "草场关联率", value: grasslandLinkRate, unit: "%", icon: "rate" },
{ key: "located-pasture-rate", name: "可定位率", value: locatedPastureRate, unit: "%", icon: "rate" },
const cxptCoreRows = [
{ key: "pasture-total", name: "牧户数量", value: 9110, unit: "户", icon: "town" },
{ key: "avg-yak-per-household", name: "户均牦牛头数", value: 52, unit: "头", icon: "yield" },
{ key: "avg-grassland-area", name: "户均草地面积", value: 1131, unit: "亩", icon: "area" },
{ key: "joint-household-count", name: "联户数量", value: 237, unit: "户", icon: "town" },
{ key: "poverty-monitor-count", name: "防反贫监测户", value: 122, unit: "户", icon: "town" },
{ key: "average-income", name: "人均收入", value: 19679, unit: "元/年", icon: "rate" },
]
export const pastureTownRows = grasslandYakRows
.map((item, index) => {
const resource = grasslandWetRows.find((row) => row.zoneName === item.zoneName) || {}
const grasslandArea = toWanMu(item.grasslandArea || resource.grasslandArea)
const wetlandArea = toWanMu(resource.wetArea)
const pastureCount = Math.max(5, Math.round((item.yakCount / Math.max(totalYakCount, 1)) * totalPastureCount))
return {
key: item.zoneName,
name: item.zoneName,
value: pastureCount,
unit: "户",
yakCount: item.yakCount,
grasslandArea,
wetlandArea,
onlineRate: Math.min(98, 78 + ((index * 7) % 18)),
density: round(item.yakCount / Math.max(grasslandArea, 1), 1),
}
})
.sort((a, b) => b.value - a.value)
export const pastureCoreRows = cxptCoreRows
export const pastureScaleRows = [
{ key: "scale-large", name: "万亩以上", value: 18, unit: "户" },
{ key: "scale-medium", name: "5000-10000亩", value: 36, unit: "户" },
{ key: "scale-normal", name: "1000-5000亩", value: 57, unit: "户" },
{ key: "scale-small", name: "1000亩以下", value: 18, unit: "户" },
]
export const pastureTopStats = cxptCoreRows.map((item) => ({
...item,
zh: item.name,
decimals: 0,
}))
export const pastureUseLevelRows = [
{ key: "use-stable", name: "适宜利用", value: totalPastureCount - overloadPastureCount - 22, unit: "户" },
{ key: "use-watch", name: "重点关注", value: 22, unit: "户" },
{ key: "use-overload", name: "疑似超载", value: overloadPastureCount, unit: "户" },
]
export const pastureTownRows = [
{ areaCode: "513233100", areaName: "邛溪镇", number: 1452, year: 2024 },
{ areaCode: "513233104", areaName: "刷经寺镇", number: 1500, year: 2024 },
{ areaCode: "513233101", areaName: "安曲镇", number: 76, year: 2024 },
{ areaCode: "513233103", areaName: "龙日镇", number: 828, year: 2024 },
{ areaCode: "513233204", areaName: "江茸乡", number: 902, year: 2024 },
{ areaCode: "513233205", areaName: "查尔玛乡", number: 450, year: 2024 },
{ areaCode: "513233102", areaName: "瓦切镇", number: 602, year: 2024 },
{ areaCode: "513233203", areaName: "阿木乡", number: 300, year: 2024 },
{ areaCode: "513233202", areaName: "麦洼乡", number: 1204, year: 2024 },
{ areaCode: "513233105", areaName: "色地乡", number: 2638, year: 2024 },
].map((item) => ({
...item,
key: item.areaCode,
name: item.areaName,
value: item.number,
unit: "户",
}))
export const pastureOnlineRows = pastureTownRows.slice(0, 6).map((item) => ({
key: `${item.key}-online`,
name: item.name,
value: item.onlineRate,
export const pastureOnlineRows = [
{ areaCode: "513233103", areaName: "龙日镇", uplineRate: 4, year: 2024 },
{ areaCode: "513233202", areaName: "麦洼乡", uplineRate: 52, year: 2024 },
{ areaCode: "513233101", areaName: "安曲镇", uplineRate: 22, year: 2024 },
{ areaCode: "513233100", areaName: "邛溪镇", uplineRate: 23, year: 2024 },
{ areaCode: "513233105", areaName: "色地乡", uplineRate: 8, year: 2024 },
{ areaCode: "513233203", areaName: "阿木乡", uplineRate: 12, year: 2024 },
{ areaCode: "513233204", areaName: "江茸乡", uplineRate: 14, year: 2024 },
{ areaCode: "513233104", areaName: "刷经寺镇", uplineRate: 9, year: 2024 },
].map((item) => ({
...item,
key: item.areaCode,
name: item.areaName,
value: item.uplineRate,
unit: "%",
extra: `${item.value}`,
extra: `${item.uplineRate}%`,
}))
export const pastureResourceRows = [
{ key: "linked-pasture", name: "已关联牧户", value: linkedPastureCount, unit: "户" },
{ key: "unlinked-pasture", name: "待关联牧户", value: Math.max(totalPastureCount - linkedPastureCount, 0), unit: "户" },
{ key: "located-pasture", name: "可定位牧户", value: locatedPastureCount, unit: "户" },
{ key: "unlocated-pasture", name: "待补坐标", value: Math.max(totalPastureCount - locatedPastureCount, 0), unit: "户" },
export const pastureStructureRows = [
{ areaName: "全县", ageRange: "0-20", sex: "男", number: 340, value: 340, year: 2024 },
{ areaName: "全县", ageRange: "0-20", sex: "女", number: 348, value: 348, year: 2024 },
{ areaName: "全县", ageRange: "21-40", sex: "男", number: 1700, value: 1700, year: 2024 },
{ areaName: "全县", ageRange: "21-40", sex: "女", number: 2040, value: 2040, year: 2024 },
{ areaName: "全县", ageRange: "41-60", sex: "男", number: 2040, value: 2040, year: 2024 },
{ areaName: "全县", ageRange: "41-60", sex: "女", number: 2104, value: 2104, year: 2024 },
{ areaName: "全县", ageRange: "61-80", sex: "男", number: 680, value: 680, year: 2024 },
{ areaName: "全县", ageRange: "61-80", sex: "女", number: 580, value: 580, year: 2024 },
{ areaName: "全县", ageRange: "81-100", sex: "男", number: 60, value: 60, year: 2024 },
{ areaName: "全县", ageRange: "81-100", sex: "女", number: 60, value: 60, year: 2024 },
]
export const pastureServiceRows = [
{ key: "confirmed-rate", name: "草场确权率", value: 91.6, unit: "%", extra: "确权面积口径" },
{ key: "link-rate", name: "草场关联率", value: grasslandLinkRate, unit: "%", extra: `${linkedPastureCount}户已关联` },
{ key: "locatable-rate", name: "可定位牧户", value: locatedPastureCount, unit: "户", extra: `${locatedPastureRate}%可定位` },
{ key: "overload-watch", name: "超载关注", value: overloadPastureCount, unit: "户", extra: "需核查草畜平衡" },
const aggregateBy = (rows, nameKey, valueKey, unit) => Array.from(rows.reduce((map, item) => {
const name = item[nameKey] || "未标注"
const row = map.get(name) || { key: name, name, value: 0, unit }
row.value += Number(item[valueKey]) || 0
map.set(name, row)
return map
}, new Map()).values())
export const pastureAgeRows = aggregateBy(pastureStructureRows, "ageRange", "value", "人")
export const pastureGenderRows = [
{ key: "male", name: "男", value: 4820, unit: "人" },
{ key: "female", name: "女", value: 5132, unit: "人" },
]
export const pastureListRows = [
{ id: "MH-001", name: "泽让多吉", township: "色地乡", grasslandArea: 68240, yakCount: 286, location: [102.5792, 32.7124] },
{ id: "MH-002", name: "俄木措", township: "瓦切镇", grasslandArea: 54120, yakCount: 218, location: [102.4891, 32.8068] },
{ id: "MH-003", name: "杨扎西", township: "阿木乡", grasslandArea: 49360, yakCount: 196, location: [102.6125, 32.5931] },
{ id: "MH-004", name: "李更登", township: "安曲镇", grasslandArea: 46280, yakCount: 184, location: [102.4457, 32.6316] },
{ id: "MH-005", name: "降央卓玛", township: "邛溪镇", grasslandArea: 43890, yakCount: 173, location: [102.5434, 32.7986] },
{ id: "MH-006", name: "仁青甲", township: "麦洼乡", grasslandArea: 40760, yakCount: 165, location: [102.6963, 32.7429] },
{ id: "MH-007", name: "周扎西", township: "查尔玛乡", grasslandArea: 37650, yakCount: 149, location: [102.3384, 32.7372] },
{ id: "MH-008", name: "拉姆措", township: "江茸乡", grasslandArea: 34980, yakCount: 136, location: [102.7683, 32.6288] },
{ id: "MH-009", name: "桑吉加", township: "龙日镇", grasslandArea: 32640, yakCount: 128, location: [102.2578, 32.6805] },
{ id: "MH-010", name: "王让布", township: "壤口乡", grasslandArea: 29130, yakCount: 114, location: [102.9027, 32.5604] },
{ name: "更洼尔", areaName: "瓦切乡", acreage: 4704.87, summerAcreage: 2267.26, winterAcreage: 2437.61, year: 2024 },
{ name: "涉尔丹", areaName: "麦洼乡", acreage: 1426.53, summerAcreage: 687.82, winterAcreage: 738.71, year: 2024 },
{ name: "热布丹", areaName: "查尔玛乡", acreage: 2176.09, summerAcreage: 1049.37, winterAcreage: 1126.72, year: 2024 },
{ name: "王西卓玛", areaName: "阿木乡", acreage: 4126.13, summerAcreage: 1989.59, winterAcreage: 2136.54, year: 2024 },
{ name: "阿尔", areaName: "色地乡", acreage: 1249.48, summerAcreage: 602.25, winterAcreage: 647.23, year: 2024 },
{ name: "尕让吉", areaName: "邛溪镇", acreage: 3688.72, summerAcreage: 1778.56, winterAcreage: 1910.16, year: 2024 },
{ name: "华尔甲", areaName: "安曲镇", acreage: 2956.44, summerAcreage: 1425.01, winterAcreage: 1531.43, year: 2024 },
{ name: "泽让措", areaName: "江茸乡", acreage: 3448.36, summerAcreage: 1662.61, winterAcreage: 1785.75, year: 2024 },
{ name: "扎西让布", areaName: "龙日镇", acreage: 2689.1, summerAcreage: 1296.15, winterAcreage: 1392.95, year: 2024 },
{ name: "格桑卓玛", areaName: "刷经寺镇", acreage: 3187.64, summerAcreage: 1536.84, winterAcreage: 1650.8, year: 2024 },
].map((item) => ({
...item,
value: item.grasslandArea,
township: item.areaName,
grasslandArea: item.acreage,
value: item.acreage,
unit: "亩",
yakNumber: item.yakCount,
acreage: item.grasslandArea,
locateText: "定位",
searchText: `${item.name} ${item.township} ${item.id}`,
canLocate: false,
locateText: "--",
searchText: `${item.name} ${item.areaName}`,
}))
export const pastureGrasslandRows = aggregateBy(pastureListRows, "township", "grasslandArea", "亩").map((item) => ({
...item,
value: toWanMu(item.value),
unit: "万亩",
}))
export const pastureSeasonRows = Array.from(pastureListRows.reduce((map, item) => {
const name = item.township || "未填写"
const row = map.get(name) || { key: `${name}-season`, name, values: { summer: 0, winter: 0 } }
row.values.summer += Number(item.summerAcreage) || 0
row.values.winter += Number(item.winterAcreage) || 0
map.set(name, row)
return map
}, new Map()).values()).map((item) => ({
...item,
values: {
summer: toWanMu(item.values.summer),
winter: toWanMu(item.values.winter),
},
unit: "万亩",
}))
export const pastureMapRows = pastureTownRows.map((item) => ({
@ -146,8 +140,8 @@ export const pastureFallbackProfile = {
topStats: pastureTopStats,
coreRows: pastureCoreRows,
townRows: pastureTownRows,
scaleRows: pastureScaleRows,
useLevelRows: pastureUseLevelRows,
ageRows: pastureStructureRows,
genderRows: pastureGenderRows,
onlineRows: pastureOnlineRows,
list: pastureListRows,
}

@ -5,6 +5,7 @@
class="pasture-map-scene"
:active-layer="pastureMapLayer"
:map-ranking="mapRanking"
@scene-mounted="handleMapSceneMounted"
/>
<div class="pasture-map-scrim"></div>
<div v-if="!mapReady" class="pasture-map-loading">3D地图加载中</div>
@ -12,45 +13,32 @@
<div class="pasture-management-ui">
<div class="pasture-left">
<div class="pasture-side-stack is-left">
<m-card class="pasture-side-card left-card" title="牧场核心指标" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="corePanel" compact />
<m-card class="pasture-side-card left-card" title="牧户区域统计" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="townPanel" compact />
</m-card>
<m-card class="pasture-side-card left-card" title="乡镇牧场分布" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="townPanel" compact />
<m-card class="pasture-side-card left-card" title="草地面积分布" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="grasslandPanel" compact />
</m-card>
<m-card class="pasture-side-card left-card" title="草场规模分布" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="scalePanel" compact />
<m-card class="pasture-side-card left-card" title="夏冬草场面积" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="seasonPanel" compact />
</m-card>
<m-card class="pasture-side-card left-card" title="草畜利用状态" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="useLevelPanel" compact />
<m-card class="pasture-side-card left-card" title="牧户上线率" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="onlinePanel" compact />
</m-card>
</div>
</div>
<section class="pasture-center-panel">
<div class="pasture-center-title">
<span>Pasture Management</span>
<strong>牧场管理</strong>
</div>
<div class="pasture-center-metrics">
<div v-for="item in centerMetrics" :key="item.key" class="pasture-center-metric">
<span>{{ item.name }}</span>
<strong>{{ formatNumber(item.value) }}<em>{{ item.unit }}</em></strong>
</div>
</div>
</section>
<div class="pasture-right">
<div class="pasture-side-stack is-right">
<m-card class="pasture-side-card right-card" title="牧户定位覆盖" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="locatePanel" compact />
<m-card class="pasture-side-card right-card" title="牧户年龄统计" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="agePanel" compact />
</m-card>
<m-card class="pasture-side-card right-card" title="草场关联状态" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="resourcePanel" compact />
<m-card class="pasture-side-card right-card" title="牧户性别统计" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="genderPanel" compact />
</m-card>
<m-card class="pasture-side-card right-card pasture-list-card" title="牧户列表" :width="sideCardWidth" :height="doubleCardHeight">
@ -58,14 +46,6 @@
</m-card>
</div>
</div>
<div class="pasture-bottom-status">
<div v-for="item in serviceRows" :key="item.key" class="pasture-status-item">
<span>{{ item.name }}</span>
<strong>{{ formatNumber(item.value) }}<em>{{ item.unit }}</em></strong>
<small>{{ item.extra }}</small>
</div>
</div>
</div>
</div>
</template>
@ -79,17 +59,17 @@ import { Assets } from "@/views/forestGrassWet/assets"
import { getLayerByKey } from "@/config/layers"
import { getPastureGrasslandsById, getPastureProfile } from "@/services/pastureApi"
import {
pastureAgeRows,
pastureCoreRows,
pastureFallbackProfile,
pastureGenderRows,
pastureGrasslandRows,
pastureListRows,
pastureMapRows,
pastureOnlineRows,
pastureResourceRows,
pastureServiceRows,
pastureScaleRows,
pastureSeasonRows,
pastureTopStats,
pastureTownRows,
pastureUseLevelRows,
} from "./data"
const emit = defineEmits(["module-stats", "loading-progress", "map-ready"])
@ -102,23 +82,21 @@ const mapReady = ref(false)
const apiProfile = ref(pastureFallbackProfile)
const pastureRows = ref(pastureListRows)
const pastureMapLayer = getLayerByKey("grassland", "grassland-distribution")
const pastureTopStatNames = ["牧户数量", "户均牦牛头数", "户均草地面积", "联户数量"]
let mapAssets = null
let mapAssetsReady = false
let destroyed = false
const coreRows = computed(() => normalizeCoreRows(apiProfile.value.core).length ? normalizeCoreRows(apiProfile.value.core) : pastureCoreRows)
const townRows = computed(() => normalizeTownRows(apiProfile.value.distribution).length ? normalizeTownRows(apiProfile.value.distribution) : pastureTownRows)
const onlineRows = computed(() => normalizeOnlineRows(apiProfile.value.online).length ? normalizeOnlineRows(apiProfile.value.online) : pastureOnlineRows)
const scaleRows = computed(() => pastureScaleRows)
const useLevelRows = computed(() => pastureUseLevelRows)
const serviceRows = computed(() => pastureServiceRows)
const centerMetrics = computed(() => [
coreRows.value[0] || pastureCoreRows[0],
coreRows.value[1] || pastureCoreRows[1],
coreRows.value[2] || pastureCoreRows[2],
])
const ageRows = computed(() => normalizeAgeRows(apiProfile.value.age).length ? normalizeAgeRows(apiProfile.value.age) : pastureAgeRows)
const genderRows = computed(() => normalizeGenderRows(apiProfile.value.age).length ? normalizeGenderRows(apiProfile.value.age) : pastureGenderRows)
const grasslandRows = computed(() => aggregatePastureAreaRows(pastureRows.value).length ? aggregatePastureAreaRows(pastureRows.value) : pastureGrasslandRows)
const seasonRows = computed(() => aggregatePastureSeasonRows(pastureRows.value).length ? aggregatePastureSeasonRows(pastureRows.value) : pastureSeasonRows)
const mapRanking = computed(() => ({
title: "牧分布排行",
metricLabel: "牧",
title: "牧户分布排行",
metricLabel: "牧户",
unit: "户",
rows: (townRows.value.length ? townRows.value : pastureMapRows).slice(0, 8).map((item) => ({
name: item.name,
@ -127,60 +105,60 @@ const mapRanking = computed(() => ({
})),
}))
const corePanel = computed(() => ({
key: "pasture-core",
type: "iconMetric",
rows: coreRows.value,
}))
const townPanel = computed(() => ({
key: "pasture-town",
type: "bar",
variant: "distribution",
rows: townRows.value.slice(0, 7),
rows: townRows.value,
}))
const scalePanel = computed(() => ({
key: "pasture-scale",
type: "column",
variant: "slim",
rows: scaleRows.value,
const grasslandPanel = computed(() => ({
key: "pasture-grassland-area",
type: "bar",
variant: "distribution",
rows: grasslandRows.value,
}))
const useLevelPanel = computed(() => ({
key: "pasture-use-level",
type: "donut",
variant: "ring",
rows: useLevelRows.value,
const seasonPanel = computed(() => ({
key: "pasture-season",
type: "stackColumn",
segments: [
{ key: "summer", name: "夏草场" },
{ key: "winter", name: "冬草场" },
],
rows: seasonRows.value.slice(0, 7),
}))
const locatePanel = computed(() => ({
key: "pasture-locate",
const onlinePanel = computed(() => ({
key: "pasture-online",
type: "line",
rows: onlineRows.value,
}))
const resourcePanel = computed(() => ({
key: "pasture-resource",
type: "structure",
rows: pastureResourceRows,
const agePanel = computed(() => ({
key: "pasture-age",
type: "column",
variant: "slim",
rows: ageRows.value,
}))
const genderPanel = computed(() => ({
key: "pasture-gender",
type: "donut",
rows: genderRows.value,
}))
const pastureListPanel = computed(() => ({
key: "pasture-herdsman-list",
type: "projectTable",
searchPlaceholder: "搜索牧户 / 乡镇",
emptyText: "无匹配牧户",
searchKeys: ["name", "township", "id", "grasslandArea", "yakCount"],
searchKeys: ["name", "township", "id", "grasslandArea"],
columns: [
{ key: "name", label: "牧户名称", field: "name", width: "30%" },
{ key: "grasslandArea", label: "草场面积", field: "grasslandArea", width: "24%", align: "right", strong: true, format: "number", unit: "亩" },
{ key: "yakCount", label: "牦牛数量", field: "yakCount", width: "22%", align: "right", strong: true, format: "number", unit: "头" },
{ key: "locate", label: "定位", field: "locateText", width: "14%", align: "center", fallback: "定位" },
{ key: "township", label: "区域", field: "township", width: "10%" },
{ key: "grasslandArea", label: "草场面积", field: "grasslandArea", width: "28%", align: "right", strong: true, format: "number", unit: "亩" },
{ key: "township", label: "区域", field: "township", width: "24%" },
{ key: "locate", label: "定位", field: "locateText", width: "18%", align: "center", fallback: "定位" },
],
rows: pastureRows.value,
}))
onMounted(() => {
emit("module-stats", {
moduleKey: "pasture-management",
items: pastureTopStats,
})
emitPastureTopStats()
initPastureMap()
loadPastureProfile()
})
@ -198,6 +176,7 @@ async function loadPastureProfile() {
...pastureFallbackProfile,
...profile,
}
emitPastureTopStats()
const normalizedList = normalizePastureRows(profile.list)
pastureRows.value = normalizedList.length ? normalizedList : pastureListRows
} catch (error) {
@ -207,7 +186,11 @@ async function loadPastureProfile() {
}
async function handlePastureSelect(row) {
const pastureId = row?.id || row?.pastureId || row?.muhuId
if (!isPastureLocatable(row)) {
mapSceneRef.value?.focusPasture?.({ name: row?.name })
return
}
const pastureId = getRealPastureId(row)
let grasslands = []
if (pastureId) {
try {
@ -216,15 +199,22 @@ async function handlePastureSelect(row) {
grasslands = []
}
}
const rowGrasslands = normalizeRowGrasslands(row)
const geometryGrasslands = grasslands.filter(hasGrasslandGeometry)
const pastureGrasslands = geometryGrasslands.length ? geometryGrasslands : rowGrasslands
if (!pastureGrasslands.length) {
mapSceneRef.value?.focusPasture?.({ name: row?.name })
return
}
mapSceneRef.value?.focusPasture?.({
name: row?.name,
location: row?.location,
grasslands,
grasslands: pastureGrasslands,
})
}
function initPastureMap() {
mapAssets = new Assets()
mapAssetsReady = false
emit("loading-progress", { progress: 0 })
mapAssets.instance?.on?.("onProgress", (path, itemsLoaded, itemsTotal) => {
const total = itemsTotal || 1
@ -235,57 +225,132 @@ function initPastureMap() {
itemsTotal,
})
})
const loadMapOnce = async (attempt = 0) => {
if (destroyed) return
await nextTick()
const scene = mapSceneRef.value
scene?.loadMap?.(mapAssets)
if (scene?.canvasMap) {
mapReady.value = true
emit("loading-progress", { progress: 100 })
emit("map-ready")
scene.play?.()
return
}
if (attempt < 24) {
window.setTimeout(() => loadMapOnce(attempt + 1), 120)
return
}
mapAssets.ready?.then(() => {
mapAssetsReady = true
loadPastureMapScene()
}).catch(() => {
mapReady.value = true
emit("map-ready")
}
mapAssets.ready?.then(loadMapOnce).catch(() => {
})
}
function handleMapSceneMounted() {
if (mapAssetsReady) loadPastureMapScene()
}
async function loadPastureMapScene(attempt = 0) {
if (destroyed || !mapAssetsReady || !mapAssets) return
await nextTick()
const scene = mapSceneRef.value
scene?.loadMap?.(mapAssets)
if (scene?.canvasMap) {
mapReady.value = true
emit("loading-progress", { progress: 100 })
emit("map-ready")
scene.play?.()
return
}
if (attempt < 24) {
window.setTimeout(() => loadPastureMapScene(attempt + 1), 120)
return
}
mapReady.value = true
emit("map-ready")
}
function emitPastureTopStats() {
emit("module-stats", {
moduleKey: "pasture-management",
items: buildPastureTopStats().map((item) => ({
zh: item.name,
name: item.name,
value: item.value,
unit: item.unit,
decimals: Number.isInteger(Number(item.value)) ? 0 : 2,
})),
})
}
function buildPastureTopStats() {
const metrics = new Map()
;(Array.isArray(coreRows.value) ? coreRows.value : []).forEach((item) => {
if (!item?.name || isHiddenCoreMetric(item.name)) return
metrics.set(item.name, item)
})
return pastureTopStatNames
.map((name) => metrics.get(name) || pastureTopStats.find((item) => item.name === name || item.zh === name))
.filter(Boolean)
.slice(0, 4)
}
function normalizeCoreRows(rows = []) {
return (Array.isArray(rows) ? rows : [])
.map((item, index) => {
const name = item.name || item.title || item.label || item.zh
const key = item.key || item.id || item.code || item.name || item.zh || `${index}`
const rawName = item.name || item.title || item.label || item.zh
const mappedName = normalizeCoreName(rawName, key)
const value = pickValue(item, ["value", "num", "count", "dataValue"])
if (!name || value === undefined || value === null || value === "") return null
if (!mappedName || value === undefined || value === null || value === "") return null
const normalizedMetric = normalizeCoreMetric(mappedName, value, item.unit || item.dw || "")
return {
key: item.key || item.id || name,
name,
value: Number(value) || 0,
unit: item.unit || item.dw || "",
key,
name: normalizedMetric.name,
value: normalizedMetric.value,
unit: normalizedMetric.unit,
icon: item.icon || ["town", "area", "yield", "rate"][index % 4],
}
})
.filter(Boolean)
.slice(0, 4)
}
function normalizeCoreName(name, key) {
const text = String(name || key || "")
const lowerKey = String(key || "").toLowerCase()
if (/avg.*yak|yak.*avg|yak.*household|yak.*per|hjyak/.test(lowerKey) || /户均.*牦牛|牦牛.*户均/.test(text)) return "户均牦牛头数"
if (/avg.*grass|grass.*avg|grass.*household|grass.*per/.test(lowerKey) || /户均.*草地|草地.*户均/.test(text)) return "户均草地面积"
if (/income|revenue|人均收入/.test(lowerKey) || text.includes("人均收入")) return "人均收入"
if (/poverty|monitor|防反贫|监测户/.test(lowerKey) || text.includes("防反贫") || text.includes("监测户")) return "防反贫监测户"
if (/joint|union|link|联户/.test(lowerKey) || text.includes("联户")) return "联户数量"
if (/herdsman|muhu|pasture.*count|牧户数量/.test(lowerKey) || text.includes("牧户数量")) return "牧户数量"
if (/yak/.test(lowerKey) || text.includes("牦牛")) return null
if (/grass/.test(lowerKey) || text.includes("草地面积")) return "草地面积"
if (/wet/.test(lowerKey) || text.includes("湿地")) return "湿地面积"
if (text.includes("牧户") || text.includes("牧场")) return "牧户数量"
if (/project/.test(lowerKey) || text.includes("工程") || text.includes("项目")) return "工程数量"
if (/base/.test(lowerKey) || text.includes("基地")) return "基地数量"
return text
}
function normalizeCoreMetric(name, value, unit) {
const number = Number(value) || 0
if (name === "户均牦牛头数") return { name, value: number, unit: unit || "头" }
if (name === "户均草地面积") return { name, value: number, unit: unit || "亩" }
if (name === "人均收入") return { name, value: number, unit: unit || "元/年" }
if (name === "联户数量" || name === "防反贫监测户") return { name, value: number, unit: unit || "户" }
if ((name.includes("草地") || name.includes("湿地")) && !String(unit).includes("万")) {
return {
name,
value: Math.round((number / 10000) * 100) / 100,
unit: "万亩",
}
}
if (name.includes("牧户")) return { name, value: number, unit: unit || "户" }
if (name.includes("工程") || name.includes("项目") || name.includes("基地")) return { name, value: number, unit: unit || "个" }
return { name, value: number, unit }
}
function isHiddenCoreMetric(name) {
return name === "草地面积" || name === "湿地面积" || name === "区域数量" || name === "牦牛数量"
}
function normalizeTownRows(rows = []) {
return (Array.isArray(rows) ? rows : [])
.map((item) => {
const name = pickValue(item, ["areaName", "name", "townName", "zoneName"])
const value = pickValue(item, ["value", "count", "num", "pastureCount", "muhuCount"])
const name = pickValue(item, ["areaName", "area_name", "name", "townName", "zoneName"])
const value = pickValue(item, ["number", "value", "count", "num", "pastureCount", "muhuCount", "muhu_count"])
if (!name || value === undefined || value === null || value === "") return null
return {
key: item.code || item.areaCode || name,
key: item.code || item.areaCode || item.area_code || name,
name,
value: Number(value) || 0,
unit: "户",
@ -298,42 +363,81 @@ function normalizeTownRows(rows = []) {
function normalizeOnlineRows(rows = []) {
return (Array.isArray(rows) ? rows : [])
.map((item) => {
const name = pickValue(item, ["areaName", "name", "townName", "zoneName"])
const value = pickValue(item, ["onlinePercent", "value", "percent", "rate"])
const name = pickValue(item, ["areaName", "area_name", "name", "townName", "zoneName"])
const registered = Number(pickValue(item, ["registeredMuhuCount", "registered_muhu_count", "registeredCount"]))
const online = Number(pickValue(item, ["muhuCount", "muhu_count", "onlineCount"]))
const rawValue = pickValue(item, ["onlinePercent", "uplineRate", "upline_rate", "onlineRate", "value", "percent", "rate"])
const value = rawValue !== undefined && rawValue !== null && rawValue !== ""
? rawValue
: registered > 0 ? (online / registered) * 100 : undefined
if (!name || value === undefined || value === null || value === "") return null
return {
key: item.code || item.areaCode || name,
key: item.code || item.areaCode || item.area_code || name,
name,
value: Number(String(value).replace("%", "")) || 0,
unit: "%",
extra: Number.isFinite(online) && Number.isFinite(registered) && registered > 0 ? `${online}/${registered}` : item.extra,
}
})
.filter(Boolean)
.slice(0, 7)
}
function normalizeAgeRows(rows = []) {
const groups = new Map()
;(Array.isArray(rows) ? rows : []).forEach((item) => {
const name = pickValue(item, ["ageRange", "age_range", "age", "name", "label"])
const value = pickValue(item, ["number", "value", "count", "num"])
if (!name || value === undefined || value === null || value === "") return
groups.set(name, (groups.get(name) || 0) + (Number(value) || 0))
})
return Array.from(groups.entries()).map(([name, value]) => ({
key: name,
name,
value,
unit: "人",
}))
}
function normalizeGenderRows(rows = []) {
const groups = new Map()
;(Array.isArray(rows) ? rows : []).forEach((item) => {
const name = pickValue(item, ["sex", "gender", "name"])
const value = pickValue(item, ["number", "value", "count", "num"])
if (!name || value === undefined || value === null || value === "") return
const label = String(name).includes("女") ? "女" : String(name).includes("男") ? "男" : name
groups.set(label, (groups.get(label) || 0) + (Number(value) || 0))
})
return Array.from(groups.entries()).map(([name, value]) => ({
key: name,
name,
value,
unit: "人",
}))
}
function normalizePastureRows(rows = []) {
return (Array.isArray(rows) ? rows : [])
.map((item, index) => {
const name = pickValue(item, ["name", "muhuName", "pastureName", "herdsmanName"])
const name = pickValue(item, ["name", "pasturesName", "pastures_name", "muhuName", "muhu_name", "pastureName", "pasture_name", "herdsmanName"])
if (!name) return null
const grasslandArea = pickValue(item, ["grasslandArea", "acreage", "area", "cdAcreage", "totalAcreage"])
const yakCount = pickValue(item, ["yakCount", "yakNumber", "cowNumber", "livestockCount"])
const location = normalizeLocation(item.location || item.lnglat || item.coordinate || item)
const township = pickValue(item, ["township", "townName", "areaName", "zoneName", "address"])
const grasslandArea = pickValue(item, ["grasslandArea", "grassland_area", "acreage", "area", "areaRange", "area_range", "cdAcreage", "cd_acreage", "totalAcreage", "total_acreage"])
const township = pickValue(item, ["township", "townName", "areaName", "area_name", "zoneName", "address"])
const realId = getRealPastureId(item)
const grasslands = normalizeRowGrasslands(item)
const normalized = {
...item,
key: item.key || item.id || `${name}-${index}`,
id: item.id || item.pastureId || item.muhuId || item.key,
key: item.key || item.id || item.pasturesId || item.pastures_id || `${name}-${index}`,
id: realId || item.id || item.pastureId || item.pasturesId || item.pastures_id || item.muhuId || item.key,
realPastureId: realId,
name,
township: township || "--",
grasslandArea: Number(grasslandArea) || 0,
acreage: Number(grasslandArea) || 0,
yakCount: Number(yakCount) || 0,
yakNumber: Number(yakCount) || 0,
location,
locateText: "定位",
grasslands,
}
normalized.canLocate = Boolean(realId || grasslands.some(hasGrasslandGeometry) || hasGrasslandGeometry(item))
normalized.locateText = normalized.canLocate ? "定位" : "--"
normalized.value = normalized.grasslandArea
normalized.unit = "亩"
normalized.searchText = [normalized.name, normalized.township, normalized.id].filter(Boolean).join(" ")
@ -342,19 +446,92 @@ function normalizePastureRows(rows = []) {
.filter(Boolean)
}
function normalizeLocation(value) {
if (Array.isArray(value)) {
const lng = Number(value[0])
const lat = Number(value[1])
return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null
}
if (typeof value === "string") {
const [lng, lat] = value.split(/[,\s]+/).map(Number)
return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null
function aggregatePastureAreaRows(rows = []) {
return aggregatePastureRows(rows, (row) => Number(row.grasslandArea) || 0, "万亩", (value) => Math.round((value / 10000) * 100) / 100)
}
function aggregatePastureSeasonRows(rows = []) {
const map = new Map()
;(Array.isArray(rows) ? rows : []).forEach((row) => {
const name = row?.township || row?.areaName || row?.area_name
if (!name || name === "--") return
const current = map.get(name) || { key: `${name}-season`, name, values: { summer: 0, winter: 0 }, total: 0, unit: "万亩" }
current.values.summer += Number(pickValue(row, ["summerAcreage", "summer_acreage", "summerArea", "summer_area"])) || 0
current.values.winter += Number(pickValue(row, ["winterAcreage", "winter_acreage", "winterArea", "winter_area"])) || 0
current.total = current.values.summer + current.values.winter
map.set(name, current)
})
return Array.from(map.values())
.filter((row) => row.total > 0)
.map((row) => ({
...row,
value: Math.round((row.total / 10000) * 100) / 100,
values: {
summer: Math.round((row.values.summer / 10000) * 100) / 100,
winter: Math.round((row.values.winter / 10000) * 100) / 100,
},
}))
.sort((a, b) => b.value - a.value)
}
function aggregatePastureRows(rows = [], pick, unit, transform = (value) => value) {
const map = new Map()
;(Array.isArray(rows) ? rows : []).forEach((row) => {
const name = row?.township || row?.areaName || row?.area_name
if (!name || name === "--") return
const current = map.get(name) || { key: name, name, value: 0, unit }
const picked = pick(row)
current.value += Number(picked) || 0
map.set(name, current)
})
return Array.from(map.values())
.map((row) => ({ ...row, value: transform(row.value) }))
.filter((row) => Number.isFinite(row.value) && row.value > 0)
.sort((a, b) => b.value - a.value)
}
function normalizeRowGrasslands(item = {}) {
const rows = []
const nestedKeys = ["grasslands", "grasslandList", "grasslandRows", "landList", "plots", "features"]
nestedKeys.forEach((key) => {
const value = item?.[key]
if (Array.isArray(value)) rows.push(...value)
else if (Array.isArray(value?.rows)) rows.push(...value.rows)
else if (Array.isArray(value?.list)) rows.push(...value.list)
else if (Array.isArray(value?.data)) rows.push(...value.data)
})
const wkt = pickValue(item, ["wkt", "geo", "geom", "geometryWkt", "shape"])
if (wkt || item?.geometry) {
rows.push({
...item,
id: item?.grasslandId || item?.gid || item?.id,
pastureId: item?.pastureId || item?.pasturesId || item?.pastures_id || item?.muhuId || item?.id,
name: item?.grasslandName || item?.name,
wkt,
geometry: item?.geometry,
})
}
const lng = Number(value?.lng ?? value?.lon ?? value?.longitude ?? value?.x)
const lat = Number(value?.lat ?? value?.latitude ?? value?.y)
return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null
return rows.filter(Boolean)
}
function hasGrasslandGeometry(item = {}) {
if (typeof item === "string") return Boolean(item.trim())
if (item?.type === "Feature") return true
if (item?.type && item?.coordinates) return true
if (item?.geometry?.type || item?.geometry?.coordinates || typeof item?.geometry === "string") return true
return Boolean(pickValue(item, ["wkt", "geo", "geom", "geometryWkt", "shape"]))
}
function getRealPastureId(row = {}) {
const id = row?.realPastureId || row?.pastureId || row?.pasturesId || row?.pastures_id || row?.muhuId
return id === undefined || id === null || id === "" ? null : id
}
function isPastureLocatable(row = {}) {
if (row?.canLocate) return true
if (getRealPastureId(row)) return true
if (hasGrasslandGeometry(row)) return true
return normalizeRowGrasslands(row).some(hasGrasslandGeometry)
}
function pickValue(item = {}, keys = []) {
@ -365,13 +542,6 @@ function pickValue(item = {}, keys = []) {
return undefined
}
function formatNumber(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "--"
return number.toLocaleString("zh-CN", {
maximumFractionDigits: Number.isInteger(number) ? 0 : 2,
})
}
</script>
<style lang="scss">
@ -491,159 +661,28 @@ function formatNumber(value) {
text-shadow: none !important;
}
.pasture-center-panel {
position: absolute;
left: 50%;
bottom: 142px;
z-index: 5;
width: 520px;
padding: 18px 22px 16px;
border: 1px solid rgba(48, 220, 255, 0.18);
background:
linear-gradient(90deg, rgba(3, 18, 30, 0.08), rgba(3, 18, 30, 0.52), rgba(3, 18, 30, 0.08)),
linear-gradient(180deg, rgba(48, 220, 255, 0.09), rgba(36, 246, 196, 0.04));
box-shadow:
inset 0 0 24px rgba(48, 220, 255, 0.08),
0 18px 44px rgba(0, 0, 0, 0.18);
transform: translateX(-50%);
pointer-events: all;
box-sizing: border-box;
}
.pasture-center-title {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(48, 220, 255, 0.16);
span {
color: rgba(126, 251, 246, 0.66);
font-family: D-DIN, Arial, sans-serif;
font-size: 13px;
letter-spacing: 0;
text-transform: uppercase;
}
.pasture-management .business-bars.is-distribution {
max-height: 100%;
padding-right: 8px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(48, 220, 255, 0.72) rgba(10, 42, 58, 0.42);
strong {
color: #fff;
font-size: 24px;
line-height: 28px;
&::-webkit-scrollbar {
width: 5px;
}
}
.pasture-center-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.pasture-center-metric {
min-width: 0;
padding: 10px 12px;
border: 1px solid rgba(48, 220, 255, 0.14);
background: rgba(4, 27, 42, 0.48);
box-sizing: border-box;
span {
display: block;
overflow: hidden;
color: rgba(223, 250, 255, 0.62);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
&::-webkit-scrollbar-track {
border-radius: 999px;
background: rgba(10, 42, 58, 0.42);
}
strong {
display: block;
margin-top: 7px;
color: #7efbf6;
font-family: D-DIN, Arial, sans-serif;
font-size: 24px;
line-height: 26px;
text-shadow: 0 0 14px rgba(126, 251, 246, 0.45);
white-space: nowrap;
}
em {
margin-left: 4px;
color: rgba(255, 255, 255, 0.58);
font-family: AlibabaPuHuiTi, Arial, sans-serif;
font-size: 11px;
font-style: normal;
&::-webkit-scrollbar-thumb {
border-radius: 999px;
background: linear-gradient(180deg, rgba(48, 220, 255, 0.9), rgba(36, 246, 196, 0.68));
box-shadow: 0 0 8px rgba(48, 220, 255, 0.38);
}
}
.pasture-bottom-status {
position: absolute;
left: 50%;
bottom: 42px;
z-index: 8;
display: grid;
grid-template-columns: repeat(4, 132px);
gap: 10px;
transform: translateX(-50%);
pointer-events: all;
}
.pasture-status-item {
position: relative;
min-width: 0;
height: 68px;
padding: 10px 12px 8px;
border: 1px solid rgba(48, 220, 255, 0.18);
background: linear-gradient(180deg, rgba(5, 33, 48, 0.66), rgba(3, 18, 30, 0.5));
box-shadow: inset 0 0 18px rgba(48, 220, 255, 0.08);
box-sizing: border-box;
&::before {
content: "";
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
width: 2px;
background: #24f6c4;
box-shadow: 0 0 10px rgba(36, 246, 196, 0.72);
}
span,
small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
color: rgba(223, 250, 255, 0.72);
font-size: 12px;
}
strong {
display: inline-flex;
align-items: baseline;
margin-top: 4px;
color: #fff;
font-family: D-DIN, Arial, sans-serif;
font-size: 22px;
line-height: 23px;
}
em {
margin-left: 3px;
color: rgba(255, 255, 255, 0.58);
font-family: AlibabaPuHuiTi, Arial, sans-serif;
font-size: 10px;
font-style: normal;
}
small {
margin-top: 2px;
color: rgba(126, 251, 246, 0.54);
font-size: 10px;
}
}
</style>

@ -5,8 +5,10 @@
'is-home': activeModule.key === 'home',
'is-forest-grass-wet': activeModule.key === 'forest-grass-wet',
'is-ecological-protection': activeModule.key === 'ecological-protection',
'is-grass-livestock-balance': activeModule.key === 'grass-livestock-balance',
'is-yak-management': activeModule.key === 'yak-management',
'is-pasture-management': activeModule.key === 'pasture-management',
'is-yak-industry-chain': activeModule.key === 'yak-industry-chain',
'is-smart-management': activeModule.key === 'smart-management',
}"
id="large-screen"
@ -41,6 +43,17 @@
@view-change="handleEcologyViewChange"
/>
</div>
<div
v-if="renderedModules['grass-livestock-balance']"
v-show="activeModule.key === 'grass-livestock-balance'"
class="platform-module-pane"
>
<grass-livestock-balance
@loading-progress="handleHomeLoadingProgress"
@map-ready="handleHomeMapReady"
@module-stats="handleModuleStats"
/>
</div>
<div
v-if="renderedModules['yak-management']"
v-show="activeModule.key === 'yak-management'"
@ -59,6 +72,17 @@
@module-stats="handleModuleStats"
/>
</div>
<div
v-if="renderedModules['yak-industry-chain']"
v-show="activeModule.key === 'yak-industry-chain'"
class="platform-module-pane"
>
<yak-industry-chain
@loading-progress="handleHomeLoadingProgress"
@map-ready="handleHomeMapReady"
@module-stats="handleModuleStats"
/>
</div>
<div
v-if="renderedModules['smart-management']"
v-show="activeModule.key === 'smart-management'"
@ -73,8 +97,13 @@
sub-text="Hongyuan Digital Animal Husbandry Supervision Platform"
>
<template #right>
<div class="m-header-date">
<span>{{ currentDate }}</span><span>{{ currentTime }}</span>
<div class="m-header-tools">
<div class="m-header-date">
<span>{{ currentDate }}</span><span>{{ currentTime }}</span>
</div>
<button class="logout-button" title="退出登录" type="button" @click="handleLogout">
退出
</button>
</div>
</template>
</m-header>
@ -89,7 +118,7 @@
</div>
<div
v-if="activeModule.key !== 'smart-management'"
v-if="totalView.length && !['smart-management', 'grass-livestock-balance'].includes(activeModule.key)"
class="top-count-card"
:class="{ 'is-four': totalView.length > 3 }"
>
@ -102,7 +131,7 @@
</div>
<div
v-if="!['home', 'forest-grass-wet', 'ecological-protection', 'yak-management', 'pasture-management', 'smart-management'].includes(activeModule.key)"
v-if="!['home', 'forest-grass-wet', 'ecological-protection', 'grass-livestock-balance', 'yak-management', 'pasture-management', 'yak-industry-chain', 'smart-management'].includes(activeModule.key)"
class="bottom-tray"
>
<m-svgline-animation
@ -180,11 +209,14 @@ import mSvglineAnimation from "@/components/mSvglineAnimation/index.vue"
import HomeDashboard from "@/views/home/index.vue"
import ForestGrassWet from "@/views/forestGrassWet/index.vue"
import EcologicalProtection from "@/views/ecologicalProtection/index.vue"
import GrassLivestockBalance from "@/views/grassLivestockBalance/index.vue"
import YakManagement from "@/views/yakManagement/index.vue"
import PastureManagement from "@/views/pastureManagement/index.vue"
import YakIndustryChain from "@/views/yakIndustryChain/index.vue"
import WisdomManagement from "@/views/wisdomManagement/index.vue"
import { getModuleByKey, platformModules } from "@/config/modules"
import { ecologicalProtectionMenu } from "@/views/ecologicalProtection/data"
import { logout } from "@/services/auth"
import "@/assets/style/home.scss"
const activeModuleKey = ref("home")
@ -215,6 +247,7 @@ const activeBottomKey = computed(() => {
})
const totalView = computed(() => {
const currentStats = moduleTopStats.value[activeModuleKey.value]
if (Array.isArray(currentStats)) return currentStats
if (currentStats?.length) {
return currentStats
}
@ -277,7 +310,7 @@ function handleBottomMenuSelect(item) {
}
function handleEcologyViewChange(key) {
activeEcologyView.value = key === "restoration" ? "restoration" : "national-park"
activeEcologyView.value = ecologicalProtectionMenu.some((item) => item.key === key) ? key : "national-park"
nextTick(resetRootScroll)
}
@ -362,7 +395,7 @@ function buildTopCountItems(items = []) {
}
function renderModule(key) {
if (!["home", "forest-grass-wet", "ecological-protection", "yak-management", "pasture-management", "smart-management"].includes(key)) return
if (!["home", "forest-grass-wet", "ecological-protection", "grass-livestock-balance", "yak-management", "pasture-management", "yak-industry-chain", "smart-management"].includes(key)) return
if (renderedModules.value[key]) return
renderedModules.value = {
...renderedModules.value,
@ -384,6 +417,10 @@ function resetRootScroll() {
const app = document.querySelector("#app")
if (app) app.scrollTop = 0
}
function handleLogout() {
logout()
}
</script>
<style lang="scss">
@ -421,6 +458,17 @@ function resetRootScroll() {
}
}
&.is-grass-livestock-balance {
.large-screen-wrap::after {
opacity: 0.24;
}
.large-screen-left-zsline,
.large-screen-right-zsline {
filter: brightness(1.24) drop-shadow(0 0 14px rgba(255, 215, 108, 0.18));
}
}
&.is-yak-management {
.large-screen-wrap::after {
opacity: 0.24;
@ -443,6 +491,17 @@ function resetRootScroll() {
}
}
&.is-yak-industry-chain {
.large-screen-wrap::after {
opacity: 0.24;
}
.large-screen-left-zsline,
.large-screen-right-zsline {
filter: brightness(1.28) drop-shadow(0 0 14px rgba(255, 224, 107, 0.2));
}
}
&.is-smart-management {
.large-screen-wrap::after {
opacity: 0.2;
@ -474,10 +533,17 @@ function resetRootScroll() {
}
}
.m-header-date {
.m-header-tools {
display: flex;
align-items: center;
gap: 12px;
pointer-events: auto;
.m-header-date {
display: flex;
align-items: center;
gap: 12px;
}
span {
color: #c4f3fe;
@ -486,6 +552,21 @@ function resetRootScroll() {
}
}
.logout-button {
height: 28px;
padding: 0 12px;
border: 1px solid rgba(48, 220, 255, 0.36);
background: rgba(2, 18, 34, 0.72);
color: #c4f3fe;
cursor: pointer;
font-size: 13px;
}
.logout-button:hover {
border-color: rgba(48, 220, 255, 0.78);
color: #fff;
}
.top-count-card {
left: 460px;
right: 460px;

@ -0,0 +1,576 @@
import {
estimatedStock,
grasslandYakRows,
homeCore,
townRecognitionRows,
tradeImpactRows,
} from "@/views/gdMap/livestockData"
export const industryStageDefs = [
{ key: "infrastructure", name: "基础设施", color: "#32dcff", unit: "项", metricLabel: "项目" },
{ key: "breeding", name: "种养基地", color: "#24f6c4", unit: "个", metricLabel: "基地" },
{ key: "support", name: "配套产业", color: "#ffe06b", unit: "个", metricLabel: "点位" },
{ key: "processing", name: "加工基地", color: "#ff9a62", unit: "家", metricLabel: "企业" },
{ key: "service", name: "社会化服务", color: "#9ea7ff", unit: "个", metricLabel: "主体" },
{ key: "equipment", name: "机械设备", color: "#7ee269", unit: "套", metricLabel: "设备" },
{ key: "vr", name: "产业VR", color: "#30dcff", unit: "项", metricLabel: "场景" },
]
export const supportPointRows = [
{
key: "support-disaster-001",
name: "嚷口防灾减灾饲草储备库",
stageKey: "support",
typeName: "防震减灾",
capacity: 1500,
capacityUnit: "吨",
town: "刷经寺镇",
address: "壤口村",
status: "正常运营",
products: "燕麦草、披碱草、老芒麦",
longitude: 102.489619,
latitude: 32.20577086,
},
{
key: "support-disaster-002",
name: "安曲防灾减灾饲草储备库",
stageKey: "support",
typeName: "防震减灾",
capacity: 1500,
capacityUnit: "吨",
town: "安曲镇",
address: "夺龙村",
status: "正常运营",
products: "燕麦草、披碱草、老芒麦",
longitude: 102.3283137,
latitude: 32.63727531,
},
{
key: "support-disaster-003",
name: "瓦切唐日合作社饲草储备库",
stageKey: "support",
typeName: "防震减灾",
capacity: 1500,
capacityUnit: "吨",
town: "瓦切镇",
address: "唐日村",
status: "正常运营",
products: "燕麦草、披碱草、老芒麦",
longitude: 102.6162349,
latitude: 33.11047864,
},
{
key: "support-disaster-004",
name: "瓦切牧草交易中心饲草储备库",
stageKey: "support",
typeName: "防震减灾",
capacity: 1500,
capacityUnit: "吨",
town: "瓦切镇",
address: "达俄村",
status: "正常运营",
products: "燕麦草、披碱草、老芒麦",
longitude: 102.6014834,
latitude: 33.09604707,
},
{
key: "support-disaster-005",
name: "龙日坝龙日坝村 1 饲草储备库",
stageKey: "support",
typeName: "防震减灾",
capacity: 1500,
capacityUnit: "吨",
town: "龙日镇",
address: "龙日坝村",
status: "正常运营",
products: "燕麦草、披碱草、老芒麦",
longitude: 102.383916,
latitude: 32.44992916,
},
{
key: "support-disaster-006",
name: "龙日坝龙日坝村 2 饲草储备库",
stageKey: "support",
typeName: "防震减灾",
capacity: 1500,
capacityUnit: "吨",
town: "龙日镇",
address: "龙日坝村",
status: "正常运营",
products: "燕麦草、披碱草、老芒麦",
longitude: 102.3834086,
latitude: 32.45041994,
},
{
key: "support-disaster-007",
name: "江茸乡饲草储备库",
stageKey: "support",
typeName: "防震减灾",
capacity: 1500,
capacityUnit: "吨",
town: "江茸乡",
address: "茸日玛村",
status: "正常运营",
products: "燕麦草、披碱草、老芒麦",
longitude: 102.256868,
latitude: 32.46996565,
},
]
export const processingPointRows = [
{
key: "support-processing-001",
name: "西部牦牛产业集团有限责任公司",
stageKey: "processing",
typeName: "乳制品加工",
capacity: 3000,
capacityUnit: "吨",
outputValue: 30000,
town: "邛溪镇",
address: "瑞庆西路37号",
status: "存续(在营)",
products: "牦牛奶粉",
longitude: 102.5292,
latitude: 32.7917,
},
{
key: "support-processing-002",
name: "四川红原菊乐牦牛乳业有限公司",
stageKey: "processing",
typeName: "乳制品加工",
capacity: 4000,
capacityUnit: "吨",
outputValue: 7000,
town: "邛溪镇",
address: "瑞庆东路76号附6号",
status: "存续(在营)",
products: "圣舟牦牛奶",
longitude: 102.5666,
latitude: 32.8179,
},
{
key: "support-processing-003",
name: "四川雅克乳业有限公司",
stageKey: "processing",
typeName: "乳制品加工",
capacity: 1000,
capacityUnit: "吨",
outputValue: 3000,
town: "邛溪镇",
address: "瑞庆西路",
status: "存续(在营)",
products: "牦牛酸奶、牦牛奶粉",
longitude: 102.5315,
latitude: 32.7921,
},
{
key: "support-processing-004",
name: "四川红原遛遛牛食品有限责任公司",
stageKey: "processing",
typeName: "肉制品加工",
capacity: 4500,
capacityUnit: "吨",
outputValue: 8000,
town: "邛溪镇",
address: "瑞庆东路72号",
status: "存续(在营)",
products: "风干牦牛肉、卤制牦牛肉",
longitude: 102.5592,
latitude: 32.8151,
},
{
key: "support-processing-005",
name: "四川红原黑帐篷牦牛肉食品有限责任公司",
stageKey: "processing",
typeName: "肉制品加工",
capacity: 300,
capacityUnit: "吨",
outputValue: 1000,
town: "邛溪镇",
address: "瑞庆中路",
status: "存续(在营)",
products: "风干牦牛肉、牦牛肉礼包",
longitude: 102.551,
latitude: 32.7987,
},
{
key: "support-processing-006",
name: "红原御坊斋牦牛肉制品厂",
stageKey: "processing",
typeName: "肉制品加工",
capacity: 200,
capacityUnit: "吨",
outputValue: 600,
town: "邛溪镇",
address: "阳嘎北街",
status: "存续(在营)",
products: "牛肉干、牦牛肉粒",
longitude: 102.5431,
latitude: 32.7962,
},
{
key: "support-processing-007",
name: "四川牦牛火了生态农业科技有限公司",
stageKey: "processing",
typeName: "肉制品加工",
capacity: 0,
capacityUnit: "吨",
outputValue: 500,
town: "邛溪镇",
address: "瑞庆中路13号附1号",
status: "存续(在营)",
products: "包装牦牛肉干",
longitude: 102.5402,
latitude: 32.7958,
},
]
export const industryPointRows = [...supportPointRows, ...processingPointRows]
const infrastructureProjectDistribution = [3, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1]
const serviceSubjectTotal = 2618
const tradeFlowTotal = 220605
const equipmentTotal = townRecognitionRows.reduce((sum, item) => sum + Number(item.devices || 0), 0)
const supportCapacity = supportPointRows.reduce((sum, item) => sum + Number(item.capacity || 0), 0)
const processingCapacity = processingPointRows.reduce((sum, item) => sum + Number(item.capacity || 0), 0)
const processingOutputValue = processingPointRows.reduce((sum, item) => sum + Number(item.outputValue || 0), 0)
const allTownCount = new Set(grasslandYakRows.map((item) => item.zoneName)).size
const carrierTotal = homeCore.projectCount + homeCore.baseCount + industryPointRows.length + serviceSubjectTotal + equipmentTotal
export const industryTopStats = [
{ zh: "牦牛存栏估算", name: "牦牛存栏估算", value: estimatedStock, unit: "头" },
{ zh: "全链载体", name: "全链载体", value: carrierTotal, unit: "个/套" },
{ zh: "加工储备能力", name: "加工储备能力", value: supportCapacity + processingCapacity, unit: "吨" },
{ zh: "覆盖乡镇", name: "覆盖乡镇", value: allTownCount, unit: "个" },
]
export const industryOverviewRows = [
{ key: "chain-node", name: "产业节点", value: industryStageDefs.length, unit: "类", icon: "town" },
{ key: "chain-carrier", name: "主体点位", value: carrierTotal, unit: "个/套", icon: "yield" },
{ key: "chain-capacity", name: "加工储备", value: supportCapacity + processingCapacity, unit: "吨", icon: "stack" },
{ key: "chain-town", name: "镇域覆盖", value: allTownCount, unit: "个", icon: "area" },
]
export const industryStageRows = [
{
key: "infrastructure",
name: "基础设施",
value: homeCore.projectCount,
unit: "项",
capacity: homeCore.projectCount,
capacityUnit: "项",
note: "产业道路、工程与配套项目",
},
{
key: "breeding",
name: "种养基地",
value: homeCore.baseCount,
unit: "个",
capacity: estimatedStock,
capacityUnit: "头",
note: "基地、牧场与存栏承载",
},
{
key: "support",
name: "配套产业",
value: supportPointRows.length,
unit: "个",
capacity: supportCapacity,
capacityUnit: "吨",
note: "饲草储备与防灾保障",
},
{
key: "processing",
name: "加工基地",
value: processingPointRows.length,
unit: "家",
capacity: processingCapacity,
capacityUnit: "吨",
note: "乳品、肉品加工转化",
},
{
key: "service",
name: "社会化服务",
value: serviceSubjectTotal,
unit: "个",
capacity: tradeFlowTotal,
capacityUnit: "头",
note: "保险、交易、检疫与流通",
},
{
key: "equipment",
name: "机械设备",
value: equipmentTotal,
unit: "套",
capacity: equipmentTotal,
capacityUnit: "套",
note: "识别、监测与生产装备",
},
{
key: "vr",
name: "产业VR",
value: 1,
unit: "项",
capacity: 6,
capacityUnit: "类",
note: "产业链沉浸式展示",
},
]
export const chainFlowRows = [
{ key: "resource", name: "草地供给", value: 84, unit: "%", extra: "资源底座" },
{ key: "breeding", name: "基地养殖", value: 88, unit: "%", extra: "存栏校准" },
{ key: "health", name: "防疫保障", value: 92, unit: "%", extra: "台账接入" },
{ key: "trade", name: "交易流通", value: 78, unit: "%", extra: "检疫出证" },
{ key: "processing", name: "加工转化", value: 81, unit: "%", extra: "乳肉加工" },
{ key: "market", name: "品牌销售", value: 73, unit: "%", extra: "外销拓展" },
]
export const stageHealthRows = {
infrastructure: [
{ key: "project-online", name: "项目入库", value: 95, unit: "%", extra: "95%" },
{ key: "facility-cover", name: "设施覆盖", value: 82, unit: "%", extra: "82%" },
{ key: "fund-track", name: "资金跟踪", value: 76, unit: "%", extra: "76%" },
{ key: "maintenance", name: "运维闭环", value: 68, unit: "%", extra: "68%" },
],
breeding: [
{ key: "base-online", name: "基地在线", value: 92, unit: "%", extra: "92%" },
{ key: "stock-match", name: "存栏匹配", value: 88, unit: "%", extra: "88%" },
{ key: "pasture-link", name: "草场关联", value: 74, unit: "%", extra: "74%" },
{ key: "source-trace", name: "源头追溯", value: 69, unit: "%", extra: "69%" },
],
support: [
{ key: "storage-ready", name: "储备达标", value: 91, unit: "%", extra: "91%" },
{ key: "town-cover", name: "镇域覆盖", value: 64, unit: "%", extra: "64%" },
{ key: "dispatch", name: "调拨能力", value: 83, unit: "%", extra: "83%" },
{ key: "risk", name: "防灾韧性", value: 79, unit: "%", extra: "79%" },
],
processing: [
{ key: "license", name: "许可状态", value: 86, unit: "%", extra: "86%" },
{ key: "capacity", name: "产能利用", value: 77, unit: "%", extra: "77%" },
{ key: "cold-chain", name: "冷链能力", value: 72, unit: "%", extra: "72%" },
{ key: "farmer-drive", name: "带动牧户", value: 84, unit: "%", extra: "84%" },
],
service: [
{ key: "insurance", name: "保险服务", value: 89, unit: "%", extra: "89%" },
{ key: "quarantine", name: "检疫出证", value: 92, unit: "%", extra: "92%" },
{ key: "trade", name: "交易撮合", value: 78, unit: "%", extra: "78%" },
{ key: "supply-demand", name: "供需明细", value: 81, unit: "%", extra: "81%" },
],
equipment: [
{ key: "drone", name: "无人机识别", value: 86, unit: "%", extra: "86%" },
{ key: "iot", name: "物联感知", value: 79, unit: "%", extra: "79%" },
{ key: "machine", name: "生产装备", value: 68, unit: "%", extra: "68%" },
{ key: "offline", name: "在线巡检", value: 74, unit: "%", extra: "74%" },
],
vr: [
{ key: "resource-scene", name: "资源场景", value: 88, unit: "%", extra: "88%" },
{ key: "base-scene", name: "基地场景", value: 82, unit: "%", extra: "82%" },
{ key: "processing-scene", name: "加工场景", value: 76, unit: "%", extra: "76%" },
{ key: "display-scene", name: "展陈联动", value: 72, unit: "%", extra: "72%" },
],
}
export function getStageDef(key) {
return industryStageDefs.find((item) => item.key === key) || industryStageDefs[0]
}
export function getStageRow(key) {
return industryStageRows.find((item) => item.key === key) || industryStageRows[0]
}
export function getStageCapacityRows(stageKey) {
if (stageKey === "support") {
return aggregateRowsByField(supportPointRows, "town", "capacity", "吨")
}
if (stageKey === "processing") {
return processingPointRows
.map((item) => ({
key: item.key,
name: item.name.replace("四川红原", "").replace("有限责任公司", ""),
value: item.capacity,
unit: item.capacityUnit,
extra: item.typeName,
}))
.sort((a, b) => b.value - a.value)
}
if (stageKey === "breeding") {
return grasslandYakRows
.map((item) => ({
key: item.zoneName,
name: item.zoneName,
value: item.yakCount,
unit: "头",
extra: `${formatWanMu(item.grasslandArea)}万亩`,
}))
.sort((a, b) => b.value - a.value)
.slice(0, 7)
}
if (stageKey === "equipment") {
return townRecognitionRows
.map((item) => ({ key: item.name, name: item.name, value: item.devices, unit: "套", extra: `${item.count}` }))
.sort((a, b) => b.value - a.value)
.slice(0, 7)
}
if (stageKey === "service") {
return tradeImpactRows.map((item) => ({
key: item.label,
name: item.label,
value: item.value,
unit: "头",
extra: `${item.yoy > 0 ? "+" : ""}${item.yoy}%`,
}))
}
if (stageKey === "vr") {
return chainFlowRows.map((item) => ({
key: item.key,
name: item.name,
value: item.value,
unit: item.unit,
extra: item.extra,
}))
}
return getInfrastructureTownRows().slice(0, 7)
}
export function getStageTownRows(stageKey) {
if (stageKey === "support") {
return aggregateRowsByField(supportPointRows, "town", "capacity", "吨")
}
if (stageKey === "processing") {
return aggregateRowsByField(processingPointRows, "town", "capacity", "吨")
}
if (stageKey === "equipment") {
return townRecognitionRows.map((item) => ({
key: item.name,
name: item.name,
value: item.devices,
unit: "套",
}))
}
if (stageKey === "service") {
const totalYak = grasslandYakRows.reduce((sum, item) => sum + Number(item.yakCount || 0), 0) || 1
return grasslandYakRows
.map((item) => ({
key: item.zoneName,
name: item.zoneName,
value: Math.max(12, Math.round((item.yakCount / totalYak) * serviceSubjectTotal)),
unit: "个",
extra: "服务主体",
}))
.sort((a, b) => b.value - a.value)
}
if (stageKey === "vr") {
return [
{ key: "vr-resource", name: "资源底图", value: 6, unit: "类", extra: "三维展示" },
{ key: "vr-base", name: "种养基地", value: homeCore.baseCount, unit: "个", extra: "基地漫游" },
{ key: "vr-processing", name: "加工企业", value: processingPointRows.length, unit: "家", extra: "工艺展示" },
{ key: "vr-service", name: "服务链路", value: 4, unit: "类", extra: "业务联动" },
]
}
if (stageKey === "infrastructure") return getInfrastructureTownRows()
return grasslandYakRows
.map((item) => ({ key: item.zoneName, name: item.zoneName, value: item.yakCount, unit: "头" }))
.sort((a, b) => b.value - a.value)
}
export function getStageDetailRows(stageKey) {
if (stageKey === "support") return supportPointRows.map(toDetailRow)
if (stageKey === "processing") return processingPointRows.map(toDetailRow)
if (stageKey === "breeding") {
return grasslandYakRows.map((item, index) => ({
key: `base-${index + 1}`,
name: `${item.zoneName}生态种养基地`,
stageKey,
typeName: "种养基地",
town: item.zoneName,
capacity: item.yakCount,
capacityUnit: "头",
status: "在线",
products: `${formatWanMu(item.grasslandArea)}万亩草地`,
}))
}
if (stageKey === "equipment") {
return townRecognitionRows.map((item, index) => ({
key: `equipment-${index + 1}`,
name: `${item.name}识别监测设备`,
stageKey,
typeName: "机械设备",
town: item.name,
capacity: item.devices,
capacityUnit: "套",
status: "在线",
products: `${item.count}头识别样本`,
}))
}
if (stageKey === "service") {
return [
{ key: "service-insurance", name: "牦牛保险服务", stageKey, typeName: "保险", town: "全县", capacity: serviceSubjectTotal, capacityUnit: "户", status: "运行中", products: "参保、理赔、风险事项" },
{ key: "service-quarantine", name: "检疫出证流通服务", stageKey, typeName: "检疫", town: "全县", capacity: 9140, capacityUnit: "条", status: "运行中", products: "A证/B证流通明细" },
{ key: "service-trade", name: "活畜交易撮合服务", stageKey, typeName: "交易", town: "全县", capacity: tradeFlowTotal, capacityUnit: "头", status: "运行中", products: "交易总览、供需明细" },
{ key: "service-trace", name: "质量追溯与品牌服务", stageKey, typeName: "追溯", town: "全县", capacity: 6, capacityUnit: "类", status: "建设中", products: "批次、检疫、加工、销售" },
]
}
if (stageKey === "vr") {
return [
{ key: "vr-resource", name: "林草湿资源 VR 场景", stageKey, typeName: "产业VR", town: "全县", capacity: 6, capacityUnit: "类", status: "可展示", products: "草地、湿地、林地、牧场" },
{ key: "vr-base", name: "种养基地 VR 场景", stageKey, typeName: "产业VR", town: "全县", capacity: homeCore.baseCount, capacityUnit: "个", status: "可展示", products: "基地、物联感知、虫情监测" },
{ key: "vr-processing", name: "加工基地 VR 场景", stageKey, typeName: "产业VR", town: "邛溪镇", capacity: processingPointRows.length, capacityUnit: "家", status: "可展示", products: "乳品加工、肉品加工" },
{ key: "vr-chain", name: "全链路 VR 展陈", stageKey, typeName: "产业VR", town: "全县", capacity: industryStageDefs.length, capacityUnit: "段", status: "建设中", products: "生产、加工、流通、品牌" },
]
}
return getInfrastructureTownRows().map((item, index) => ({
key: `infra-${index + 1}`,
name: `${item.name}产业基础设施项目`,
stageKey,
typeName: "基础设施",
town: item.name,
capacity: item.value,
capacityUnit: item.unit,
status: "入库",
products: item.extra,
}))
}
function toDetailRow(item) {
return {
...item,
value: Number(item.capacity || 0),
unit: item.capacityUnit,
}
}
function getInfrastructureTownRows() {
return grasslandYakRows.map((item, index) => ({
key: item.zoneName,
name: item.zoneName,
value: infrastructureProjectDistribution[index] || 1,
unit: "项",
extra: `${formatWanMu(item.grasslandArea)}万亩`,
}))
}
function aggregateRowsByField(rows, nameField, valueField, unit) {
const map = new Map()
rows.forEach((item) => {
const name = item[nameField] || "未填写"
const current = map.get(name) || { key: name, name, value: 0, count: 0, unit, extra: "" }
current.value += Number(item[valueField] || 0)
current.count += 1
current.extra = `${current.count}`
map.set(name, current)
})
return [...map.values()].sort((a, b) => b.value - a.value || b.count - a.count)
}
function formatWanMu(value) {
const number = Number(value || 0) / 10000
return Number(number.toFixed(1))
}
export const industryFinancialRows = [
{ key: "support-capacity", name: "饲草储备能力", value: supportCapacity, unit: "吨", extra: `${supportPointRows.length}个点位` },
{ key: "processing-capacity", name: "加工转化能力", value: processingCapacity, unit: "吨", extra: `${processingPointRows.length}家企业` },
{ key: "processing-output", name: "加工产值", value: processingOutputValue, unit: "万元", extra: "乳品/肉品" },
{ key: "trade-flow", name: "检疫流通头数", value: tradeFlowTotal, unit: "头", extra: "供需明细" },
]

@ -0,0 +1,392 @@
<template>
<div class="yak-industry-chain">
<map-scene
ref="mapSceneRef"
class="industry-map-scene"
:active-layer="industryMapLayer"
:map-ranking="mapRanking"
/>
<div class="industry-map-scrim"></div>
<div v-if="!mapReady" class="industry-map-loading">3D地图加载中</div>
<div class="industry-left industry-side-stack">
<m-card class="industry-card left-card" title="全链核心指标" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="overviewPanel" compact />
</m-card>
<m-card class="industry-card left-card" title="产业节点结构" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="stageStructurePanel" compact />
</m-card>
<m-card class="industry-card left-card" title="链路协同指数" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="chainFlowPanel" compact />
</m-card>
<m-card class="industry-card left-card" :title="`${activeStage.name}能力排行`" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="capacityRankPanel" compact />
</m-card>
</div>
<div class="industry-right industry-side-stack">
<m-card class="industry-card right-card" title="加工储备产能" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="financialPanel" compact />
</m-card>
<m-card class="industry-card right-card" :title="`${activeStage.name}镇域覆盖`" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="townCoveragePanel" compact />
</m-card>
<m-card class="industry-card right-card" :title="`${activeStage.name}运行画像`" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="stageHealthPanel" compact />
</m-card>
<m-card class="industry-card right-card industry-list-card" :title="`${activeStage.name}明细`" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="detailTablePanel" compact @row-select="handleDetailSelect" />
</m-card>
</div>
<div class="industry-center">
<div class="industry-chain-title">
<span>牦牛全产业链</span>
<strong>{{ activeStage.name }}</strong>
</div>
</div>
<div class="industry-bottom-switcher">
<topic-switcher
:model-value="activeStageKey"
:topics="stageSwitcherTopics"
@update:model-value="handleStageSelect"
/>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
import mCard from "@/components/mCard/index.vue"
import MapScene from "@/views/forestGrassWet/map.vue"
import BusinessPanelRenderer from "@/views/forestGrassWet/components/BusinessPanelRenderer.vue"
import TopicSwitcher from "@/views/forestGrassWet/components/TopicSwitcher.vue"
import { Assets } from "@/views/forestGrassWet/assets"
import { getLayerByKey } from "@/config/layers"
import {
chainFlowRows,
getStageCapacityRows,
getStageDef,
getStageDetailRows,
getStageTownRows,
industryFinancialRows,
industryOverviewRows,
industryStageDefs,
industryStageRows,
industryTopStats,
stageHealthRows,
} from "./data"
const emit = defineEmits(["module-stats", "loading-progress", "map-ready"])
const sideCardWidth = 398
const sideCardHeight = 190
const activeStageKey = ref("support")
const mapSceneRef = ref(null)
const mapReady = ref(false)
const industryMapLayer = getLayerByKey("grassland", "grassland-distribution")
let mapAssets = null
let destroyed = false
const activeStage = computed(() => getStageDef(activeStageKey.value))
const capacityRows = computed(() => getStageCapacityRows(activeStageKey.value))
const townRows = computed(() => getStageTownRows(activeStageKey.value))
const detailRows = computed(() => getStageDetailRows(activeStageKey.value))
const stageSwitcherTopics = computed(() =>
industryStageDefs.map((stage) => ({
key: stage.key,
name: stage.name,
color: stage.color,
}))
)
const mapRanking = computed(() => ({
title: `${activeStage.value.name}排行`,
metricLabel: activeStage.value.metricLabel,
unit: townRows.value[0]?.unit || activeStage.value.unit,
rows: townRows.value.slice(0, 8).map((item) => ({
name: item.name,
value: Number(item.value) || 0,
unit: item.unit || activeStage.value.unit,
})),
}))
const overviewPanel = computed(() => ({
key: "industry-overview",
type: "iconMetric",
rows: industryOverviewRows,
}))
const stageStructurePanel = computed(() => ({
key: "industry-stage-structure",
type: "bar",
variant: "distribution",
rows: industryStageRows.map((item) => ({
...item,
extra: item.note,
})),
}))
const chainFlowPanel = computed(() => ({
key: "industry-chain-flow",
type: "line",
rows: chainFlowRows,
}))
const capacityRankPanel = computed(() => ({
key: `industry-capacity-${activeStageKey.value}`,
type: "bar",
variant: "distribution",
rows: capacityRows.value.slice(0, 7),
}))
const financialPanel = computed(() => ({
key: "industry-financial",
type: "column",
variant: "slim",
rows: industryFinancialRows,
}))
const townCoveragePanel = computed(() => ({
key: `industry-town-${activeStageKey.value}`,
type: "bar",
variant: "distribution",
rows: townRows.value.slice(0, 7),
}))
const stageHealthPanel = computed(() => ({
key: `industry-health-${activeStageKey.value}`,
type: "heat",
rows: stageHealthRows[activeStageKey.value] || [],
}))
const detailTablePanel = computed(() => ({
key: `industry-detail-${activeStageKey.value}`,
type: "projectTable",
searchPlaceholder: "搜索主体 / 乡镇",
emptyText: "无匹配主体",
searchKeys: ["name", "typeName", "town", "address", "products", "status"],
columns: [
{ key: "name", label: "主体名称", field: "name", width: "34%" },
{ key: "type", label: "类型", field: "typeName", width: "18%" },
{ key: "town", label: "乡镇", field: "town", width: "16%" },
{ key: "capacity", label: "能力", field: "capacity", width: "18%", align: "right", strong: true, format: "number", unitField: "capacityUnit" },
{ key: "status", label: "状态", field: "status", width: "14%" },
],
rows: detailRows.value,
}))
onMounted(() => {
emit("module-stats", {
moduleKey: "yak-industry-chain",
items: industryTopStats,
})
initIndustryMap()
})
onBeforeUnmount(() => {
destroyed = true
mapSceneRef.value?.destroyMap?.()
})
function handleStageSelect(key) {
activeStageKey.value = key
}
function initIndustryMap() {
mapAssets = new Assets()
emit("loading-progress", { progress: 0 })
mapAssets.instance?.on?.("onProgress", (path, itemsLoaded, itemsTotal) => {
const total = itemsTotal || 1
emit("loading-progress", {
progress: Math.floor((itemsLoaded / total) * 100),
path,
itemsLoaded,
itemsTotal,
})
})
const loadMapOnce = async (attempt = 0) => {
if (destroyed) return
await nextTick()
const scene = mapSceneRef.value
scene?.loadMap?.(mapAssets)
if (scene?.canvasMap) {
mapReady.value = true
emit("loading-progress", { progress: 100 })
emit("map-ready")
scene.play?.()
return
}
if (attempt < 24) {
window.setTimeout(() => loadMapOnce(attempt + 1), 120)
return
}
mapReady.value = true
emit("map-ready")
}
mapAssets.ready?.then(loadMapOnce).catch(() => {
mapReady.value = true
emit("map-ready")
})
}
function handleDetailSelect(row) {
const lng = Number(row?.longitude)
const lat = Number(row?.latitude)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
const canvasMap = mapSceneRef.value?.canvasMap?.value || mapSceneRef.value?.canvasMap
canvasMap?.flyToLngLat?.([lng, lat], {
duration: 0.72,
statusText: "已定位产业点位",
})
}
</script>
<style lang="scss">
.yak-industry-chain {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
color: #fff;
background: #03121c;
}
.industry-map-scene {
position: absolute;
inset: 0;
z-index: 1;
}
.industry-map-scrim {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background:
radial-gradient(circle at 50% 48%, rgba(4, 18, 28, 0.02) 0%, rgba(4, 18, 28, 0.08) 37%, rgba(2, 10, 17, 0.45) 78%, rgba(1, 7, 12, 0.72) 100%),
linear-gradient(90deg, rgba(0, 12, 20, 0.42), transparent 26%, transparent 74%, rgba(0, 12, 20, 0.42));
}
.industry-map-loading {
position: absolute;
left: 50%;
top: 50%;
z-index: 4;
transform: translate(-50%, -50%);
color: rgba(220, 250, 255, 0.78);
font-size: 18px;
}
.industry-side-stack {
position: absolute;
top: 176px;
z-index: 5;
display: flex;
flex-direction: column;
gap: 12px;
width: 398px;
pointer-events: all;
}
.industry-left {
left: 32px;
transform: perspective(900px) rotateY(4deg);
}
.industry-right {
right: 32px;
transform: perspective(900px) rotateY(-4deg);
}
.industry-card {
flex: 0 0 auto;
.m-card-bd-content {
top: 40px;
bottom: 8px;
color: #f3feff;
}
.m-card-bd-bg {
filter: brightness(1.08) saturate(1.05);
}
.m-card-bd-bg::after {
content: "";
position: absolute;
inset: 4px 5px 6px;
z-index: 0;
pointer-events: none;
border: 1px solid rgba(48, 220, 255, 0.14);
background:
linear-gradient(180deg, rgba(15, 69, 88, 0.16), rgba(6, 28, 43, 0.13)),
linear-gradient(90deg, rgba(48, 220, 255, 0.09), transparent 42%, rgba(255, 224, 107, 0.05));
box-shadow:
inset 0 0 0 1px rgba(48, 220, 255, 0.05),
inset 0 12px 28px rgba(48, 220, 255, 0.04);
}
}
.yak-industry-chain .m-card-hd-title {
color: #ffffff !important;
background: none !important;
-webkit-text-fill-color: #ffffff !important;
text-shadow: none !important;
}
.industry-center {
position: absolute;
left: 50%;
top: 690px;
z-index: 4;
width: 820px;
transform: translateX(-50%);
pointer-events: none;
}
.industry-chain-title {
display: flex;
align-items: baseline;
justify-content: center;
gap: 14px;
margin-bottom: 13px;
text-shadow: 0 0 18px rgba(48, 220, 255, 0.28);
span {
color: rgba(224, 252, 255, 0.9);
font-size: 20px;
font-weight: 700;
}
strong {
color: #30dcff;
font-size: 32px;
font-family: AlimamaShuHeiTi, AlibabaPuHuiTi, sans-serif;
letter-spacing: 0;
}
}
.industry-bottom-switcher {
position: absolute;
left: 50%;
bottom: 25px;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
transform: translateX(-50%);
pointer-events: all;
.topic-switcher {
width: 940px;
}
.topic-switcher-item {
width: 112px;
}
.topic-switcher-item .topic-name {
font-size: 15px;
}
}
</style>

@ -1,6 +1,4 @@
import {
estimatedStock,
expertCorrection,
recognitionTotal,
townRecognitionRows,
tradeDeduction,
@ -9,13 +7,14 @@ import {
} from "@/views/gdMap/livestockData"
export {
estimatedStock,
expertCorrection,
recognitionTotal,
tradeDeduction,
yakForecastRows,
}
export const expertCorrection = 1716
export const estimatedStock = recognitionTotal + expertCorrection + tradeDeduction
export const townNameAliases = {
色地镇: "色地乡",
瓦切镇: "瓦切乡",
@ -27,7 +26,9 @@ export function canonicalTownName(name) {
return townNameAliases[name] || name
}
export const townRows = townRecognitionRows.slice().sort((a, b) => b.count - a.count)
export const townRows = townRecognitionRows
.map(({ name, count }) => ({ name, count }))
.sort((a, b) => b.count - a.count)
export const structureRows = withStockCounts(
[
@ -62,7 +63,7 @@ export const forecastPeriods = [
stock: 781236,
births: 44763,
outflow: 21684,
correction: -21843,
correction: -21735,
structure: { adultCow: 47, otherAdult: 22, calf: 16, yearling: 11, bull: 4 },
},
{
@ -73,7 +74,7 @@ export const forecastPeriods = [
stock: 779428,
births: 121374,
outflow: 61286,
correction: -60660,
correction: -60552,
structure: { adultCow: 47, otherAdult: 21, calf: 17, yearling: 11, bull: 4 },
},
{
@ -84,7 +85,7 @@ export const forecastPeriods = [
stock: 782617,
births: 199642,
outflow: 102517,
correction: -94508,
correction: -94400,
structure: { adultCow: 46, otherAdult: 22, calf: 17, yearling: 11, bull: 4 },
},
]
@ -147,7 +148,7 @@ export function buildForecastSeries(period) {
})
const stock = labels.map((_, index) => {
const ratio = index / period.months
const seasonal = Math.sin(index / 2.6) * Math.min(360, period.months * 18)
const seasonal = Math.sin(index / 2.6) * Math.sin(Math.PI * ratio) * Math.min(360, period.months * 18)
return Math.round(estimatedStock + (period.stock - estimatedStock) * ratio + seasonal)
})
const births = labels.map((_, index) => Math.round((period.births / period.months) * index))
@ -169,9 +170,8 @@ export function aggregateTownRows(rows) {
const map = new Map()
rows.forEach((row) => {
const name = canonicalTownName(row.name)
const current = map.get(name) || { name, count: 0, devices: 0 }
const current = map.get(name) || { name, count: 0 }
current.count += Number(row.count || 0)
current.devices += Number(row.devices || 0)
map.set(name, current)
})
return [...map.values()].sort((a, b) => b.count - a.count)

File diff suppressed because it is too large Load Diff

@ -20,6 +20,7 @@ function resolve(url) {
export default ({ mode }) => {
// 获取环境变量
const env = loadEnv(mode, process.cwd());
const accountApiTarget = env.VITE_ACCOUNT_API_PROXY_TARGET || 'http://127.0.0.1:8888';
const openApiTarget = env.VITE_OPEN_API_PROXY_TARGET || 'http://127.0.0.1:9999';
const jsyApiTarget = env.VITE_JSY_API_PROXY_TARGET || openApiTarget;
const geoserverTarget = env.VITE_GEOSERVER_PROXY_TARGET || 'http://127.0.0.1:8080';
@ -72,6 +73,34 @@ export default ({ mode }) => {
},
server: {
proxy: {
'/login': {
target: accountApiTarget,
changeOrigin: true,
},
'/logout': {
target: accountApiTarget,
changeOrigin: true,
},
'/getInfo': {
target: accountApiTarget,
changeOrigin: true,
},
'/getRouters': {
target: accountApiTarget,
changeOrigin: true,
},
'/captchaImage': {
target: accountApiTarget,
changeOrigin: true,
},
'/user': {
target: accountApiTarget,
changeOrigin: true,
rewrite: (proxyPath) => proxyPath
.replace(/^\/user\/login$/, '/login')
.replace(/^\/user\/logout$/, '/logout')
.replace(/^\/user\/getInfo$/, '/getInfo'),
},
'/openApi': {
target: openApiTarget,
changeOrigin: true,

Loading…
Cancel
Save