Przeglądaj źródła

feat:添加新增分区和查看地图大图页面

wangsisi 1 tydzień temu
rodzic
commit
9c989a0680

+ 20 - 0
src/i18n/messages.js

@@ -137,6 +137,16 @@ export default {
             diagnosisReport: "诊断报告",
             addZone: "新增分区",
             addAlbum: "新增相册",
+            selectZoneHint: "请勾选您的分区区域",
+            searchLocation: "搜索位置",
+            defaultCity: "武汉市",
+            cancelSelect: "取消勾选",
+            confirmArea: "确认区域",
+            pleaseDrawZone: "请先勾画分区区域",
+            createZoneSuccess: "分区创建成功",
+            phenologyInconsistent: "物候不一致?",
+            customizeZoneHint: "为了更好记录长势,请自定义分区",
+            zoneTwo: "分区二",
             photoCount: "{count}张",
             zoneLabel: "分区{n}",
             zoneOne: "分区一",
@@ -464,6 +474,16 @@ export default {
             diagnosisReport: "Diagnosis report",
             addZone: "Add zone",
             addAlbum: "Add album",
+            selectZoneHint: "Please select your zone area",
+            searchLocation: "Search location",
+            defaultCity: "Wuhan",
+            cancelSelect: "Clear selection",
+            confirmArea: "Confirm area",
+            pleaseDrawZone: "Please draw a zone first",
+            createZoneSuccess: "Zone created successfully",
+            phenologyInconsistent: "Inconsistent phenology?",
+            customizeZoneHint: "Customize zones to better record growth",
+            zoneTwo: "Zone 2",
             photoCount: "{count} photos",
             zoneLabel: "Zone {n}",
             zoneOne: "Zone 1",

+ 14 - 0
src/router/globalRoutes.js

@@ -177,4 +177,18 @@ export default [
         meta: { keepAlive: true },
         component: () => import("@/views/old_mini/agri_file/pages/regionAlbums.vue"),
     },
+    // 新增分区
+    {
+        path: "/add_zone",
+        name: "AddZone",
+        meta: { keepAlive: true },
+        component: () => import("@/views/old_mini/agri_file/pages/addZone.vue"),
+    },
+    // 农情相册地图
+    {
+        path: "/album_map",
+        name: "AlbumMap",
+        meta: { keepAlive: true },
+        component: () => import("@/views/old_mini/agri_file/pages/albumMap.vue"),
+    },
 ];

+ 205 - 8
src/views/old_mini/agri_file/fileMap.js

@@ -5,9 +5,11 @@ import StaticImgLayer from "@/utils/ol-map/StaticImgLayer";
 import { Vector as VectorSource } from "ol/source.js";
 import Style from "ol/style/Style";
 import Text from "ol/style/Text";
+import Icon from "ol/style/Icon";
 import { Fill, Stroke } from "ol/style";
 import { WKT } from "ol/format";
 import Feature from "ol/Feature";
+import Point from "ol/geom/Point";
 import {
     createEmpty,
     extend as extendExtent,
@@ -46,19 +48,168 @@ const RECORD_TYPE_STYLE = {
     zone: { fill: "rgba(28, 158, 128, 0.45)", stroke: "#1c9e80" },
     growth: { fill: "rgba(255, 120, 0, 0.5)", stroke: "#cc5500" },
     pest: { fill: "rgba(224, 49, 49, 0.45)", stroke: "#e03131" },
+    album: { fill: "rgba(255, 255, 255, 0.28)", stroke: "#ffffff" },
 };
 
-/** 物候→管理分区,农事→管理分区,异常→病虫害异常 */
+/** 物候→管理分区,农事→管理分区,异常→病虫害异常,相册→白色分区 */
 const TAB_STYLE_TYPE = {
     phenology: "zone",
     farming: "zone",
     abnormal: "pest",
+    album: "album",
 };
 
 export const RECORD_TAB_KEYS = ["phenology", "farming", "abnormal"];
 
 const DEFAULT_CENTER = "POINT(113.6142086995688 23.585836479509055)";
 
+function roundRectPath(ctx, x, y, width, height, radius) {
+    const r = Math.min(radius, width / 2, height / 2);
+    ctx.beginPath();
+    ctx.moveTo(x + r, y);
+    ctx.arcTo(x + width, y, x + width, y + height, r);
+    ctx.arcTo(x + width, y + height, x, y + height, r);
+    ctx.arcTo(x, y + height, x, y, r);
+    ctx.arcTo(x, y, x + width, y, r);
+    ctx.closePath();
+}
+
+function createHiDpiCanvas(cssWidth, cssHeight) {
+    const dpr = window.devicePixelRatio || 1;
+    const canvas = document.createElement("canvas");
+    canvas.width = Math.ceil(cssWidth * dpr);
+    canvas.height = Math.ceil(cssHeight * dpr);
+    const ctx = canvas.getContext("2d");
+    ctx.scale(dpr, dpr);
+    return { canvas, ctx, dpr };
+}
+
+function iconFromCanvas(canvas, dpr, anchor) {
+    return new Icon({
+        src: canvas.toDataURL(),
+        scale: 1 / dpr,
+        anchor,
+        anchorXUnits: "fraction",
+        anchorYUnits: "fraction",
+    });
+}
+
+function createZoneLabelStyle(name) {
+    const text = String(name || "");
+    const font = "13px sans-serif";
+    const padX = 10;
+    const padY = 4;
+    const measure = document.createElement("canvas").getContext("2d");
+    measure.font = font;
+    const textWidth = Math.ceil(measure.measureText(text).width);
+    const boxW = textWidth + padX * 2;
+    const boxH = 21;
+    const { canvas, ctx, dpr } = createHiDpiCanvas(boxW + 8, boxH + 8);
+    const x = 4;
+    const y = 4;
+    ctx.shadowColor = "rgba(0, 0, 0, 0.12)";
+    ctx.shadowBlur = 4;
+    ctx.shadowOffsetY = 1;
+    roundRectPath(ctx, x, y, boxW, boxH, 4);
+    ctx.fillStyle = "#ffffff";
+    ctx.fill();
+    ctx.shadowColor = "transparent";
+    ctx.fillStyle = "#1F1F1F";
+    ctx.font = font;
+    ctx.textAlign = "center";
+    ctx.textBaseline = "middle";
+    ctx.fillText(text, x + boxW / 2, y + boxH / 2);
+    return new Style({
+        image: iconFromCanvas(canvas, dpr, [0.5, 0.5]),
+    });
+}
+
+function drawCoverImage(ctx, img, x, y, size) {
+    const scale = Math.max(size / img.width, size / img.height);
+    const dw = img.width * scale;
+    const dh = img.height * scale;
+    ctx.drawImage(img, x + (size - dw) / 2, y + (size - dh) / 2, dw, dh);
+}
+
+function createPhotoMarkerStyle(img, count) {
+    const thumb = 42;
+    const arrowH = 6;
+    const arrowW = 10;
+    const badgeH = 16;
+    const countText = String(count ?? "");
+    const badgeW = Math.max(16, 8 + countText.length * 7);
+    const cssW = thumb + 12;
+    const cssH = 6 + thumb + arrowH;
+    const { canvas, ctx, dpr } = createHiDpiCanvas(cssW, cssH);
+    const thumbX = (cssW - thumb) / 2;
+    const thumbY = 6;
+
+    ctx.save();
+    roundRectPath(ctx, thumbX, thumbY, thumb, thumb, 6);
+    ctx.clip();
+    if (img) {
+        drawCoverImage(ctx, img, thumbX, thumbY, thumb);
+    } else {
+        ctx.fillStyle = "#d9d9d9";
+        ctx.fillRect(thumbX, thumbY, thumb, thumb);
+    }
+    ctx.restore();
+
+    roundRectPath(ctx, thumbX, thumbY, thumb, thumb, 6);
+    ctx.strokeStyle = "#ffffff";
+    ctx.lineWidth = 2;
+    ctx.stroke();
+
+    const ax = thumbX + thumb / 2;
+    const ay = thumbY + thumb - 1;
+    ctx.beginPath();
+    ctx.moveTo(ax - arrowW / 2, ay);
+    ctx.lineTo(ax + arrowW / 2, ay);
+    ctx.lineTo(ax, ay + arrowH);
+    ctx.closePath();
+    ctx.fillStyle = "#ffffff";
+    ctx.fill();
+
+    const bx = thumbX + thumb - badgeW + 4;
+    const by = thumbY - 6;
+    roundRectPath(ctx, bx, by, badgeW, badgeH, 8);
+    ctx.fillStyle = "#ffffff";
+    ctx.fill();
+    ctx.fillStyle = "#1F1F1F";
+    ctx.font = "10px sans-serif";
+    ctx.textAlign = "center";
+    ctx.textBaseline = "middle";
+    ctx.fillText(countText, bx + badgeW / 2, by + badgeH / 2 + 0.5);
+
+    return new Style({
+        image: iconFromCanvas(canvas, dpr, [0.5, 1]),
+    });
+}
+
+function loadImage(src) {
+    return new Promise((resolve) => {
+        if (!src) {
+            resolve(null);
+            return;
+        }
+        const img = new Image();
+        if (/^https?:\/\//i.test(String(src))) {
+            img.crossOrigin = "anonymous";
+        }
+        img.onload = () => resolve(img);
+        img.onerror = () => resolve(null);
+        img.src = src;
+    });
+}
+
+function createPointGeometry(lng, lat, projection) {
+    const geometry = new Point([lng, lat]);
+    if (projection && projection.getCode() !== "EPSG:4326") {
+        geometry.transform("EPSG:4326", projection);
+    }
+    return geometry;
+}
+
 function getItemPolygon(item) {
     const wkt = item?.polygon ?? item?.geom ?? item?.geomWkt;
     return typeof wkt === "string" ? wkt.trim() : "";
@@ -127,6 +278,7 @@ export function recordsToCenterPoint(records) {
 class FileMap {
     constructor() {
         this._pending = null;
+        this._pendingMarkers = null;
         this._fitTimer = null;
         this._renderToken = 0;
         this.baseImageLayers = [];
@@ -140,9 +292,9 @@ class FileMap {
             source: new VectorSource({}),
             style: (f) => {
                 const colors = RECORD_TYPE_STYLE[f.get("styleType")] || RECORD_TYPE_STYLE.zone;
-                const polygonStyle = vectorStyle.getPolygonStyle(colors.fill, colors.stroke, 3);
+                const polygonStyle = vectorStyle.getPolygonStyle(colors.fill, colors.stroke, 2);
                 const label = f.get("zone_name");
-                if (!label) return [polygonStyle];
+                if (!label || f.get("styleType") === "album") return [polygonStyle];
                 return [
                     polygonStyle,
                     new Style({
@@ -156,6 +308,11 @@ class FileMap {
                 ];
             },
         });
+        this.albumMarkerLayer = new KMap.VectorLayer("fileAlbumMarkerLayer", 1001, {
+            minZoom: 8,
+            maxZoom: 22,
+            source: new VectorSource({}),
+        });
     }
 
     initBaseImageLayers() {
@@ -209,11 +366,7 @@ class FileMap {
         if (this.kmap?.map) {
             this.kmap.map.setTarget(target);
             this.kmap.map.updateSize();
-            if (this._pending) {
-                const pending = this._pending;
-                this._pending = null;
-                this.setRecordPolygons(pending.records, pending.tabKey);
-            }
+            this.flushPending();
             return;
         }
 
@@ -221,12 +374,21 @@ class FileMap {
         this.kmap.addXYZLayer(config.base_img_url3 + "map/lby/{z}/{x}/{y}.png", { minZoom: 8, maxZoom: 22 }, 2);
         this.initBaseImageLayers();
         this.kmap.addLayer(this.recordPolygonLayer.layer);
+        this.kmap.addLayer(this.albumMarkerLayer.layer);
+        this.flushPending();
+    }
 
+    flushPending() {
         if (this._pending) {
             const pending = this._pending;
             this._pending = null;
             this.setRecordPolygons(pending.records, pending.tabKey);
         }
+        if (this._pendingMarkers) {
+            const pending = this._pendingMarkers;
+            this._pendingMarkers = null;
+            this.setAlbumMarkers(pending);
+        }
     }
 
     setRecordPolygons(records, tabKey = "phenology") {
@@ -264,6 +426,41 @@ class FileMap {
         this.fitView(renderToken);
     }
 
+    setAlbumMarkers({ labels = [], photos = [] } = {}) {
+        if (!this.kmap) {
+            this._pendingMarkers = { labels, photos };
+            return;
+        }
+
+        const source = this.albumMarkerLayer.source;
+        source.clear(true);
+        const projection = this.kmap.map.getView().getProjection();
+
+        labels.forEach((item) => {
+            if (!item?.name || item.longitude == null || item.latitude == null) return;
+            const feature = new Feature({
+                geometry: createPointGeometry(item.longitude, item.latitude, projection),
+            });
+            feature.setStyle(createZoneLabelStyle(item.name));
+            source.addFeature(feature);
+        });
+
+        photos.forEach((item) => {
+            if (item.longitude == null || item.latitude == null) return;
+            const feature = new Feature({
+                geometry: createPointGeometry(item.longitude, item.latitude, projection),
+            });
+            feature.setStyle(createPhotoMarkerStyle(null, item.count));
+            source.addFeature(feature);
+            loadImage(item.cover).then((img) => {
+                feature.setStyle(createPhotoMarkerStyle(img, item.count));
+            });
+        });
+
+        this.albumMarkerLayer.layer.changed();
+        source.changed();
+    }
+
     fitView(renderToken) {
         if (!this.kmap?.map) return;
         if (renderToken != null && renderToken !== this._renderToken) return;

+ 35 - 24
src/views/old_mini/agri_file/index.vue

@@ -23,9 +23,9 @@
             </div>
         </div>
         <div v-show="activeNav === 'album'" class="content-panel">
-            <div class="map-card">
+            <div class="map-card" @click="goAlbumMap">
                 <div ref="mapContainer" class="map-container"></div>
-                <div class="add-zone-btn">
+                <div class="add-zone-btn" @click.stop="goAddZone">
                     <el-icon>
                         <Plus />
                     </el-icon>
@@ -52,22 +52,23 @@
                     <div class="album-card__cover">
                         <img :src="item.cover" alt="" />
                         <span class="album-card__count">{{ t("agriFile.photoCount", { count: item.count }) }}</span>
+                        <div class="album-card__add">
+                            <el-icon>
+                                <Plus />
+                            </el-icon>
+                        </div>
                     </div>
                     <div class="album-card__name">{{ item.name }}</div>
                 </div>
             </div>
         </div>
-        <div class="add-album-btn">
-            <el-icon>
-                <Plus />
-            </el-icon>
-        </div>
     </div>
 </template>
 
 <script setup>
 import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from "vue";
 import { useStore } from "vuex";
+import { useRouter } from "vue-router";
 import { Switch, Plus } from "@element-plus/icons-vue";
 import Overlay from "ol/Overlay";
 import { useI18n } from "@/i18n";
@@ -76,6 +77,7 @@ import * as util from "@/common/ol_common.js";
 
 const { t } = useI18n();
 const store = useStore();
+const router = useRouter();
 const tabBarHeight = computed(() => store.state.home.tabBarHeight);
 
 const defaultCover = require("@/assets/img/home/banner.png");
@@ -196,6 +198,14 @@ const loadFarmInfo = () => {
     }
 };
 
+const goAddZone = () => {
+    router.push("/add_zone");
+};
+
+const goAlbumMap = () => {
+    router.push("/album_map");
+};
+
 onMounted(() => {
     fillMockLabels();
     loadFarmInfo();
@@ -265,6 +275,7 @@ watch(activeNav, (key) => {
         display: flex;
         justify-content: space-around;
         padding: 10px 10px;
+        margin-bottom: 6px;
 
         .nav-card {
             position: relative;
@@ -454,28 +465,28 @@ watch(activeNav, (key) => {
                 color: #fff;
             }
 
+            &__add {
+                position: absolute;
+                right: 8px;
+                bottom: 8px;
+                z-index: 1;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                width: 44px;
+                height: 44px;
+                border-radius: 50%;
+                background: #2199F8;
+                color: #fff;
+                font-size: 20px;
+                box-shadow: 0 2px 8px rgba(33, 153, 248, 0.35);
+            }
+
             &__name {
                 margin-top: 10px;
                 color: #1F1F1F;
             }
         }
     }
-
-    .add-album-btn {
-        position: absolute;
-        bottom: 78px;
-        right: 16px;
-        z-index: 10;
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        width: 44px;
-        height: 44px;
-        border-radius: 50%;
-        background: #2199F8;
-        color: #fff;
-        font-size: 22px;
-        box-shadow: 0 4px 10px rgba(33, 153, 248, 0.35);
-    }
 }
 </style>

+ 318 - 0
src/views/old_mini/agri_file/pages/addZone.vue

@@ -0,0 +1,318 @@
+<template>
+    <div class="add-zone-page">
+        <custom-header :name="t('agriFile.addZone')" />
+        <div class="add-zone-content">
+            <div class="map-container" ref="mapContainer"></div>
+            <div class="search-bar">
+                <div class="search-bar__city">
+                    <span class="search-bar__city-name van-ellipsis">{{ cityName }}</span>
+                    <el-icon class="search-bar__city-icon">
+                        <ArrowDown />
+                    </el-icon>
+                </div>
+                <location-search class="search-bar__search" :user-location="userLocation"
+                    @change="handleLocationChange" />
+            </div>
+            <div class="map-tip">{{ t("agriFile.selectZoneHint") }}</div>
+            <div class="locate-btn" @click="handleLocate">
+                <img class="locate-btn__icon" src="@/assets/img/map/map-icon.png" alt="" />
+            </div>
+        </div>
+        <div class="custom-bottom-fixed-btns">
+            <div class="bottom-btn secondary-btn" @click="handleClearDraw">{{ t("agriFile.cancelSelect") }}</div>
+            <div class="bottom-btn primary-btn" @click="handleConfirmDraw">{{ t("agriFile.confirmArea") }}</div>
+        </div>
+        <RegionNamePopup v-model:show="showRegionNamePopup" @confirm="handleRegionNameConfirm" />
+    </div>
+</template>
+
+<script setup>
+import { nextTick, onActivated, onDeactivated, ref } from "vue";
+import { useRouter } from "vue-router";
+import { useStore } from "vuex";
+import { ElMessage } from "element-plus";
+import { ArrowDown } from "@element-plus/icons-vue";
+import customHeader from "@/components/customHeader.vue";
+import locationSearch from "@/components/pageComponents/locationSearch.vue";
+import RegionNamePopup from "@/views/old_mini/recordDetails/components/RegionNamePopup.vue";
+import MapManage from "@/views/old_mini/recordDetails/map/mapManage.js";
+import * as util from "@/common/ol_common.js";
+import { useI18n } from "@/i18n";
+
+const { t } = useI18n();
+const router = useRouter();
+const store = useStore();
+const mapContainer = ref(null);
+const mapManage = new MapManage();
+const showRegionNamePopup = ref(false);
+
+const DEFAULT_MAP_LOCATION = "POINT(113.6142086995688 23.585836479509055)";
+const ZONE_STYLE = {
+    fill: "rgba(33, 153, 248, 0.35)",
+    fillSelected: "rgba(33, 153, 248, 0.45)",
+    stroke: "#2199F8",
+};
+
+const cityName = ref("");
+const userLocation = ref(
+    store.state.home.miniUserLocation ||
+    localStorage.getItem("MINI_USER_LOCATION") ||
+    "113.61702297075017,23.584863449735067"
+);
+
+function getFarmData() {
+    try {
+        return JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
+    } catch {
+        return {};
+    }
+}
+
+function isPointWkt(value) {
+    return typeof value === "string" && /^POINT\s*\(/i.test(value.trim());
+}
+
+function wkbHexToPointWkt(hex) {
+    const text = String(hex || "").trim();
+    if (!/^[0-9a-fA-F]+$/.test(text) || text.length < 42) return null;
+    const bytes = new Uint8Array(text.length / 2);
+    for (let i = 0; i < bytes.length; i++) {
+        bytes[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
+    }
+    const view = new DataView(bytes.buffer);
+    const little = view.getUint8(0) === 1;
+    let type = view.getUint32(1, little);
+    const hasSrid = (type & 0x20000000) !== 0;
+    type &= 0xff;
+    if (type !== 1) return null;
+    let offset = 5;
+    if (hasSrid) offset += 4;
+    if (offset + 16 > bytes.length) return null;
+    const lng = view.getFloat64(offset, little);
+    const lat = view.getFloat64(offset + 8, little);
+    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
+    return `POINT(${lng} ${lat})`;
+}
+
+function toPointWkt(value) {
+    if (!value || typeof value !== "string") return null;
+    if (isPointWkt(value)) return value.trim();
+    return wkbHexToPointWkt(value);
+}
+
+function getFarmMapLocation() {
+    const farmData = getFarmData();
+    const candidates = [farmData.wkt, farmData.geom_wkt, farmData.farm_location];
+    for (const item of candidates) {
+        const pointWkt = toPointWkt(item);
+        if (pointWkt) return pointWkt;
+    }
+    return DEFAULT_MAP_LOCATION;
+}
+
+function getDefaultMapLocation() {
+    return (
+        toPointWkt(localStorage.getItem("MINI_USER_LOCATION_POINT")) ||
+        toPointWkt(store.state.home.miniUserLocationPoint) ||
+        getFarmMapLocation()
+    );
+}
+
+function getDefaultMapCoordinate() {
+    return util.wktCastGeom(getDefaultMapLocation()).getFirstCoordinate();
+}
+
+function loadCityName() {
+    const farmData = getFarmData();
+    cityName.value = farmData.city || farmData.farm_city || t("agriFile.defaultCity");
+}
+
+function initMapView() {
+    if (!mapContainer.value) return;
+    mapManage.destroyMap();
+    mapManage.initMap(getDefaultMapLocation(), mapContainer.value, {
+        editable: true,
+        zoneStyle: ZONE_STYLE,
+    });
+    mapManage.enableRegionDrawing();
+}
+
+const handleLocationChange = (payload) => {
+    if (!payload?.coordinateArray) return;
+    mapManage.setMapPosition(payload.coordinateArray);
+    if (payload.city) {
+        cityName.value = payload.city;
+    }
+};
+
+const handleLocate = () => {
+    mapManage.setMapPosition(getDefaultMapCoordinate());
+};
+
+const handleClearDraw = () => {
+    mapManage.clearLayer();
+};
+
+const handleConfirmDraw = () => {
+    const payload = mapManage.getAreaGeometry();
+    if (!payload.geometryArr.length) {
+        ElMessage.warning(t("agriFile.pleaseDrawZone"));
+        return;
+    }
+    showRegionNamePopup.value = true;
+};
+
+const handleRegionNameConfirm = (regionName) => {
+    const payload = mapManage.getAreaGeometry();
+    sessionStorage.setItem(
+        "ADD_ZONE_GEOMETRY",
+        JSON.stringify({
+            zone_name: regionName,
+            geometryArr: payload.geometryArr,
+            mianji: payload.mianji,
+        })
+    );
+    ElMessage.success(t("agriFile.createZoneSuccess"));
+    router.back();
+};
+
+onActivated(() => {
+    loadCityName();
+    nextTick(() => initMapView());
+});
+
+onDeactivated(() => {
+    mapManage.destroyMap();
+});
+</script>
+
+<style lang="scss" scoped>
+.add-zone-page {
+    width: 100%;
+    height: 100vh;
+    overflow: hidden;
+    background: #fff;
+    display: flex;
+    flex-direction: column;
+}
+
+.add-zone-content {
+    position: relative;
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+
+    .map-container {
+        width: 100%;
+        height: 100%;
+    }
+
+    .search-bar {
+        position: absolute;
+        top: 12px;
+        left: 12px;
+        right: 12px;
+        z-index: 2;
+        display: flex;
+        align-items: center;
+        height: 40px;
+        padding: 0 4px 0 12px;
+        border-radius: 20px;
+        background: rgba(0, 0, 0, 0.45);
+        box-sizing: border-box;
+
+        &__city {
+            display: flex;
+            align-items: center;
+            flex-shrink: 0;
+            max-width: 92px;
+            color: #fff;
+            font-size: 14px;
+        }
+
+        &__city-name {
+            max-width: 72px;
+        }
+
+        &__city-icon {
+            margin-left: 2px;
+            font-size: 12px;
+        }
+
+        &__search {
+            flex: 1;
+            min-width: 0;
+            margin-left: 8px;
+
+            :deep(.el-select__wrapper) {
+                background: transparent;
+                box-shadow: none;
+                border: none;
+                min-height: 40px;
+                padding-left: 0;
+            }
+
+            :deep(.el-select__placeholder),
+            :deep(.el-select__input) {
+                color: rgba(255, 255, 255, 0.7);
+            }
+
+            :deep(.el-icon) {
+                color: rgba(255, 255, 255, 0.85);
+            }
+        }
+    }
+
+    .map-tip {
+        position: absolute;
+        top: 64px;
+        left: 50%;
+        z-index: 2;
+        transform: translateX(-50%);
+        padding: 8px 30px;
+        border-radius: 25px;
+        background: rgba(0, 0, 0, 0.5);
+        color: #7CC5FF;
+        font-size: 12px;
+    }
+
+    .locate-btn {
+        position: absolute;
+        right: 12px;
+        bottom: 24px;
+        z-index: 2;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 36px;
+        height: 36px;
+        border-radius: 8px;
+        background: #fff;
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+
+        &__icon {
+            width: 16px;
+            height: 18px;
+        }
+    }
+}
+
+.custom-bottom-fixed-btns {
+    position: relative;
+    justify-content: space-between;
+
+    .bottom-btn {
+        padding: 10px 20px;
+        border-radius: 25px;
+    }
+
+    .secondary-btn {
+        color: #666;
+        border: 1px solid rgba(153, 153, 153, 0.5);
+    }
+
+    .primary-btn {
+        background: #2199f8;
+    }
+}
+</style>

+ 353 - 0
src/views/old_mini/agri_file/pages/albumMap.vue

@@ -0,0 +1,353 @@
+<template>
+    <div class="album-map-page">
+        <custom-header :name="t('agriFile.agriAlbum')" />
+        <div class="album-map-content">
+            <div class="map-container" ref="mapContainer"></div>
+            <div class="search-bar">
+                <div class="search-bar__city">
+                    <span class="search-bar__city-name van-ellipsis">{{ cityName }}</span>
+                    <el-icon class="search-bar__city-icon"><ArrowDown /></el-icon>
+                </div>
+                <location-search
+                    class="search-bar__search"
+                    :user-location="userLocation"
+                    @change="handleLocationChange"
+                />
+            </div>
+            <div class="locate-btn" @click="handleLocate">
+                <img class="locate-btn__icon" src="@/assets/img/map/map-icon.png" alt="" />
+            </div>
+            <div class="album-map-footer">
+                <div class="album-map-footer__text">
+                    <div class="album-map-footer__title">{{ t("agriFile.phenologyInconsistent") }}</div>
+                    <div class="album-map-footer__desc">{{ t("agriFile.customizeZoneHint") }}</div>
+                </div>
+                <div class="album-map-footer__btn" @click="goAddZone">
+                    <el-icon><Plus /></el-icon>
+                    <span>{{ t("agriFile.addZone") }}</span>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { nextTick, onActivated, ref } from "vue";
+import { useRouter } from "vue-router";
+import { useStore } from "vuex";
+import { ArrowDown, Plus } from "@element-plus/icons-vue";
+import customHeader from "@/components/customHeader.vue";
+import locationSearch from "@/components/pageComponents/locationSearch.vue";
+import FileMap from "../fileMap";
+import * as util from "@/common/ol_common.js";
+import { useI18n } from "@/i18n";
+
+const { t } = useI18n();
+const router = useRouter();
+const store = useStore();
+const mapContainer = ref(null);
+const fileMap = new FileMap();
+
+const defaultCover = require("@/assets/img/home/banner.png");
+const DEFAULT_MAP_LOCATION = "POINT(113.6142086995688 23.585836479509055)";
+
+const cityName = ref("");
+const userLocation = ref(
+    store.state.home.miniUserLocation ||
+        localStorage.getItem("MINI_USER_LOCATION") ||
+        "113.61702297075017,23.584863449735067"
+);
+
+const MOCK_ZONES = [
+    { id: 1, nameKey: "agriFile.zoneOne", dlng: -0.00115, dlat: 0.00055, delta: 0.00155 },
+    { id: 2, nameKey: "agriFile.zoneTwo", dlng: 0.00105, dlat: -0.00095, delta: 0.00128 },
+];
+
+const MOCK_PHOTOS = [
+    { id: 1, dlng: -0.00185, dlat: 0.00115 },
+    { id: 2, dlng: 0.00155, dlat: 0.00075 },
+    { id: 3, dlng: -0.00055, dlat: -0.00135 },
+    { id: 4, dlng: 0.00135, dlat: -0.00075 },
+    { id: 5, dlng: -0.00235, dlat: -0.00015 },
+    { id: 6, dlng: 0.00015, dlat: 0.00155 },
+    { id: 7, dlng: 0.00255, dlat: -0.00135 },
+    { id: 8, dlng: -0.00015, dlat: -0.00195 },
+];
+
+function squarePolygonWkt(lng, lat, delta = 0.0014) {
+    const ring = [
+        [lng - delta, lat + delta * 0.7],
+        [lng + delta * 0.35, lat + delta],
+        [lng + delta, lat - delta * 0.2],
+        [lng + delta * 0.15, lat - delta],
+        [lng - delta * 0.85, lat - delta * 0.45],
+        [lng - delta, lat + delta * 0.7],
+    ];
+    return `POLYGON((${ring.map((point) => point.join(" ")).join(", ")}))`;
+}
+
+function getFarmData() {
+    try {
+        return JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
+    } catch {
+        return {};
+    }
+}
+
+function isPointWkt(value) {
+    return typeof value === "string" && /^POINT\s*\(/i.test(value.trim());
+}
+
+function wkbHexToPointWkt(hex) {
+    const text = String(hex || "").trim();
+    if (!/^[0-9a-fA-F]+$/.test(text) || text.length < 42) return null;
+    const bytes = new Uint8Array(text.length / 2);
+    for (let i = 0; i < bytes.length; i++) {
+        bytes[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
+    }
+    const view = new DataView(bytes.buffer);
+    const little = view.getUint8(0) === 1;
+    let type = view.getUint32(1, little);
+    const hasSrid = (type & 0x20000000) !== 0;
+    type &= 0xff;
+    if (type !== 1) return null;
+    let offset = 5;
+    if (hasSrid) offset += 4;
+    if (offset + 16 > bytes.length) return null;
+    const lng = view.getFloat64(offset, little);
+    const lat = view.getFloat64(offset + 8, little);
+    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
+    return `POINT(${lng} ${lat})`;
+}
+
+function toPointWkt(value) {
+    if (!value || typeof value !== "string") return null;
+    if (isPointWkt(value)) return value.trim();
+    return wkbHexToPointWkt(value);
+}
+
+function getFarmMapLocation() {
+    const farmData = getFarmData();
+    const candidates = [farmData.wkt, farmData.geom_wkt, farmData.farm_location];
+    for (const item of candidates) {
+        const pointWkt = toPointWkt(item);
+        if (pointWkt) return pointWkt;
+    }
+    return DEFAULT_MAP_LOCATION;
+}
+
+function getDefaultMapLocation() {
+    return (
+        toPointWkt(localStorage.getItem("MINI_USER_LOCATION_POINT")) ||
+        toPointWkt(store.state.home.miniUserLocationPoint) ||
+        getFarmMapLocation()
+    );
+}
+
+function getDefaultMapCoordinate() {
+    return util.wktCastGeom(getDefaultMapLocation()).getFirstCoordinate();
+}
+
+const loadAlbumLayers = () => {
+    const farmData = getFarmData();
+    cityName.value = farmData.city || farmData.farm_city || t("agriFile.defaultCity");
+    const [lng, lat] = getDefaultMapCoordinate();
+    const labels = MOCK_ZONES.map((item) => ({
+        name: t(item.nameKey),
+        longitude: lng + item.dlng,
+        latitude: lat + item.dlat,
+    }));
+    const photos = MOCK_PHOTOS.map((item) => ({
+        count: 2,
+        cover: defaultCover,
+        longitude: lng + item.dlng,
+        latitude: lat + item.dlat,
+    }));
+    const zoneRecords = MOCK_ZONES.map((item) => ({
+        zone_name: "",
+        polygon: squarePolygonWkt(lng + item.dlng, lat + item.dlat, item.delta),
+    }));
+    fileMap.setRecordPolygons(zoneRecords, "album");
+    fileMap.setAlbumMarkers({ labels, photos });
+};
+
+const initAlbumMap = async () => {
+    await nextTick();
+    if (!mapContainer.value) return;
+    fileMap.initMap(getDefaultMapLocation(), mapContainer.value);
+    loadAlbumLayers();
+    fileMap.kmap?.map?.updateSize?.();
+};
+
+const handleLocationChange = (payload) => {
+    if (!payload?.coordinateArray || !fileMap.kmap) return;
+    fileMap.kmap.getView().animate({
+        center: payload.coordinateArray,
+        zoom: 16,
+        duration: 0,
+    });
+    if (payload.city) cityName.value = payload.city;
+};
+
+const handleLocate = () => {
+    if (!fileMap.kmap) return;
+    fileMap.kmap.getView().animate({
+        center: getDefaultMapCoordinate(),
+        zoom: 16,
+        duration: 0,
+    });
+};
+
+const goAddZone = () => {
+    router.push("/add_zone");
+};
+
+onActivated(() => {
+    initAlbumMap();
+});
+</script>
+
+<style lang="scss" scoped>
+.album-map-page {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    height: 100vh;
+    overflow: hidden;
+    background: #fff;
+}
+
+.album-map-content {
+    position: relative;
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+
+    .map-container {
+        width: 100%;
+        height: 100%;
+    }
+}
+
+.search-bar {
+    position: absolute;
+    top: 12px;
+    left: 12px;
+    right: 12px;
+    z-index: 2;
+    display: flex;
+    align-items: center;
+    height: 40px;
+    padding: 0 4px 0 12px;
+    border-radius: 20px;
+    background: rgba(0, 0, 0, 0.45);
+    box-sizing: border-box;
+
+    &__city {
+        display: flex;
+        align-items: center;
+        flex-shrink: 0;
+        max-width: 92px;
+        color: #fff;
+        font-size: 14px;
+    }
+
+    &__city-name {
+        max-width: 72px;
+    }
+
+    &__city-icon {
+        margin-left: 2px;
+        font-size: 12px;
+    }
+
+    &__search {
+        flex: 1;
+        min-width: 0;
+        margin-left: 8px;
+
+        :deep(.el-select__wrapper) {
+            background: transparent;
+            box-shadow: none;
+            border: none;
+            min-height: 40px;
+            padding-left: 0;
+        }
+
+        :deep(.el-select__placeholder),
+        :deep(.el-select__input) {
+            color: rgba(255, 255, 255, 0.7);
+        }
+
+        :deep(.el-icon) {
+            color: rgba(255, 255, 255, 0.85);
+        }
+    }
+}
+
+.locate-btn {
+    position: absolute;
+    right: 12px;
+    bottom: 96px;
+    z-index: 2;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 36px;
+    height: 36px;
+    border-radius: 8px;
+    background: #fff;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+
+    &__icon {
+        width: 16px;
+        height: 18px;
+    }
+}
+
+.album-map-footer {
+    position: absolute;
+    left: 12px;
+    right: 12px;
+    bottom: 16px;
+    z-index: 2;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    padding: 12px 12px 12px 16px;
+    border-radius: 8px;
+    background: #fff;
+    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+    box-sizing: border-box;
+
+    &__title {
+        color: #2199F8;
+        font-size: 15px;
+        font-weight: 600;
+        line-height: 21px;
+    }
+
+    &__desc {
+        margin-top: 4px;
+        color: rgba(0, 0, 0, 0.45);
+        font-size: 12px;
+        line-height: 17px;
+    }
+
+    &__btn {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-shrink: 0;
+        gap: 2px;
+        height: 36px;
+        padding: 0 14px;
+        border-radius: 18px;
+        background: #2199F8;
+        color: #fff;
+        font-size: 14px;
+        box-sizing: border-box;
+    }
+}
+</style>