Преглед изворни кода

📦 海天特殊村社首页

快乐的梦鱼 пре 9 часа
родитељ
комит
6788b1078c

+ 2 - 2
src/api/light/LightVillageApi.ts

@@ -450,9 +450,9 @@ export class LightVillageApi extends AppServerRequestModule<DataModel> {
   }
 
   async updateVillageSkin(id: number, currentSkinId: number) {
-    return await this.post<KeyValue>('/village/village/save', '更新村社皮肤', {
+    return await this.post<KeyValue>('/village/village/setSkin', '更新村社皮肤', {
       id: id,
-      current_skin_id: currentSkinId,
+      skin_id: currentSkinId,
     });
   }
 

+ 3 - 1
src/common/components/parts/Box2LineImageRightShadow.vue

@@ -34,7 +34,7 @@
         <FlexRow>
           <Tag v-if="badge" :text="badge" scheme="light" type="success" size="small" />
         </FlexRow>
-        <RoundTags v-if="tags" :tags="tags" small tagType="round" />
+        <RoundTags v-if="tags" :tags="tags" small tagType="round" @update:active="emit('click')" />
       </FlexCol>
     </FlexRow>
     <Text color="primary-second-text" fontConfig="caption">{{ right }}</Text>
@@ -75,4 +75,6 @@ defineProps({
   },
   props: Object,
 })
+
+const emit = defineEmits(['click'])
 </script>

+ 1 - 1
src/common/components/parts/Box2LineLargeImageUserShadow.vue

@@ -70,7 +70,7 @@
       <Image width="40" :src="IconChat" mode="widthFix" />
       <Text fontConfig="subText">{{ comment }}</Text>
     </FlexRow>
-    <RoundTags v-if="tags" :tags="tags" small tagType="round" />
+    <RoundTags v-if="tags" :tags="tags" small tagType="round" @update:active="$emit('click')" />
     <FlexRow v-if="bottomTime" :gap="20">
       <Image width="40" :src="IconTime" mode="widthFix" />
       <Text fontConfig="subText">{{ bottomTime }}</Text>

+ 1 - 1
src/common/components/parts/HomeTitle.vue

@@ -9,7 +9,7 @@
   >
     <template v-if="showIcon" #icon>
       <FlexRow align="center">
-        <Image :src="icon" :width="66" :height="42" mode="widthFix" />
+        <Image :src="icon" :width="66" :height="50" mode="aspectFit" :showBackgroundEffect="false" />
         <Width :width="15" />
       </FlexRow>
     </template>

+ 1 - 1
src/common/components/parts/IndexRoundBoxSimpleItem.vue

@@ -20,7 +20,7 @@
     />
     <Text v-if="item.title" fontConfig="contentSpeicalText" fontFamily="SongtiSCBlack" :lines="1" :wrap="false">{{ item.title }}</Text>
     <Text v-if="item.desc" fontSize="fontSize.sm" :text="item.desc" :lines="1" :maxWidth="400" />
-    <RoundTags v-if="item.tags" :gap="item.tags?.length > 0" :tags="item.tags" small tagType="round" />
+    <RoundTags v-if="item.tags" :gap="item.tags?.length > 0" :tags="item.tags" small tagType="round" @update:active="emit('click', item)" />
   </Touchable>
 </template>
 

+ 1 - 1
src/common/components/parts/RoundTags.vue

@@ -47,7 +47,7 @@
             'https://xy.wenlvti.net/app_static/images/village/TagNormal.png'"
           :backgroundCutBorder="[10, 10, 10, 10]"
           :backgroundCutBorderSize="[10, 10, 10, 10]"
-          :padding="small ? [10, 15] : [15, 20]"
+          :padding="small ? [20, 25] : [25, 30]"
           :text="tag"
         >
           <Touchable @click="emit('update:active', tag)">

+ 2 - 35
src/components/dynamic/minisc/MiniScript.ts

@@ -830,39 +830,6 @@ const PARAMS_NAME = 'params';
 /** 深只读代理缓存:保证同一对象多次包装返回同一代理,维持对象身份一致 */
 const readonlyProxyCache = new WeakMap<object, object>();
 
-/**
- * 将 params 包装为深度只读对象。脚本读取 params.xxx 正常,对其(含嵌套对象)赋值、
- * 新增或删除属性会抛出 MiniScriptError。原始对象不会被修改。
- */
-function makeReadonlyObject<T>(value: T): T {
-  if (value === null || typeof value !== 'object') return value;
-  const cached = readonlyProxyCache.get(value);
-  if (cached) return cached as T;
-  const proxy = new Proxy(value, {
-    get(target, prop, receiver) {
-      const result = Reflect.get(target, prop, receiver);
-      return makeReadonlyObject(result);
-    },
-    set(_target, prop) {
-      throw new MiniScriptError(`params 为只读参数,不能修改属性 "${String(prop)}"`, {
-        phase: 'runtime',
-      });
-    },
-    defineProperty(_target, prop) {
-      throw new MiniScriptError(`params 为只读参数,不能定义属性 "${String(prop)}"`, {
-        phase: 'runtime',
-      });
-    },
-    deleteProperty(_target, prop) {
-      throw new MiniScriptError(`params 为只读参数,不能删除属性 "${String(prop)}"`, {
-        phase: 'runtime',
-      });
-    },
-  }) as object;
-  readonlyProxyCache.set(value, proxy);
-  return proxy as T;
-}
-
 class Env {
   private readonly context: Record<string, unknown> | null;
   private readonly params: Record<string, unknown> | null;
@@ -935,9 +902,9 @@ class Env {
   }
 
   get(name: string): unknown {
+    if (this.isParamsName(name)) return this.params!;
     if (this.vars.has(name)) return this.vars.get(name);
     if (this.contextHas(name)) return this.context![name];
-    if (this.isParamsName(name)) return this.params!;
     if (this.parent) return this.parent.get(name);
     throw new MiniScriptError(
       `未定义的标识符: ${name}`,
@@ -1359,7 +1326,7 @@ export class MiniScript {
       this.getGlobals(),
       new Map(),
       context,
-      params === undefined ? undefined : makeReadonlyObject(params),
+      params,
       { source: script },
     );
     return runProgram(prog, root);

+ 8 - 0
src/pages.json

@@ -515,6 +515,14 @@
           }
         },
         {
+          "path": "forms/list-map",
+          "style": {
+            "navigationBarTitleText": "信息地图",
+            "enablePullDownRefresh": false,
+            "navigationStyle": "custom"
+          }
+        },
+        {
           "path": "forms/submits",
           "style": {
             "navigationBarTitleText": "我的投稿",

+ 369 - 0
src/pages/dig/forms/list-map.vue

@@ -0,0 +1,369 @@
+<template>
+  <CommonRoot>
+    <map
+      id="listMap"
+      map-id="listMap"
+      class="list-map"
+      :longitude="center[0]"
+      :latitude="center[1]"
+      :scale="scale"
+      :markers="markers"
+      @markertap="onMarkerTap"
+    />
+
+    <FlexCol position="fixed" :top="0" :left="0" :right="0" :zIndex="1000" backgroundColor="white">
+      <StatusBarSpace backgroundColor="white" />
+      <NavBar
+        leftButton="back"
+        :title="currentTitle"
+        :titleStyle="{
+          fontSize: '40rpx',
+          fontFamily: 'SongtiSCBlack',
+        }"
+        textColor="text.title"
+        :leftButtonProps="{
+          buttonStyle: { marginLeft: '20rpx' },
+          shape: 'round-full',
+          backgroundColor: 'background.primary',
+        }"
+      />
+      <FlexRow center justify="space-between" gap="space.sm" :padding="[10, 20, 20, 20]">
+        <FlexRow center gap="space.sm" backgroundColor="background.primary" radius="50" flex="1" :padding="[0, 20, 0, 0]">
+          <SearchBar
+            v-model="searchText"
+            placeholder="搜一搜"
+            :innerStyle="{ flex: 1 }"
+            @search="search"
+          />
+          <picker
+            v-if="currentCatalogList.length > 0"
+            @change="handleCatalogChange"
+            :value="currentCatalogIndex"
+            :range="currentCatalogList" range-key="name"
+          >
+            <FlexRow center gap="space.md">
+              <Text :text="(currentCatalog?.title || '未选择栏目')" :lines="1" :maxWidth="140" />
+              <Icon icon="arrow-down" :size="40" />
+            </FlexRow>
+          </picker>
+          <Width :width="10" />
+        </FlexRow>
+        <PrimaryButton
+          text="+ 编写"
+          @click="newData"
+        />
+      </FlexRow>
+    </FlexCol>
+
+    <FlexCol v-if="mapLoader.error.value" position="fixed" :top="0" :left="0" :right="0" :bottom="0" :zIndex="900" center>
+      <Result :title="mapLoader.error.value" />
+    </FlexCol>
+  </CommonRoot>
+</template>
+
+<script setup lang="ts">
+import { computed, getCurrentInstance, ref, watch } from 'vue';
+import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
+import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
+import { useAuthStore } from '@/store/auth';
+import { useUserTools } from '@/common/composeabe/UserTools';
+import { navTo } from '@/components/utils/PageAction';
+import { confirm } from '@/components/utils/DialogAction';
+import { getVillageInfoForm } from './forms';
+import { CollectableModulesIdMap } from "@/common/data/CollectableModulesIdMap";
+import { type TaskMenuDefGoForm } from './tasks';
+import VillageInfoApi from '@/api/inhert/VillageInfoApi';
+import VillageApi, { VillageCatalogListItem } from '@/api/inhert/VillageApi';
+import { isValidLonLat } from '@/pages/home/composeabe/LonLat';
+import type { MapMarker } from '@/types/Map';
+import AppCofig from '@/common/config/AppCofig';
+import ImagesUrls from '@/common/config/ImagesUrls';
+import CommonRoot from '@/components/dialog/CommonRoot.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import Text from '@/components/basic/Text.vue';
+import Icon from '@/components/basic/Icon.vue';
+import Width from '@/components/layout/space/Width.vue';
+import SearchBar from '@/components/form/SearchBar.vue';
+import PrimaryButton from '@/common/components/PrimaryButton.vue';
+import StatusBarSpace from '@/components/layout/space/StatusBarSpace.vue';
+import NavBar from '@/components/nav/NavBar.vue';
+import Result from '@/components/feedback/Result.vue';
+
+interface MapDataItem {
+  id: number;
+  image: string;
+  title: string;
+  villageVolunteerId: number;
+  longitude: number;
+  latitude: number;
+}
+
+const instance = getCurrentInstance();
+const mapCtx = uni.createMapContext('listMap', instance);
+
+const subTitle = ref('');
+const searchText = ref('');
+const { getIsVolunteer, getCanCollect, getIsManagement, volunteerInfo } = useUserTools();
+const canCollect = ref(false);
+const isManagement = ref(false);
+const authStore = useAuthStore();
+const error = ref('');
+
+const center = ref<[number, number]>([AppCofig.defaultLonLat[0], AppCofig.defaultLonLat[1]]);
+const scale = ref(12);
+
+const mapLoader = useSimpleDataLoader<MapDataItem[]>(async () => {
+  const params = currentLoadData.value;
+  if (!params.collectModuleId)
+    throw new Error("params.collectModuleId");
+  if (!params.villageId)
+    throw new Error("params.villageId");
+  const info = await VillageInfoApi.getList({
+    collectModuleId: params.collectModuleId,
+    subId: params.subKey ? params.subId : undefined,
+    subKey: params.subKey,
+    villageId: params.villageId,
+    catalogId: currentCatalog.value?.id || 0,
+    page: 1,
+    pageSize: 200,
+    keywords: searchText.value,
+  });
+  return info.list
+    .map((item) => ({
+      id: item.id,
+      image: item.image,
+      title: item.title,
+      villageVolunteerId: item.villageVolunteerId,
+      longitude: Number(item.longitude),
+      latitude: Number(item.latitude),
+    }))
+    .filter((item) => isValidLonLat({ longitude: item.longitude, latitude: item.latitude }));
+}, false);
+
+const markers = computed<MapMarker[]>(() => {
+  return (mapLoader.content.value || []).map((item) => ({
+    id: item.id,
+    title: item.title,
+    longitude: item.longitude,
+    latitude: item.latitude,
+    iconPath: ImagesUrls.IconMarker,
+    width: 30,
+    height: 30,
+    callout: {
+      display: 'BYCLICK',
+      content: item.title,
+      color: '#000000',
+      fontSize: 12,
+      padding: 5,
+      bgColor: '#ffffff',
+      borderRadius: 5,
+    },
+  }));
+});
+
+watch(markers, () => {
+  if (markers.value.length === 0) {
+    center.value = [AppCofig.defaultLonLat[0], AppCofig.defaultLonLat[1]];
+    scale.value = 12;
+    return;
+  }
+  setTimeout(() => {
+    mapCtx.includePoints({
+      points: markers.value.map((p) => ({
+        latitude: p.latitude,
+        longitude: p.longitude,
+      })),
+      padding: [40, 40, 40, 40],
+    });
+  }, 200);
+});
+
+function newData() {
+  if (!canCollect.value) {
+    confirm({
+      title: '提示',
+      content: "您还不是当前村社的志愿者,无法采编信息,请先加入志愿者队伍哦",
+      confirmText: '去加入',
+    }).then((res) => {
+      if (res) {
+        goJoin();
+      }
+    });
+    return;
+  }
+  navTo('common', {
+    id: -1,
+    villageId: querys.value.villageId,
+    villageVolunteerId: volunteerInfo.value?.id || 0,
+    catalogId: currentCatalog.value?.id || 0,
+    collectModuleId: currentLoadData.value.collectModuleId,
+    subId: currentLoadData.value.subId,
+    subTitle: currentLoadData.value.subTitle,
+    subKey: currentLoadData.value.subKey,
+  });
+}
+function goDetail(item: { id: number }) {
+  navTo('/pages/home/discover/details', {
+    villageId: querys.value.villageId,
+    id: item.id,
+  });
+}
+function goEdit(item: { id: number, villageVolunteerId: number }) {
+  navTo('common', {
+    id: item.id,
+    villageId: querys.value.villageId,
+    villageVolunteerId: item.villageVolunteerId,
+    catalogId: currentLoadData.value.catalogId,
+    collectModuleId: querys.value.collectModuleId,
+    subKey: currentLoadData.value.subKey,
+    subId: currentLoadData.value.subId,
+    subTitle: currentLoadData.value.subTitle,
+  });
+}
+function search() {
+  mapLoader.reload();
+}
+function goJoin() {
+  navTo('/pages/home/light/submit-volunteer', {
+    villageId: querys.value.villageId,
+  });
+}
+function onMarkerTap(e: any) {
+  const markerId = e?.markerId ?? e?.detail?.markerId;
+  const item = mapLoader.content.value?.find((p) => p.id === markerId);
+  if (item)
+    goDetail(item);
+}
+
+const currentTitle = ref('');
+const catalogs = ref<VillageCatalogListItem[]>([]);
+const currentCatalogIndex = ref(0);
+const currentCatalogList = computed(() => {
+  return catalogs.value.map((p) => ({
+    id: p.id,
+    name: p.title,
+  }));
+});
+const currentCatalog = ref<VillageCatalogListItem | null>(null);
+const currentLoadData = ref({
+  collectModuleId: 0,
+  subId: 0,
+  subKey: '',
+  subTitle: '',
+  catalogId: 0 as number|undefined,
+  villageId: 0,
+  villageVolunteerId: 0,
+});
+
+function handleCatalogChange(e: any) {
+  currentCatalogIndex.value = e.detail.value;
+  loadListCatalog(catalogs.value[currentCatalogIndex.value] as VillageCatalogListItem);
+}
+
+function loadListCatalog(catalog: VillageCatalogListItem) {
+  if (currentCatalog.value?.id === catalog.id)
+    return;
+  currentCatalog.value = catalog;
+  try {
+    if (catalog.id === 0) {
+      currentLoadData.value =  {
+        collectModuleId: querys.value.collectModuleId,
+        subId: -1,
+        subKey: 'type',
+        subTitle: querys.value.title,
+        catalogId: undefined,
+        villageId: querys.value.villageId,
+        villageVolunteerId: volunteerInfo.value?.id || 0,
+      }
+      mapLoader.load(true)
+      return;
+    }
+    const formDefine = getVillageInfoForm(querys.value.collectModuleId, -1);
+    const goForm = [
+      querys.value.collectModuleId,
+      catalog.typeId ?? -1,
+      formDefine?.[2].typeName,
+      querys.value.collectModuleId === CollectableModulesIdMap['overview'] ? 'common' : undefined,
+      catalog.title,
+      catalog.id
+    ] as TaskMenuDefGoForm;
+
+    currentLoadData.value = {
+      collectModuleId: querys.value.collectModuleId,
+      subId: goForm[1],
+      subKey: goForm[2] || 'type',
+      subTitle: goForm[4] || querys.value.title,
+      catalogId: goForm[5] || 0,
+      villageId: querys.value.villageId,
+      villageVolunteerId: volunteerInfo.value?.id || 0,
+    }
+  } catch (e) {
+    console.error(e);
+    error.value = '任务不存在';
+    return;
+  }
+  mapLoader.load(true)
+}
+
+async function loadVolunteerInfo() {
+  isManagement.value = await getIsManagement(querys.value.villageId);
+  try {
+    //普通用户进入预览模式
+    await getIsVolunteer();
+    canCollect.value = await getCanCollect(querys.value.villageId);
+  } catch {
+    canCollect.value = false;
+  }
+}
+
+const { querys } = useLoadQuerys({
+  collectModuleId: 0,
+  villageId: 0,
+  title: '',
+}, async (querys) => {
+  await loadVolunteerInfo();
+
+  function pushCatalogWithCurrentCatalog(catalog: VillageCatalogListItem) {
+    if (catalog.collectModuleId === querys.collectModuleId) {
+      catalogs.value.push(catalog);
+    }
+    catalog.childlist.forEach((c) => {
+      pushCatalogWithCurrentCatalog(c);
+    });
+  }
+  (await VillageApi.getCatalogList(
+    querys.villageId,
+    authStore.isAdmin || isManagement.value ? undefined : volunteerInfo.value?.id || 0,
+  )).forEach((catalog) => {
+    pushCatalogWithCurrentCatalog(catalog);
+  });
+
+  catalogs.value.unshift(new VillageCatalogListItem().setSelfValues({
+    id: 0,
+    title: '全部',
+  }));
+
+  loadListCatalog(catalogs.value[0] as VillageCatalogListItem);
+  currentTitle.value = querys.title || '共编村史';
+});
+
+defineExpose({
+  onPageBack(name: string, param: any) {
+    if (param && param.needRefresh)
+      mapLoader.reload();
+    if (name === 'registerDone')
+      loadVolunteerInfo();
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.list-map {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+}
+</style>

+ 0 - 1
src/pages/dig/forms/list-ordinary.vue

@@ -122,7 +122,6 @@ import Width from '@/components/layout/space/Width.vue';
 import PrimaryButton from '@/common/components/PrimaryButton.vue';
 import FrameButton from '@/common/components/FrameButton.vue';
 import IconButton from '@/components/basic/IconButton.vue';
-import { isTestEnv } from '@/common/config/AppCofig';
 
 const subTitle = ref('');
 const searchText = ref('');

+ 15 - 5
src/pages/home/village/introd/card.vue

@@ -249,6 +249,13 @@
                 :scriptEngine="context.getScriptEngine()"
                 :getNodeContext="() => node.getNodeScriptContext?.() || {}"
               />
+              <!-- 地图内容 -->
+              <ContentMap
+                v-else-if="node.type === 'Block:ContentMap'"
+                v-bind="node.props"
+                :scriptEngine="context.getScriptEngine()"
+                :getNodeContext="() => node.getNodeScriptContext?.() || {}"
+              />
               <!-- 网格按钮组 -->
               <GridButtonBlock 
                 v-else-if="node.type === 'Block:GridButtonBlock'"
@@ -385,6 +392,7 @@ import Rank from './card/blocks/Rank.vue';
 import GridButtonBlock from './card/blocks/GridButtonBlock.vue';
 import ContentBlocks from './card/content/ContentBlocks.vue';
 import ContentTitle from './card/content/ContentTitle.vue';
+import ContentMap from './card/content/ContentMap.vue';
 import type { ContentTitleBlockDefine } from './card/content/ContentBlocks';
 
 const emit = defineEmits<{
@@ -474,7 +482,7 @@ function handleGoNew() {
     title: '新手上路',
   });
 }
-function handleGoCollect(collectModuleId?: number, title?: string) {
+function handleGoCollect(collectModuleId?: number, title?: string, catalogId?: number, map = false) {
   if (!collectModuleId) {
     navTo('/pages/dig/details', {
       villageId: villageStore.currentVillage?.id ?? undefined,
@@ -482,9 +490,10 @@ function handleGoCollect(collectModuleId?: number, title?: string) {
     });
     return;
   }
-  navTo('/pages/dig/forms/list-ordinary', {
+  navTo('/pages/dig/forms/' + (map ? 'list-map' : 'list-ordinary'), {
     villageId: villageStore.currentVillage?.id ?? undefined,
     collectModuleId: collectModuleId,
+    catalogId: catalogId,
     title: title,
   });
 }
@@ -589,11 +598,12 @@ const topBanner = inject('topBanner') as Ref<string>;
 const pageThemeLoader = useSimpleDataLoader(async () => {
   assertNotNull(villageStore.currentVillage)
   //加载村社主题
-  /*const theme = (villageStore.currentVillage.currentSkinId === 1 || !villageStore.currentVillage.currentSkinId) ?
+ 
+   /*const theme = TestCard;*/
+  const theme = (villageStore.currentVillage.currentSkinId === 1 || !villageStore.currentVillage.currentSkinId) ?
     DefaultCard :
     JSON.parse((await SkinApi.getSkinInfo(villageStore.currentVillage.currentSkinId)).skinProps);
-  */
-  const theme = TestCard;
+  
   const page = theme as DynamicXPage;
   topBanner.value = page.props?.topBanner || 'https://xy.wenlvti.net/app_static/images/home/BannerHome.png';
   return page;

+ 222 - 1
src/pages/home/village/introd/card/content/ContentMap.vue

@@ -1,7 +1,228 @@
 <template>
+  <view class="content-map">
+    <map
+      id="contentMap"
+      map-id="contentMap"
+      class="content-map-map"
+      :markers="markers"
+      :scale="scale"
+      :longitude="center.longitude"
+      :latitude="center.latitude"
+      :enable-scroll="props.mapClickType !== 'global'"
+      :enable-zoom="props.mapClickType !== 'global'"
+      :enable-rotate="props.mapClickType !== 'global'"
+      :enable-poi="props.mapClickType !== 'global'"
+      @markertap="onMarkerTap"
+      @tap="onMapTap"
+    />
 
+    <view
+      v-if="props.footer && (props.footer.title || props.footer.button)"
+      class="content-map-footer"
+      @click="handleFooterClick"
+    >
+      <Text v-if="props.footer.title" :text="props.footer.title" color="white" fontSize="fontSize.sm" />
+      <FrameButton 
+        v-if="props.footer.button" :text="props.footer.button" size="small" 
+        @click="handleFooterClick"
+      />
+    </view>
+  </view>
 </template>
 
 <script setup lang="ts">
+import { computed, getCurrentInstance, onMounted, ref, watch, type PropType } from 'vue';
+import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
+import { doLoadDynamicListData } from '../data/DynamicData';
+import type { ContentBlockDefine } from './ContentBlocks';
+import type { MiniScript } from '@/components/dynamic/minisc/MiniScript';
+import type { MapMarker } from '@/types/Map';
+import { isValidLonLat, type LonLat } from '@/pages/home/composeabe/LonLat';
+import AppCofig from '@/common/config/AppCofig';
+import Text from '@/components/basic/Text.vue';
+import FrameButton from '@/common/components/FrameButton.vue';
 
-</script>
+interface MapDataItem {
+  id?: number | string;
+  title?: string;
+  image?: string;
+  longitude?: number;
+  latitude?: number;
+}
+
+const props = defineProps({
+  /**
+   * 分类定义(单个)
+   */
+  categoryDefine: {
+    type: Object as PropType<ContentBlockDefine | null>,
+    default: null,
+  },
+  scriptEngine: {
+    type: Object as PropType<MiniScript>,
+    default: () => ({}),
+  },
+  getNodeContext: {
+    type: Function as PropType<() => Record<string, any>>,
+    default: () => ({}),
+  },
+  /** 地图点击类型: global=点击任意位置, item=点击标记点 */
+  mapClickType: {
+    type: String as PropType<'global' | 'item'>,
+    default: 'item',
+  },
+  /** 底部条: 文字与按钮 */
+  footer: {
+    type: Object as PropType<{ title?: string; button?: string }>,
+    default: () => ({ title: '', button: '' }),
+  },
+  /** 地图缩放级别 */
+  scale: {
+    type: Number,
+    default: 12,
+  },
+  onGlobalClick: {
+    type: String,
+    default: '',
+  },
+  onItemClick: {
+    type: String,
+    default: '',
+  },
+  onFooterClick: {
+    type: String,
+    default: '',
+  },
+});
+
+const dataLoader = useSimpleDataLoader<any[]>(async () => {
+  const item = props.categoryDefine;
+  let res: any[] = [];
+  if (!item?.content)
+    return res;
+  res = (await doLoadDynamicListData(
+    item.content, 1, item.count || 100, '',
+    props.scriptEngine,
+    props.getNodeContext,
+  )).list;
+  if (item.onContentSolve)
+    res = props.scriptEngine.execute(item.onContentSolve || '', props.getNodeContext(), { res }) as any;
+  return res;
+}, false);
+
+function toLonLat(item: MapDataItem): LonLat | null {
+  if (item == null)
+    return null;
+  const longitude = Number(item.longitude);
+  const latitude = Number(item.latitude);
+  if (!isValidLonLat({ longitude, latitude }))
+    return null;
+  return { longitude, latitude };
+}
+
+const markers = computed<MapMarker[]>(() => {
+  const list = dataLoader.content.value || [];
+  const res: MapMarker[] = [];
+  for (const item of list) {
+    if (item == null)
+      continue;
+    const lonlat = toLonLat(item);
+    const id = Number(item.id);
+    if (lonlat == null || item.id == null || !Number.isFinite(id) || !item.title || !item.image)
+      continue;
+    res.push({
+      id,
+      title: item.title,
+      iconPath: item.image,
+      longitude: lonlat.longitude,
+      latitude: lonlat.latitude,
+      width: 30,
+      height: 30,
+    });
+  }
+  return res;
+});
+
+const instance = getCurrentInstance();
+const mapCtx = uni.createMapContext('contentMap', instance);
+const scale = ref(props.scale);
+
+const center = computed(() => {
+  const first = markers.value[0];
+  if (first)
+    return { longitude: first.longitude, latitude: first.latitude };
+  return { longitude: AppCofig.defaultLonLat[0], latitude: AppCofig.defaultLonLat[1] };
+});
+
+watch(markers, () => {
+  if (markers.value.length === 0) {
+    scale.value = props.scale;
+    return;
+  }
+  setTimeout(() => {
+    mapCtx.includePoints({
+      points: markers.value.map((p) => ({
+        latitude: p.latitude,
+        longitude: p.longitude,
+      })),
+      padding: [40, 40, 40, 40],
+    });
+  }, 200);
+});
+
+function handleGlobalClick() {
+  if (props.onGlobalClick)
+    props.scriptEngine.execute(props.onGlobalClick, props.getNodeContext(), {});
+}
+function handleItemClick(markerId: number) {
+  if (props.onItemClick)
+    props.scriptEngine.execute(props.onItemClick, props.getNodeContext(), { markerId });
+}
+function handleFooterClick() {
+  if (props.onFooterClick)
+    props.scriptEngine.execute(props.onFooterClick, props.getNodeContext(), {});
+}
+function onMarkerTap(e: any) {
+  if (props.mapClickType === 'global') {
+    handleGlobalClick();
+  } else {
+    handleItemClick(e?.markerId ?? e?.detail?.markerId);
+  }
+}
+function onMapTap() {
+  if (props.mapClickType === 'global')
+    handleGlobalClick();
+}
+
+watch(() => props.categoryDefine, () => dataLoader.load(true));
+onMounted(() => dataLoader.load(true));
+</script>
+
+<style lang="scss" scoped>
+.content-map {
+  position: relative;
+  width: 100%;
+  height: 400rpx;
+  border-radius: 20rpx;
+  overflow: hidden;
+
+  .content-map-map {
+    width: 100%;
+    height: 400rpx;
+  }
+
+  .content-map-footer {
+    position: absolute;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 10;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-between;
+    padding: 15rpx;
+    background: rgba(0, 0, 0, 0.3);
+  }
+}
+</style>

+ 2 - 0
src/pages/home/village/introd/config/config-tags.vue

@@ -45,6 +45,7 @@ import Dialog from '@/components/dialog/Dialog.vue';
 import Field from '@/components/form/Field.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
+import { alert } from '@/components/dialog/CommonRoot';
 import { back } from '@/components/utils/PageAction';
 import { onMounted, ref } from 'vue';
 import { defaultTags } from '../data/DefaultTag';
@@ -77,6 +78,7 @@ const handleAddTag = () => {
 
 const handleSave = () => {
   //TODO: saveTags();
+  alert({ content: '缺少接口,敬请期待' })
 }
 
 const handleCancel = () => {

+ 1 - 1
src/pages/home/village/introd/data/DefaultCard.json

@@ -188,7 +188,7 @@
               "props": {
                 "title": "文脉乡源",
                 "showMore": false,
-                "showIcon": false,
+                "showIcon": true,
                 "showTopMargin": false
               }
             }

+ 39 - 5
src/pages/home/village/introd/data/TestCard.json

@@ -36,7 +36,39 @@
                 { "type": "Card:Basic" },
                 { "type": "Card:Level" },
                 { "type": "Card:Gallery" },
-                { "type": "Card:AddressAndMap" },
+                
+                {
+                  "type": "Block:ContentTitle",
+                  "props": {
+                    "title": "[海天足迹·故事活地图]",
+                    "icon": "https://xy.wenlvti.net/app_static/images/village/speical/IconMap.png",
+                    "showIcon": true,
+                    "showMore": true,
+                    "size": "medium",
+                    "onMoreClicked": "globalContext.handleGoCollect(13, '海天足迹·故事活地图', 88, true)"
+                  }
+                },
+                {
+                  "type": "Block:ContentMap",
+                  "props": {
+                    "mapClickType": "global",
+                    "footer": {
+                      "title": "奋斗路线打卡",
+                      "button": "进入"
+                    },
+                    "onGlobalClick": "globalContext.handleGoCollect(13, '海天足迹·故事活地图', 88, true)",
+                    "onFooterClick": "globalContext.handleGoCollect(13, '海天足迹·故事活地图', 88, true)",
+                    "categoryDefine": {
+                      "content": {
+                        "type": "VillageInfoList",
+                        "params": {
+                          "collectModuleId": 13,
+                          "catalogId": 88
+                        }
+                      }
+                    }
+                  }
+                },
                 {
                   "type": "flex",
                   "props": {
@@ -106,8 +138,7 @@
                     "center": true,
                     "showMore": false,
                     "showIcon": false,
-                    "showTopMargin": false,
-                    "onMoreClicked": "globalContext.todo()"
+                    "showTopMargin": false
                   }
                 },
                 {
@@ -119,6 +150,7 @@
                           "type": "staticData",
                           "data": [
                             {
+                              "id": 1,
                               "title": "口述纪实馆",
                               "image": "https://xy.wenlvti.net/app_static/images/village/speical/IconSpeackerStore.png",
                               "tags": [ "登录" ],
@@ -132,6 +164,7 @@
                               }
                             },
                             {
+                              "id": 2,
                               "props": {
                                 "width": "170rpx",
                                 "height": "220rpx",
@@ -144,6 +177,7 @@
                               }
                             },
                             {
+                              "id": 3,
                               "title": "故事共创站",
                               "image": "https://xy.wenlvti.net/app_static/images/village/speical/IconStoryStation.png",
                               "tags": [ "进入" ],
@@ -159,7 +193,7 @@
                           ]
                         },
                         "type": "box-grid",
-                        "onItemDetailClicked": "globalContext.handleGoCollect(17, '老建设者足迹', 85)"
+                        "onItemDetailClicked": "if (params.dataItem.id === 1) { globalContext.handleGoCollect(17, '老建设者足迹', 85) } else if (params.dataItem.id === 2) { globalContext.handleGoCollect(17, '口述纪实馆', 86) } else if (params.dataItem.id === 3) { globalContext.handleGoCollect(17, '故事共创站', 87) }"
                       }
                     ]
                   }
@@ -297,7 +331,7 @@
                     "showMore": true,
                     "showIcon": false,
                     "showTopMargin": false,
-                    "onMoreClicked": "globalContext.todo()"
+                    "onMoreClicked": "globalContext.handleGoCollect(17, '老建设者足迹', 85)"
                   }
                 },
                 {