优化大屏界面与地图图层

V2.0
M先生 1 month ago
parent de2625b27c
commit 1279b948d2
  1. 0
      docs/assets/AlibabaPuHuiTi-14435b65-balabala-1783298613872.woff2
  2. 0
      docs/assets/AlimamaShuHeiTi-Bold-6401fd0d-balabala-1783298613872.ttf
  3. 0
      docs/assets/AlimamaShuHeiTi-Bold-a95023f2-balabala-1783298613872.woff
  4. 0
      docs/assets/AlimamaShuHeiTi-Bold-fa4a258d-balabala-1783298613872.woff2
  5. 2
      docs/assets/SmartChartRenderer-6f0457ba-1783298613872.js
  6. 0
      docs/assets/SmartChartRenderer-8fd37aa6-balabala-1783298613872.css
  7. 0
      docs/assets/area-b57940eb-balabala-1783298613872.png
  8. 0
      docs/assets/bg-3acb94e3-balabala-1783298613872.png
  9. 0
      docs/assets/bottom-menu-arrow-big-180b7071-balabala-1783298613872.svg
  10. 0
      docs/assets/bottom-menu-arrow-small-19304179-balabala-1783298613872.svg
  11. 0
      docs/assets/bottom-menu-bg-b465793d-balabala-1783298613872.png
  12. 0
      docs/assets/bottom-menu-btn-ee5c8064-balabala-1783298613872.png
  13. 0
      docs/assets/bottom-menu-btn-hover-df33e514-balabala-1783298613872.png
  14. 6
      docs/assets/chartTheme-6ff1ec31-1783298613872.js
  15. 0
      docs/assets/chartTheme-812dab15-balabala-1783298613872.css
  16. 0
      docs/assets/chinaBlurLine-1a2cbc0f-balabala-1783298613872.png
  17. 0
      docs/assets/content-bottom-left-arrow-3a8a61f3-balabala-1783298613872.svg
  18. 0
      docs/assets/content-bottom-right-arrow-a3c5270d-balabala-1783298613872.svg
  19. 0
      docs/assets/content-middle-line-162bc0d5-balabala-1783298613872.svg
  20. 0
      docs/assets/focus_bg-b6803e2d-balabala-1783298613872.png
  21. 0
      docs/assets/focus_move_bg-cf41417a-balabala-1783298613872.png
  22. 0
      docs/assets/header-bg-65b38012-balabala-1783298613872.png
  23. 0
      docs/assets/huiguang-3c5dfda5-balabala-1783298613872.png
  24. 0
      docs/assets/icon1-6f7c222f-balabala-1783298613872.png
  25. 0
      docs/assets/icon2-4f77ccec-balabala-1783298613872.png
  26. 2
      docs/assets/index-0e792d13-1783298613872.js
  27. 2
      docs/assets/index-1783298613872.js
  28. 2
      docs/assets/index-35b2fd67-balabala-1783298613872.css
  29. 1
      docs/assets/index-36e36714-balabala-1783298613872.css
  30. 0
      docs/assets/index-47d6f37a-balabala-1783298613872.css
  31. 2
      docs/assets/index-625ae259-1783298613872.js
  32. 1
      docs/assets/index-8cfc31f2-balabala-1783264824576.css
  33. 0
      docs/assets/index-8d752fbe-balabala-1783298613872.css
  34. 344
      docs/assets/index-c5bb6f01-1783298613872.js
  35. 0
      docs/assets/leaf-7be9efef-balabala-1783298613872.png
  36. 0
      docs/assets/left-kuang-5d6bd1ae-balabala-1783298613872.svg
  37. 0
      docs/assets/live-scene-1-786cdd0a-balabala-1783298613872.png
  38. 0
      docs/assets/menu-btn-91a961cf-balabala-1783298613872.png
  39. 0
      docs/assets/menu-btn-hover-62eba0bb-balabala-1783298613872.png
  40. 0
      docs/assets/ocean-bg-8c848b8f-balabala-1783298613872.png
  41. 0
      docs/assets/park-ee0533e0-balabala-1783298613872.png
  42. 0
      docs/assets/pathLine2-d4134d4b-balabala-1783298613872.png
  43. 0
      docs/assets/right-kuang-b1937caf-balabala-1783298613872.svg
  44. 0
      docs/assets/ring2-a1b91808-balabala-1783298613872.png
  45. 0
      docs/assets/ring3-fe0f60d7-balabala-1783298613872.png
  46. 0
      docs/assets/ring4-f2e324d6-balabala-1783298613872.png
  47. 0
      docs/assets/rotationBorder1-2ce8e0ef-balabala-1783298613872.png
  48. 0
      docs/assets/rotationBorder2-7496b968-balabala-1783298613872.png
  49. 0
      docs/assets/saoguang-ccf84a88-balabala-1783298613872.svg
  50. 0
      docs/assets/stack-7a426057-balabala-1783298613872.png
  51. 0
      docs/assets/title-zs1-c15a5ee8-balabala-1783298613872.svg
  52. 0
      docs/assets/town-f8c0c9cc-balabala-1783298613872.png
  53. 0
      docs/assets/yield-66e9af79-balabala-1783298613872.png
  54. 4
      docs/index.html
  55. 58
      src/views/ecologicalProtection/data.js
  56. 47
      src/views/ecologicalProtection/index.vue
  57. 27
      src/views/forestGrassWet/components/BusinessPanelRenderer.vue
  58. 41
      src/views/forestGrassWet/index.vue
  59. 297
      src/views/forestGrassWet/map.js
  60. 5
      src/views/forestGrassWet/map.vue
  61. 49
      src/views/grassLivestockBalance/data.js
  62. 42
      src/views/grassLivestockBalance/index.vue
  63. 302
      src/views/home/index.vue
  64. 10
      src/views/pastureManagement/data.js
  65. 169
      src/views/pastureManagement/index.vue
  66. 199
      src/views/platform/index.vue
  67. 121
      src/views/yakIndustryChain/data.js
  68. 390
      src/views/yakIndustryChain/index.vue
  69. 355
      src/views/yakManagement/YakOlMap.vue
  70. 400
      src/views/yakManagement/index.vue

@ -1,2 +1,2 @@
var D=Object.defineProperty,W=Object.defineProperties;var H=Object.getOwnPropertyDescriptors;var z=Object.getOwnPropertySymbols;var F=Object.prototype.hasOwnProperty,P=Object.prototype.propertyIsEnumerable;var k=(r,o,n)=>o in r?D(r,o,{enumerable:!0,configurable:!0,writable:!0,value:n}):r[o]=n,h=(r,o)=>{for(var n in o||(o={}))F.call(o,n)&&k(r,n,o[n]);if(z)for(var n of z(o))P.call(o,n)&&k(r,n,o[n]);return r},p=(r,o)=>W(r,H(o));import{e as g,j as R,k as V,L as y,o as x,c as S,a as f,t as d,g as _,b as j,u as q,n as G}from"./index-1783264824576.js";import{aS as M}from"./chartTheme-3259b6f8-1783264824576.js";const E={class:"smart-chart-header"},I={key:0},U={key:0,class:"smart-chart-source"},Q={__name:"SmartChartRenderer",props:{chart:{type:Object,required:!0}},emits:["ready"],setup(r,{emit:o}){const n=o,i=r,l=["#30dcff","#27f0c0","#ffd76c","#7aa7ff","#ff8f6b","#a6f06d"],N=g(()=>{const e=(i.chart.rows||[]).filter(t=>Number.isFinite(Number(t.value)));return i.chart.type==="pie"?w(e):i.chart.type==="line"?B(e):A(e)}),T=g(()=>{const e=i.chart.rows||[];return i.chart.type==="bar"&&L(e)}),v=g(()=>{const e=i.chart.source||{},t=$(e),a=m(e.period||e.dataPeriod||i.chart.period),b=m(e.generatedAt||e.updateTime||e.updatedAt,!0),u=[t,a,b].filter(Boolean);return u.length?`统计口径:${u.join(" / ")}`:"统计口径:业务统计库"});R(()=>{V(()=>{window.requestAnimationFrame(()=>n("ready"))})});function A(e){return L(e)?C(e):{animationDuration:700,grid:{left:52,right:26,top:28,bottom:54},tooltip:s(),xAxis:{type:"category",data:e.map(t=>t.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:e.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:e.length>7?16:22,data:e.map((t,a)=>({value:t.value,name:t.name,itemStyle:{borderRadius:[8,8,0,0],color:new y(0,0,0,1,[{offset:0,color:l[a%l.length]},{offset:1,color:"rgba(48, 220, 255, 0.08)"}])}})),label:{show:!0,position:"top",color:"#ffffff",fontSize:11,formatter:({value:t})=>c(t)}}]}}function C(e){return{animationDuration:700,grid:{left:78,right:72,top:22,bottom:20},tooltip:p(h({},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:e.map(t=>t.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:e.map((t,a)=>({value:t.value,name:t.name,itemStyle:{borderRadius:[0,8,8,0],color:new y(0,0,1,0,[{offset:0,color:"rgba(48, 220, 255, 0.16)"},{offset:1,color:l[a%l.length]}])}})),label:{show:!0,position:"right",color:"#ffffff",fontSize:11,formatter:({value:t})=>c(t)}}]}}function w(e){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}
var D=Object.defineProperty,W=Object.defineProperties;var H=Object.getOwnPropertyDescriptors;var z=Object.getOwnPropertySymbols;var F=Object.prototype.hasOwnProperty,P=Object.prototype.propertyIsEnumerable;var k=(r,o,n)=>o in r?D(r,o,{enumerable:!0,configurable:!0,writable:!0,value:n}):r[o]=n,h=(r,o)=>{for(var n in o||(o={}))F.call(o,n)&&k(r,n,o[n]);if(z)for(var n of z(o))P.call(o,n)&&k(r,n,o[n]);return r},p=(r,o)=>W(r,H(o));import{e as g,j as R,k as V,L as y,o as x,c as S,a as f,t as d,g as _,b as j,u as q,n as G}from"./index-1783298613872.js";import{aS as M}from"./chartTheme-6ff1ec31-1783298613872.js";const E={class:"smart-chart-header"},I={key:0},U={key:0,class:"smart-chart-source"},Q={__name:"SmartChartRenderer",props:{chart:{type:Object,required:!0}},emits:["ready"],setup(r,{emit:o}){const n=o,i=r,l=["#30dcff","#27f0c0","#ffd76c","#7aa7ff","#ff8f6b","#a6f06d"],N=g(()=>{const e=(i.chart.rows||[]).filter(t=>Number.isFinite(Number(t.value)));return i.chart.type==="pie"?w(e):i.chart.type==="line"?B(e):A(e)}),T=g(()=>{const e=i.chart.rows||[];return i.chart.type==="bar"&&L(e)}),v=g(()=>{const e=i.chart.source||{},t=$(e),a=m(e.period||e.dataPeriod||i.chart.period),b=m(e.generatedAt||e.updateTime||e.updatedAt,!0),u=[t,a,b].filter(Boolean);return u.length?`统计口径:${u.join(" / ")}`:"统计口径:业务统计库"});R(()=>{V(()=>{window.requestAnimationFrame(()=>n("ready"))})});function A(e){return L(e)?C(e):{animationDuration:700,grid:{left:52,right:26,top:28,bottom:54},tooltip:s(),xAxis:{type:"category",data:e.map(t=>t.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:e.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:e.length>7?16:22,data:e.map((t,a)=>({value:t.value,name:t.name,itemStyle:{borderRadius:[8,8,0,0],color:new y(0,0,0,1,[{offset:0,color:l[a%l.length]},{offset:1,color:"rgba(48, 220, 255, 0.08)"}])}})),label:{show:!0,position:"top",color:"#ffffff",fontSize:11,formatter:({value:t})=>c(t)}}]}}function C(e){return{animationDuration:700,grid:{left:78,right:72,top:22,bottom:20},tooltip:p(h({},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:e.map(t=>t.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:e.map((t,a)=>({value:t.value,name:t.name,itemStyle:{borderRadius:[0,8,8,0],color:new y(0,0,1,0,[{offset:0,color:"rgba(48, 220, 255, 0.16)"},{offset:1,color:l[a%l.length]}])}})),label:{show:!0,position:"right",color:"#ffffff",fontSize:11,formatter:({value:t})=>c(t)}}]}}function w(e){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:e.map((t,a)=>({name:t.name,value:t.value,itemStyle:{color:l[a%l.length]}}))}]}}function B(e){return{animationDuration:760,grid:{left:52,right:28,top:30,bottom:48},tooltip:p(h({},s()),{trigger:"axis",axisPointer:{type:"line",lineStyle:{color:"rgba(255, 215, 108, 0.52)"}}}),xAxis:{type:"category",boundaryGap:!1,data:e.map(t=>t.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 y(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:t})=>c(t)},data:e.map(t=>({value:t.value,name:t.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:e=>{const t=(i.chart.rows||[]).find(u=>u.name===e.name),a=(t==null?void 0:t.unit)||i.chart.unit||"",b=t!=null&&t.extra?`<br/><span style="color:rgba(223,250,255,.62)">${t.extra}</span>`:"";return`${e.marker}${e.name}: ${c(e.value)}${a}${b}`}}}function L(e=[]){return e.length>6||e.some(t=>String(t.name||"").length>4)}function $(e={}){const t=m(e.sourceName||e.name||e.label);if(t)return t;const a=String(e.mode||"").trim();return/openapi|api|stat/i.test(a)?"":/local|mock|fallback/i.test(a)?"":""}function m(e,t=!1){const a=String(e||"").trim();return a?t&&/^\d{4}-\d{2}-\d{2}/.test(a)?a.slice(0,10):O(a)?"":a:""}function O(e){return/^[a-zA-Z0-9_./:-]+$/.test(e)&&(e.includes("_")||e.length>24)}function c(e){const t=Number(e);return Number.isFinite(t)?Number.isInteger(t)?t.toLocaleString("zh-CN"):t.toLocaleString("zh-CN",{maximumFractionDigits:2}):"--"}return(e,t)=>(x(),S("div",{class:G(["smart-chart-card",{"is-horizontal":T.value}])},[f("div",E,[f("div",null,[f("strong",null,d(r.chart.title),1),f("span",null,d(r.chart.subtitle),1)]),r.chart.unit?(x(),S("em",I,d(r.chart.unit),1)):_("",!0)]),j(q(M),{class:"smart-chart",option:N.value,autoresize:""},null,8,["option"]),v.value?(x(),S("div",U,d(v.value),1)):_("",!0)],2))}};export{Q as default};

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -6,8 +6,8 @@
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>红原县数字畜牧业监管平台</title>
<script type="module" crossorigin src="./assets/index-1783264824576.js"></script>
<link rel="stylesheet" href="./assets/index-47d6f37a-balabala-1783264824576.css">
<script type="module" crossorigin src="./assets/index-1783298613872.js"></script>
<link rel="stylesheet" href="./assets/index-47d6f37a-balabala-1783298613872.css">
</head>
<body>

@ -546,6 +546,17 @@ const parkTownYakRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
extra: `${round((item.yakCount / Math.max(parkSummary.yakCount, 1)) * 100, 1)}%`,
}))
const parkTownHouseholdRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
key: `${item.name}-household`,
name: item.name,
value: item.householdCount,
unit: "户",
metricLabel: "牧户",
ecologyJobs: item.ecologyJobs,
peopleCount: item.peopleCount,
extra: `公益岗位 ${item.ecologyJobs}`,
}))
const parkTownBalanceRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
key: `${item.name}-balance`,
name: item.name,
@ -555,11 +566,23 @@ const parkTownBalanceRows = sortParkTownRows(parkTownBusinessRows).map((item) =>
extra: `载畜 ${item.livestockCapacity}`,
}))
const parkBalanceGaugeRows = [
{
key: "park-balance-overload-rate",
name: "超载率",
value: parkSummary.balanceOverloadRate,
unit: "%",
status: "总体可控",
color: "#24f6c4",
},
]
const parkTownEcologyJobRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({
key: `${item.name}-job`,
name: item.name,
value: item.ecologyJobs,
unit: "个",
unit: "户",
color: item.name === "麦洼乡" ? "#24f6c4" : item.name === "瓦切镇" ? "#32dcff" : "#ffe06b",
extra: `牧户 ${item.householdCount}`,
}))
@ -577,7 +600,7 @@ export const parkTopStats = [
{ zh: "红原县划定面积", value: parkSummary.hongyuanDelimitedAreaWanMu, unit: "万亩", decimals: 2 },
{ zh: "牦牛数量", value: parkSummary.yakCount, unit: "头", decimals: 0 },
{ zh: "牧户数量", value: parkSummary.householdCount, unit: "户", decimals: 0 },
{ zh: "草畜平衡超载率", value: parkSummary.balanceOverloadRate, unit: "%", decimals: 2 },
{ zh: "公益岗位", value: parkSummary.ecologyJobCount, unit: "", decimals: 0 },
{ zh: "瓦切镇面积", value: parkTownBusinessRows.find((item) => item.name === "瓦切镇")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 },
{ zh: "麦洼乡面积", value: parkTownBusinessRows.find((item) => item.name === "麦洼乡")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 },
{ zh: "色地镇面积", value: parkTownBusinessRows.find((item) => item.name === "色地镇")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 },
@ -648,7 +671,7 @@ export const parkPanels = {
title: "草畜平衡超载率",
type: "gauge",
max: 100,
rows: parkTownBalanceRows,
rows: parkBalanceGaugeRows,
},
],
right: [
@ -658,6 +681,19 @@ export const parkPanels = {
type: "donut",
rows: withParkPercentExtra(parkZoningRows),
},
{
key: "park-town-household",
title: "牧户分布",
type: "split",
rows: parkTownHouseholdRows,
},
{
key: "park-town-ecology-job",
title: "公益岗位分布",
type: "column",
variant: "slim",
rows: parkTownEcologyJobRows,
},
],
}
@ -806,13 +842,15 @@ export const restorationPanels = {
title: "年度项目数量",
type: "column",
variant: "projectYear",
rows: (restorationSummary.years || []).map((item) => ({
key: item.name,
name: `${item.name}`,
value: item.projectCount,
unit: "个",
extra: `${round(item.areaWanMu, 2)}万亩`,
})),
rows: (restorationSummary.years || [])
.filter((item) => item.name !== "未知")
.map((item) => ({
key: item.name,
name: `${item.name}`,
value: item.projectCount,
unit: "个",
extra: `${round(item.areaWanMu, 2)}万亩`,
})),
},
{
key: "restoration-projects",

@ -360,6 +360,37 @@ async function loadMapOnce(attempt = 0) {
mapLoadStarted = false
}
function clearMapLoadTimer() {
if (!mapLoadTimer) return
window.clearTimeout(mapLoadTimer)
mapLoadTimer = null
}
function refreshMapView() {
mapSceneRef.value?.resume?.()
mapSceneRef.value?.refreshView?.()
}
function resetMap() {
closeFeaturePanel()
mapSceneRef.value?.setBoundaryDrawing?.(false)
mapSceneRef.value?.clearAnalysisBoundary?.()
if (mapSceneRef.value?.hasMap?.()) {
refreshMapView()
return
}
clearMapLoadTimer()
mapLoadStarted = false
nextTick(() => {
scheduleMapLoadIfActive()
})
}
defineExpose({
refreshMapView,
resetMap,
})
function toScreenLocalPoint(point) {
const screen = document.querySelector(".ecological-protection")
const rect = screen?.getBoundingClientRect()
@ -507,23 +538,27 @@ function hideLoading() {
<style lang="scss">
.ecological-protection {
.fgw-map-layer-tools {
top: 372px;
top: auto;
right: 458px;
bottom: 72px;
left: auto;
width: 248px;
height: 430px;
&.is-analysis {
top: 282px;
right: 72px;
transform: scale(0.92);
top: auto;
right: 458px;
bottom: 72px;
left: auto;
transform: none;
transform-origin: right top;
}
&.is-national-park {
top: auto;
left: auto;
right: 452px;
bottom: 144px;
right: 458px;
bottom: 72px;
width: 178px;
height: 118px;
opacity: 0.92;

@ -328,7 +328,10 @@
v-for="(item, index) in chartRows"
:key="item.key || item.name"
class="business-column-item"
:class="{ 'is-active': index === normalizedActiveIndex }"
:class="{
'is-active': index === normalizedActiveIndex,
'is-zero': item?.zero === true || item?.isZero === true,
}"
:data-hover-value="columnHoverValue(item)"
:title="columnHoverValue(item)"
:data-chart-tooltip="chartTooltipText(item)"
@ -1235,7 +1238,9 @@ function gaugeColor(item, index) {
function gaugeStatusText(item) {
if (item?.status || item?.level) return item.status || item.level
const value = gaugeRawValue(item)
if (value <= 0) return "未超载"
if (value < 0) return "合理"
if (value <= 5) return "基本实现草畜平衡"
if (value <= 10) return "轻度超载"
if (value > 100) return "高超载"
return "超载预警"
}
@ -3870,9 +3875,9 @@ function columnGradient(index, item = {}) {
}
&.is-variant-projectYear {
grid-template-columns: repeat(7, minmax(38px, 1fr));
gap: 10px;
padding: 26px 16px 10px;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 3px;
padding: 25px 14px 9px 8px;
&:before {
top: 47px;
@ -3887,7 +3892,7 @@ function columnGradient(index, item = {}) {
.business-column-item strong {
color: #ffffff;
font-size: 14px;
font-size: 11px;
line-height: 18px;
text-align: center;
text-shadow: 0 0 9px rgba(255, 255, 255, 0.28);
@ -3896,14 +3901,14 @@ function columnGradient(index, item = {}) {
.business-column-item span {
max-width: none;
color: rgba(240, 253, 255, 0.9);
font-size: 12px;
font-size: 10px;
line-height: 15px;
white-space: nowrap;
-webkit-line-clamp: 1;
}
.business-column-track {
width: 28px;
width: 19px;
height: 92px;
min-height: 74px;
border: 1px solid rgba(48, 220, 255, 0.18);
@ -3919,7 +3924,7 @@ function columnGradient(index, item = {}) {
position: absolute;
left: 50%;
bottom: -6px;
width: 42px;
width: 28px;
height: 8px;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(48, 220, 255, 0.38), rgba(48, 220, 255, 0) 72%);
@ -3928,8 +3933,8 @@ function columnGradient(index, item = {}) {
}
.business-column-track i {
left: 3px;
right: 3px;
left: 2px;
right: 2px;
min-height: 12px;
border-radius: 4px 4px 1px 1px;
box-shadow:

@ -391,6 +391,43 @@ async function loadMapOnce(attempt = 0) {
mapLoadStarted = false
}
function clearMapLoadTimer() {
if (!mapLoadTimer) return
window.clearTimeout(mapLoadTimer)
mapLoadTimer = null
}
function refreshMapView() {
mapSceneRef.value?.resume?.()
mapSceneRef.value?.refreshView?.()
}
function resetMap() {
closeFeaturePanel()
layerStatus.value = {
type: "loading",
text: "地图正在复位",
}
if (mapSceneRef.value?.hasMap?.()) {
refreshMapView()
layerStatus.value = {
type: "success",
text: "地图已复位",
}
return
}
clearMapLoadTimer()
mapLoadStarted = false
nextTick(() => {
scheduleMapLoadIfActive()
})
}
defineExpose({
refreshMapView,
resetMap,
})
async function loadBusinessStats(topicKey, layerKey = activeLayerKey.value) {
const requestId = ++businessRequestId
coreLoading.value = true
@ -692,8 +729,10 @@ function hideLoading() {
}
.fgw-map-layer-tools {
top: 372px;
top: auto;
right: 458px;
left: auto;
bottom: 72px;
width: 248px;
height: 430px;

@ -74,6 +74,15 @@ const SELECTED_FEATURE_GLOW = "#FF4A3D"
const SELECTED_FEATURE_FILL_HEX = 0xff2d2d
const SELECTED_FEATURE_STROKE_HEX = 0xffecec
const SELECTED_FEATURE_GLOW_HEX = 0xff4a3d
const YAK_INDUSTRY_MARKER_TYPES = {
"family-pasture": "pasture",
"artificial-grassland": "grass",
"scale-breeding": "breeding",
"forage-storage": "storage",
"slaughter-processing": "processing",
"farm-equipment": "equipment",
"social-service": "service",
}
function sortByValue(data) {
data.sort((a, b) => b.value - a.value)
return data
@ -102,6 +111,9 @@ function isVillageRanking(ranking) {
function isTradeFlowRanking(ranking) {
return ranking?.scope === "trade-flow" || ranking?.labelMode === "trade-flow"
}
function shouldShowTradeFlowLines(ranking) {
return ranking?.showFlowLines !== false
}
function shouldHideTownNames(ranking) {
return Boolean(ranking?.hideTownNames || ranking?.showTownNames === false)
}
@ -114,6 +126,9 @@ function isCalloutRanking(ranking) {
function isVectorTopicLayer(layer) {
return ["wkt-api", "geojson", "wfs"].includes(layer?.sourceType)
}
function isReadOnlyTopicLayer(layer) {
return layer?.key === "yak-recognition-layer"
}
function buildPublicUrl(path) {
if (!path) return ""
if (/^https?:\/\//.test(path) || path.startsWith("/")) return path
@ -1132,7 +1147,11 @@ export class World extends Mini3d {
applyLayerMapView(layer, options = {}) {
if (!this.camera?.instance || !this.camera?.controls) return
if (!this.mapAnimationComplete && !options.force) return
const view = layer?.mapView || (layer?.cameraPosition ? { cameraPosition: layer.cameraPosition } : null)
const ranking = layer ? null : this.currentTownRanking
const view = layer?.mapView
|| (layer?.cameraPosition ? { cameraPosition: layer.cameraPosition } : null)
|| ranking?.mapView
|| (ranking?.cameraPosition ? { cameraPosition: ranking.cameraPosition } : null)
const target = view ? this.getLayerMapViewTarget(view) : this.defaultMapTarget.clone()
const position = view ? this.getLayerMapViewCameraPosition(view, target) : this.defaultMapCameraPosition.clone()
const duration = view && Number.isFinite(view.duration) ? view.duration : 0.75
@ -1739,27 +1758,29 @@ export class World extends Mini3d {
markerFeatures.forEach((feature, index) => {
let marker = this.yakIndustryPointMarkers[index]
const style = this.getVectorFeatureStyle(layer, feature)
const material = this.getYakIndustryMarkerMaterial(style)
const material = this.getYakIndustryMarkerMaterial(style, feature)
const position = this.lngLatToLayerLocalPoint(feature.geometry.coordinates, this.depth + 0.98)
const scale = Number(layer?.pointSpriteScale) || 0.34
const markerType = this.getYakIndustryMarkerType(feature)
if (!marker) {
marker = new Sprite(material)
marker.name = "yak-industry-point-marker"
marker.renderOrder = 42
marker.center.set(0.5, 0.08)
this.bindYakIndustryPointMarkerEvents(marker)
this.interactionManager.add(marker)
this.yakIndustryPointMarkerGroup.add(marker)
this.yakIndustryPointMarkers.push(marker)
}
marker.material = material
marker.center.set(0.5, 0.5)
marker.position.copy(position)
marker.scale.set(scale, scale * 1.32, 1)
marker.scale.set(scale * 1.08, scale * 1.08, 1)
marker.visible = true
marker.userData = {
feature,
layer,
index,
markerType,
}
})
this.yakIndustryPointMarkers.slice(markerFeatures.length).forEach((marker) => {
@ -1810,11 +1831,12 @@ export class World extends Mini3d {
document.body.style.cursor = "default"
})
}
getYakIndustryMarkerMaterial(style) {
const key = `${style.fill || "#24F6C4"}-${style.stroke || "#B7FFF2"}`
getYakIndustryMarkerMaterial(style, feature) {
const markerType = style.markerType || this.getYakIndustryMarkerType(feature)
const key = `${style.fill || "#24F6C4"}-${style.stroke || "#B7FFF2"}-${markerType}`
const cached = this.yakIndustryMarkerMaterialCache.get(key)
if (cached) return cached
const texture = this.createYakIndustryMarkerTexture(style.fill, style.stroke)
const texture = this.createYakIndustryMarkerTexture(style.fill, style.stroke, markerType)
const material = new SpriteMaterial({
map: texture,
transparent: true,
@ -1825,55 +1847,244 @@ export class World extends Mini3d {
this.yakIndustryMarkerMaterialCache.set(key, material)
return material
}
createYakIndustryMarkerTexture(fill = "#24F6C4", stroke = "#B7FFF2") {
getYakIndustryMarkerType(feature = {}) {
const stageKey = String(feature?.properties?.stageKey || "")
return YAK_INDUSTRY_MARKER_TYPES[stageKey] || "point"
}
createYakIndustryMarkerTexture(fill = "#24F6C4", stroke = "#B7FFF2", markerType = "point") {
const canvas = document.createElement("canvas")
canvas.width = 96
canvas.height = 124
canvas.width = 112
canvas.height = 112
const context = canvas.getContext("2d")
const x = 48
const top = 10
const tip = 111
const headY = 43
const radius = 28
const x = 56
const y = 56
context.clearRect(0, 0, canvas.width, canvas.height)
context.beginPath()
context.arc(x, 62, 38, 0, Math.PI * 2)
context.arc(x, y, 42, 0, Math.PI * 2)
context.fillStyle = this.hexToRgba(fill, 0.18)
context.shadowColor = fill
context.shadowBlur = 18
context.shadowBlur = 20
context.fill()
context.beginPath()
context.moveTo(x, tip)
context.bezierCurveTo(x + 31, 79, x + 38, top + 20, x, top)
context.bezierCurveTo(x - 38, top + 20, x - 31, 79, x, tip)
context.closePath()
context.fillStyle = this.hexToRgba(fill, 0.96)
context.strokeStyle = this.hexToRgba(stroke || fill, 0.98)
context.lineWidth = 5
context.shadowColor = stroke || fill
context.shadowBlur = 14
context.fill()
context.stroke()
this.drawYakIndustryMarkerFrame(context, markerType, x, y, fill, stroke || fill)
context.save()
context.shadowBlur = 0
context.beginPath()
context.arc(x, headY, radius * 0.46, 0, Math.PI * 2)
context.fillStyle = this.hexToRgba("#FFFFFF", 0.95)
context.arc(x, y, 19, 0, Math.PI * 2)
context.fillStyle = "rgba(3, 23, 34, 0.64)"
context.fill()
context.beginPath()
context.arc(x, headY, radius * 0.74, 0, Math.PI * 2)
context.strokeStyle = this.hexToRgba("#FFFFFF", 0.38)
context.lineWidth = 4
context.strokeStyle = this.hexToRgba("#FFFFFF", 0.32)
context.lineWidth = 2
context.stroke()
context.restore()
this.drawYakIndustryMarkerGlyph(context, markerType, x, y, "#FFFFFF")
const texture = new CanvasTexture(canvas)
texture.colorSpace = SRGBColorSpace
texture.needsUpdate = true
return texture
}
drawYakIndustryMarkerFrame(context, markerType, x, y, fill = "#24F6C4", stroke = "#B7FFF2") {
context.save()
context.translate(x, y)
context.fillStyle = this.hexToRgba(fill, 0.92)
context.strokeStyle = this.hexToRgba(stroke || fill, 0.98)
context.lineWidth = 4
context.shadowColor = stroke || fill
context.shadowBlur = 14
if (markerType === "pasture") {
context.beginPath()
context.moveTo(-34, -9)
context.lineTo(0, -38)
context.lineTo(34, -9)
context.lineTo(28, -9)
context.lineTo(28, 24)
context.quadraticCurveTo(28, 31, 21, 31)
context.lineTo(-21, 31)
context.quadraticCurveTo(-28, 31, -28, 24)
context.lineTo(-28, -9)
context.closePath()
} else if (markerType === "grass") {
context.beginPath()
context.moveTo(0, -38)
context.bezierCurveTo(31, -22, 34, 18, 0, 38)
context.bezierCurveTo(-34, 18, -31, -22, 0, -38)
context.closePath()
} else if (markerType === "breeding") {
context.beginPath()
for (let index = 0; index < 6; index += 1) {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 6
const px = Math.cos(angle) * 35
const py = Math.sin(angle) * 35
if (index === 0) context.moveTo(px, py)
else context.lineTo(px, py)
}
context.closePath()
} else if (markerType === "storage") {
context.beginPath()
context.moveTo(-34, -25)
context.lineTo(25, -25)
context.lineTo(35, -13)
context.lineTo(35, 27)
context.lineTo(-25, 27)
context.lineTo(-35, 15)
context.lineTo(-35, -25)
context.closePath()
} else if (markerType === "processing") {
context.beginPath()
for (let index = 0; index < 10; index += 1) {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 10
const radius = index % 2 === 0 ? 38 : 30
const px = Math.cos(angle) * radius
const py = Math.sin(angle) * radius
if (index === 0) context.moveTo(px, py)
else context.lineTo(px, py)
}
context.closePath()
} else if (markerType === "equipment") {
context.beginPath()
for (let index = 0; index < 16; index += 1) {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 16
const radius = index % 2 === 0 ? 38 : 30
const px = Math.cos(angle) * radius
const py = Math.sin(angle) * radius
if (index === 0) context.moveTo(px, py)
else context.lineTo(px, py)
}
context.closePath()
} else if (markerType === "service") {
context.beginPath()
context.moveTo(0, -39)
context.lineTo(31, -24)
context.lineTo(25, 14)
context.quadraticCurveTo(0, 39, -25, 14)
context.lineTo(-31, -24)
context.closePath()
} else {
context.beginPath()
context.arc(0, 0, 34, 0, Math.PI * 2)
}
context.fill()
context.stroke()
context.restore()
}
drawYakIndustryMarkerGlyph(context, markerType, x, y, fill = "#24F6C4") {
context.save()
context.translate(x, y)
context.lineCap = "round"
context.lineJoin = "round"
context.shadowBlur = 0
context.fillStyle = this.hexToRgba(fill, 0.98)
context.strokeStyle = this.hexToRgba(fill, 0.98)
context.lineWidth = 3
if (markerType === "pasture") {
context.beginPath()
context.moveTo(-12, -2)
context.lineTo(0, -11)
context.lineTo(12, -2)
context.stroke()
context.fillRect(-9, -2, 18, 12)
context.clearRect(-3, 2, 6, 8)
} else if (markerType === "grass") {
context.beginPath()
context.moveTo(0, 10)
context.quadraticCurveTo(-10, 1, -7, -10)
context.moveTo(0, 10)
context.quadraticCurveTo(10, 1, 7, -10)
context.moveTo(0, 10)
context.quadraticCurveTo(0, 0, 1, -12)
context.stroke()
} else if (markerType === "breeding") {
context.beginPath()
context.moveTo(-8, -5)
context.quadraticCurveTo(-15, -12, -18, -4)
context.moveTo(8, -5)
context.quadraticCurveTo(15, -12, 18, -4)
context.stroke()
context.beginPath()
context.ellipse(0, 1, 11, 12, 0, 0, Math.PI * 2)
context.fill()
context.fillStyle = this.hexToRgba("#08202D", 0.85)
context.fillRect(-5, -2, 3, 3)
context.fillRect(2, -2, 3, 3)
} else if (markerType === "storage") {
context.beginPath()
context.moveTo(-13, -3)
context.lineTo(0, -11)
context.lineTo(13, -3)
context.lineTo(13, 9)
context.lineTo(-13, 9)
context.closePath()
context.fill()
context.strokeStyle = this.hexToRgba("#08202D", 0.72)
context.lineWidth = 2
context.beginPath()
context.moveTo(-8, 0)
context.lineTo(8, 0)
context.moveTo(-8, 5)
context.lineTo(8, 5)
context.stroke()
} else if (markerType === "processing") {
context.beginPath()
context.moveTo(-13, 8)
context.lineTo(-13, -1)
context.lineTo(-5, -6)
context.lineTo(-5, -1)
context.lineTo(3, -6)
context.lineTo(3, -1)
context.lineTo(12, -6)
context.lineTo(12, 8)
context.closePath()
context.fill()
context.fillRect(-13, -11, 5, 7)
context.fillStyle = this.hexToRgba("#08202D", 0.72)
context.fillRect(-8, 2, 4, 3)
context.fillRect(0, 2, 4, 3)
context.fillRect(7, 2, 4, 3)
} else if (markerType === "equipment") {
for (let index = 0; index < 8; index += 1) {
context.save()
context.rotate((Math.PI * 2 * index) / 8)
context.fillRect(8, -2, 5, 4)
context.restore()
}
context.beginPath()
context.arc(0, 0, 9, 0, Math.PI * 2)
context.fill()
context.fillStyle = this.hexToRgba("#FFFFFF", 0.98)
context.beginPath()
context.arc(0, 0, 4, 0, Math.PI * 2)
context.fill()
} else if (markerType === "service") {
context.beginPath()
context.moveTo(0, -13)
context.lineTo(12, -7)
context.lineTo(9, 6)
context.quadraticCurveTo(0, 14, -9, 6)
context.lineTo(-12, -7)
context.closePath()
context.fill()
context.strokeStyle = this.hexToRgba("#FFFFFF", 0.94)
context.lineWidth = 2.4
context.beginPath()
context.moveTo(0, -7)
context.lineTo(0, 6)
context.moveTo(-6, -1)
context.lineTo(6, -1)
context.stroke()
} else {
context.beginPath()
context.arc(0, 0, 7, 0, Math.PI * 2)
context.fill()
}
context.restore()
}
ensureYakIndustryPointLabelGroup() {
if (this.yakIndustryPointLabelGroup) return
this.yakIndustryPointLabelGroup = new Group()
@ -2114,6 +2325,7 @@ export class World extends Mini3d {
glow: true,
halo: true,
marker: true,
markerType: YAK_INDUSTRY_MARKER_TYPES[String(properties.stageKey || "")] || "point",
}
}
if (layer?.key === "national-park-zoning") {
@ -2835,10 +3047,10 @@ export class World extends Mini3d {
this.applyLayerMapView(this.currentTopicLayer, { force: true })
}
}
createTradeSourceMarker(row, index, maxValue, source) {
createTradeSourceMarker(row, index, maxValue, source, showFlowLine = true) {
const color = new Color(index % 3 === 1 ? 0xffd76c : index % 3 === 2 ? 0x27f0c0 : 0x30dcff)
const endPoint = this.getTradeFlowPoint(row, 0.72)
if (source) {
if (source && showFlowLine) {
const curve = this.getLocalTradeFlowCurve(row, source, index)
const flowMaterial = new MeshBasicMaterial({
color,
@ -2984,10 +3196,11 @@ export class World extends Mini3d {
}
this.clearTradeFlowLayer()
this.tradeFlowGroup.visible = this.mapAnimationComplete
const showFlowLines = shouldShowTradeFlowLines(ranking)
this.createTradeMarketMarker(source)
const maxValue = Math.max(...rows.map((row) => row.value), 1)
rows.forEach((row, index) => {
this.createTradeSourceMarker(row, index, maxValue, source)
this.createTradeSourceMarker(row, index, maxValue, source, showFlowLines)
})
const requiredLabelCount = rows.length + 1
@ -3367,6 +3580,7 @@ export class World extends Mini3d {
})
}
this.updateTradeFlowLayer(ranking)
if (ranking?.mapView || ranking?.cameraPosition) this.applyLayerMapView(null)
return
}
this.setTradeNationalMapVisible(false)
@ -3971,6 +4185,11 @@ export class World extends Mini3d {
})
return
}
if (isReadOnlyTopicLayer(this.currentTopicLayer)) {
this.clearSelectedFeature()
this.callbacks.onFeatureInfo?.({ feature: null })
return
}
if (isVectorTopicLayer(this.currentTopicLayer)) {
const picked = this.pickVectorFeatureAtPoint(lngLat, screen)
const feature = picked?.feature || picked

@ -208,11 +208,15 @@ function refreshView() {
connectMapResizeObserver();
const refresh = () => {
if (!canvasMap.value) return;
canvasMap.value?.resumeScene?.();
if (!canvasMap.value.resumeScene) canvasMap.value.time?.resume?.();
canvasMap.value?.sizes?.init?.();
canvasMap.value?.resize?.();
canvasMap.value?.label3d?.resize?.();
canvasMap.value?.label3d?.update?.();
canvasMap.value?.setTopicLayers?.(getActiveLayerStack(props.activeLayer), props.activeLayer);
canvasMap.value?.setTownRanking?.(props.mapRanking);
canvasMap.value?.ensureMapRevealed?.();
};
scheduleMapResize();
window.requestAnimationFrame(refresh);
@ -307,6 +311,7 @@ function getMapRankingSignature(ranking) {
ranking.showBars ?? "",
ranking.showTownNames ?? "",
ranking.showMapLabels ?? "",
ranking.showFlowLines ?? "",
ranking.onlyRankedTownLabels ?? "",
ranking.showMissingTownStats ?? "",
ranking.rows?.length || 0,

@ -14,17 +14,25 @@ export const COUNTY_TARGET = {
export const RISK_LEVELS = [
{
key: "healthy",
label: "草畜平衡",
shortLabel: "平衡",
range: " 0%",
label: "合理",
shortLabel: "合理",
range: "< 0%",
description: "承载力覆盖实际载畜量,当前放牧压力处于合理区间。",
actions: ["保持当前载畜水平与轮牧节奏", "定期复测草地生产力和载畜量", "关注极端天气对牧草生长的影响"],
},
{
key: "watch",
label: "基本实现草畜平衡",
shortLabel: "基本平衡",
range: "0% - 5%",
description: "实际载畜量与草地承载力基本匹配,草畜平衡总体实现。",
actions: ["保持当前载畜规模基本稳定", "持续核查草地生产力和牲畜存栏", "结合季节变化做好补饲和轮牧安排"],
},
{
key: "light",
label: "轻度超载",
shortLabel: "关注",
range: "0% - 10%",
range: "5% - 10%",
description: "已出现轻度超载迹象,建议提前介入调控。",
actions: ["适度压减超载畜群规模", "延长冬春舍饲补饲周期", "增加人工草场补饲供应"],
},
@ -98,10 +106,11 @@ export function formatPercent(value) {
export function getRiskLevel(value) {
const rate = toNumber(value)
if (rate === null || rate <= 0) return RISK_LEVELS[0]
if (rate <= 10) return RISK_LEVELS[1]
if (rate <= 20) return RISK_LEVELS[2]
return RISK_LEVELS[3]
if (rate === null || rate < 0) return RISK_LEVELS[0]
if (rate <= 5) return RISK_LEVELS[1]
if (rate <= 10) return RISK_LEVELS[2]
if (rate <= 20) return RISK_LEVELS[3]
return RISK_LEVELS[4]
}
function calculateLivestockCapacity(yakCount) {
@ -160,22 +169,22 @@ export const countyBalanceRow = {
bearingCapacity: 471506,
livestockCapacity: 474995,
overloadRate: 0.74,
level: "轻度超载",
level: "基本实现草畜平衡",
advice: CXPT_BALANCE_ADVICE,
year: "2026",
}
export const balanceTownRows = [
{ key: "town-邛溪镇", type: "town", name: "邛溪镇", areaCode: "513233100", areaRange: 930604.2, yakNumber: 81698, bearingCapacity: 49006, livestockCapacity: 50461, overloadRate: 2.97, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-刷经寺镇", type: "town", name: "刷经寺镇", areaCode: "513233101", areaRange: 743557.5, yakNumber: 1264, bearingCapacity: 29082, livestockCapacity: 29536, overloadRate: 1.56, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-安曲镇", type: "town", name: "安曲镇", areaCode: "513233103", areaRange: 900696.75, yakNumber: 138268, bearingCapacity: 65850, livestockCapacity: 67253, overloadRate: 2.13, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-龙日镇", type: "town", name: "龙日镇", areaCode: "513233105", areaRange: 628303.8, yakNumber: 66822, bearingCapacity: 36216, livestockCapacity: 36886, overloadRate: 1.85, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-江茸乡", type: "town", name: "江茸乡", areaCode: "513233202", areaRange: 496888.95, yakNumber: 34575, bearingCapacity: 26567, livestockCapacity: 26859, overloadRate: 1.1, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-查尔玛乡", type: "town", name: "查尔玛乡", areaCode: "513233203", areaRange: 808628.4, yakNumber: 47941, bearingCapacity: 43160, livestockCapacity: 43958, overloadRate: 1.85, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-瓦切镇", type: "town", name: "瓦切镇", areaCode: "513233204", areaRange: 747837.6, yakNumber: 121654, bearingCapacity: 65410, livestockCapacity: 66751, overloadRate: 2.05, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-阿木乡", type: "town", name: "阿木乡", areaCode: "513233205", areaRange: 863346, yakNumber: 64165, bearingCapacity: 44369, livestockCapacity: 45589, overloadRate: 2.75, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-麦洼乡", type: "town", name: "麦洼乡", areaCode: "513233207", areaRange: 458338.35, yakNumber: 78465, bearingCapacity: 55321, livestockCapacity: 55974, overloadRate: 1.18, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-色地镇", type: "town", name: "色地镇", areaCode: "513233208", areaRange: 991265.25, yakNumber: 131956, bearingCapacity: 69233, livestockCapacity: 70715, overloadRate: 2.14, level: "轻度超载", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-邛溪镇", type: "town", name: "邛溪镇", areaCode: "513233100", areaRange: 930604.2, yakNumber: 81698, bearingCapacity: 49006, livestockCapacity: 50461, overloadRate: 2.97, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-刷经寺镇", type: "town", name: "刷经寺镇", areaCode: "513233101", areaRange: 743557.5, yakNumber: 1264, bearingCapacity: 29082, livestockCapacity: 29536, overloadRate: 1.56, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-安曲镇", type: "town", name: "安曲镇", areaCode: "513233103", areaRange: 900696.75, yakNumber: 138268, bearingCapacity: 65850, livestockCapacity: 67253, overloadRate: 2.13, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-龙日镇", type: "town", name: "龙日镇", areaCode: "513233105", areaRange: 628303.8, yakNumber: 66822, bearingCapacity: 36216, livestockCapacity: 36886, overloadRate: 1.85, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-江茸乡", type: "town", name: "江茸乡", areaCode: "513233202", areaRange: 496888.95, yakNumber: 34575, bearingCapacity: 26567, livestockCapacity: 26859, overloadRate: 1.1, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-查尔玛乡", type: "town", name: "查尔玛乡", areaCode: "513233203", areaRange: 808628.4, yakNumber: 47941, bearingCapacity: 43160, livestockCapacity: 43958, overloadRate: 1.85, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-瓦切镇", type: "town", name: "瓦切镇", areaCode: "513233204", areaRange: 747837.6, yakNumber: 121654, bearingCapacity: 65410, livestockCapacity: 66751, overloadRate: 2.05, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-阿木乡", type: "town", name: "阿木乡", areaCode: "513233205", areaRange: 863346, yakNumber: 64165, bearingCapacity: 44369, livestockCapacity: 45589, overloadRate: 2.75, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-麦洼乡", type: "town", name: "麦洼乡", areaCode: "513233207", areaRange: 458338.35, yakNumber: 78465, bearingCapacity: 55321, livestockCapacity: 55974, overloadRate: 1.18, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
{ key: "town-色地镇", type: "town", name: "色地镇", areaCode: "513233208", areaRange: 991265.25, yakNumber: 131956, bearingCapacity: 69233, livestockCapacity: 70715, overloadRate: 2.14, level: "基本实现草畜平衡", advice: CXPT_BALANCE_ADVICE, year: "2026" },
]
export const balanceFallbackRows = [
@ -319,7 +328,7 @@ export function splitAdvice(row = {}, risk = getRiskLevel(row.overloadRate)) {
: []
const unsuitableWhenOverloaded = ["提高放牧畜禽的密度", "增加草地的利用率", "密度不能过高"]
const outdatedSafeAdvice = ["轻度超载", "已出现超载", "超载迹象", "压减超载畜群规模"]
const safeRisk = risk.key === "healthy"
const safeRisk = ["healthy", "watch"].includes(risk.key)
const cleanedApiPoints = apiPoints.filter((item) => {
if (unsuitableWhenOverloaded.some((keyword) => item.includes(keyword))) return false
if (safeRisk && outdatedSafeAdvice.some((keyword) => item.includes(keyword))) return false

@ -322,13 +322,15 @@ const conclusionSummary = computed(() => {
})
const verdictHint = computed(() => {
if (activeDerived.value.risk.key === "healthy") return "保持监测"
if (activeDerived.value.risk.key === "watch") return "关注调控"
if (activeDerived.value.risk.key === "watch") return "基本平衡"
if (activeDerived.value.risk.key === "light") return "关注调控"
if (activeDerived.value.delta === null) return "按结果表判定"
return activeDerived.value.delta > 0 ? "需调控载畜量" : "承载能力可覆盖"
})
const decisionHint = computed(() => {
if (activeDerived.value.risk.key === "healthy") return "保持监测"
if (activeDerived.value.risk.key === "watch") return "关注调控"
if (activeDerived.value.risk.key === "watch") return "保持监测"
if (activeDerived.value.risk.key === "light") return "关注调控"
if (activeDerived.value.delta === null) return "结果表口径"
return activeDerived.value.delta > 0 ? "需要调控" : "保持监测"
})
@ -397,7 +399,7 @@ const analysisProcessRows = computed(() => {
name: "平衡判定",
desc: "生成结论与调控方向",
pending: "等待平衡判定",
plan: derived.risk.key === "healthy" ? "判定平衡状态并生成监测方案" : "判定超载风险并生成调控方案",
plan: ["healthy", "watch"].includes(derived.risk.key) ? "判定平衡状态并生成监测方案" : "判定超载风险并生成调控方案",
},
]
})
@ -590,9 +592,38 @@ function buildVillageRows(rows = [], town = {}) {
const name = pickAreaName(row)
return Boolean(areaCode || name)
})
return validRows
const normalizedRows = validRows
.map((row, index) => buildVillageRow(row, index, town))
.filter(Boolean)
return dedupeVillageRows(normalizedRows, town)
}
function dedupeVillageRows(rows = [], town = {}) {
const rowMap = new Map()
rows.forEach((row) => {
const dedupeKey = normalizeVillageDedupeKey(row, town)
const current = rowMap.get(dedupeKey)
if (!current) {
rowMap.set(dedupeKey, { ...row, duplicateSources: [row.source].filter(Boolean) })
return
}
const keepNextAreaCode = !current.areaCode && row.areaCode
const nextRow = keepNextAreaCode ? { ...current, ...row } : current
rowMap.set(dedupeKey, {
...nextRow,
duplicateSources: [
...(current.duplicateSources || []),
row.source,
].filter(Boolean),
})
})
return [...rowMap.values()]
}
function normalizeVillageDedupeKey(row = {}, town = {}) {
const nameKey = String(row.name || "").replace(/\s+/g, "").trim()
const townKey = String(row.township || town.name || "").replace(/\s+/g, "").trim()
return `${townKey || "unknown-town"}-${nameKey || row.areaCode || "unknown-village"}`
}
function buildVillageRow(row = {}, index = 0, town = {}) {
@ -1831,7 +1862,8 @@ function getProcessStepState(index) {
}
&.is-healthy strong,
&.is-balanced strong {
&.is-balanced strong,
&.is-watch strong {
color: #7efbf6;
}

@ -187,6 +187,27 @@
:option="panel.option"
:autoresize="true"
/>
<div v-else-if="panel.key === 'home-town-balance-gauge'" class="home-balance-gauge-grid">
<div
v-for="(item, index) in homeBalanceGaugeRows"
:key="item.key"
class="home-balance-gauge-card"
:class="{ 'is-active': index === homeBalanceActiveGaugeIndex }"
:style="homeBalanceGaugeStyle(item)"
>
<div class="home-balance-gauge-head">
<strong>{{ item.status }}</strong>
</div>
<div class="home-balance-gauge-ring">
<div class="home-balance-gauge-value">
<strong>{{ formatNumber(item.value) }}</strong>
<em>%</em>
</div>
</div>
<div class="home-balance-gauge-name">{{ item.name }}</div>
<div class="home-balance-gauge-extra">{{ item.extra }}</div>
</div>
</div>
<business-panel-renderer
v-else
:panel="panel"
@ -328,11 +349,14 @@ const featurePanelStyle = computed(() => {
if (!featurePanelPoint.value) return {}
const panelWidth = 460
const panelHeight = featurePanelHeight.value
const margin = 24
const mapSafeLeft = 430
const mapSafeRight = 430
const point = toHomeScreenPoint(featurePanelPoint.value)
const xOffset = point.x > 820 ? -panelWidth - 28 : 28
const x = Math.min(Math.max(point.x + xOffset, margin), 1920 - panelWidth - margin)
const y = Math.min(Math.max(point.y - panelHeight - 72, 188), 1080 - panelHeight - 132)
const x = Math.min(
Math.max(point.x - panelWidth / 2, mapSafeLeft),
1920 - panelWidth - mapSafeRight,
)
const y = Math.min(Math.max(point.y - panelHeight - 36, 188), 1080 - panelHeight - 132)
return {
left: `${x}px`,
top: `${y}px`,
@ -345,20 +369,30 @@ const activeForecastYearIndex = computed(() => getLoopIndex(forecastPeriods.leng
const activeForecastGranularityMeta = computed(() => getForecastGranularity(activeForecastGranularity.value))
const trendSeries = computed(() => buildForecastSeriesByGranularity(forecastTrendPeriod, activeForecastGranularityMeta.value.key))
const activeForecastTrendIndex = computed(() => getLoopIndex(trendSeries.value.labels.length))
const homeBalanceGaugeRows = computed(() => {
const homeBalanceRows = computed(() => {
const rows = balanceTownRows.filter((item) => item?.name)
const pageSize = 3
const start = rows.length ? (Math.floor(chartMotionTick.value / 2) * pageSize) % rows.length : 0
return Array.from({ length: Math.min(pageSize, rows.length) }, (_, index) => rows[(start + index) % rows.length]).map((item, index) => ({
return rows.map((item, index) => ({
key: `home-balance-${item.areaCode || item.key || item.name}`,
sourceIndex: index,
name: item.name,
value: item.overloadRate,
unit: "%",
status: item.level || "总体可控",
extra: `载畜 ${formatNumber(item.livestockCapacity)}`,
color: palette[(start + index) % palette.length],
color: palette[index % palette.length],
}))
})
const homeBalanceGaugeRows = computed(() => {
const rows = homeBalanceRows.value
const pageSize = 3
const start = rows.length ? (Math.floor(chartMotionTick.value / 2) * pageSize) % rows.length : 0
return Array.from({ length: Math.min(pageSize, rows.length) }, (_, index) => rows[(start + index) % rows.length])
})
const homeBalanceActiveGaugeIndex = computed(() => {
const length = homeBalanceGaugeRows.value.length
if (!length) return 0
return chartMotionTick.value % length
})
const emptyHomeVideo = {
key: "home-empty-video",
title: "暂无在线可播放摄像头",
@ -709,31 +743,53 @@ function initHomeMap() {
itemsTotal,
})
})
const loadMapOnce = async (attempt = 0) => {
if (destroyed) return
await nextTick()
const scene = mapSceneRef.value
const mapLoaded = Boolean(scene?.loadMap?.(mapAssets) || scene?.hasMap?.())
if (mapLoaded) {
emit("loading-progress", { progress: 100 })
emit("map-ready")
scheduleHomeIdleWork()
if (props.active) scene.play?.()
return
}
if (attempt < 24) {
window.setTimeout(() => loadMapOnce(attempt + 1), 120)
return
}
mapAssets.ready?.then(() => loadHomeMapOnce()).catch(() => {
emit("map-ready")
scheduleHomeIdleWork()
}
mapAssets.ready?.then(loadMapOnce).catch(() => {
})
}
async function loadHomeMapOnce(attempt = 0) {
if (destroyed) return
await nextTick()
const scene = mapSceneRef.value
const mapLoaded = Boolean(scene?.loadMap?.(mapAssets) || scene?.hasMap?.())
if (mapLoaded) {
emit("loading-progress", { progress: 100 })
emit("map-ready")
scheduleHomeIdleWork()
if (props.active) scene.play?.()
return
}
if (attempt < 24) {
window.setTimeout(() => loadHomeMapOnce(attempt + 1), 120)
return
}
emit("map-ready")
scheduleHomeIdleWork()
}
function refreshMapView() {
mapSceneRef.value?.resume?.()
mapSceneRef.value?.refreshView?.()
}
function resetMap() {
closeFeaturePanel()
if (mapSceneRef.value?.hasMap?.()) {
refreshMapView()
return
}
nextTick(() => {
loadHomeMapOnce()
})
}
defineExpose({
refreshMapView,
resetMap,
})
function scheduleHomeIdleWork() {
cancelHomeIdleWork()
const run = () => {
@ -848,6 +904,20 @@ function stepVideo(step) {
chartMotionTick.value = 0
}
function homeBalanceGaugeProgress(item) {
const value = Math.max(0, Number(item?.value || 0))
const maxValue = Math.max(5, ...homeBalanceRows.value.map((row) => Math.max(0, Number(row.value || 0))))
return Math.max(6, Math.min(100, (value / maxValue) * 100))
}
function homeBalanceGaugeStyle(item) {
const color = item?.color || "#30dcff"
return {
"--balance-color": color,
"--balance-progress": `${homeBalanceGaugeProgress(item)}%`,
}
}
async function playCurrentVideo() {
const live = currentHomeVideo.value
if (!live?.hdPlayUrl || !live?.token) {
@ -1477,8 +1547,10 @@ function handleOutsideFeaturePointerDown(event) {
.home-layer-tools {
position: absolute;
top: 372px;
top: auto;
right: 458px;
bottom: 72px;
left: auto;
z-index: 7;
width: 248px;
box-sizing: border-box;
@ -1809,6 +1881,180 @@ function handleOutsideFeaturePointerDown(event) {
height: 100%;
}
.home-balance-gauge-grid {
display: grid;
height: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 8px 12px 10px;
box-sizing: border-box;
overflow: hidden;
}
.home-balance-gauge-card {
position: relative;
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 32px minmax(72px, 1fr) 22px 16px;
justify-items: center;
padding: 10px 8px 9px;
overflow: hidden;
border: 1px solid rgba(48, 220, 255, 0.18);
background:
radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--balance-color) 14%, transparent), transparent 55%),
linear-gradient(180deg, rgba(48, 220, 255, 0.075), rgba(5, 27, 40, 0.46));
box-shadow:
inset 0 0 18px rgba(48, 220, 255, 0.07),
0 0 0 rgba(48, 220, 255, 0);
transition:
border-color 0.35s ease,
box-shadow 0.35s ease,
background 0.35s ease;
&::before {
position: absolute;
inset: 9px;
content: "";
border: 1px solid rgba(255, 255, 255, 0.035);
pointer-events: none;
}
&.is-active {
border-color: color-mix(in srgb, var(--balance-color) 64%, rgba(48, 220, 255, 0.22));
background:
radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--balance-color) 20%, transparent), transparent 58%),
linear-gradient(180deg, rgba(48, 220, 255, 0.095), rgba(5, 27, 40, 0.52));
box-shadow:
inset 0 0 22px rgba(48, 220, 255, 0.09),
0 0 14px color-mix(in srgb, var(--balance-color) 16%, transparent);
}
}
.home-balance-gauge-head {
position: relative;
z-index: 1;
display: flex;
width: 100%;
align-items: center;
justify-content: center;
span,
strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
color: rgba(238, 253, 255, 0.58);
font-size: 10px;
font-weight: 700;
}
strong {
width: 100%;
color: var(--balance-color);
font-size: 11px;
font-weight: 900;
text-align: center;
text-shadow: 0 0 8px color-mix(in srgb, var(--balance-color) 34%, transparent);
}
}
.home-balance-gauge-ring {
position: relative;
z-index: 1;
align-self: center;
width: min(94px, 78%);
aspect-ratio: 1;
border-radius: 50%;
background:
conic-gradient(from 218deg, var(--balance-color) 0 var(--balance-progress), rgba(93, 175, 199, 0.18) var(--balance-progress) 100%),
rgba(3, 23, 35, 0.78);
box-shadow:
0 0 18px color-mix(in srgb, var(--balance-color) 22%, transparent),
inset 0 0 18px rgba(0, 0, 0, 0.42);
&::before {
position: absolute;
inset: 12px;
content: "";
border-radius: 50%;
background:
radial-gradient(circle at 50% 45%, rgba(48, 220, 255, 0.12), rgba(3, 23, 35, 0.98) 62%),
rgba(3, 23, 35, 0.96);
box-shadow:
inset 0 0 12px rgba(0, 0, 0, 0.42),
inset 0 0 0 1px rgba(48, 220, 255, 0.08);
}
&::after {
position: absolute;
left: 50%;
bottom: 9px;
width: 2px;
height: 16px;
content: "";
background: rgba(0, 10, 18, 0.8);
transform: translateX(-50%) rotate(35deg);
transform-origin: 50% 100%;
box-shadow: 0 0 5px rgba(0, 10, 18, 0.58);
}
}
.home-balance-gauge-value {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
strong {
color: #ffffff;
font-family: D-DIN, Arial, sans-serif;
font-size: 24px;
line-height: 28px;
font-weight: 900;
text-shadow: 0 0 10px color-mix(in srgb, var(--balance-color) 42%, transparent);
}
em {
margin-left: 2px;
color: rgba(238, 253, 255, 0.76);
font-size: 11px;
font-style: normal;
font-weight: 800;
}
}
.home-balance-gauge-name,
.home-balance-gauge-extra {
position: relative;
z-index: 1;
max-width: 100%;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-balance-gauge-name {
color: #ffffff;
font-size: 15px;
line-height: 21px;
font-weight: 900;
text-shadow: 0 0 8px rgba(48, 220, 255, 0.18);
}
.home-balance-gauge-extra {
color: rgba(238, 253, 255, 0.66);
font-size: 11px;
line-height: 16px;
font-weight: 700;
}
.home-forecast-trend-card {
position: relative;
width: 100%;

@ -1,3 +1,5 @@
import { grasslandYakRows } from "@/views/gdMap/livestockData"
const round = (value, decimals = 2) => {
const factor = 10 ** decimals
return Math.round((Number(value) || 0) * factor) / factor
@ -110,10 +112,12 @@ export const pastureListRows = [
searchText: `${item.name} ${item.areaName}`,
}))
export const pastureGrasslandRows = aggregateBy(pastureListRows, "township", "grasslandArea", "亩").map((item) => ({
...item,
value: toWanMu(item.value),
export const pastureGrasslandRows = grasslandYakRows.map((item) => ({
key: item.zoneName,
name: item.zoneName,
value: toWanMu(item.grasslandArea),
unit: "万亩",
extra: "草场资源",
}))
export const pastureSeasonRows = Array.from(pastureListRows.reduce((map, item) => {

@ -1,15 +1,19 @@
<template>
<div class="pasture-management">
<map-scene
<yak-ol-map
:key="pastureMapKey"
ref="mapSceneRef"
class="pasture-map-scene"
:active-layer="pastureMapLayer"
:map-ranking="mapRanking"
:admin-boundary-line-scale="2.4"
@scene-mounted="handleMapSceneMounted"
class="pasture-ol-map-layer"
:active="props.active"
:tile-url-template="pastureMapTileUrlTemplate"
:center="pastureOlCenter"
:initial-zoom="16"
:max-zoom="21"
:overlay-layer="pastureMapLayer"
@ready="handlePastureOlReady"
/>
<div class="pasture-map-scrim"></div>
<div v-if="!mapReady" class="pasture-map-loading">3D地图加载中</div>
<div v-if="!mapReady" class="pasture-map-loading">影像地图加载中</div>
<div class="pasture-management-ui">
<div class="pasture-left">
@ -18,7 +22,7 @@
<business-panel-renderer :panel="householdColumnPanel" compact />
</m-card>
<m-card class="pasture-side-card left-card" title="草面积分布" :width="sideCardWidth" :height="sideCardHeight">
<m-card class="pasture-side-card left-card" title="草面积分布" :width="sideCardWidth" :height="sideCardHeight">
<business-panel-renderer :panel="grasslandColumnPanel" compact />
</m-card>
@ -101,11 +105,9 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import EZUIKit from "ezuikit-js"
import mCard from "@/components/mCard/index.vue"
import MapScene from "@/views/forestGrassWet/map.vue"
import YakOlMap from "@/views/yakManagement/YakOlMap.vue"
import LayerPanel from "@/views/forestGrassWet/components/LayerPanel.vue"
import BusinessPanelRenderer from "@/views/forestGrassWet/components/BusinessPanelRenderer.vue"
import { Assets } from "@/views/forestGrassWet/assets"
import { getLayerByKey } from "@/config/layers"
import { getHongyuanTownshipNameByCode, hongyuanTownshipsGeoJsonRaw, isHongyuanTownshipName } from "@/config/townshipBoundaries"
import { getHomeMonitorings } from "@/services/homeVideoApi"
import { getPastureGrasslandsById, getPastureProfile, getPasturesDetailsById } from "@/services/pastureApi"
@ -144,14 +146,19 @@ const sideCardHeight = computed(() => {
const doubleCardHeight = computed(() => sideCardHeight.value * 2 + cardGap)
const mapSceneRef = ref(null)
const mapReady = ref(false)
const pastureMapKey = ref(0)
const apiProfile = ref(pastureFallbackProfile)
const pastureRows = ref(pastureListRows)
const activeLayerKey = ref("")
const pastureBoundaryLayerKey = "pasture-grassland-boundary"
const activeLayerKey = ref(pastureBoundaryLayerKey)
const selectedLegendKey = ref("")
const pastureTopStatNames = ["牧户数量", "户均牦牛头数", "户均草地面积", "联户数量"]
const pasturePalette = ["#23f6c8", "#35d8ff", "#f0d96a", "#76e36b", "#ff9a5c", "#a59cff", "#20e5c4"]
const pastureVideoElementId = `pasture-video-player-${Math.random().toString(36).slice(2)}`
const pastureVideoLoadTimeoutMs = 12000
const pastureMapTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png"
const pastureOlCenter = [103.07984, 33.029058]
const pastureBoundarySldBody = `<?xml version="1.0" encoding="UTF-8"?><StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><NamedLayer><Name>t_grassland_result_caodi</Name><UserStyle><Title>pasture boundary</Title><FeatureTypeStyle><Rule><PolygonSymbolizer><Fill><CssParameter name="fill">#FFE35A</CssParameter><CssParameter name="fill-opacity">0</CssParameter></Fill><Stroke><CssParameter name="stroke">#FFE35A</CssParameter><CssParameter name="stroke-width">2.4</CssParameter><CssParameter name="stroke-opacity">0.98</CssParameter></Stroke></PolygonSymbolizer></Rule></FeatureTypeStyle></UserStyle></NamedLayer></StyledLayerDescriptor>`
const emptyPastureVideo = {
key: "pasture-empty-video",
title: "暂无在线可播放摄像头",
@ -176,10 +183,6 @@ const activePastureVideoManualIndex = ref(0)
const pastureVideoPlaying = ref(false)
const pastureVideoLoading = ref(false)
const pastureVideoError = ref("")
let mapAssets = null
let mapAssetsReady = false
let mapLoadStarted = false
let mapLoadTimer = null
let destroyed = false
let pastureEnrichToken = 0
let pastureEnrichTimer = null
@ -235,15 +238,39 @@ const pastureFallbackTownshipByName = {
}
const pastureTownshipFeatures = normalizeTownshipFeatures(hongyuanTownshipsGeoJsonRaw)
const pastureLayerOptions = computed(() => {
const boundaryLayer = getLayerByKey("grassland", "grassland-distribution")
if (!boundaryLayer) return []
return [{
...boundaryLayer,
const pastureLayerOptions = computed(() => [
{
key: pastureBoundaryLayerKey,
sourceType: "wms",
renderMode: "image",
layerName: "ne:t_grassland_result_caodi",
name: "草场边界",
legends: [{ name: "草场边界", fillColor: "#1D953F", strokeColor: "#1D953F" }],
}]
})
styles: "",
sldBody: pastureBoundarySldBody,
color: "#FFE35A",
fillColor: "rgba(255, 227, 90, 0.04)",
strokeColor: "#FFE35A",
glowColor: "rgba(255, 227, 90, 0.34)",
zIndex: 35,
opacity: 0.96,
queryBuffer: 8,
mapView: {
targetLngLat: [103.07984, 33.029058],
distanceScale: 0.66,
targetLocalZ: 0.78,
duration: 0.85,
},
legends: [
{
key: "pasture-grassland-boundary",
name: "草场边界",
fillColor: "#FFE35A",
strokeColor: "#FFE35A",
selectable: false,
},
],
},
])
const basePastureMapLayer = computed(() =>
activeLayerKey.value ? pastureLayerOptions.value.find((layer) => layer.key === activeLayerKey.value) || null : null
)
@ -270,8 +297,7 @@ const onlineRows = computed(() => {
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(() => {
const aggregatedRows = aggregatePastureAreaRows(pastureRows.value)
return completePastureGrasslandRows(aggregatedRows.length ? aggregatedRows : pastureGrasslandRows)
return completePastureGrasslandRows(pastureGrasslandRows)
})
const mapRanking = computed(() => ({
title: "牧户分布排行",
@ -392,10 +418,8 @@ onBeforeUnmount(() => {
destroyed = true
window.removeEventListener("resize", updateScreenViewportHeight)
stopPastureVideoPlayer()
if (mapLoadTimer) window.clearTimeout(mapLoadTimer)
if (pastureEnrichTimer) window.clearTimeout(pastureEnrichTimer)
pastureEnrichToken += 1
mapSceneRef.value?.destroyMap?.()
})
watch(
@ -404,13 +428,11 @@ watch(
if (!active) stopPastureVideoPlayer({ resetError: true })
if (!mapSceneRef.value) return
if (active) {
schedulePastureMapSceneLoad()
mapSceneRef.value.resume?.()
mapSceneRef.value.refreshView?.()
} else {
if (pastureEnrichTimer) window.clearTimeout(pastureEnrichTimer)
pastureEnrichTimer = null
pastureEnrichToken += 1
mapSceneRef.value.pause?.()
}
},
)
@ -804,74 +826,40 @@ async function handlePastureSelect(row) {
}
function initPastureMap() {
mapAssets = new Assets()
mapAssetsReady = false
mapReady.value = false
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,
})
})
mapAssets.ready?.then(() => {
mapAssetsReady = true
schedulePastureMapSceneLoad()
}).catch(() => {
mapReady.value = true
emit("map-ready")
})
}
function handleMapSceneMounted() {
schedulePastureMapSceneLoad()
function handlePastureOlReady() {
mapReady.value = true
emit("loading-progress", { progress: 100 })
emit("map-ready")
}
function schedulePastureMapSceneLoad() {
if (destroyed || !props.active || !mapAssetsReady || mapLoadStarted) return
mapLoadStarted = true
if (mapLoadTimer) window.clearTimeout(mapLoadTimer)
mapLoadTimer = window.setTimeout(() => {
mapLoadTimer = null
loadPastureMapScene()
}, 600)
function refreshMapView() {
mapSceneRef.value?.refreshView?.()
}
async function loadPastureMapScene(attempt = 0) {
if (destroyed || !mapAssetsReady || !mapAssets) return
if (!props.active) {
mapLoadStarted = false
return
}
await nextTick()
if (!props.active) {
mapLoadStarted = false
return
}
const scene = mapSceneRef.value
scene?.loadMap?.(mapAssets)
if (scene?.canvasMap) {
function resetMap() {
if (mapSceneRef.value?.hasMap?.()) {
mapReady.value = true
emit("loading-progress", { progress: 100 })
emit("map-ready")
if (props.active) {
scene.play?.()
} else {
scene.pause?.()
}
return
}
if (attempt < 24) {
window.setTimeout(() => loadPastureMapScene(attempt + 1), 120)
mapSceneRef.value?.resetView?.()
return
}
mapLoadStarted = false
mapReady.value = true
emit("map-ready")
mapReady.value = false
pastureMapKey.value += 1
nextTick(() => {
window.setTimeout(() => {
mapSceneRef.value?.refreshView?.()
}, 160)
})
}
defineExpose({
refreshMapView,
resetMap,
})
function emitPastureTopStats() {
emit("module-stats", {
moduleKey: "pasture-management",
@ -1491,7 +1479,8 @@ function isLngLatInPolygon(point, polygon) {
background: #03121c;
}
.pasture-map-scene {
.pasture-map-scene,
.pasture-ol-map-layer {
position: absolute;
inset: 0;
z-index: 1;
@ -2022,8 +2011,10 @@ function isLngLatInPolygon(point, polygon) {
.pasture-map-layer-tools {
position: absolute;
top: 372px;
top: auto;
right: 458px;
bottom: 72px;
left: auto;
z-index: 7;
width: 248px;
height: 430px;

@ -1,5 +1,6 @@
<template>
<div
ref="platformScreenRef"
class="large-screen platform-screen"
:class="{
'is-home': activeModule.key === 'home',
@ -20,6 +21,7 @@
class="platform-module-pane"
>
<home-dashboard
ref="homeDashboardRef"
:active="activeModule.key === 'home'"
@loading-progress="handleHomeLoadingProgress"
@map-ready="handleHomeMapReady"
@ -32,6 +34,7 @@
class="platform-module-pane"
>
<forest-grass-wet
ref="forestGrassWetRef"
:active="activeModule.key === 'forest-grass-wet'"
:show-loading="false"
@topic-stats="handleModuleStats"
@ -43,6 +46,7 @@
class="platform-module-pane"
>
<ecological-protection
ref="ecologicalProtectionRef"
:active="activeModule.key === 'ecological-protection'"
:active-view="activeEcologyView"
:show-loading="false"
@ -68,6 +72,7 @@
class="platform-module-pane"
>
<yak-management
ref="yakManagementRef"
:active="activeModule.key === 'yak-management'"
@module-stats="handleModuleStats"
/>
@ -78,6 +83,7 @@
class="platform-module-pane"
>
<pasture-management
ref="pastureManagementRef"
:active="activeModule.key === 'pasture-management'"
@loading-progress="handleHomeLoadingProgress"
@map-ready="handleHomeMapReady"
@ -90,6 +96,7 @@
class="platform-module-pane"
>
<yak-industry-chain
ref="yakIndustryChainRef"
:active="activeModule.key === 'yak-industry-chain'"
@loading-progress="handleHomeLoadingProgress"
@map-ready="handleHomeMapReady"
@ -163,6 +170,15 @@
</template>
</div>
<div v-if="mapToolsVisible" class="platform-map-tools" aria-label="地图工具">
<button type="button" :title="platformFullscreenLabel" :aria-label="platformFullscreenLabel" @click="handlePlatformFullscreen">
<span class="platform-map-tool-icon is-fullscreen" aria-hidden="true"></span>
</button>
<button type="button" title="复位地图" aria-label="复位地图" @click="handleActiveMapReset">
<span class="platform-map-tool-icon is-reset" aria-hidden="true"></span>
</button>
</div>
<div v-if="activeModule.status !== 'ready'" class="module-coming">
<div class="module-coming-title">{{ activeModule.label }}</div>
<div class="module-coming-text">模块建设中平台框架已预留入口</div>
@ -263,6 +279,14 @@ const renderedModules = ref({ home: true })
const moduleTopStats = ref({})
const loadingVisible = ref(true)
const loadingProgress = ref(0)
const platformScreenRef = ref(null)
const homeDashboardRef = ref(null)
const forestGrassWetRef = ref(null)
const ecologicalProtectionRef = ref(null)
const yakManagementRef = ref(null)
const pastureManagementRef = ref(null)
const yakIndustryChainRef = ref(null)
const platformFullscreen = ref(false)
let screenAutofit = null
let loadingHideStarted = false
let pendingModuleRenderTimer = null
@ -292,6 +316,8 @@ const topStatIconPool = [
const topStatIconSet = new Set(topStatIconPool)
const activeModule = computed(() => getModuleByKey(activeModuleKey.value))
const platformFullscreenLabel = computed(() => (platformFullscreen.value ? "退出" : "全屏"))
const mapToolsVisible = computed(() => !["smart-management", "grass-livestock-balance"].includes(activeModule.value.key))
const bottomMenuItems = computed(() => {
if (activeModuleKey.value === "ecological-protection") return ecologicalProtectionMenu
return platformModules
@ -343,11 +369,14 @@ onMounted(() => {
el: "#large-screen",
resize: true,
})
bindPlatformFullscreenEvents()
syncPlatformFullscreenState()
resetRootScroll()
})
onBeforeUnmount(() => {
screenAutofit?.off?.("#large-screen")
unbindPlatformFullscreenEvents()
if (pendingModuleRenderTimer) window.clearTimeout(pendingModuleRenderTimer)
if (pendingModuleRenderFrame) window.cancelAnimationFrame(pendingModuleRenderFrame)
gsap.killTweensOf(loadingTweenState)
@ -550,6 +579,82 @@ function resetRootScroll() {
if (app) app.scrollTop = 0
}
const platformFullscreenEvents = ["fullscreenchange", "webkitfullscreenchange", "mozfullscreenchange", "MSFullscreenChange"]
function getPlatformFullscreenElement() {
if (typeof document === "undefined") return null
return document.fullscreenElement
|| document.webkitFullscreenElement
|| document.mozFullScreenElement
|| document.msFullscreenElement
|| null
}
function syncPlatformFullscreenState() {
const fullscreenElement = getPlatformFullscreenElement()
const root = platformScreenRef.value
platformFullscreen.value = Boolean(root && fullscreenElement && (
fullscreenElement === root
|| root.contains(fullscreenElement)
|| fullscreenElement.contains?.(root)
))
}
function bindPlatformFullscreenEvents() {
if (typeof document === "undefined") return
platformFullscreenEvents.forEach((eventName) => document.addEventListener(eventName, syncPlatformFullscreenState))
}
function unbindPlatformFullscreenEvents() {
if (typeof document === "undefined") return
platformFullscreenEvents.forEach((eventName) => document.removeEventListener(eventName, syncPlatformFullscreenState))
}
function refreshYakMapAfterResize() {
getActiveMapModuleRef()?.refreshMapView?.()
}
async function handlePlatformFullscreen() {
if (typeof document === "undefined") return
const fullscreenElement = getPlatformFullscreenElement()
try {
if (fullscreenElement) {
const exitFullscreen = document.exitFullscreen
|| document.webkitExitFullscreen
|| document.mozCancelFullScreen
|| document.msExitFullscreen
await exitFullscreen?.call(document)
} else {
const target = platformScreenRef.value || document.documentElement
const requestFullscreen = target.requestFullscreen
|| target.webkitRequestFullscreen
|| target.mozRequestFullScreen
|| target.msRequestFullscreen
await requestFullscreen?.call(target)
}
} catch (error) {
console.warn("[platform] fullscreen toggle failed", error)
} finally {
syncPlatformFullscreenState()
window.setTimeout(refreshYakMapAfterResize, 80)
window.setTimeout(refreshYakMapAfterResize, 280)
}
}
function handleActiveMapReset() {
getActiveMapModuleRef()?.resetMap?.()
}
function getActiveMapModuleRef() {
if (activeModule.value.key === "home") return homeDashboardRef.value
if (activeModule.value.key === "forest-grass-wet") return forestGrassWetRef.value
if (activeModule.value.key === "ecological-protection") return ecologicalProtectionRef.value
if (activeModule.value.key === "yak-management") return yakManagementRef.value
if (activeModule.value.key === "pasture-management") return pastureManagementRef.value
if (activeModule.value.key === "yak-industry-chain") return yakIndustryChainRef.value
return null
}
function handleLogout() {
logout()
}
@ -652,6 +757,100 @@ function handleLogout() {
z-index: 1;
}
.platform-map-tools {
position: absolute;
left: 448px;
bottom: 168px;
z-index: 80;
display: flex;
flex-direction: column;
gap: 7px;
pointer-events: all;
transform: perspective(620px) rotateY(6deg);
transform-origin: left center;
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid rgba(126, 251, 246, 0.62);
border-radius: 3px;
color: #f0feff;
font-weight: 900;
background:
linear-gradient(180deg, rgba(18, 99, 123, 0.92), rgba(4, 36, 56, 0.96)),
rgba(4, 24, 37, 0.9);
box-shadow:
0 0 14px rgba(48, 220, 255, 0.32),
0 0 3px rgba(126, 251, 246, 0.4),
inset 0 0 0 1px rgba(255, 255, 255, 0.1),
inset 0 -8px 14px rgba(48, 220, 255, 0.16);
cursor: pointer;
&:hover {
border-color: rgba(126, 251, 246, 0.76);
color: #ffffff;
background:
linear-gradient(180deg, rgba(25, 127, 148, 0.96), rgba(6, 48, 68, 0.98)),
rgba(6, 35, 52, 0.92);
box-shadow:
0 0 18px rgba(48, 220, 255, 0.42),
0 0 4px rgba(126, 251, 246, 0.5),
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -8px 14px rgba(48, 220, 255, 0.18);
}
&:active {
transform: translateY(1px);
}
}
}
.platform-map-tool-icon {
position: relative;
flex: 0 0 15px;
width: 15px;
height: 15px;
color: #7efbf6;
&.is-fullscreen {
background:
linear-gradient(currentColor, currentColor) left top / 5px 2px no-repeat,
linear-gradient(currentColor, currentColor) left top / 2px 5px no-repeat,
linear-gradient(currentColor, currentColor) right top / 5px 2px no-repeat,
linear-gradient(currentColor, currentColor) right top / 2px 5px no-repeat,
linear-gradient(currentColor, currentColor) left bottom / 5px 2px no-repeat,
linear-gradient(currentColor, currentColor) left bottom / 2px 5px no-repeat,
linear-gradient(currentColor, currentColor) right bottom / 5px 2px no-repeat,
linear-gradient(currentColor, currentColor) right bottom / 2px 5px no-repeat;
}
&.is-reset {
width: 15px;
height: 15px;
border: 2px solid currentColor;
border-left-color: transparent;
border-radius: 50%;
box-sizing: border-box;
&::after {
content: "";
position: absolute;
left: -2px;
top: 0;
width: 0;
height: 0;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
border-right: 5px solid currentColor;
transform: rotate(-18deg);
}
}
}
.top-menu {
position: absolute;
left: 50%;

@ -320,7 +320,6 @@ export const breedingLevelRows = buildBreedingLevelRows()
export const forageSecurityRows = aggregateRows(forageStorageItems, normalizeTown, (item) => metricValue(item, ["总库容 (吨)"]))
.map((item) => ({ ...item, unit: "吨", extra: `${item.count}个库点` }))
.slice(0, 6)
export const industryValueRows = [
{ key: "milk-processing", name: "乳品加工", value: sumBy(processingItems.filter(isMilkProcessing), (item) => metricValue(item, ["年产值 (万元)"])), unit: "万元", color: "#32dcff" },
@ -349,9 +348,9 @@ export function getStagePanelConfig(stageKey) {
const configs = {
"family-pasture": {
left: [
chartPanel("family-town", "牧场镇域分布", "bar", getStageCapacityRows(stageKey).slice(0, 5), { variant: "rankBar" }),
chartPanel("family-town-stock", "镇域存栏支撑", "column", getTownYakStockRows(), { variant: "slim" }),
chartPanel("family-grass-resource", "草地资源保障", "heat", getTownGrasslandResourceRows()),
chartPanel("family-town", "牧场镇域分布", "column", getStageCapacityRows(stageKey), { variant: "townCompact", scrollable: false }),
chartPanel("family-town-stock", "镇域存栏支撑", "column", getTownYakStockRows(), { variant: "townCompact", scrollable: false }),
chartPanel("family-grass-resource", "草地资源保障", "structure", getTownGrasslandResourceRows()),
],
right: [
industryChainListPanel(stageKey),
@ -359,9 +358,9 @@ export function getStagePanelConfig(stageKey) {
},
"artificial-grassland": {
left: [
chartPanel("grass-town", "种草面积排行", "column", getStageCapacityRows(stageKey).slice(0, 7), { variant: "slim" }),
chartPanel("grass-heat", "基地面积热区", "heat", getForageBaseAreaRows()),
chartPanel("grass-type", "草种保障结构", "structure", forageGrassTypeRows),
chartPanel("grass-town", "种草面积分布", "column", getStageCapacityRows(stageKey), { variant: "townCompact", scrollable: false }),
chartPanel("grass-base-town", "基地点位分布", "column", getTownCountRows(forageBaseItems, "基地点位"), { variant: "townCompact", scrollable: false }),
chartPanel("grass-type", "草种保障结构", "donut", forageGrassTypeRows, { variant: "ring" }),
],
right: [
industryChainListPanel(stageKey),
@ -369,9 +368,9 @@ export function getStagePanelConfig(stageKey) {
},
"scale-breeding": {
left: [
chartPanel("breeding-stock", "设计存栏排行", "bar", getStageCapacityRows(stageKey).slice(0, 5), { variant: "rankBar" }),
chartPanel("breeding-output", "年出栏能力排行", "bar", getBreedingOutputRows(), { variant: "raceBar" }),
chartPanel("breeding-level", "育种等级结构", "donut", breedingLevelRows),
chartPanel("breeding-stock", "设计存栏分布", "column", getStageCapacityRows(stageKey), { variant: "townCompact", scrollable: false }),
chartPanel("breeding-output", "年出栏能力分布", "column", getTownMetricRows(breedingBaseItems, ["年出栏量 (头)"], { unit: "头", extra: "年出栏" }), { variant: "townCompact", scrollable: false }),
chartPanel("breeding-level", "育种等级结构", "donut", breedingLevelRows, { variant: "ring" }),
],
right: [
industryChainListPanel(stageKey),
@ -379,8 +378,8 @@ export function getStagePanelConfig(stageKey) {
},
"forage-storage": {
left: [
chartPanel("storage-town", "库容镇域排行", "bar", getStageCapacityRows(stageKey).slice(0, 7)),
chartPanel("storage-point", "库点能力热区", "heat", getStoragePointRows()),
chartPanel("storage-town", "库容镇域分布", "column", getStageCapacityRows(stageKey), { variant: "townCompact", scrollable: false }),
chartPanel("storage-point", "库点数量分布", "column", getTownCountRows(forageStorageItems, "储备库点"), { variant: "townCompact", scrollable: false }),
chartPanel("storage-grass", "储备草种结构", "structure", forageGrassTypeRows),
],
right: [
@ -389,9 +388,9 @@ export function getStagePanelConfig(stageKey) {
},
"slaughter-processing": {
left: [
chartPanel("processing-value", "产值结构", "bar", industryValueRows),
chartPanel("processing-value", "产值结构", "donut", industryValueRows, { variant: "ring" }),
chartPanel("processing-chain", "产能与带动", "structure", processingChainRows),
chartPanel("processing-heads", "屠宰能力排行", "bar", getSlaughterHeadRows(), { variant: "raceBar" }),
chartPanel("processing-heads", "屠宰能力分布", "column", getTownMetricRows(slaughterItems, ["年屠宰总量 (头)"], { unit: "头", extra: "年屠宰" }), { variant: "townCompact", scrollable: false }),
],
right: [
industryChainListPanel(stageKey),
@ -399,8 +398,8 @@ export function getStagePanelConfig(stageKey) {
},
"farm-equipment": {
left: [
chartPanel("equipment-device", "识别设备分布", "column", equipmentDeviceRows.slice(0, 7), { variant: "slim" }),
chartPanel("equipment-recognition", "识别样本排行", "bar", equipmentRecognitionRows.slice(0, 7)),
chartPanel("equipment-device", "识别设备分布", "column", completeTownColumnRows(equipmentDeviceRows, { unit: "套", extra: "识别设备" }), { variant: "townCompact", scrollable: false }),
chartPanel("equipment-recognition", "识别样本分布", "column", completeTownColumnRows(equipmentRecognitionRows, { unit: "头", extra: "识别样本" }), { variant: "townCompact", scrollable: false }),
],
right: [
industryChainListPanel(stageKey),
@ -408,9 +407,9 @@ export function getStagePanelConfig(stageKey) {
},
"social-service": {
left: [
chartPanel("service-type", "服务主体结构", "structure", serviceTypeRows),
chartPanel("service-credit", "金融授信排行", "bar", getFinanceCreditRows()),
chartPanel("service-coverage", "服务覆盖对比", "bar", getServiceCoverageRows(), { variant: "raceBar" }),
chartPanel("service-type", "服务主体结构", "donut", serviceTypeRows, { variant: "ring" }),
chartPanel("service-town", "服务镇域分布", "column", getTownCountRows([...diseaseControlItems, ...trainingItems, ...financeItems, ...insuranceItems], "服务主体"), { variant: "townCompact", scrollable: false }),
chartPanel("service-training-town", "培训人次分布", "column", getTownMetricRows(trainingItems, ["年培训人次"], { unit: "人次", extra: "技术培训" }), { variant: "townCompact", scrollable: false }),
chartPanel("service-training", "培训服务能力", "line", getTrainingRows()),
],
right: [
@ -421,9 +420,9 @@ export function getStagePanelConfig(stageKey) {
const fallback = {
left: [
chartPanel(`${stage.key}-rank`, `${stage.metricLabel}排行`, "bar", getStageCapacityRows(stageKey).slice(0, 7)),
chartPanel(`${stage.key}-rank`, `${stage.metricLabel}分布`, "column", getStageCapacityRows(stageKey), { variant: "townCompact", scrollable: false }),
chartPanel(`${stage.key}-subject`, "主体结构", "structure", subjectRows),
chartPanel(`${stage.key}-town`, "镇域覆盖热区", "heat", getStageTownRows(stageKey).slice(0, 8)),
chartPanel(`${stage.key}-town`, "镇域覆盖分布", "column", completeTownColumnRows(getStageTownRows(stageKey), { unit: "个", extra: "镇域覆盖" }), { variant: "townCompact", scrollable: false }),
chartPanel(`${stage.key}-capacity`, "能力规模分布", "split", getStageCapacityRows(stageKey).slice(0, 7)),
],
right: [
@ -455,30 +454,22 @@ export function getStageInsightRows(stageKey) {
export function getStageCapacityRows(stageKey) {
if (stageKey === "family-pasture") {
return aggregateRows(familyPastureItems, normalizeTown, () => 1)
const rows = aggregateRows(familyPastureItems, normalizeTown, () => 1)
.map((item) => ({ ...item, unit: "户", extra: "家庭生态牧场" }))
.slice(0, 7)
return completeTownColumnRows(rows, { unit: "户", extra: "家庭生态牧场" })
}
if (stageKey === "artificial-grassland") {
return aggregateRows(forageBaseItems, normalizeTown, (item) => metricValue(item, ["种植面积 (亩)", "acreage"]) || parseNumber(item.scale))
const rows = aggregateRows(forageBaseItems, normalizeTown, (item) => metricValue(item, ["种植面积 (亩)", "acreage"]) || parseNumber(item.scale))
.map((item) => ({ ...item, value: round(item.value / 10000, 2), unit: "万亩", extra: `${item.count}个基地` }))
.slice(0, 7)
return completeTownColumnRows(rows, { unit: "万亩", extra: "种草基地" })
}
if (stageKey === "scale-breeding") {
return categoryItems("YC-BREEDING-BASE")
.map((item) => ({
key: item.id,
name: compactName(item.name),
value: metricValue(item, ["设计存栏量 (头)"]),
unit: "头",
extra: `年出栏${formatNumber(metricValue(item, ["年出栏量 (头)"]))}`,
}))
.filter((item) => item.value > 0)
.sort(sortByValue)
.slice(0, 7)
const rows = aggregateRows(categoryItems("YC-BREEDING-BASE"), normalizeTown, (item) => metricValue(item, ["设计存栏量 (头)"]))
.map((item) => ({ ...item, unit: "头", extra: `${item.count}个基地` }))
return completeTownColumnRows(rows, { unit: "头", extra: "养殖基地" })
}
if (stageKey === "forage-storage") {
return forageSecurityRows
return completeTownColumnRows(forageSecurityRows, { unit: "吨", extra: "饲草储备" })
}
if (stageKey === "slaughter-processing") {
return [...processingItems, ...slaughterItems]
@ -846,7 +837,7 @@ function industryChainListPanel(stageKey) {
}
function getTownYakStockRows() {
return grasslandYakRows
const rows = grasslandYakRows
.map((item) => ({
key: item.zoneName,
name: item.zoneName,
@ -855,8 +846,7 @@ function getTownYakStockRows() {
extra: "牦牛存栏",
}))
.filter((item) => item.value > 0)
.sort(sortByValue)
.slice(0, 8)
return completeTownColumnRows(rows, { unit: "头", extra: "牦牛存栏" })
}
function getTownGrasslandResourceRows() {
@ -870,7 +860,26 @@ function getTownGrasslandResourceRows() {
}))
.filter((item) => item.value > 0)
.sort(sortByValue)
.slice(0, 8)
}
function getTownMetricRows(items = [], metricNames = [], options = {}) {
const rows = aggregateRows(items, normalizeTown, (item) => metricValue(item, metricNames))
.map((item) => ({
...item,
unit: options.unit || "",
extra: options.extra || `${item.count}个点位`,
}))
return completeTownColumnRows(rows, options)
}
function getTownCountRows(items = [], extra = "点位") {
const rows = aggregateRows(items, normalizeTown, () => 1)
.map((item) => ({
...item,
unit: "个",
extra,
}))
return completeTownColumnRows(rows, { unit: "个", extra })
}
function getForageBaseAreaRows() {
@ -1035,6 +1044,36 @@ function aggregateRows(rows, nameGetter, valueGetter) {
return [...map.values()].sort(sortByValue)
}
function completeTownColumnRows(rows = [], options = {}) {
const byTown = new Map()
rows.forEach((row) => {
const name = cleanText(row?.name)
const matchedTown = townNames.find((town) => town === name) || townNames.find((town) => {
const stem = town.replace(/[镇乡]$/, "")
return stem.length >= 2 && name.includes(stem)
})
if (!matchedTown) return
byTown.set(matchedTown, row)
})
return townNames.map((town, index) => {
const row = byTown.get(town)
const rawValue = Number(row?.value || 0)
const hasValue = Number.isFinite(rawValue) && rawValue > 0
return {
key: row?.key || town,
name: town,
value: hasValue ? rawValue : 0.001,
displayValue: hasValue ? row.displayValue : "0",
scaleValue: row?.scaleValue,
unit: row?.unit || options.unit || "",
extra: row?.extra || options.extra || "",
color: row?.color,
zero: !hasValue,
sourceIndex: index,
}
})
}
function metricValue(item = {}, names = []) {
const metrics = item.metrics || {}
const nameList = Array.isArray(names) ? names : [names]

@ -21,8 +21,14 @@
:width="sideCardWidth"
:height="panel.height"
>
<v-chart
v-if="isTownColumnPanel(panel)"
class="industry-town-column-chart"
:option="createTownColumnOption(panel)"
:autoresize="true"
/>
<business-panel-renderer
v-if="panel.renderer === 'business'"
v-else-if="panel.renderer === 'business'"
:panel="panel"
compact
@row-select="handleIndustryListSelect"
@ -50,8 +56,14 @@
:width="sideCardWidth"
:height="panel.height"
>
<v-chart
v-if="isTownColumnPanel(panel)"
class="industry-town-column-chart"
:option="createTownColumnOption(panel)"
:autoresize="true"
/>
<business-panel-renderer
v-if="panel.renderer === 'business'"
v-else-if="panel.renderer === 'business'"
:panel="panel"
compact
@row-select="handleIndustryListSelect"
@ -95,6 +107,7 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import VChart from "vue-echarts"
import mCard from "@/components/mCard/index.vue"
import MapScene from "@/views/forestGrassWet/map.vue"
import BusinessPanelRenderer from "@/views/forestGrassWet/components/BusinessPanelRenderer.vue"
@ -281,6 +294,182 @@ function filterDisplayPanels(panels = []) {
return panels.filter((panel) => panel?.type !== "iconMetric")
}
function isTownColumnPanel(panel = {}) {
return panel?.renderer === "business" && panel?.type === "column" && panel?.variant === "townCompact"
}
function createTownColumnOption(panel = {}) {
const rows = Array.isArray(panel.rows) ? panel.rows : []
const values = rows.map((item) => Number(item?.zero ? 0 : item?.value || 0))
const maxValue = Math.max(...values, 1)
const unit = getSharedUnit(rows)
return {
animationDuration: 720,
animationEasing: "cubicOut",
grid: {
top: 30,
right: 8,
bottom: 58,
left: 8,
containLabel: false,
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
shadowStyle: {
color: "rgba(48, 220, 255, 0.08)",
},
},
confine: true,
backgroundColor: "rgba(4, 24, 36, 0.94)",
borderColor: "rgba(48, 220, 255, 0.42)",
borderWidth: 1,
padding: [7, 9],
textStyle: {
color: "#e8fcff",
fontSize: 12,
},
formatter(params = []) {
const data = params[0]?.data || {}
const value = data.zero ? "0" : data.displayValue
const valueUnit = data.unit || unit || ""
const extra = data.extra ? `<br/><span style="color:rgba(232,252,255,.62)">${data.extra}</span>` : ""
return `${data.name || ""}<br/><b style="color:#35f1ff">${value}${valueUnit}</b>${extra}`
},
},
graphic: unit
? [{
type: "text",
right: 10,
top: 4,
silent: true,
style: {
text: `单位:${unit}`,
fill: "rgba(232,252,255,0.68)",
font: "10px PingFang SC, Arial",
},
}]
: [],
xAxis: {
type: "category",
data: rows.map((item) => item?.name || ""),
axisTick: { show: false },
axisLine: {
show: true,
lineStyle: {
color: "rgba(68, 238, 255, 0.42)",
},
},
axisLabel: {
color: "rgba(255,255,255,0.9)",
fontSize: 11,
fontWeight: 700,
lineHeight: 14,
interval: 0,
margin: 10,
formatter: formatTownAxisLabel,
},
},
yAxis: {
type: "value",
min: 0,
max: Math.ceil(maxValue * 1.18),
splitNumber: 3,
axisLabel: { show: false },
axisTick: { show: false },
axisLine: { show: false },
splitLine: {
show: true,
lineStyle: {
color: "rgba(159, 220, 230, 0.14)",
type: "dashed",
},
},
},
series: [{
type: "bar",
data: rows.map((item, index) => {
const zero = Boolean(item?.zero || item?.isZero)
const value = zero ? 0 : Number(item?.value || 0)
return {
name: item?.name,
value,
displayValue: item?.displayValue ?? formatChartNumber(value),
unit: item?.unit || unit || "",
extra: item?.extra || "",
zero,
itemStyle: {
color: zero ? "rgba(48, 220, 255, 0.1)" : townColumnColor(index, item),
},
}
}),
barWidth: 14,
barMinHeight: 3,
showBackground: true,
backgroundStyle: {
color: "rgba(48, 220, 255, 0.06)",
borderColor: "rgba(48, 220, 255, 0.08)",
borderWidth: 1,
},
itemStyle: {
borderRadius: [4, 4, 0, 0],
},
label: {
show: false,
},
emphasis: {
focus: "series",
itemStyle: {
shadowBlur: 14,
shadowColor: "rgba(36, 246, 196, 0.42)",
},
},
}],
}
}
function getSharedUnit(rows = []) {
const units = [...new Set(rows.map((item) => item?.unit).filter(Boolean))]
return units.length === 1 ? units[0] : ""
}
function formatTownAxisLabel(name) {
const text = String(name || "").replace(/\s+/g, "")
return Array.from(text).slice(0, 3).join("\n")
}
function townColumnColor(index, item = {}) {
if (item.color || item.fillColor) return item.color || item.fillColor
const palette = [
["#35f1ff", "#13aebd"],
["#5cf5a6", "#18b978"],
["#f6d663", "#c69c28"],
["#7aa3ff", "#3a65d8"],
["#ff8b62", "#c25d3d"],
]
const [top, bottom] = palette[index % palette.length]
return {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: "rgba(255,255,255,0.96)" },
{ offset: 0.14, color: top },
{ offset: 0.72, color: bottom },
{ offset: 1, color: "rgba(20, 244, 220, 0.28)" },
],
}
}
function formatChartNumber(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "--"
return Number.isInteger(number) ? `${number}` : `${Number(number.toFixed(2))}`
}
function updateScreenViewportHeight() {
screenViewportHeight.value = typeof window === "undefined" ? 1080 : window.innerHeight
}
@ -353,6 +542,37 @@ async function loadIndustryMapOnce(attempt = 0) {
emit("map-ready")
}
function clearIndustryMapLoadTimer() {
if (!mapLoadTimer) return
window.clearTimeout(mapLoadTimer)
mapLoadTimer = null
}
function refreshMapView() {
mapSceneRef.value?.resume?.()
mapSceneRef.value?.refreshView?.()
}
function resetMap() {
clearSelectedPoint()
if (mapSceneRef.value?.hasMap?.()) {
mapReady.value = true
refreshMapView()
return
}
clearIndustryMapLoadTimer()
mapReady.value = false
mapLoadStarted = false
nextTick(() => {
scheduleIndustryMapLoad()
})
}
defineExpose({
refreshMapView,
resetMap,
})
function handleMapFeatureInfo(payload = {}) {
const properties = payload.feature?.properties || {}
if (properties.stageKey) {
@ -542,6 +762,172 @@ function normalizeFeatureScreen(screen = {}) {
inset 0 0 0 1px rgba(48, 220, 255, 0.06),
inset 0 12px 28px rgba(48, 220, 255, 0.04);
}
.industry-town-column-chart {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
min-height: 0;
}
.business-columns.is-variant-plain {
grid-template-columns: repeat(auto-fit, minmax(34px, 1fr));
gap: 8px;
padding-right: 12px;
padding-left: 12px;
}
.business-columns.is-variant-plain .business-column-item {
grid-template-rows: 16px minmax(62px, 1fr) 32px;
}
.business-columns.is-variant-plain .business-column-item span {
display: -webkit-box;
max-width: 58px;
color: rgba(240, 253, 255, 0.86);
line-height: 13px;
white-space: normal;
word-break: keep-all;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.business-columns.is-variant-plain .business-column-track {
width: 18px;
}
.business-columns.is-variant-townCompact {
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 5px;
padding: 35px 13px 10px;
}
.business-columns.is-variant-townCompact::before {
left: 13px;
right: 13px;
top: 55px;
bottom: 40px;
opacity: 0.46;
background:
linear-gradient(to bottom, transparent calc(100% - 1px), rgba(48, 220, 255, 0.58) calc(100% - 1px)),
repeating-linear-gradient(
to bottom,
rgba(159, 220, 230, 0.16) 0 1px,
transparent 1px 21px
);
}
.business-columns.is-variant-townCompact::after {
content: "";
position: absolute;
left: 13px;
right: 13px;
bottom: 39px;
height: 1px;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(60, 244, 255, 0.72), transparent);
box-shadow: 0 0 10px rgba(48, 220, 255, 0.24);
}
.business-columns.is-variant-townCompact .business-column-item {
position: relative;
z-index: 1;
grid-template-rows: 17px minmax(74px, 1fr) 29px;
min-width: 0;
}
.business-columns.is-variant-townCompact .business-column-item strong {
width: 100%;
color: rgba(244, 252, 255, 0.94);
font-size: 10px;
line-height: 14px;
text-align: center;
text-shadow: 0 0 8px rgba(255, 255, 255, 0.18);
}
.business-columns.is-variant-townCompact .business-column-item span {
display: -webkit-box;
align-self: start;
width: 100%;
max-width: 36px;
height: 25px;
margin-top: 3px;
color: rgba(232, 252, 255, 0.82);
font-size: 10px;
line-height: 12px;
text-align: center;
white-space: normal;
word-break: break-all;
writing-mode: horizontal-tb;
text-orientation: initial;
letter-spacing: 0;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.business-columns.is-variant-townCompact .business-column-track {
width: 24px;
height: 100%;
min-height: 74px;
align-self: end;
border: 0;
border-radius: 2px 2px 0 0;
background:
linear-gradient(180deg, rgba(48, 220, 255, 0.12), rgba(36, 246, 196, 0.015)),
linear-gradient(90deg, rgba(48, 220, 255, 0.1), rgba(48, 220, 255, 0.02) 48%, rgba(48, 220, 255, 0.1)),
rgba(4, 26, 38, 0.22);
box-shadow:
inset 0 -1px 0 rgba(48, 220, 255, 0.2),
inset 0 0 10px rgba(48, 220, 255, 0.06);
}
.business-columns.is-variant-townCompact .business-column-track::after {
content: "";
position: absolute;
left: 50%;
bottom: -5px;
width: 34px;
height: 7px;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(36, 246, 196, 0.28), rgba(36, 246, 196, 0) 68%);
transform: translateX(-50%);
}
.business-columns.is-variant-townCompact .business-column-track i {
left: 4px;
right: 4px;
min-height: 5px;
border-radius: 3px 3px 0 0;
box-shadow:
0 0 12px rgba(36, 246, 196, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.26);
}
.business-columns.is-variant-townCompact .business-column-track i::before,
.business-columns.is-variant-townCompact .business-column-track i::after {
display: none;
}
.business-columns.is-variant-townCompact .business-column-item.is-zero strong {
opacity: 0;
}
.business-columns.is-variant-townCompact .business-column-item.is-zero .business-column-track i {
height: 4px !important;
opacity: 0.16;
box-shadow: none;
}
.business-bars.is-rank-bar {
padding-right: 12px;
padding-left: 12px;
}
.business-bars.is-rank-bar .business-bar-name {
font-size: 12px;
line-height: 14px;
}
}
.industry-ledger-list {

@ -1,6 +1,6 @@
<template>
<div class="yak-ol-map">
<div ref="mapEl" class="yak-ol-map__canvas"></div>
<div :ref="setMapElement" class="yak-ol-map__canvas"></div>
<div
v-if="coordinateCopyTip.visible"
class="yak-ol-map__copy-tip"
@ -29,16 +29,24 @@ import Map from "ol/Map.js"
import View from "ol/View.js"
import Feature from "ol/Feature.js"
import GeoJSON from "ol/format/GeoJSON.js"
import ImageLayer from "ol/layer/Image.js"
import TileLayer from "ol/layer/Tile.js"
import VectorLayer from "ol/layer/Vector.js"
import Point from "ol/geom/Point.js"
import ImageWMS from "ol/source/ImageWMS.js"
import TileWMS from "ol/source/TileWMS.js"
import XYZ from "ol/source/XYZ.js"
import VectorSource from "ol/source/Vector.js"
import { bbox as bboxStrategy } from "ol/loadingstrategy.js"
import { defaults as defaultControls } from "ol/control/defaults.js"
import { unByKey } from "ol/Observable.js"
import { fromLonLat, toLonLat } from "ol/proj.js"
import { Circle as CircleStyle, Fill, RegularShape, Stroke, Style, Text } from "ol/style.js"
import CircleStyle from "ol/style/Circle.js"
import Fill from "ol/style/Fill.js"
import RegularShape from "ol/style/RegularShape.js"
import Stroke from "ol/style/Stroke.js"
import Style from "ol/style/Style.js"
import Text from "ol/style/Text.js"
import { hongyuanTownshipLabelPoints, hongyuanTownshipsGeoJson } from "@/config/townshipBoundaries"
const props = defineProps({
@ -48,7 +56,7 @@ const props = defineProps({
},
tileUrlTemplate: {
type: String,
default: "/hy-result/{z}/{x}/{y}.png",
default: "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png",
},
center: {
type: Array,
@ -62,6 +70,22 @@ const props = defineProps({
type: Number,
default: 21,
},
initialZoom: {
type: Number,
default: 21,
},
overlayLayer: {
type: Object,
default: () => ({
sourceType: "wms",
layerName: "ne:daping_yak_marks",
styles: "yak_marks",
cqlFilter: "gov_show = true",
name: "牦牛识别标记",
color: "#FFFF00",
opacity: 0.96,
}),
},
})
const emit = defineEmits(["ready", "tile-status", "layer-status"])
@ -95,7 +119,8 @@ const labelPointStyle = new Style({
const mapEl = ref(null)
let olMap = null
let baseSource = null
let yakWmsSource = null
let overlaySource = null
let overlayMapLayer = null
let contextMenuHandler = null
let rightPointerDownHandler = null
let coordinateTipTimer = null
@ -124,6 +149,11 @@ const tileState = {
zoom: INITIAL_ZOOM,
}
function setMapElement(el) {
mapEl.value = el || null
if (el) initMap()
}
function tileCoordKey(event) {
const coord = event?.tile?.getTileCoord?.()
return Array.isArray(coord) ? coord.join("/") : ""
@ -131,7 +161,7 @@ function tileCoordKey(event) {
function currentZoom() {
const zoom = Number(olMap?.getView?.()?.getZoom?.())
return Number.isFinite(zoom) ? Math.round(zoom) : INITIAL_ZOOM
return Number.isFinite(zoom) ? Math.round(zoom) : normalizeInitialZoom()
}
function normalizeCenter(center = props.center) {
@ -144,12 +174,21 @@ function normalizeCenter(center = props.center) {
function locateMap(center = props.center) {
const view = olMap?.getView?.()
if (!view) return
resetTileState(INITIAL_ZOOM)
const zoom = normalizeInitialZoom()
resetTileState(zoom)
view.setCenter(fromLonLat(normalizeCenter(center)))
view.setZoom(INITIAL_ZOOM)
view.setZoom(zoom)
window.setTimeout(() => olMap?.updateSize?.(), 0)
}
function normalizeInitialZoom() {
const zoom = Number(props.initialZoom)
const minZoom = Number(props.minZoom)
const maxZoom = Number(props.maxZoom)
const safeZoom = Number.isFinite(zoom) ? zoom : INITIAL_ZOOM
return Math.min(Number.isFinite(maxZoom) ? maxZoom : INITIAL_ZOOM, Math.max(Number.isFinite(minZoom) ? minZoom : 0, safeZoom))
}
function resetTileState(zoom = currentZoom()) {
pendingTiles.clear()
tileState.requested = 0
@ -224,29 +263,244 @@ function buildBaseSource() {
return baseSource
}
function buildYakWmsSource() {
yakWmsSource = new TileWMS({
url: "/geoserver/ne/wms",
params: {
SERVICE: "WMS",
VERSION: "1.1.1",
LAYERS: "ne:daping_yak_marks",
STYLES: "yak_marks",
CQL_FILTER: "gov_show = true",
FORMAT: "image/png",
TRANSPARENT: true,
TILED: true,
},
function normalizeOverlayLayer(layer = props.overlayLayer) {
if (!layer || layer.visible === false || !layer.layerName) return null
const sourceType = layer.sourceType || "wms"
return {
sourceType,
url: layer.url || (sourceType === "wfs" ? "/geoserver/ne/wfs" : "/geoserver/ne/wms"),
renderMode: layer.renderMode || "tile",
layerName: layer.layerName,
styles: layer.styles || "",
sldBody: layer.sldBody || "",
cqlFilter: layer.cqlFilter || "",
name: layer.name || "业务图层",
color: layer.color || layer.strokeColor || layer.fillColor || "#FFFF00",
fillColor: layer.fillColor || layer.color || "rgba(255, 227, 90, 0.05)",
strokeColor: layer.strokeColor || layer.color || "#FFFF00",
glowColor: layer.glowColor || "rgba(255, 227, 90, 0.3)",
opacity: Number.isFinite(Number(layer.opacity)) ? Number(layer.opacity) : 0.96,
lineWidth: Number.isFinite(Number(layer.lineWidth)) ? Number(layer.lineWidth) : 2.6,
zIndex: Number.isFinite(Number(layer.zIndex)) ? Number(layer.zIndex) : 20,
maxFeatures: layer.maxFeatures || 12000,
geometryField: layer.geometryField || "geom",
loadingStrategy: layer.loadingStrategy || "",
propertyName: layer.propertyName || (layer.boundaryOnly ? [layer.geometryField || "geom"] : undefined),
bboxPadding: Number.isFinite(Number(layer.bboxPadding)) ? Number(layer.bboxPadding) : 0.003,
}
}
function buildOverlayWmsParams(layer) {
const params = {
SERVICE: "WMS",
VERSION: "1.1.1",
LAYERS: layer.layerName,
FORMAT: "image/png",
TRANSPARENT: true,
TILED: true,
}
if (layer.styles) params.STYLES = layer.styles
if (layer.cqlFilter) params.CQL_FILTER = layer.cqlFilter
if (layer.sldBody) params.SLD_BODY = layer.sldBody
return params
}
function bindOverlayImageEvents(source, layer) {
wmsEventKeys.push(source.on("imageloadstart", () => emitLayerStatus("loading", `正在加载${layer.name}`)))
wmsEventKeys.push(source.on("imageloadend", () => emitLayerStatus("success", `${layer.name}已加载`)))
wmsEventKeys.push(source.on("imageloaderror", () => emitLayerStatus("error", `${layer.name}加载失败`)))
}
function bindOverlayTileEvents(source, layer) {
wmsEventKeys.push(source.on("tileloadstart", () => emitLayerStatus("loading", `正在加载${layer.name}`)))
wmsEventKeys.push(source.on("tileloadend", () => emitLayerStatus("success", `${layer.name}已加载`)))
wmsEventKeys.push(source.on("tileloaderror", () => emitLayerStatus("error", `${layer.name}加载失败`)))
}
function buildOverlayImageWmsSource(layer) {
const source = new ImageWMS({
url: layer.url,
params: buildOverlayWmsParams(layer),
serverType: "geoserver",
crossOrigin: "anonymous",
ratio: 1,
})
bindOverlayImageEvents(source, layer)
return source
}
function buildOverlayWmsSource(layer) {
const source = new TileWMS({
url: layer.url,
params: buildOverlayWmsParams(layer),
serverType: "geoserver",
crossOrigin: "anonymous",
transition: 0,
})
wmsEventKeys.push(yakWmsSource.on("tileloadstart", () => emitLayerStatus("loading", "正在加载牦牛识别标记")))
wmsEventKeys.push(yakWmsSource.on("tileloadend", () => emitLayerStatus("success", "牦牛识别标记已加载")))
wmsEventKeys.push(yakWmsSource.on("tileloaderror", () => emitLayerStatus("error", "牦牛识别标记加载失败")))
bindOverlayTileEvents(source, layer)
return source
}
function combineCqlFilters(...filters) {
const clauses = filters.map((item) => String(item || "").trim()).filter(Boolean)
if (!clauses.length) return ""
return clauses.length === 1 ? clauses[0] : clauses.map((item) => `(${item})`).join(" AND ")
}
function buildOverlayWfsUrl(layer, options = {}) {
const params = new URLSearchParams({
service: "WFS",
version: "1.0.0",
request: "GetFeature",
typeName: layer.layerName,
outputFormat: "json",
maxFeatures: String(layer.maxFeatures),
})
const propertyName = options.propertyName ?? layer.propertyName
if (Array.isArray(propertyName) && propertyName.length) params.set("propertyName", propertyName.join(","))
else if (propertyName) params.set("propertyName", propertyName)
const cqlFilter = combineCqlFilters(layer.cqlFilter, options.cqlFilter)
if (cqlFilter) params.set("CQL_FILTER", cqlFilter)
return `${layer.url}?${params.toString()}`
}
function createBboxCqlFilter(layer, extent) {
const bottomLeft = toLonLat([extent[0], extent[1]])
const topRight = toLonLat([extent[2], extent[3]])
const padding = layer.bboxPadding
const minLng = Math.min(bottomLeft[0], topRight[0]) - padding
const minLat = Math.min(bottomLeft[1], topRight[1]) - padding
const maxLng = Math.max(bottomLeft[0], topRight[0]) + padding
const maxLat = Math.max(bottomLeft[1], topRight[1]) + padding
return `BBOX(${layer.geometryField},${minLng},${minLat},${maxLng},${maxLat})`
}
function buildOverlayBboxVectorSource(layer) {
const source = new VectorSource({
wrapX: false,
strategy: bboxStrategy,
})
const format = new GeoJSON()
const loadedFeatureIds = new Set()
source.setLoader((extent, resolution, projection, success, failure) => {
emitLayerStatus("loading", `正在加载${layer.name}`)
fetch(buildOverlayWfsUrl(layer, { cqlFilter: createBboxCqlFilter(layer, extent) }), { credentials: "include" })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
})
.then((geojson) => {
if (source !== overlaySource) return
const features = format.readFeatures(geojson, {
dataProjection: "EPSG:4326",
featureProjection: projection,
})
const nextFeatures = features.filter((feature) => {
const id = feature.getId?.()
if (!id || loadedFeatureIds.has(id)) return false
loadedFeatureIds.add(id)
return true
})
source.addFeatures(nextFeatures)
success?.(nextFeatures)
emitLayerStatus(features.length ? "success" : "warning", features.length ? `${layer.name}已加载` : `${layer.name}暂无数据`)
})
.catch(() => {
if (source !== overlaySource) return
source.removeLoadedExtent?.(extent)
failure?.()
emitLayerStatus("error", `${layer.name}加载失败`)
})
})
return source
}
function buildOverlayVectorSource(layer) {
if (layer.loadingStrategy === "bbox") return buildOverlayBboxVectorSource(layer)
const source = new VectorSource({ wrapX: false })
const format = new GeoJSON()
emitLayerStatus("loading", `正在加载${layer.name}`)
fetch(buildOverlayWfsUrl(layer), { credentials: "include" })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
})
.then((geojson) => {
if (source !== overlaySource) return
const features = format.readFeatures(geojson, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
})
source.clear(true)
source.addFeatures(features)
emitLayerStatus(features.length ? "success" : "warning", features.length ? `${layer.name}已加载` : `${layer.name}暂无数据`)
})
.catch(() => {
if (source !== overlaySource) return
emitLayerStatus("error", `${layer.name}加载失败`)
})
return source
}
function createOverlayVectorStyle(layer) {
const lineWidth = layer.lineWidth
return [
new Style({
fill: new Fill({ color: layer.fillColor }),
stroke: new Stroke({ color: layer.glowColor, width: lineWidth + 5 }),
}),
new Style({
stroke: new Stroke({ color: layer.strokeColor, width: lineWidth }),
}),
]
}
function createOverlayMapLayer() {
const layer = normalizeOverlayLayer()
if (!layer) return null
if (layer.sourceType === "wfs") {
overlaySource = buildOverlayVectorSource(layer)
const overlayVectorStyle = createOverlayVectorStyle(layer)
return new VectorLayer({
source: overlaySource,
style: () => overlayVectorStyle,
zIndex: layer.zIndex,
renderBuffer: 160,
updateWhileAnimating: true,
updateWhileInteracting: true,
})
}
if (layer.renderMode === "image") {
overlaySource = buildOverlayImageWmsSource(layer)
return new ImageLayer({
source: overlaySource,
opacity: layer.opacity,
zIndex: layer.zIndex,
})
}
overlaySource = buildOverlayWmsSource(layer)
return new TileLayer({
source: overlaySource,
opacity: layer.opacity,
zIndex: layer.zIndex,
})
}
return yakWmsSource
function refreshOverlayLayer() {
if (!olMap) return
if (overlayMapLayer) {
olMap.removeLayer(overlayMapLayer)
overlayMapLayer = null
}
if (wmsEventKeys.length) {
unByKey(wmsEventKeys)
wmsEventKeys = []
}
overlaySource = null
overlayMapLayer = createOverlayMapLayer()
if (overlayMapLayer) olMap.addLayer(overlayMapLayer)
}
function createBoundaryLayers() {
@ -275,7 +529,7 @@ function createBoundaryLayers() {
return [
new VectorLayer({
source: boundarySource,
style: boundaryGlowStyle,
style: () => boundaryGlowStyle,
zIndex: 28,
renderBuffer: 80,
updateWhileAnimating: true,
@ -283,7 +537,7 @@ function createBoundaryLayers() {
}),
new VectorLayer({
source: boundarySource,
style: boundaryLineStyle,
style: () => boundaryLineStyle,
zIndex: 29,
renderBuffer: 80,
updateWhileAnimating: true,
@ -521,14 +775,15 @@ function unbindContextMenuCopy() {
}
async function initMap() {
if (olMap || !props.active) return
if (olMap) return
await nextTick()
if (!mapEl.value || olMap || !props.active) return
if (!mapEl.value || olMap) return
resetTileState(INITIAL_ZOOM)
const initialZoom = normalizeInitialZoom()
resetTileState(initialZoom)
const view = new View({
center: fromLonLat(normalizeCenter()),
zoom: INITIAL_ZOOM,
zoom: initialZoom,
minZoom: props.minZoom,
maxZoom: props.maxZoom,
enableRotation: false,
@ -549,15 +804,11 @@ async function initMap() {
preload: 1,
zIndex: 1,
}),
new TileLayer({
source: buildYakWmsSource(),
opacity: 0.96,
zIndex: 20,
}),
...createBoundaryLayers(),
],
view,
})
refreshOverlayLayer()
bindContextMenuCopy()
eventKeys.push(
@ -588,7 +839,8 @@ function disposeMap() {
}
pendingTiles.clear()
baseSource = null
yakWmsSource = null
overlaySource = null
overlayMapLayer = null
if (olMap) {
olMap.setTarget(null)
olMap.dispose?.()
@ -602,8 +854,31 @@ function refreshView() {
window.setTimeout(() => olMap?.updateSize?.(), 120)
}
function refreshTileSources() {
if (baseSource) {
resetTileState(currentZoom())
baseSource.refresh?.()
emitTileStatus("loading", `正在刷新 hy-result ${tileState.zoom}级影像底图`)
}
overlaySource?.refresh?.()
}
function resetView() {
locateMap()
refreshTileSources()
olMap?.updateSize?.()
window.setTimeout(() => olMap?.updateSize?.(), 80)
window.setTimeout(() => olMap?.updateSize?.(), 220)
}
function hasMap() {
return Boolean(olMap)
}
defineExpose({
refreshView,
resetView,
hasMap,
})
onMounted(() => {
@ -645,6 +920,14 @@ watch(
emitTileStatus("loading", `正在加载 hy-result ${tileState.zoom}级影像底图`)
},
)
watch(
() => props.overlayLayer,
() => {
refreshOverlayLayer()
},
{ deep: true },
)
</script>
<style scoped lang="scss">

@ -1,5 +1,5 @@
<template>
<div ref="yakManagementRef" class="yak-management">
<div class="yak-management">
<map-scene
v-show="!isYakOlMapView"
:key="yakMapSceneKey"
@ -27,16 +27,6 @@
/>
<div class="yak-map-scrim" :class="{ 'is-ol-map': isYakOlMapView }"></div>
<div v-if="yakMapLoadingVisible" class="yak-map-loading">{{ yakMapLoadingText }}</div>
<div class="yak-map-tools" aria-label="地图工具">
<button type="button" :title="yakFullscreenLabel" :aria-label="yakFullscreenLabel" @click="handleYakMapFullscreen">
<span class="yak-map-tool-icon is-fullscreen" aria-hidden="true"></span>
<em>{{ yakFullscreenLabel }}</em>
</button>
<button type="button" title="复位地图" aria-label="复位地图" @click="handleYakMapReset">
<span class="yak-map-tool-icon is-reset" aria-hidden="true"></span>
<em>复位</em>
</button>
</div>
<div class="yak-management-ui">
<div v-if="activeView === 'trade' && tradeYears.length" class="trade-year-switcher" aria-label="交易年份切换">
<span>交易年份</span>
@ -369,9 +359,12 @@
v-for="item in forecastHeatRows"
:key="item.name"
class="heat-row"
:style="{ '--heat-width': `${item.width}%` }"
:class="{ 'is-negative': item.change < 0 }"
:style="forecastHeatStyle(item)"
:title="forecastHeatTitle(item)"
>
<span>{{ item.name }}</span>
<b><i></i></b>
<strong>{{ formatSigned(item.change) }}</strong>
</div>
</div>
@ -716,7 +709,9 @@
<button type="button" @click="closeEpidemicImagePreview">关闭</button>
</div>
<div class="epidemic-image-preview-body">
<img :src="epidemicImagePreviewUrl" alt="防疫照片预览" />
<div class="epidemic-image-preview-frame">
<img :src="epidemicImagePreviewUrl" alt="防疫照片预览" />
</div>
</div>
</div>
</div>
@ -789,9 +784,15 @@ const epidemicLedgerCardHeight = computed(() => sideCardHeight.value * 4 + sideC
const tradeSourceCardHeight = computed(() => sideCardHeight.value + 24)
const tradeLedgerCardHeight = computed(() => sideCardHeight.value * 2 - sideCardGap)
const epidemicImageBaseUrl = "https://qkl-hy.oss-cn-chengdu.aliyuncs.com/yak-vaccine-images"
const yakMapTileUrlTemplate = "/hy-result/{z}/{x}/{y}.png"
const yakMapTileUrlTemplate = "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png"
const yakStructureMapCenter = [103.07984, 33.029058]
const yakForecastMapCenter = [102.682809, 33.131561]
const yakTradeMapView = {
targetLngLat: [102.58, 32.69],
targetLocalZ: 0.72,
distanceScale: 1.12,
duration: 0.75,
}
const yakMapSceneRevision = "hy-result-dynamic-zoom21-v4"
const palette = ["#30dcff", "#27f0c0", "#ffd76c", "#7aa7ff", "#ff8f6b", "#a6f06d"]
const epidemicPieColors = ["#24f6c4", "#32dcff", "#ffe06b", "#7ee269", "#ff9a62", "#9ea7ff"]
@ -869,12 +870,10 @@ const tradeLedgerModalFilters = ref({
usageType: "",
certType: "",
})
const yakManagementRef = ref(null)
const mapSceneRef = ref(null)
const yakOlMapRef = ref(null)
const mapReady = ref(false)
const olMapReady = ref(false)
const isYakFullscreen = ref(false)
const yakMapSceneResetRevision = ref(0)
const yakOlMapKey = ref(0)
const tileStatus = ref({ type: "idle", text: "" })
@ -1297,7 +1296,6 @@ const yakMapLoadingVisible = computed(() => {
return !mapReady.value
})
const yakMapLoadingText = computed(() => (isYakOlMapView.value ? "影像地图加载中" : "3D地图加载中"))
const yakFullscreenLabel = computed(() => (isYakFullscreen.value ? "退出" : "全屏"))
const mapRanking = computed(() => {
if (activeView.value === "trade") {
return {
@ -1310,6 +1308,8 @@ const mapRanking = computed(() => {
total: tradeMetricValue("headCount"),
},
rows: tradeFlowRows.value,
showFlowLines: false,
mapView: yakTradeMapView,
}
}
return {
@ -1938,6 +1938,20 @@ const forecastHeatRows = computed(() => {
}))
})
function forecastHeatStyle(item) {
const positive = Number(item?.change || 0) >= 0
return {
"--heat-width": `${item.width}%`,
"--heat-color": positive ? "#24f6c4" : "#ff8f6b",
"--heat-color-soft": positive ? "rgba(36, 246, 196, 0.28)" : "rgba(255, 143, 107, 0.26)",
"--heat-color-dim": positive ? "rgba(36, 246, 196, 0.08)" : "rgba(255, 143, 107, 0.08)",
}
}
function forecastHeatTitle(item) {
return `${item.name}${formatSigned(item.change)}`
}
const epidemicTrendOption = computed(() => {
const rows = epidemicTrendRows.value
const values = rows.map((item) => item.value)
@ -2402,11 +2416,18 @@ const epidemicPreventionStatOption = computed(() => {
selectedMode: false,
},
],
grid: { left: 36, right: 8, top: 38, bottom: 50 },
grid: { left: 42, right: 12, top: 46, bottom: 68 },
xAxis: {
type: "category",
data: rows.map((item) => item.name),
axisLabel: { color: "#fff", fontSize: 10, interval: 0, formatter: verticalAxisLabel },
axisLabel: {
color: "#fff",
fontSize: 10,
interval: 0,
lineHeight: 11,
margin: 8,
formatter: verticalTownAxisLabel,
},
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
@ -2711,37 +2732,6 @@ function updateScreenViewportHeight() {
screenViewportHeight.value = typeof window === "undefined" ? 1080 : window.innerHeight
}
const yakFullscreenEvents = ["fullscreenchange", "webkitfullscreenchange", "mozfullscreenchange", "MSFullscreenChange"]
function getFullscreenElement() {
if (typeof document === "undefined") return null
return document.fullscreenElement
|| document.webkitFullscreenElement
|| document.mozFullScreenElement
|| document.msFullscreenElement
|| null
}
function syncYakFullscreenState() {
const fullscreenElement = getFullscreenElement()
const root = yakManagementRef.value
isYakFullscreen.value = Boolean(root && fullscreenElement && (
fullscreenElement === root
|| root.contains(fullscreenElement)
|| fullscreenElement.contains?.(root)
))
}
function bindYakFullscreenEvents() {
if (typeof document === "undefined") return
yakFullscreenEvents.forEach((eventName) => document.addEventListener(eventName, syncYakFullscreenState))
}
function unbindYakFullscreenEvents() {
if (typeof document === "undefined") return
yakFullscreenEvents.forEach((eventName) => document.removeEventListener(eventName, syncYakFullscreenState))
}
function refreshYakMapView() {
if (isYakOlMapView.value) {
yakOlMapRef.value?.refreshView?.()
@ -2751,33 +2741,6 @@ function refreshYakMapView() {
mapSceneRef.value?.refreshView?.()
}
async function handleYakMapFullscreen() {
if (typeof document === "undefined") return
const fullscreenElement = getFullscreenElement()
try {
if (fullscreenElement) {
const exitFullscreen = document.exitFullscreen
|| document.webkitExitFullscreen
|| document.mozCancelFullScreen
|| document.msExitFullscreen
await exitFullscreen?.call(document)
} else {
const target = yakManagementRef.value || document.documentElement
const requestFullscreen = target.requestFullscreen
|| target.webkitRequestFullscreen
|| target.mozRequestFullScreen
|| target.msRequestFullscreen
await requestFullscreen?.call(target)
}
} catch (error) {
console.warn("[yak-management] fullscreen toggle failed", error)
} finally {
syncYakFullscreenState()
window.setTimeout(refreshYakMapView, 80)
window.setTimeout(refreshYakMapView, 280)
}
}
function clearYakMapLoadTimer() {
if (!mapLoadTimer) return
window.clearTimeout(mapLoadTimer)
@ -2789,29 +2752,41 @@ function handleYakMapReset() {
topicLayerStatus.value = { type: "idle", text: "" }
if (isYakOlMapView.value) {
olMapReady.value = false
yakOlMapKey.value += 1
nextTick(() => {
window.setTimeout(() => yakOlMapRef.value?.refreshView?.(), 160)
})
if (yakOlMapRef.value?.hasMap?.()) {
olMapReady.value = true
yakOlMapRef.value?.resetView?.()
} else {
olMapReady.value = false
yakOlMapKey.value += 1
nextTick(() => {
window.setTimeout(() => yakOlMapRef.value?.refreshView?.(), 160)
})
}
return
}
if (mapSceneRef.value?.hasMap?.()) {
mapReady.value = true
refreshYakMapView()
return
}
clearYakMapLoadTimer()
mapReady.value = false
mapLoadStarted = false
mapSceneRef.value?.destroyMap?.()
yakMapSceneResetRevision.value += 1
nextTick(() => {
scheduleYakMapLoad()
})
}
defineExpose({
refreshMapView: refreshYakMapView,
resetMap: handleYakMapReset,
})
onMounted(() => {
updateScreenViewportHeight()
window.addEventListener("resize", updateScreenViewportHeight)
bindYakFullscreenEvents()
syncYakFullscreenState()
initYakMap()
loadRemoteRows()
loadEpidemicData()
@ -2823,7 +2798,6 @@ onMounted(() => {
onBeforeUnmount(() => {
destroyed = true
window.removeEventListener("resize", updateScreenViewportHeight)
unbindYakFullscreenEvents()
if (mapLoadTimer) window.clearTimeout(mapLoadTimer)
stopForecastRotation()
stopFoundationRotation()
@ -3191,6 +3165,11 @@ function verticalAxisLabel(value) {
return formatPlaceAxisLabel(value)
}
function verticalTownAxisLabel(value) {
const text = String(value || "").trim().replace(/\s+/g, "")
return text ? Array.from(text).join("\n") : ""
}
function formatPlaceAxisLabel(value) {
const text = String(value || "").trim()
if (!text) return ""
@ -5339,105 +5318,6 @@ function formatSignedWan(value) {
transform: translate(-50%, -50%);
}
.yak-map-tools {
position: absolute;
left: 462px;
bottom: 112px;
z-index: 9;
display: flex;
gap: 8px;
pointer-events: all;
transform: perspective(620px) rotateY(6deg);
transform-origin: left center;
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 72px;
height: 34px;
padding: 0 12px;
border: 1px solid rgba(48, 220, 255, 0.46);
border-radius: 3px;
color: rgba(224, 252, 255, 0.92);
font-size: 13px;
line-height: 34px;
font-weight: 900;
background:
linear-gradient(180deg, rgba(13, 67, 88, 0.74), rgba(5, 27, 43, 0.86)),
rgba(4, 24, 37, 0.72);
box-shadow:
0 0 16px rgba(48, 220, 255, 0.16),
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
inset 0 -10px 18px rgba(48, 220, 255, 0.08);
cursor: pointer;
&:hover {
border-color: rgba(126, 251, 246, 0.76);
color: #ffffff;
background:
linear-gradient(180deg, rgba(21, 96, 116, 0.86), rgba(6, 39, 58, 0.92)),
rgba(6, 35, 52, 0.78);
box-shadow:
0 0 20px rgba(48, 220, 255, 0.3),
inset 0 0 0 1px rgba(255, 255, 255, 0.09),
inset 0 -10px 18px rgba(48, 220, 255, 0.14);
}
&:active {
transform: translateY(1px);
}
em {
font-style: normal;
white-space: nowrap;
}
}
}
.yak-map-tool-icon {
position: relative;
flex: 0 0 16px;
width: 16px;
height: 16px;
color: #7efbf6;
&.is-fullscreen {
background:
linear-gradient(currentColor, currentColor) left top / 7px 2px no-repeat,
linear-gradient(currentColor, currentColor) left top / 2px 7px no-repeat,
linear-gradient(currentColor, currentColor) right top / 7px 2px no-repeat,
linear-gradient(currentColor, currentColor) right top / 2px 7px no-repeat,
linear-gradient(currentColor, currentColor) left bottom / 7px 2px no-repeat,
linear-gradient(currentColor, currentColor) left bottom / 2px 7px no-repeat,
linear-gradient(currentColor, currentColor) right bottom / 7px 2px no-repeat,
linear-gradient(currentColor, currentColor) right bottom / 2px 7px no-repeat;
}
&.is-reset {
width: 15px;
height: 15px;
border: 2px solid currentColor;
border-left-color: transparent;
border-radius: 50%;
box-sizing: border-box;
&::after {
content: "";
position: absolute;
left: -2px;
top: 1px;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-right: 6px solid currentColor;
transform: rotate(-18deg);
}
}
}
.yak-bottom-topic {
position: absolute;
left: 50%;
@ -6019,78 +5899,137 @@ function formatSignedWan(value) {
display: flex;
height: 100%;
flex-direction: column;
gap: 2px;
padding: 7px 12px 8px;
gap: 4px;
padding: 8px 13px 9px;
box-sizing: border-box;
}
.heat-row {
position: relative;
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
grid-template-columns: 70px minmax(0, 1fr) 60px;
align-items: center;
gap: 8px;
min-height: 0;
flex: 1 1 0;
overflow: hidden;
border: 1px solid rgba(48, 220, 255, 0.22);
padding: 0 8px;
overflow: visible;
border: 1px solid rgba(48, 220, 255, 0.14);
background:
linear-gradient(90deg, rgba(2, 19, 31, 0.96), rgba(2, 19, 31, 0.78)),
rgba(3, 18, 29, 0.88);
linear-gradient(90deg, rgba(48, 220, 255, 0.08), transparent 34%, rgba(36, 246, 196, 0.04)),
rgba(2, 18, 30, 0.58);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.025),
inset 0 0 16px rgba(0, 0, 0, 0.2);
inset 0 0 0 1px rgba(255, 255, 255, 0.018),
inset 0 0 12px rgba(48, 220, 255, 0.04);
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--heat-width);
background:
linear-gradient(90deg, rgba(255, 215, 108, 0.52), rgba(48, 220, 255, 0.44)),
linear-gradient(90deg, rgba(255, 255, 255, 0.14), transparent);
box-shadow: 0 0 16px rgba(48, 220, 255, 0.24);
left: 78px;
right: 68px;
top: 50%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(48, 220, 255, 0.24), transparent);
transform: translateY(-50%);
}
&::after {
content: "";
position: absolute;
right: 6px;
top: 4px;
bottom: 4px;
width: 64px;
background: linear-gradient(90deg, transparent, rgba(2, 17, 28, 0.78) 34%, rgba(2, 17, 28, 0.94));
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--heat-color);
box-shadow: 0 0 10px var(--heat-color);
opacity: 0.78;
}
span,
strong {
strong,
b {
position: relative;
z-index: 1;
padding: 0 9px;
white-space: nowrap;
}
span {
color: #effdff;
font-size: 13px;
line-height: 15px;
font-size: 12px;
line-height: 14px;
font-weight: 900;
overflow: hidden;
text-overflow: ellipsis;
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.72),
0 0 8px rgba(48, 220, 255, 0.18);
}
b {
display: block;
height: 11px;
padding: 2px;
border: 1px solid rgba(48, 220, 255, 0.22);
background:
linear-gradient(90deg, rgba(48, 220, 255, 0.08), rgba(48, 220, 255, 0.025)),
rgba(0, 14, 24, 0.66);
box-shadow:
inset 0 0 10px rgba(0, 0, 0, 0.34),
0 0 10px rgba(48, 220, 255, 0.08);
i {
position: relative;
display: block;
width: var(--heat-width);
height: 100%;
min-width: 8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.86), var(--heat-color) 16%, rgba(48, 220, 255, 0.72) 78%, rgba(255, 255, 255, 0.9)),
linear-gradient(90deg, var(--heat-color-soft), transparent);
box-shadow:
0 0 10px var(--heat-color-soft),
0 0 18px var(--heat-color-dim);
overflow: visible;
&::after {
content: "";
position: absolute;
right: -4px;
top: 50%;
width: 7px;
height: 7px;
border-radius: 50%;
background: #ffffff;
box-shadow:
0 0 8px #ffffff,
0 0 14px var(--heat-color);
transform: translateY(-50%);
}
&::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.42), transparent);
opacity: 0.72;
}
}
}
strong {
color: #ffffff;
font-family: D-DIN, Arial, sans-serif;
font-size: 14px;
line-height: 16px;
font-size: 15px;
line-height: 17px;
font-weight: 900;
text-align: right;
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.72),
0 0 10px rgba(48, 220, 255, 0.24);
0 0 10px var(--heat-color-soft);
}
&.is-negative {
border-color: rgba(255, 143, 107, 0.18);
}
}
@ -6835,6 +6774,7 @@ function formatSignedWan(value) {
overflow: hidden;
.epidemic-modal-photo-thumb {
position: relative;
width: 100%;
height: 96px;
padding: 0;
@ -6848,6 +6788,17 @@ function formatSignedWan(value) {
border-color: rgba(255, 215, 108, 0.58);
box-shadow: 0 0 14px rgba(255, 215, 108, 0.16);
}
&::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 18px;
content: "";
background: linear-gradient(180deg, rgba(0, 12, 20, 0), rgba(0, 12, 20, 0.92) 52%, rgba(0, 12, 20, 0.98));
pointer-events: none;
}
}
img {
@ -6954,6 +6905,16 @@ function formatSignedWan(value) {
justify-content: center;
padding: 16px;
background: rgba(0, 10, 17, 0.42);
}
.epidemic-image-preview-frame {
position: relative;
display: flex;
max-width: 100%;
max-height: calc(78vh - 84px);
align-items: center;
justify-content: center;
overflow: hidden;
img {
display: block;
@ -6961,6 +6922,17 @@ function formatSignedWan(value) {
max-height: calc(78vh - 84px);
object-fit: contain;
}
&::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 38px;
content: "";
background: linear-gradient(180deg, rgba(0, 10, 17, 0), rgba(0, 10, 17, 0.96) 58%, rgba(0, 10, 17, 0.99));
pointer-events: none;
}
}
.epidemic-ledger-pager {

Loading…
Cancel
Save