Prechádzať zdrojové kódy

Merge branch 'agriculture' of http://www.sysuimars.cn:3000/feiniao/feiniao-farm-h5 into agriculture

wangsisi 1 týždeň pred
rodič
commit
5b15c51f3b

+ 6 - 1
src/api/modules/monitor.js

@@ -50,4 +50,9 @@ module.exports = {
         url: config.base_new_url + "farm_phenology_adjust",
         type: "post",
     },
-};
+    //临时:分区相册图片
+    getRegionAlbumImages: {
+        url: config.base_dev_site_url + "adm/images/{organId}/{areaId}?key=" + config.mini_key,
+        type: "get",
+    },
+};

+ 190 - 26
src/components/popup/ImagePreviewPopup.vue

@@ -5,11 +5,11 @@
         teleport="body"
         position="center"
         :close-on-click-overlay="true"
-        :overlay-style="{ backdropFilter: 'blur(4px)' }"
+        :overlay-style="{ backdropFilter: 'blur(4px)', background: 'rgba(0, 0, 0, 0.92)' }"
         class="image-preview-popup"
     >
         <div class="image-preview-popup__content" @click="handleClose">
-            <div class="image-preview-popup__swipe-wrap" @click.stop>
+            <div class="image-preview-popup__swipe-wrap">
                 <Swipe
                     ref="previewSwipeRef"
                     v-model="activeIndex"
@@ -19,16 +19,24 @@
                     indicator-color="#2199F8"
                     @change="handleSwipeChange"
                 >
-                    <SwipeItem v-for="(img, index) in imageList" :key="`${img}-${index}`">
+                    <SwipeItem v-for="(img, index) in normalizedImages" :key="`${img.url}-${index}`">
                         <div class="image-preview-popup__slide">
-                            <img class="image-preview-popup__img" :src="img" alt="" />
+                            <img class="image-preview-popup__img" :src="img.url" alt="" />
+                            <!-- 图片底部:日期 + 文案标签 -->
+                            <div
+                                v-if="img.date || img.tag"
+                                class="image-preview-popup__meta"
+                            >
+                                <span v-if="img.date" class="image-preview-popup__date">{{ img.date }}</span>
+                                <span v-if="img.tag" class="image-preview-popup__tag">{{ img.tag }}</span>
+                            </div>
                         </div>
                     </SwipeItem>
                 </Swipe>
 
                 <!-- 左箭头 -->
                 <div
-                    v-if="imageList.length > 1 && activeIndex > 0"
+                    v-if="normalizedImages.length > 1 && activeIndex > 0"
                     class="image-preview-popup__arrow image-preview-popup__arrow--left"
                     @click.stop="handlePrev"
                 >
@@ -38,7 +46,7 @@
                 </div>
                 <!-- 右箭头 -->
                 <div
-                    v-if="imageList.length > 1 && activeIndex < imageList.length - 1"
+                    v-if="normalizedImages.length > 1 && activeIndex < normalizedImages.length - 1"
                     class="image-preview-popup__arrow image-preview-popup__arrow--right"
                     @click.stop="handleNext"
                 >
@@ -47,8 +55,29 @@
                     </el-icon>
                 </div>
             </div>
+
+            <!-- 自定义底部(如识别结果) -->
             <div v-if="$slots.footer" class="image-preview-popup__footer" @click.stop>
-                <slot name="footer" />
+                <slot name="footer" :current="currentImage" :index="activeIndex" />
+            </div>
+
+            <!-- 页面底部备注输入 -->
+            <div
+                v-else-if="showRemark"
+                class="image-preview-popup__remark"
+                @click.stop
+            >
+                <el-icon class="image-preview-popup__remark-icon" :size="16">
+                    <EditPen />
+                </el-icon>
+                <input
+                    class="image-preview-popup__remark-input"
+                    type="text"
+                    :value="currentRemark"
+                    placeholder="添加备注"
+                    @input="handleRemarkInput"
+                    @keyup.enter="handleRemarkConfirm"
+                />
             </div>
         </div>
     </popup>
@@ -57,7 +86,7 @@
 <script setup>
 import { computed, ref, watch } from 'vue';
 import { Popup, Swipe, SwipeItem } from 'vant';
-import { ArrowLeftBold, ArrowRightBold } from '@element-plus/icons-vue';
+import { ArrowLeftBold, ArrowRightBold, EditPen } from '@element-plus/icons-vue';
 
 const props = defineProps({
     /** 是否显示弹窗,支持 v-model:show */
@@ -65,7 +94,10 @@ const props = defineProps({
         type: Boolean,
         default: false,
     },
-    /** 预览图片列表 */
+    /**
+     * 预览图片列表
+     * 支持 string,或 { url, date, tag, remark }
+     */
     images: {
         type: Array,
         default: () => [],
@@ -75,12 +107,19 @@ const props = defineProps({
         type: Number,
         default: 0,
     },
+    /** 是否显示底部备注输入 */
+    showRemark: {
+        type: Boolean,
+        default: false,
+    },
 });
 
-const emit = defineEmits(['update:show', 'change']);
+const emit = defineEmits(['update:show', 'change', 'remark-change', 'remark-confirm']);
 
 const previewSwipeRef = ref(null);
 const activeIndex = ref(0);
+/** 本地备注草稿,key 为图片索引 */
+const remarkDraftMap = ref({});
 
 /** 弹窗显隐双向绑定 */
 const showValue = computed({
@@ -88,45 +127,90 @@ const showValue = computed({
     set: (value) => emit('update:show', value),
 });
 
-/** 过滤后的有效图片列表 */
-const imageList = computed(() => props.images.filter(Boolean));
+/** 统一为对象结构,兼容纯 url 字符串 */
+const normalizedImages = computed(() =>
+    (props.images || [])
+        .map((item) => {
+            if (!item) return null;
+            if (typeof item === 'string') {
+                return { url: item, date: '', tag: '', remark: '' };
+            }
+            const url = item.url || item.src || '';
+            if (!url) return null;
+            return {
+                url,
+                date: item.date || '',
+                tag: item.tag || '',
+                remark: item.remark || '',
+                raw: item,
+            };
+        })
+        .filter(Boolean)
+);
+
+const currentImage = computed(() => normalizedImages.value[activeIndex.value] || null);
+
+const currentRemark = computed(() => {
+    const idx = activeIndex.value;
+    if (Object.prototype.hasOwnProperty.call(remarkDraftMap.value, idx)) {
+        return remarkDraftMap.value[idx];
+    }
+    return currentImage.value?.remark || '';
+});
 
-/** 打开弹窗或 startIndex 变化时,定位到初始索引 */
 watch(
-    () => [props.show, props.startIndex],
+    () => [props.show, props.startIndex, props.images],
     ([show, start]) => {
         if (!show) return;
-        const lastIndex = Math.max(imageList.value.length - 1, 0);
+        const lastIndex = Math.max(normalizedImages.value.length - 1, 0);
         const normalizedIndex = Math.min(Math.max(start || 0, 0), lastIndex);
         activeIndex.value = normalizedIndex;
+        remarkDraftMap.value = {};
         previewSwipeRef.value?.swipeTo(activeIndex.value);
     }
 );
 
-/** Swipe 滑动切换 */
 function handleSwipeChange(index) {
     activeIndex.value = Number(index) || 0;
-    emit('change', activeIndex.value);
+    emit('change', activeIndex.value, currentImage.value);
 }
 
-/** 切换到上一张 */
 function handlePrev() {
-    if (!imageList.value.length) return;
+    if (!normalizedImages.value.length) return;
     activeIndex.value = Math.max(activeIndex.value - 1, 0);
     previewSwipeRef.value?.swipeTo(activeIndex.value);
-    emit('change', activeIndex.value);
+    emit('change', activeIndex.value, currentImage.value);
 }
 
-/** 切换到下一张 */
 function handleNext() {
-    if (!imageList.value.length) return;
-    const lastIndex = imageList.value.length - 1;
+    if (!normalizedImages.value.length) return;
+    const lastIndex = normalizedImages.value.length - 1;
     activeIndex.value = Math.min(activeIndex.value + 1, lastIndex);
     previewSwipeRef.value?.swipeTo(activeIndex.value);
-    emit('change', activeIndex.value);
+    emit('change', activeIndex.value, currentImage.value);
+}
+
+function handleRemarkInput(e) {
+    const value = e?.target?.value ?? '';
+    remarkDraftMap.value = {
+        ...remarkDraftMap.value,
+        [activeIndex.value]: value,
+    };
+    emit('remark-change', {
+        index: activeIndex.value,
+        remark: value,
+        image: currentImage.value,
+    });
+}
+
+function handleRemarkConfirm() {
+    emit('remark-confirm', {
+        index: activeIndex.value,
+        remark: currentRemark.value,
+        image: currentImage.value,
+    });
 }
 
-/** 关闭弹窗 */
 function handleClose() {
     showValue.value = false;
 }
@@ -141,14 +225,23 @@ function handleClose() {
     &__content {
         width: 100%;
         box-sizing: border-box;
+        background: #000;
+        min-height: 100vh;
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
     }
 
     &__swipe-wrap {
         position: relative;
         width: 100%;
+        flex: 1;
+        display: flex;
+        align-items: center;
     }
 
     &__slide {
+        position: relative;
         display: flex;
         align-items: center;
         justify-content: center;
@@ -158,11 +251,45 @@ function handleClose() {
     &__img {
         display: block;
         width: 100%;
-        max-height: 100vh;
+        max-height: 75vh;
         object-fit: contain;
         user-select: none;
     }
 
+    &__meta {
+        position: absolute;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        z-index: 2;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 12px;
+        padding: 8px 10px;
+        box-sizing: border-box;
+        backdrop-filter: blur(4px);
+        background: rgba(0, 0, 0, 0.36);
+        color: #fff;
+        font-size: 16px;
+    }
+
+    &__date {
+        flex-shrink: 0;
+    }
+
+    &__tag {
+        flex-shrink: 0;
+        padding: 0 8px;
+        border-radius: 2px;
+        background: rgba(0, 0, 0, 0.45);
+        backdrop-filter: blur(6px);
+        height: 24px;
+        color: #fff;
+        font-size: 12px;
+        line-height: 24px;
+    }
+
     &__arrow {
         position: absolute;
         top: 50%;
@@ -185,5 +312,42 @@ function handleClose() {
             right: 8px;
         }
     }
+
+    &__footer {
+        width: 100%;
+        padding: 12px 16px 24px;
+        box-sizing: border-box;
+    }
+
+    &__remark {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        margin: 12px 16px 28px;
+        padding: 10px 14px;
+        border-radius: 8px;
+        background: rgba(60, 60, 60, 0.95);
+        box-sizing: border-box;
+    }
+
+    &__remark-icon {
+        color: #fff;
+        flex-shrink: 0;
+    }
+
+    &__remark-input {
+        flex: 1;
+        min-width: 0;
+        border: none;
+        outline: none;
+        background: transparent;
+        color: #fff;
+        font-size: 14px;
+        line-height: 20px;
+
+        &::placeholder {
+            color: rgba(255, 255, 255, 0.55);
+        }
+    }
 }
 </style>

+ 7 - 0
src/router/globalRoutes.js

@@ -170,4 +170,11 @@ export default [
         meta: { keepAlive: false },
         component: () => import("@/views/old_mini/entry_information/selectLocation.vue"),
     },
+    // 分区相册
+    {
+        path: "/region_albums",
+        name: "RegionAlbums",
+        meta: { keepAlive: true },
+        component: () => import("@/views/old_mini/agri_file/pages/regionAlbums.vue"),
+    },
 ];

+ 303 - 0
src/views/old_mini/agri_file/components/albumUploadPopup.vue

@@ -0,0 +1,303 @@
+<template>
+    <popup
+        v-model:show="showValue"
+        round
+        :close-on-click-overlay="false"
+        class="album-upload-popup"
+        @closed="handleClosed"
+    >
+        <div class="album-upload-popup__content">
+            <div class="album-upload-popup__title">上传照片</div>
+
+            <uploader
+                class="album-upload-popup__uploader"
+                v-model="fileList"
+                multiple
+                :max-count="maxCount"
+                :after-read="afterRead"
+                @delete="onDelete"
+            >
+                <div class="album-upload-popup__add">
+                    <el-icon size="22" color="rgba(0, 0, 0, 0.6)"><Plus /></el-icon>
+                    <!-- <span class="album-upload-popup__add-icon">+</span> -->
+                </div>
+            </uploader>
+
+            <el-input
+                v-model="description"
+                class="album-upload-popup__desc"
+                type="textarea"
+                :rows="4"
+                resize="none"
+                placeholder="为您的照片添加描述吧~"
+                maxlength="200"
+            />
+
+            <div class="album-upload-popup__actions">
+                <div class="album-upload-popup__btn album-upload-popup__btn--cancel" @click="handleCancel">
+                    取消
+                </div>
+                <div
+                    class="album-upload-popup__btn album-upload-popup__btn--confirm"
+                    :class="{ disabled: confirming }"
+                    @click="handleConfirm"
+                >
+                    {{ confirming ? "上传中..." : "确认上传" }}
+                </div>
+            </div>
+        </div>
+    </popup>
+</template>
+
+<script setup>
+import { ref, watch } from "vue";
+import { Popup, Uploader } from "vant";
+import { ElMessage } from "element-plus";
+import { getFileExt } from "@/utils/util";
+import UploadFile from "@/utils/upliadFile";
+import { useStore } from "vuex";
+import "vant/lib/uploader/style";
+
+const props = defineProps({
+    show: {
+        type: Boolean,
+        default: false,
+    },
+    maxCount: {
+        type: Number,
+        default: 9,
+    },
+});
+
+const emit = defineEmits(["update:show", "confirm", "cancel"]);
+
+const store = useStore();
+const miniUserId = store.state.home.miniUserId || localStorage.getItem("MINI_USER_ID");
+const uploadFileObj = new UploadFile();
+
+const showValue = ref(false);
+const fileList = ref([]);
+const uploadedKeys = ref([]);
+const description = ref("");
+const confirming = ref(false);
+
+watch(
+    () => props.show,
+    (val) => {
+        showValue.value = val;
+        if (val) {
+            resetForm();
+        }
+    },
+    { immediate: true },
+);
+
+watch(showValue, (val) => {
+    emit("update:show", val);
+});
+
+function resetForm() {
+    fileList.value = [];
+    uploadedKeys.value = [];
+    description.value = "";
+    confirming.value = false;
+}
+
+const afterRead = (file) => {
+    const files = Array.isArray(file) ? file : [file];
+    files.forEach((item) => {
+        const fileVal = item.file;
+        if (!fileVal) return;
+        item.status = "uploading";
+        item.message = "上传中...";
+        const ext = getFileExt(fileVal.name);
+        const key = `birdseye-look-mini/${miniUserId}/${Date.now()}_${Math.random()
+            .toString(36)
+            .slice(2, 8)}.${ext}`;
+        uploadFileObj
+            .put(key, fileVal)
+            .then((resFilename) => {
+                item.status = "done";
+                item.message = "";
+                item.resFilename = resFilename || key;
+                uploadedKeys.value.push(item.resFilename);
+            })
+            .catch(() => {
+                item.status = "failed";
+                item.message = "上传失败";
+                ElMessage.error("图片上传失败,请稍后再试!");
+            });
+    });
+};
+
+const onDelete = (file) => {
+    const key = file?.resFilename;
+    if (!key) return;
+    uploadedKeys.value = uploadedKeys.value.filter((item) => item !== key);
+};
+
+const handleCancel = () => {
+    showValue.value = false;
+    emit("cancel");
+};
+
+const handleConfirm = () => {
+    if (confirming.value) return;
+    const uploading = fileList.value.some((item) => item.status === "uploading");
+    if (uploading) {
+        ElMessage.warning("图片上传中,请稍候");
+        return;
+    }
+    const failed = fileList.value.some((item) => item.status === "failed");
+    if (failed) {
+        ElMessage.warning("存在上传失败的图片,请删除后重试");
+        return;
+    }
+    if (!uploadedKeys.value.length) {
+        ElMessage.warning("请先上传照片");
+        return;
+    }
+
+    confirming.value = true;
+    emit("confirm", {
+        images: [...uploadedKeys.value],
+        description: description.value.trim(),
+    });
+    // 正式上传接口未定时,先关闭弹窗由父组件处理
+    confirming.value = false;
+    showValue.value = false;
+};
+
+const handleClosed = () => {
+    resetForm();
+};
+</script>
+
+<style scoped lang="scss">
+.album-upload-popup {
+    width: 90%;
+    max-width: 420px;
+    border-radius: 12px;
+    background: #fff;
+    overflow: hidden;
+
+    &__content {
+        padding: 20px 16px 16px;
+        box-sizing: border-box;
+    }
+
+    &__title {
+        margin-bottom: 14px;
+        font-size: 16px;
+        line-height: 22px;
+        font-weight: 500;
+        color: #111;
+    }
+
+    &__uploader {
+        width: 100%;
+        margin-bottom: 12px;
+
+        :deep(.van-uploader__wrapper) {
+            gap: 8px;
+        }
+
+        :deep(.van-uploader__preview),
+        :deep(.van-uploader__upload),
+        :deep(.van-uploader__preview-image) {
+            width: calc((100vw * 0.9 - 32px - 24px) / 4);
+            height: calc((100vw * 0.9 - 32px - 24px) / 4);
+            margin: 0;
+            border-radius: 6px;
+            overflow: hidden;
+        }
+
+        :deep(.van-uploader__upload) {
+            background: transparent;
+        }
+
+        :deep(.van-uploader__preview-delete) {
+            top: 0;
+            right: 0;
+        }
+    }
+    :deep(.van-uploader__input-wrapper) {
+        width: calc(25% - 8px);
+        min-height: 66px;
+    }
+
+    &__add {
+        width: 100%;
+        height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: #F6F6F6;
+        border: 1px dashed #DDDDDD;
+        border-radius: 6px;
+        box-sizing: border-box;
+    }
+
+    &__add-icon {
+        font-size: 28px;
+        line-height: 1;
+        color: #999;
+        font-weight: 300;
+    }
+
+    &__desc {
+        border: 1px solid rgba(220, 220, 220, 1);
+        margin-bottom: 16px;
+        border-radius: 3px;
+
+        :deep(.el-textarea__inner) {
+            min-height: 96px;
+            padding: 10px 12px;
+            border-radius: 6px;
+            border-color: #e5e5e5;
+            box-shadow: none;
+            font-size: 14px;
+            line-height: 20px;
+            color: #333;
+
+            &::placeholder {
+                color: rgba(0, 0, 0, 0.3);
+            }
+        }
+    }
+
+    &__actions {
+        display: flex;
+        gap: 10px;
+    }
+
+    &__btn {
+        height: 40px;
+        line-height: 40px;
+        border-radius: 4px;
+        text-align: center;
+        font-size: 15px;
+        box-sizing: border-box;
+        cursor: pointer;
+
+        &--cancel {
+            width: 96px;
+            flex-shrink: 0;
+            color: #666;
+            background: #fff;
+            border: 1px solid #dcdfe6;
+        }
+
+        &--confirm {
+            flex: 1;
+            color: #fff;
+            background: #2199f8;
+
+            &.disabled {
+                opacity: 0.6;
+                pointer-events: none;
+            }
+        }
+    }
+}
+</style>

+ 429 - 0
src/views/old_mini/agri_file/pages/regionAlbums.vue

@@ -0,0 +1,429 @@
+<template>
+    <div class="region-albums">
+        <div class="region-albums__hero" :style="heroStyle">
+            <div class="region-albums__hero-mask"></div>
+            <div class="region-albums__nav" @click="goBack">
+                <el-icon class="region-albums__back-icon" color="#fff" :size="18">
+                    <ArrowLeftBold />
+                </el-icon>
+            </div>
+            <div class="region-albums__hero-info">
+                <span class="region-albums__title">{{ regionName }}</span>
+                <span class="region-albums__count">{{ totalCount }}张</span>
+            </div>
+        </div>
+
+        <div class="region-albums__panel" v-loading="loading">
+            <div v-if="!loading && !dateGroups.length" class="region-albums__empty">暂无相册照片</div>
+
+            <div class="region-albums__album-list">
+                <div
+                    v-for="group in dateGroups"
+                    :key="group.date"
+                    class="album-group"
+                >
+                    <div class="album-group__header">
+                        <div class="album-group__date">{{ group.dateLabel }}</div>
+                        <div class="album-group__remark">{{ group.remark || '备注备注备注备注' }}</div>
+                    </div>
+                    <div class="album-group__grid">
+                        <div
+                            v-for="(item, index) in group.list"
+                            :key="item.id || `${group.date}-${index}`"
+                            class="album-group__item"
+                            @click="previewImage(group, index)"
+                        >
+                            <img class="album-group__img" :src="item.url" alt="" loading="lazy" />
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <div class="region-albums__fab" @click="handleAdd">
+            <el-icon :size="28" color="#fff"><Plus /></el-icon>
+        </div>
+
+        <album-upload-popup
+            v-model:show="showUploadPopup"
+            @confirm="handleUploadConfirm"
+        />
+
+        <ImagePreviewPopup
+            v-model:show="showPreview"
+            :images="previewImages"
+            :start-index="previewIndex"
+            :show-remark="true"
+            @remark-change="handleRemarkChange"
+            @remark-confirm="handleRemarkConfirm"
+        />
+    </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { ElMessage } from "element-plus";
+import { ArrowLeftBold, Plus } from "@element-plus/icons-vue";
+import { base_img_url2, resize_300 } from "@/api/config";
+import ImagePreviewPopup from "@/components/popup/ImagePreviewPopup.vue";
+import albumUploadPopup from "../components/albumUploadPopup.vue";
+
+const route = useRoute();
+const router = useRouter();
+
+/** 临时默认参数,后续改成正式接口后可只走 query */
+const TEMP_ORGAN_ID = "101532";
+const TEMP_AREA_ID = "90178";
+const TEMP_DATE = "2026-08-19";
+
+const loading = ref(false);
+const imageList = ref([]);
+const showPreview = ref(false);
+const previewImages = ref([]);
+const previewIndex = ref(0);
+const showUploadPopup = ref(false);
+
+const regionName = computed(
+    () => route.query.regionName || route.query.name || "分区一"
+);
+
+const organId = computed(() => route.query.organId || TEMP_ORGAN_ID);
+const areaId = computed(() => route.query.areaId || TEMP_AREA_ID);
+const queryDate = computed(() => route.query.date || TEMP_DATE);
+
+const totalCount = computed(() => imageList.value.length);
+
+const heroCover = computed(() => {
+    const first = imageList.value[0];
+    if (first?.baseMap) return first.baseMap;
+    if (first?.url) return first.url;
+    return "";
+});
+
+const heroStyle = computed(() => {
+    if (!heroCover.value) {
+        return {
+            background:
+                "linear-gradient(180deg, rgba(34, 120, 68, 0.95) 0%, rgba(20, 80, 48, 0.9) 100%)",
+        };
+    }
+    return {
+        backgroundImage: `url(${heroCover.value})`,
+    };
+});
+
+const formatDateLabel = (dateStr) => {
+    const raw = String(dateStr || "").slice(0, 10);
+    const parts = raw.split("-");
+    if (parts.length < 3) return raw;
+    return `${parts[0]}年${parts[1]}月${parts[2]}日`;
+};
+
+const getImageUrl = (item) => {
+    if (!item?.filename) return "";
+    if (/^https?:\/\//i.test(item.filename)) return item.filename;
+    return `${base_img_url2}${item.filename}${resize_300 || ""}`;
+};
+
+const getFullImageUrl = (item) => {
+    if (!item?.filename) return "";
+    if (/^https?:\/\//i.test(item.filename)) return item.filename;
+    return `${base_img_url2}${item.filename}`;
+};
+
+const dateGroups = computed(() => {
+    const map = new Map();
+    imageList.value.forEach((item) => {
+        const date = String(item.uploadDate || "").slice(0, 10) || "未知日期";
+        if (!map.has(date)) {
+            map.set(date, {
+                date,
+                dateLabel: formatDateLabel(date),
+                remark: item.growText || item.watermarkMsg || "",
+                list: [],
+            });
+        }
+        const group = map.get(date);
+        if (!group.remark && (item.growText || item.watermarkMsg)) {
+            group.remark = item.growText || item.watermarkMsg;
+        }
+        group.list.push(item);
+    });
+    return [...map.values()].sort((a, b) => String(b.date).localeCompare(String(a.date)));
+});
+
+const normalizeList = (raw) => {
+    const list = Array.isArray(raw)
+        ? raw
+        : Array.isArray(raw?.list)
+            ? raw.list
+            : Array.isArray(raw?.records)
+                ? raw.records
+                : [];
+    return list
+        .map((item) => ({
+            ...item,
+            url: getImageUrl(item),
+            fullUrl: getFullImageUrl(item),
+        }))
+        .filter((item) => item.url);
+};
+
+const fetchAlbumImages = async () => {
+    loading.value = true;
+    try {
+        // TODO: 临时接口,后续替换正式相册接口
+        const res = await VE_API.monitor.getRegionAlbumImages({
+            organId: organId.value,
+            areaId: areaId.value,
+            date: queryDate.value,
+            limit: 100,
+            page: 1,
+        });
+        const ok = res?.code === 200 || res?.code === 0 || res?.code === 1 || res?.success;
+        if (ok) {
+            imageList.value = normalizeList(res.data);
+        } else {
+            imageList.value = [];
+            if (res?.msg) ElMessage.error(res.msg);
+        }
+    } catch {
+        imageList.value = [];
+        ElMessage.error("获取相册失败,请稍后再试");
+    } finally {
+        loading.value = false;
+    }
+};
+
+const formatPreviewDate = (dateStr) => {
+    const raw = String(dateStr || "").slice(0, 10);
+    return raw ? raw.replace(/-/g, "/") : "";
+};
+
+const previewImage = (group, index) => {
+    previewImages.value = group.list.map((item) => ({
+        url: item.fullUrl || item.url,
+        date: formatPreviewDate(item.uploadDate),
+        // 临时物候标签,有正式字段后替换
+        tag: item.phenologyName || item.tag || "预测物候期",
+        remark: item.growText || item.watermarkMsg || "",
+        id: item.id,
+    }));
+    previewIndex.value = index;
+    showPreview.value = true;
+};
+
+const handleRemarkChange = ({ index, remark }) => {
+    const previewItem = previewImages.value[index];
+    if (!previewItem) return;
+    previewItem.remark = remark;
+};
+
+const handleRemarkConfirm = ({ index, remark, image }) => {
+    const previewItem = previewImages.value[index];
+    if (previewItem) previewItem.remark = remark;
+    // 回写到列表数据(临时本地保存,后续可接保存备注接口)
+    const targetId = image?.id || previewItem?.id;
+    if (targetId == null) return;
+    const target = imageList.value.find((item) => String(item.id) === String(targetId));
+    if (target) {
+        target.growText = remark;
+    }
+};
+
+const handleAdd = () => {
+    showUploadPopup.value = true;
+};
+
+const handleUploadConfirm = ({ images, description }) => {
+    // TODO: 正式上传相册接口确定后在此提交
+    // images: OSS 相对路径数组;description: 描述文案
+    ElMessage.success(`已选择 ${images.length} 张照片${description ? ",描述已填写" : ""}`);
+    // 临时本地插入,便于联调样式;正式接口返回后改为 fetchAlbumImages()
+    const today = new Date();
+    const y = today.getFullYear();
+    const m = String(today.getMonth() + 1).padStart(2, "0");
+    const d = String(today.getDate()).padStart(2, "0");
+    const uploadDate = `${y}-${m}-${d}`;
+    const localItems = images.map((filename, index) => ({
+        id: `local-${Date.now()}-${index}`,
+        filename,
+        uploadDate,
+        growText: description || "",
+        watermarkMsg: "",
+        url: `${base_img_url2}${filename}${resize_300 || ""}`,
+        fullUrl: `${base_img_url2}${filename}`,
+    }));
+    imageList.value = [...localItems, ...imageList.value];
+};
+
+const goBack = () => {
+    router.back();
+};
+
+onMounted(() => {
+    fetchAlbumImages();
+});
+</script>
+
+<style lang="scss" scoped>
+.region-albums {
+    min-height: 100vh;
+    background: #f5f7fb;
+    position: relative;
+
+    &__hero {
+        position: relative;
+        height: 160px;
+        background-size: cover;
+        background-position: center;
+        background-repeat: no-repeat;
+    }
+
+    &__hero-mask {
+        position: absolute;
+        inset: 0;
+        background: linear-gradient(
+            180deg,
+            rgba(0, 0, 0, 0.25) 0%,
+            rgba(0, 0, 0, 0.05) 45%,
+            rgba(0, 0, 0, 0.35) 100%
+        );
+    }
+
+    &__nav {
+        position: absolute;
+        z-index: 2;
+        top: 14px;
+        left: 12px;
+        width: 32px;
+        height: 32px;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: rgba(0, 0, 0, 0.25);
+        cursor: pointer;
+    }
+
+    &__hero-info {
+        position: absolute;
+        z-index: 2;
+        left: 10px;
+        bottom: 36px;
+        display: flex;
+        align-items: center;
+        gap: 8px;
+    }
+
+    &__title {
+        font-size: 18px;
+        line-height: 27px;
+        font-weight: 500;
+        color: #fff;
+        text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
+    }
+
+    &__count {
+        padding: 0 6px;
+        height: 24px;
+        line-height: 24px;
+        border-radius: 4px;
+        backdrop-filter: blur(4px);
+        background: rgba(255, 255, 255, 0.2);
+        color: #fff;
+        font-size: 14px;
+        box-sizing: border-box;
+    }
+
+    &__panel {
+        position: relative;
+        z-index: 3;
+        margin-top: -20px;
+        height: calc(100vh - 140px);
+        background: #fff;
+        border-radius: 12px 12px 0 0;
+        padding-top: 12px;
+        box-sizing: border-box;
+    }
+
+    &__empty {
+        padding: 60px 0;
+        text-align: center;
+        color: rgba(0, 0, 0, 0.35);
+        font-size: 14px;
+    }
+
+    &__album-list {
+        overflow-y: auto;
+        height: 100%;
+        padding: 0 6px 40px;
+        box-sizing: border-box;
+    }
+
+    &__fab {
+        position: fixed;
+        right: 18px;
+        bottom: 28px;
+        z-index: 20;
+        width: 56px;
+        height: 56px;
+        border-radius: 50%;
+        background: #2199f8;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        box-shadow: 0 6px 16px rgba(33, 153, 248, 0.4);
+        cursor: pointer;
+    }
+}
+
+.album-group {
+    & + & {
+        margin-top: 22px;
+    }
+
+    &__header {
+        margin-bottom: 14px;
+        padding: 0 6px;
+    }
+
+    &__date {
+        font-size: 16px;
+        line-height: 22px;
+        color: #010101;
+    }
+
+    &__remark {
+        margin-top: 2px;
+        font-size: 12px;
+        line-height: 18px;
+        color: rgba(1, 1, 1, 0.5);
+    }
+
+    &__grid {
+        display: grid;
+        grid-template-columns: repeat(3, 1fr);
+        gap: 4px;
+    }
+
+    &__item {
+        position: relative;
+        width: 100%;
+        padding-top: 100%;
+        border-radius: 6px;
+        overflow: hidden;
+        background: #f2f2f2;
+    }
+
+    &__img {
+        position: absolute;
+        inset: 0;
+        width: 100%;
+        height: 100%;
+        object-fit: cover;
+        display: block;
+    }
+}
+</style>