feat: update screen map interactions

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

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

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

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

Loading…
Cancel
Save