快乐的梦鱼 před 3 týdny
rodič
revize
67a9aed77f

+ 28 - 0
src/common/components/dynamic/NodeRender.vue

@@ -0,0 +1,28 @@
+<template>
+  <!-- 在下方添加自定义组件 -->
+  <!-- 业务代码开始 -->
+  <template v-if="node.type === 'test'">
+    <text>这是一个测试节点</text>
+  </template>
+  <!-- 业务代码结束 -->
+  <slot v-else :node="node" />
+</template>
+
+<script setup lang="ts">
+import { ref, type PropType } from 'vue';
+import type { DynamicXNode } from '@/components/dynamic/DynamicX';
+
+const props = defineProps({	
+  node: {
+    type: Object as PropType<DynamicXNode>,
+    default: () => ({})
+  },
+});
+const emit = defineEmits([]);
+
+defineOptions({
+  options: {
+    virtualHost: true,
+  }
+})
+</script>

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

@@ -3,7 +3,7 @@
     :title="title" 
     :moreText="moreText"
     :showMore="showMore" 
-    :padding="[25, 0, 5, 0]"
+    :padding="[showTopMargin ? 25 : 5, 0, 5, 0]"
     @moreClicked="emit('moreClicked')"
   >
     <template #icon>
@@ -45,6 +45,10 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  showTopMargin: {
+    type: Boolean,
+    default: true,
+  },
   moreText: {
     type: String,
     default: '更多',

+ 1 - 1
src/components/display/Badge.vue

@@ -98,7 +98,7 @@ export interface BadgeProps {
    * 字号
    * @default 12.5
    */
-  fontSize?: number,
+  fontSize?: number|string,
   /**
    * 圆角的大小
    * @default 20

+ 3 - 2
src/components/display/title/SubTitle.vue

@@ -1,10 +1,11 @@
 <script setup lang="ts">
 import { useTheme, type ThemePaddingOrMarginType } from '@/components/theme/ThemeDefine';
 import Icon from '@/components/basic/Icon.vue';
-import Text, { type TextProps } from '@/components/basic/Text.vue';
+import Text from '@/components/basic/Text.vue';
 import Touchable from '@/components/feedback/Touchable.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
 import Width from '@/components/layout/space/Width.vue';
+import type { TextProps } from '@/components/basic/Text';
 
 defineEmits([ 'moreClicked' ])
 const props = withDefaults(defineProps<{
@@ -57,7 +58,7 @@ const finalBadgeStyle = theme.useThemeStyle({
     </FlexRow>
     <FlexRow align="center">
       <slot name="right" />
-      <Touchable v-if="showMore" align="center" touchable @click="$emit('moreClicked')">
+      <Touchable v-if="showMore" align="center" padding="padding.md" touchable @click="$emit('moreClicked')">
         <Text fontConfig="subText" :text="moreText" />
         <Width :size="10" />
         <Icon icon="arrow-right" :size="26" />

+ 35 - 0
src/components/dynamic/DynamicX.ts

@@ -0,0 +1,35 @@
+import type { MiniScript } from "./minisc/MiniScript";
+
+export interface DynamicXProps {
+  page: DynamicXPage;
+  injectScriptGlobal?: any;
+}
+
+export interface DynamicXNodeContext {
+  onChildAdded: (name: string, node: DynamicXNode) => void;
+  onChildRemoved: (name: string) => void;
+  getChild: (name: string) => DynamicXNode | undefined;
+  getScriptEngine: () => MiniScript;
+  getGlobalContext: () => Record<string, any>;
+}
+
+export interface DynamicXPage extends DynamicXNode {
+  name: string,
+  title?: string;
+  type: 'page';
+  nodes: DynamicXNode[];
+}
+
+export interface DynamicXNode {
+  type: string,
+  visible?: boolean|undefined,
+  name?: string,
+  text?: string;
+  props?: Record<string, any>;
+  style?: Record<string, any>;
+  events?: Record<string, string>;
+  nodes?: DynamicXNode[];
+  onMounted?: string;
+  onBeforeUnmount?: string;
+  getNodeScriptContext?: (params?: Record<string, any>) => Record<string, any>;
+}

+ 78 - 0
src/components/dynamic/DynamicX.vue

@@ -0,0 +1,78 @@
+<template>
+  <view :style="props.page.style">
+    <!-- #ifdef MP -->
+    <slot :page="page" :context="xContext" />
+    <!-- #endif -->
+
+    <!-- #ifndef MP -->
+    <DynamicXNodeChild :node="page">
+      <template #nodeRender="{ node }">
+        <slot name="nodeRender" :node="node" />
+      </template>
+    </DynamicXNodeChild>
+    <!-- #endif -->
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeUnmount, onMounted, provide } from 'vue';
+import { MiniScript } from './minisc/MiniScript';
+import { initDynamicXScript } from './x/DynamicXScript';
+import type { DynamicXNode, DynamicXNodeContext, DynamicXProps } from './DynamicX';
+import DynamicXNodeChild from './x/DynamicXNodeChild.vue';
+
+const props = withDefaults(defineProps<DynamicXProps>(), {
+})
+
+const emit = defineEmits<{
+  (e: 'click'): void
+}>()
+
+const script = new MiniScript();
+const scriptGlobalContext = {
+  ...props.injectScriptGlobal,
+};
+const nodeMap = new Map<string, DynamicXNode>();
+const xContext : DynamicXNodeContext = {
+  onChildAdded: (name, node) => {
+    nodeMap.set(name, node);
+  },
+  onChildRemoved: (name) => {
+    nodeMap.delete(name);
+  },
+  getChild: (name) => {
+    return nodeMap.get(name);
+  },
+  getScriptEngine: () => {
+    return script;
+  },
+  getGlobalContext: () => {
+    return scriptGlobalContext;
+  },
+}
+
+onMounted(() => { 
+  if (props.page.title) {
+    uni.setNavigationBarTitle({
+      title: props.page.title,
+    })
+  }
+  if (props.page.onMounted) {
+    script.execute(props.page.onMounted, scriptGlobalContext);
+  }
+});
+onBeforeUnmount(() => {
+  if (props.page.onBeforeUnmount) {
+    script.execute(props.page.onBeforeUnmount, scriptGlobalContext);
+  }
+});
+
+initDynamicXScript(script, xContext);
+provide('xContext', xContext);
+
+defineOptions({
+  options: {
+    virtualHost: true,
+   }
+})
+</script>

+ 19 - 0
src/components/dynamic/minisc/MiniScript.condition.test.ts

@@ -0,0 +1,19 @@
+import { MiniScript } from './MiniScript';
+
+describe('MiniScriptCondition', () => {
+  const ms = () => new MiniScript();
+
+  test('if 条件判断', () => {
+    expect(ms().execute('if (a > 1) { return 1 }', { a: 0 })).toEqual(undefined);
+    expect(ms().execute('if (a > 1) { return 1 }', { a: 2 })).toEqual(1);
+  });
+  test('if else 条件判断', () => {
+    expect(ms().execute('if (a > 1) { b = 2; return 1 + b } else { return 0 }', { a: 2 })).toEqual(3);
+    expect(ms().execute('if (a > 1) { b = 2; return 1 + b } else { return 0 }', { a: 0 })).toEqual(0);
+  });
+  test('if else if 条件判断', () => {
+    expect(ms().execute('if (a > 1) { return \'high\' } else if (a > 0 && a <= 1) { return \'mid\' } else { return \'low\' }', { a: 2 })).toEqual('high');
+    expect(ms().execute('if (a > 1) { return \'high\' } else if (a > 0 && a <= 1) { return \'mid\' } else { return \'low\' }', { a: 0.5 })).toEqual('mid');
+    expect(ms().execute('if (a > 1) { return \'high\' } else if (a > 0 && a <= 1) { return \'mid\' } else { return \'low\' }', { a: -1 })).toEqual('low');
+  });
+});

+ 65 - 0
src/components/dynamic/minisc/MiniScript.extra.test.ts

@@ -0,0 +1,65 @@
+import { MiniScript } from './MiniScript';
+
+describe('MiniScriptExtra', () => {
+  const ms = () => new MiniScript();
+
+  test('registerFunction 对象扩展', () => {
+    const m = new MiniScript();
+    m.registerFunction('uni.getSystemInfo', () => {
+      return {
+        platform: 'ios',
+        version: '1.0.0',
+        system: '1.0.0',
+      };
+    });
+    expect(m.execute('return uni.getSystemInfo()')).toEqual({
+      platform: 'ios',
+      version: '1.0.0',
+      system: '1.0.0',
+    });
+  });
+
+  test('数组调用', () => {
+    expect(ms().execute('return list[0].value + list[1].array[0].value', {
+      list: [
+        {
+          name: 'a',
+          value: 1,
+        },
+        {
+          name: 'b',
+          value: 2,
+          array: [
+            {
+              name: 'c',
+              value: 3,
+            },
+            {
+              name: 'd',
+              value: 4,
+            },
+          ]
+        },
+      ]
+    })).toEqual(4);
+  });
+  
+  test('数组筛选', () => {
+    expect(ms().execute('return list.filter(item => item.type === "fruit").length', {
+      list: [
+        {
+          name: 'apple',
+          type: 'fruit',
+        },
+        {
+          name: 'banana',
+          type: 'fruit',
+        },
+        {
+          name: 'carrot',
+          type: 'vegetable',
+        },
+      ]
+    })).toEqual(2);
+  });
+});

+ 110 - 0
src/components/dynamic/minisc/MiniScript.test.ts

@@ -0,0 +1,110 @@
+import { MiniScript, MiniScriptError } from './MiniScript';
+
+describe('MiniScript', () => {
+  const ms = () => new MiniScript();
+
+  test('return 与算术', () => {
+    expect(ms().execute('return 1 + 1', {})).toBe(2);
+    expect(ms().execute('return 10 - 3 * 2', {})).toBe(4);
+    expect(ms().execute('return 7 % 3', {})).toBe(1);
+    expect(ms().execute('return 8 / 2', {})).toBe(4);
+  });
+
+  test('无 return 时取最后表达式值', () => {
+    expect(ms().execute('2 + 3', {})).toBe(5);
+  });
+
+  test('比较与相等', () => {
+    expect(ms().execute('return 1 == 1', {})).toBe(true);
+    expect(ms().execute('return 1 == "1"', {})).toBe(true);
+    expect(ms().execute('return 1 === "1"', {})).toBe(false);
+    expect(ms().execute('return 1 !== "1"', {})).toBe(true);
+    expect(ms().execute('return 2 > 1 && 1 < 2', {})).toBe(true);
+    expect(ms().execute('return 2 >= 2 && 2 <= 3', {})).toBe(true);
+  });
+
+  test('逻辑与短路', () => {
+    expect(ms().execute('return false && noSuch', {})).toBe(false);
+    expect(ms().execute('return true || noSuch', {})).toBe(true);
+  });
+
+  test('一元 ! + -', () => {
+    expect(ms().execute('return !false', {})).toBe(true);
+    expect(ms().execute('return !true', {})).toBe(false);
+    expect(ms().execute('return -(-5)', {})).toBe(5);
+    expect(ms().execute('return +"42"', {})).toBe(42);
+  });
+
+  test('字符串与 + 拼接', () => {
+    expect(ms().execute('return "a" + "b"', {})).toBe('ab');
+    expect(ms().execute('return 1 + "x"', {})).toBe('1x');
+  });
+
+  test('Math / String / Array / Object', () => {
+    expect(ms().execute('return Math.abs(-3)', {})).toBe(3);
+    expect(ms().execute('return Math.max(1, 5, 2)', {})).toBe(5);
+    expect(ms().execute('return String.fromCharCode(65)', {})).toBe('A');
+    expect(ms().execute('return Array.isArray([1])', {})).toBe(true);
+    expect(ms().execute('return Object.keys({a:1,b:2}).length', {})).toBe(2);
+    expect(ms().execute('return parseInt("10", 10)', {})).toBe(10);
+  });
+
+  test('new Date', () => {
+    const r = ms().execute('return new Date(2020, 0, 2).getFullYear()', {}) as number;
+    expect(r).toBe(2020);
+  });
+
+  test('数组字面量与下标', () => {
+    expect(ms().execute('return [1, 2, 3][1]', {})).toBe(2);
+  });
+
+  test('对象字面量', () => {
+    expect(ms().execute('return ({ a: 1 }).a', {})).toBe(1);
+    expect(ms().execute('return ({ "x": 2 })["x"]', {})).toBe(2);
+  });
+
+  test('map 与箭头回调', () => {
+    expect(ms().execute('return [1,2,3].map(x => x * 2).join(",")', {})).toBe('2,4,6');
+    expect(ms().execute('return [1,2].map((n, i) => n + i)', {})).toEqual([1, 3]);
+  });
+
+  test('filter 回调', () => {
+    expect(ms().execute('return [1,2,3,4].filter(n => n % 2 === 0)', {})).toEqual([2, 4]);
+  });
+
+  test('registerFunction 扩展', () => {
+    const m = new MiniScript();
+    m.registerFunction('double', (x: unknown) => (x as number) * 2);
+    expect(m.execute('return double(21)', {})).toBe(42);
+  });
+
+  test('context 变量与赋值', () => {
+    expect(ms().execute('return a + b', { a: 1, b: 2 })).toBe(3);
+    const m = new MiniScript();
+    const ctx: Record<string, unknown> = { n: 0 };
+    expect(m.execute('n = 5; return n', ctx)).toBe(5);
+    expect(ctx.n).toBe(5);
+  });
+
+  test('块级作用域', () => {
+    expect(
+      ms().execute('{ x = 1; return x }', { x: 0 } as Record<string, unknown>),
+    ).toBe(1);
+  });
+
+  test('测试console', () => {
+    const m = new MiniScript();
+    expect(m.execute('console.log("Hello, World!")', {})).toBe(undefined);
+  });
+
+  test('测试执行污染', () => {
+    const m = new MiniScript();
+    expect(() => m.execute('return a', {})).toThrow(MiniScriptError);
+    m.execute('a = 1');
+    expect(() => m.execute('return a', {})).toThrow();
+  });
+
+  test('语法错误抛出 MiniScriptError', () => {
+    expect(() => ms().execute('return @', {})).toThrow(MiniScriptError);
+  });
+});

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1322 - 0
src/components/dynamic/minisc/MiniScript.ts


+ 116 - 0
src/components/dynamic/x/DynamicXNode.vue

@@ -0,0 +1,116 @@
+<template>
+  <template v-if="node.visible !== false">
+    <view v-if="node.type === 'view'" :style="node.style">
+      <slot name="renderChild" :node="node" />
+      <template v-if="node.text">{{ node.text }}</template>
+    </view>
+    <FlexView v-else-if="node.type === 'flex'" :innerStyle="node.style" v-bind="mergedProps">
+      <slot name="renderChild" :node="node" />
+      <template v-if="node.text">{{ node.text }}</template>
+    </FlexView>
+    <BackgroundBox v-else-if="node.type === 'BackgroundBox'" :innerStyle="node.style" v-bind="mergedProps">
+      <slot name="renderChild" :node="node" />
+      <template v-if="node.text">{{ node.text }}</template>
+    </BackgroundBox>
+    <Text v-else-if="node.type === 'text'" :innerStyle="node.style" v-bind="mergedProps" :text="node.text" />
+    <Image v-else-if="node.type === 'image'" :innerStyle="node.style" v-bind="mergedProps" />
+    <Icon v-else-if="node.type === 'icon'" :innerStyle="node.style" v-bind="mergedProps" />
+    <Button v-else-if="node.type === 'button'" :innerStyle="node.style" v-bind="mergedProps" />
+    <IconButton v-else-if="node.type === 'iconButton'" :innerStyle="node.style" v-bind="mergedProps" />
+    <NodeRender v-else :node="node">
+      <slot name="nodeRender" :node="node" />
+    </NodeRender>
+  </template>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, onBeforeUnmount, onMounted } from 'vue';
+import { assertNotNull } from '@imengyu/imengyu-utils';
+import type { DynamicXNode, DynamicXNodeContext } from '../DynamicX';
+import FlexView from '@/components/layout/FlexView.vue';
+import NodeRender from '@/common/components/dynamic/NodeRender.vue';
+import BackgroundBox from '@/components/display/block/BackgroundBox.vue';
+import Image from '@/components/basic/Image.vue';
+import Icon from '@/components/basic/Icon.vue';
+import Button from '@/components/basic/Button.vue';
+import IconButton from '@/components/basic/IconButton.vue';
+import Text from '@/components/basic/Text.vue';
+
+const props = withDefaults(defineProps<{
+  node: DynamicXNode;
+}>(), {
+})
+
+const emit = defineEmits<{
+  (e: 'click'): void
+}>()
+
+const nodeScriptContext = {
+  data: {},
+  params: {},
+  globalContext: {},
+};
+
+const mergedProps = computed(() => {
+  const res : Record<string, any> = {};
+  for (const key in props.node.props) {
+    const value = props.node.props[key];
+    if (typeof value === 'string' && value.startsWith('dynamic')) {
+      const script = xContext?.getScriptEngine();
+      const scriptString = value.substring('dynamic:'.length);
+      assertNotNull(script, 'DynamicXNode: Script engine not found');
+      res[key] = script.execute(scriptString, getNodeScriptContext());
+      continue;
+    }
+    res[key] = props.node.props[key];
+  }
+  for (const key in props.node.events) {
+    const scriptString = props.node.events[key];
+    if (!scriptString) 
+      continue;
+    const upperKey = key.charAt(0).toUpperCase() + key.slice(1);
+    const camelKey = upperKey.replace(/-(\w)/g, (_, c) => c.toUpperCase());
+    res[`on${camelKey}`] = (...params: any[]) => {
+      const script = xContext?.getScriptEngine();
+      assertNotNull(script, 'DynamicXNode: Script engine not found');
+      script.execute(scriptString, getNodeScriptContext(params));
+    }
+  }
+  return res;
+})
+
+const xContext = inject<DynamicXNodeContext>('xContext');
+
+function getNodeScriptContext(params?: Record<string, any>) {
+  nodeScriptContext.params = params || {};
+  nodeScriptContext.globalContext = xContext?.getGlobalContext() || {};
+  return nodeScriptContext;
+}
+
+onMounted(() => {
+  props.node.getNodeScriptContext = getNodeScriptContext;
+  if (props.node.name) 
+    xContext?.onChildAdded(props.node.name, props.node);
+  if (props.node.onMounted) {
+    const script = xContext?.getScriptEngine();
+    assertNotNull(script, 'DynamicXNode: Script engine not found');
+    script.execute(props.node.onMounted, getNodeScriptContext());
+  }
+})
+onBeforeUnmount(() => {
+  props.node.getNodeScriptContext = undefined;
+  if (props.node.name) 
+    xContext?.onChildRemoved(props.node.name);
+  if (props.node.onBeforeUnmount) {
+    const script = xContext?.getScriptEngine();
+    assertNotNull(script, 'DynamicXNode: Script engine not found');
+    script.execute(props.node.onBeforeUnmount, getNodeScriptContext());
+  }
+})
+
+defineOptions({
+  options: {
+    virtualHost: true,
+   }
+})
+</script>

+ 72 - 0
src/components/dynamic/x/DynamicXNodeChild.vue

@@ -0,0 +1,72 @@
+<template>
+  <template v-if="node.nodes && node.nodes.length > 0">
+    <!-- Level 0 -->
+    <DynamicXNodeComponent 
+      v-for="(item, index) in node.nodes" :key="item.name || index"
+      :node="item"
+    >
+      <template #nodeRender="{ node }">
+        <slot name="nodeRender" :node="node" />
+      </template>
+      <template #renderChild="{ node }">
+
+        <!-- #ifndef MP -->
+        <DynamicXNodeChildComponent :node="node">
+          <template #nodeRender="{ node }">
+            <slot name="nodeRender" :node="node" />
+          </template>
+        </DynamicXNodeChildComponent>
+        <!-- #endif -->
+        
+        <!-- 为了适应劣质微信小程序无法递归渲染插槽的问题 -->
+        <!-- #ifdef MP -->
+        <template v-if="node.nodes && node.nodes.length > 0">
+          <!-- Level 1 -->
+          <DynamicXNodeComponent v-for="(item, index) in node.nodes" :key="item.name || index" :node="item">
+            <template #nodeRender="{ node }">
+              <slot name="nodeRender" :node="node" />
+            </template>
+            <template #renderChild="{ node }">
+              <slot name="renderChild" :node="node">
+                <!-- Level 2 -->
+                <DynamicXNodeComponent v-for="(item, index) in node.nodes" :key="item.name || index" :node="item">
+                  <template #nodeRender="{ node }"><slot name="nodeRender" :node="node" /></template>
+                  <template #renderChild="{ node }">
+                    
+                  </template>
+                </DynamicXNodeComponent>
+              </slot>
+            </template>
+          </DynamicXNodeComponent>
+        </template>
+        <!-- #endif -->
+      </template>
+    </DynamicXNodeComponent>
+  </template>
+</template>
+
+<script lang="ts" setup>
+import type { DynamicXNode } from '../DynamicX';
+import DynamicXNodeComponent from './DynamicXNode.vue';
+//#ifndef MP
+import DynamicXNodeChildComponent from '../DynamicXNodeChild.vue';
+//#endif
+
+const props = withDefaults(defineProps<{
+  node: DynamicXNode;
+}>(), {
+})
+
+const emit = defineEmits<{}>()
+
+defineSlots<{
+  nodeRender: (props: { node: DynamicXNode }) => void;
+  renderChild: (props: { node: DynamicXNode }) => void;
+}>()
+
+defineOptions({
+  options: {
+    virtualHost: true,
+   }
+})
+</script>

+ 7 - 0
src/components/dynamic/x/DynamicXScript.ts

@@ -0,0 +1,7 @@
+import type { DynamicXNodeContext } from "../DynamicX";
+import type { MiniScript } from "../minisc/MiniScript";
+
+export function initDynamicXScript(script: MiniScript, xContext: DynamicXNodeContext) {
+  script.registerGlobal('x', xContext);
+  script.registerGlobal('uni', uni);
+}

+ 2 - 2
src/components/loader/SimplePageListLoader.vue

@@ -2,7 +2,7 @@
   <slot />
   <Loadmore
     v-if="loader.status.value == 'loading' 
-      || (loader.status.value == 'nomore' && !$slots.empty && showNomore)" 
+      || (loader.status.value == 'nomore' && !$slots.empty && props.showNoMore)" 
     :status="loader.status.value" 
   />
   <slot v-else-if="loader.status.value == 'empty'" name="empty">
@@ -27,7 +27,7 @@ const props = defineProps({
     type: Object as PropType<ISimplePageListLoader<any, any>>,
     default: null,
   },
-  showNomore: {
+  showNoMore: {
     type: Boolean,
     default: true,
   },

+ 7 - 0
src/pages.json

@@ -370,6 +370,13 @@
         "navigationBarTitleText": "测试空白页",
         "enablePullDownRefresh": false
       }
+    },
+    {
+      "path": "pages/test/dynamic",
+      "style": {
+        "navigationBarTitleText": "测试动态页",
+        "enablePullDownRefresh": false
+      }
     }
   ],
   "subPackages": [

+ 1 - 1
src/pages/home/discover/index.vue

@@ -44,7 +44,7 @@
     </FlexCol>
 
     <HomeTitle title="乡源好物" showMore @moreClicked="navTo('/pages/home/village/goods/index')" />
-    <SimplePageContentLoader :loader="goodsLoader" :showNomore="false">
+    <SimplePageContentLoader :loader="goodsLoader" :showNoMore="false">
       <FlexRow wrap justify="space-around" gap="gap.sm">
         <BoxMid
           v-for="good in goodsLoader.content.value" :key="good.id"

+ 8 - 1
src/pages/home/index.vue

@@ -71,7 +71,7 @@
         <!-- <Button icon="ai-thinking" @click="handleGoAI" text="问AI" /> -->
       </FlexRow>
 
-      <HomeTitle title="今日推荐" showMore @moreClicked="emit('goDiscover')" />
+      <HomeTitle title="今日推荐" showMore :showTopMargin="false" @moreClicked="handleGoRecommendMore" />
       <SimplePageContentLoader :loader="recommendLoader">
         <FlexRow gap="gap.lg" align="stretch" justify="space-between">
           <Touchable 
@@ -424,6 +424,13 @@ const recommendLoader = useSimpleDataLoader(async () => {
   return (await VillageInfoApi.getListForDiscover({ page: 1, pageSize: 2 })).list;
 });
 
+function handleGoRecommendMore() {
+  console.log('handleGoRecommendMore');
+  uni.pageScrollTo({
+    scrollTop: 1000,
+  })
+}
+
 const showLightTipTimeout = new MemoryTimeOut('LightTip', 1000 * 3600 * 2);//2h
 const showLightTip = ref(false);
 

+ 447 - 310
src/pages/home/village/introd/card.vue

@@ -1,317 +1,354 @@
 <template>
-  <FlexCol :padding="[30,30,0,30]" gap="gap.lg">
-
-    <!-- 卡片背景 -->
-    <BackgroundBox 
-      color1="#eecaa0"
-      color2="white"
-      color2Position="85%"
-      color3="white"
-      radius="radius.lg"
-      direction="column"
-      :padding="[35,30]"
-      gap="gap.lg"
-    >
-
-      <!-- 标题 -->
-      <FlexRow justify="space-between" gap="gap.lg" wrap align="center" width="100%">
-        <FlexRow gap="gap.md">
-          <Text 
-            :text="title" 
+  <DynamicX 
+    :page="page"
+    :injectScriptGlobal="pageScriptGlobal"
+  >
+    <template #default="{ page, context }">
+      
+      <DynamicXNodeChild :node="page">
+        <template #nodeRender="{ node }">
+
+          <template v-if="node.type === 'Card:Basic'">
+
+        <!-- 标题 -->
+        <FlexRow justify="space-between" gap="gap.lg" wrap align="center" width="100%">
+          <FlexRow gap="gap.md">
+            <Text 
+              :text="title" 
+              :lines="1" 
+              :fontSize="40"
+              wordBreak="break-all"
+              fontConfig="primaryTitle"
+            />
+          </FlexRow>
+          <FlexRow flex="1" justify="space-between" align="center" gap="gap.lg">
+            <IconButton icon="cascades" :size="40" @click="shareQRDialog?.show()" />
+            <FlexRow align="center" gap="gap.lg">
+              <BubbleTip
+                v-model:show="showFollowTip"
+                content="关注我,方便下次进入"
+                @contentClick="isFollowed ? undefined : onFollow()"
+                @close="handleCloseFollowTip"
+              >
+                <Button 
+                  :icon="isFollowed ? 'success' : 'https://xy.wenlvti.net/app_static/images/village/IconJoin.png'" 
+                  radius="radius.lgr"
+                  :text="isFollowed ? (title.length < 5 ? '已关注' : '') : '关注'"
+                  :padding="[15, 10]"
+                  :iconProps="{ size: 38 }"
+                  @click="isFollowed ? onUnFollow() : onFollow()"
+                />
+              </BubbleTip>
+              <FlexRow 
+                v-if="isJoined"
+                gap="gap.md"
+                radius="radius.lgr"
+                backgroundColor="background.tertiary"
+              >
+                <Avatar 
+                  :src="authStore.userInfo?.avatar" 
+                  :size="65"
+                  defaultAvatar="https://xy.wenlvti.net/app_static/images/mine/DefaultAvatar.png"
+                />
+                <FlexCol position="relative" gap="gap.sm" :padding="[0,30,0,0]">
+                  <FlexRow align="center" :gap="10">
+                    <Text :text="displayMyName" fontSize="23" :lines="1" :maxWidth="150" fontConfig="lightImportantTitle" />
+                    <Button 
+                      v-if="isStaff" 
+                      :innerStyle="{ position: 'absolute', right: 0, top: 0, transform: 'translate(5%,-50%)' }"
+                      text="管理" size="mini" radius="radius.md" type="danger" 
+                      @click="staffInfoRef?.show()" 
+                    />
+                  </FlexRow>
+                  <IconButton icon="edit-filling" size="26" @click="changeNickRef?.show()">
+                    <Text text="改昵称" :wrap="false" fontSize="22" fontConfig="subText" />
+                  </IconButton>
+                </FlexCol>
+              </FlexRow>
+              <Button 
+                v-else
+                icon="https://xy.wenlvti.net/app_static/images/village/IconFollow.png"
+                radius="radius.lgr"
+                text="加入"
+                @click="handleGoJoin()"
+              />
+            </FlexRow>
+          </FlexRow>
+        </FlexRow>
+
+        <!-- 简介 -->
+        <FlexView :direction="descOpen ? 'column' : 'row'" gap="gap.md" justify="space-between" align="flex-start">
+          <!-- 简介 -->
+          <TextEllipsis 
+            fontConfig="secondText"
+            :outerProps="{ direction: 'row', width: descOpen ? '' : '490rpx' }"
             :lines="1" 
-            :fontSize="40"
+            :expandable="(villageInfoLoader.content.value?.desc as string || '').length > 20"
+            :text="(villageInfoLoader.content.value?.desc as string) || '暂无简介,欢迎您来编写完善!'"
             wordBreak="break-all"
-            fontConfig="primaryTitle"
+            @expand="descOpen = true;"
+            @collapse="descOpen = false;"
           />
-        </FlexRow>
-        <FlexRow flex="1" justify="space-between" align="center" gap="gap.lg">
-          <IconButton icon="cascades" :size="40" @click="shareQRDialog?.show()" />
-          <FlexRow align="center" gap="gap.lg">
-            <BubbleTip
-              v-model:show="showFollowTip"
-              content="关注我,方便下次进入"
-              @contentClick="isFollowed ? undefined : onFollow()"
-              @close="handleCloseFollowTip"
+          <Button size="mini" icon="edit-filling" text="编辑简介" @click="handleGoEdit" />
+        </FlexView>
+
+          </template>
+          <template v-else-if="node.type === 'Card:Level'">
+
+            <!-- 状态与申请 -->
+            <FlexRow 
+              backgroundColor="background.tertiary" 
+              radius="radius.md" 
+              padding="space.md" 
+              justify="space-between" 
             >
+              <FlexRow align="center">
+                <FlexCol gap="gap.md"> 
+                  <FlexRow gap="gap.lg" align="center">
+                    <Icon v-if="villageInfoLoader.content.value?.vipLevel === 4" name="https://xy.wenlvti.net/app_static/images/village/IconGov.png" :size="70" />
+                    <Text :text="`${villageInfoLoader.content.value?.vipLevelText || '默认级别'}`" fontConfig="secondText" />  
+                  </FlexRow>
+                  <Text v-if="villageInfoLoader.content.value?.sizeLimit" :text="`存储空间:${villageInfoLoader.content.value?.sizeText || ''}`" :fontSize="22" fontConfig="secondText" />
+                  <Text v-else text="无限存储空间" :fontSize="22" fontConfig="secondText" />
+                </FlexCol>
+              </FlexRow>
+              <FlexRow align="center" gap="gap.md">
+                <IconButton icon="help-filling" @click="navTo('/pages/article/details', { id: 7021, modelId: 18, showRecommend: false })" />
+                <Button 
+                  v-if="(villageInfoLoader.content.value?.vipLevel || 0) < 4" 
+                  icon="https://xy.wenlvti.net/app_static/images/village/IconUser.png" 
+                  radius="radius.lgr" 
+                  :padding="[10, 30]" 
+                  backgroundColor="white" 
+                  text="升级村社"
+                  @click="upgradeRef?.show()"
+                />
+              </FlexRow>
+            </FlexRow>
+
+          </template>
+          <template v-else-if="node.type === 'Card:Gallery'">
+            
+            <!-- 图片 -->
+            <VillageGallery
+              v-if="villageInfoLoader.content.value"
+              ref="villageGalleryRef"
+              :villageId="villageStore.currentVillage?.id ?? 0" 
+              @goGallery="handleGoGallery"
+            />
+
+          </template>
+          <template v-else-if="node.type === 'Card:AddressAndMap'">
+            
+            <!-- 地址 -->
+            <FlexRow justify="space-between" align="center">
+              <FlexRow align="center" gap="gap.sm">
+                <Icon name="https://xy.wenlvti.net/app_static/images/village/IconMap.png" size="fontSize.md" />
+                <Text :text="villageInfoLoader.content.value?.address" fontConfig="contentText" fontSize="fontSize.sm" />
+              </FlexRow>
+              <Button size="mini" icon="picture" text="村社相册" @click="handleGoGallery" />
+            </FlexRow>
+            <VillageMiniMap 
+              v-if="villageInfoLoader.content.value"
+              :lonlat="{ 
+                longitude: villageInfoLoader.content.value.longitude, 
+                latitude: villageInfoLoader.content.value.latitude 
+              }" 
+              :currentNoticeContent="currentNoticeContent"
+            />
+
+          </template>
+          <template v-else-if="node.type === 'Card:Static'">
+            
+            <!-- 村社状态信息 -->
+            <FlexRow justify="space-between" align="center">
+              <FlexRow center gap="gap.lg" flexBasis="50%">
+                <Text text="村社排名" fontConfig="contentText" />
+                <Text text="No." fontConfig="lightTitle" />
+                <Text :text="villageInfoLoader.content.value?.rankText" fontConfig="primaryTitle" />
+              </FlexRow>
+              <FlexRow center gap="gap.lg" flexBasis="50%">
+                <Text text="村社等级" fontConfig="contentText" />
+                <Text :text="`${villageInfoLoader.content.value?.level}级`" fontConfig="primaryTitle" />
+              </FlexRow>
+            </FlexRow>
+            <FlexRow backgroundColor="background.tertiary" radius="radius.md" :padding="[30, 20]">
+              <Touchable direction="column" center gap="gap.sm" flexBasis="25%" @click="navTo('/pages/home/village/task/index')">
+                <Text text="乡源光" fontConfig="secondText" fontSize="fontSize.sm" />
+                <Text :text="villageInfoLoader.content.value?.light || '0'" fontConfig="importantTitle" />
+                <Button type="text" size="mini" text="做任务" @click="navTo('/pages/home/village/task/index')" />
+              </Touchable>
+              <Divider type="vertical" />
+              <FlexCol center gap="gap.sm" flexBasis="25%">
+                <Touchable direction="column" center @click="navTo('/pages/home/village/volunteer/list', { villageId: villageStore.currentVillage?.id ?? undefined })">
+                  <Text text="乡源人数" fontConfig="contentText" fontSize="fontSize.sm" />
+                  <Text :text="villageInfoLoader.content.value?.memberCount|| '0'" fontConfig="importantTitle" />
+                </Touchable>
+                <WxButton openType="share">
+                  <Button type="text" size="mini" text="邀请加入" @click="navTo('/pages/home/village/task/index')" />
+                </WxButton>
+              </FlexCol>
+              <Divider type="vertical" />
+              <Touchable direction="column" center gap="gap.sm" flexBasis="25%" @click="navTo('/pages/home/village/follow/list', {
+                villageId: villageStore.currentVillage?.id ?? undefined,
+              })">
+                <Text text="关注人数" fontConfig="contentText" fontSize="fontSize.sm" />
+                <Text :text="villageInfoLoader.content.value?.followerCount|| '0'" fontConfig="importantTitle" />
+                <Height :size="36" />
+              </Touchable>
+              <Divider type="vertical" />
               <Button 
-                :icon="isFollowed ? 'success' : 'https://xy.wenlvti.net/app_static/images/village/IconJoin.png'" 
-                radius="radius.lgr"
-                :text="isFollowed ? (title.length < 5 ? '已关注' : '') : '关注'"
-                :padding="[15, 10]"
-                :iconProps="{ size: 38 }"
-                @click="isFollowed ? onUnFollow() : onFollow()"
-              />
-            </BubbleTip>
-            <FlexRow 
-              v-if="isJoined"
-              gap="gap.md"
-              radius="radius.lgr"
-              backgroundColor="background.tertiary"
+                :padding="0" 
+                type="text" 
+                size="small" 
+                textColor="text.title"
+                rightIcon="arrow-right" 
+                @click="handleGoNew()"
+              >
+                <FlexCol>
+                  <Text text="新手" fontConfig="contentText" />
+                  <Text text="上路" fontConfig="contentText" />
+                </FlexCol>
+              </Button>
+            </FlexRow>
+
+          </template>
+          <template v-else-if="node.type === 'Block:Rank'">
+
+            <!-- 排行榜 -->
+            <HomeTitle 
+              :title="node.props?.title" 
+              showMore 
+              @moreClicked="navTo('/pages/home/village/rank/volunteer', {
+                villageId: villageStore.currentVillage?.id ?? undefined,
+              })" 
+            />
+            <VillageUserRankList 
+              :list="villageUserRankListLoader.content.value ?? []" 
+              @goDetails="navTo('/pages/home/village/volunteer/detail', { id: $event.id })"
+            />
+
+          </template>
+          <template v-else-if="node.type === 'Block:Collect'">
+
+            <!-- 魅力乡源 -->
+            <HomeTitle 
+              :title="node.props?.title" 
+              showMore 
+              @moreClicked="handleGoCollect()"
             >
-              <Avatar 
-                :src="authStore.userInfo?.avatar" 
-                :size="65"
-                defaultAvatar="https://xy.wenlvti.net/app_static/images/mine/DefaultAvatar.png"
-              />
-              <FlexCol position="relative" gap="gap.sm" :padding="[0,30,0,0]">
-                <FlexRow align="center" :gap="10">
-                  <Text :text="displayMyName" fontSize="23" :lines="1" :maxWidth="150" fontConfig="lightImportantTitle" />
-                  <Button 
-                    v-if="isStaff" 
-                    :innerStyle="{ position: 'absolute', right: 0, top: 0, transform: 'translate(5%,-50%)' }"
-                    text="管理" size="mini" radius="radius.md" type="danger" 
-                    @click="staffInfoRef?.show()" 
+              <template #right>
+                <FlexRow align="center" gap="gap.md">
+                  <FrameButton
+                    text="管理" 
+                    size="small" 
+                    primary
+                    @click="handleGoDigManage()"
+                  />
+                  <FrameButton
+                    text="共编村史" 
+                    icon="https://xy.wenlvti.net/app_static/images/village/IconHistory.png"
+                    size="small"
+                    @click="handleGoCollect()"
                   />
+                  <Width :size="15" />
                 </FlexRow>
-                <IconButton icon="edit-filling" size="26" @click="changeNickRef?.show()">
-                  <Text text="改昵称" :wrap="false" fontSize="22" fontConfig="subText" />
-                </IconButton>
-              </FlexCol>
-            </FlexRow>
-            <Button 
-              v-else
-              icon="https://xy.wenlvti.net/app_static/images/village/IconFollow.png"
-              radius="radius.lgr"
-              text="加入"
-              @click="handleGoJoin()"
+              </template>
+            </HomeTitle>
+            <ProvideVar :vars="{
+              GridItemIconSize: 90,
+              GridItemBackgroundColor: 'transparent',
+              GridItemPaddingHorizontal: 0,
+              GridItemPaddingVertical: 8,
+            }">
+              <Grid :borderGrid="false" :mainAxisCount="4">
+                <GridItem title="村社概况" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeIntrod.png" touchable @click="handleGoCollect(11, '村社概况')" />
+                <GridItem title="自然风光" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeEnvirounment.png" touchable @click="handleGoCollect(13, '自然风光')" />
+                <GridItem title="历史沿革" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeHistory.png" touchable @click="handleGoCollect(2, '历史沿革')" />
+                <GridItem title="特色产业" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeIndustry.png" touchable @click="handleGoCollect(9, '特色产业'  )"    />
+                <GridItem title="文艺活动" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeActivity.png" touchable @click="handleGoCollect(12, '文艺活动')" />
+                <GridItem title="非遗展示" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeShow.png" touchable @click="handleGoCollect(10, '非遗展示')" />
+                <GridItem title="民俗风采" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeFolkloreVibe.png" touchable @click="handleGoCollect(8, '民俗风采')" />
+                <GridItem title="历史人物" icon="https://xy.wenlvti.net/app_static/images/village/IconGoods.png" touchable @click="handleGoCollect(7, '历史人物')" />
+              </Grid>
+            </ProvideVar>
+
+          </template>
+          <template v-else-if="node.type === 'Block:Games'">
+
+            <!-- 活力乡源 -->
+            <HomeTitle :title="node.props?.title" />
+            <ProvideVar :vars="{
+              GridItemIconSize: 90,
+              GridItemBackgroundColor: 'transparent',
+              GridItemPaddingHorizontal: 0,
+              GridItemPaddingVertical: 8,
+            }">
+              <Grid :borderGrid="false" :mainAxisCount="4">
+                <GridItem 
+                  v-for="item in node.props?.items" 
+                  :key="item.title"
+                  :title="item.title"
+                  :icon="item.icon"
+                  touchable 
+                  @click="() => context.getScriptEngine().execute(item.handler, node.getNodeScriptContext?.())" 
+                />
+              </Grid>
+            </ProvideVar>
+
+          </template>
+          <template v-else-if="node.type === 'Block:Official'">
+
+            <!-- 文脉乡源 -->
+            <HomeTitle :title="node.props?.title">
+              <template #right>
+                <FlexRow align="center" gap="gap.md">
+                  <BubbleTip
+                    v-model:show="showManageTip"
+                    position="bottom"
+                    crossPosition="right"
+                    arrowOffsetX="180rpx"
+                    content="村社贴图太乱?点这里整理"
+                    @contentClick="handleCloseManageTip(true)"
+                    @close="handleCloseManageTip(false)"
+                  >
+                    <FrameButton
+                      text="管理" 
+                      size="small" 
+                      primary
+                      @click="handleGoOfficalManage()"
+                    />
+                  </BubbleTip>
+                  <FrameButton 
+                    text="乡源AI帮你写" 
+                    size="small" 
+                    icon="https://xy.wenlvti.net/app_static/images/village/IconLargeHistory.png"
+                    @click="handleGoPublish()" 
+                  />
+                </FlexRow>
+              </template>
+            </HomeTitle>
+            <RoundTags 
+              v-model:active="listActiveTag" 
+              :tags="node.props?.tags" 
+            />
+            <OfficialAccountPublishWrap
+              :topic="recommendTagName" 
+              :path="recommendPath"
+              @publishsuccess="onPublishSuccess"
+              @empty="isOfficialEmpty=true"
             />
-          </FlexRow>
-        </FlexRow>
-      </FlexRow>
 
-      <FlexView :direction="descOpen ? 'column' : 'row'" gap="gap.md" justify="space-between" align="flex-start">
-        <!-- 简介 -->
-        <TextEllipsis 
-          fontConfig="secondText"
-          :outerProps="{ direction: 'row', width: descOpen ? '' : '490rpx' }"
-          :lines="1" 
-          :expandable="(villageInfoLoader.content.value?.desc as string || '').length > 20"
-          :text="(villageInfoLoader.content.value?.desc as string) || '暂无简介,欢迎您来编写完善!'"
-          wordBreak="break-all"
-          @expand="descOpen = true;"
-          @collapse="descOpen = false;"
-        />
-        <Button size="mini" icon="edit-filling" text="编辑简介" @click="handleGoEdit" />
-      </FlexView>
-
-      <!-- 状态与申请 -->
-      <FlexRow 
-        backgroundColor="background.tertiary" 
-        radius="radius.md" 
-        padding="space.md" 
-        justify="space-between" 
-      >
-        <FlexRow align="center">
-          <FlexCol gap="gap.md"> 
-            <FlexRow gap="gap.lg" align="center">
-              <Icon v-if="villageInfoLoader.content.value?.vipLevel === 4" name="https://xy.wenlvti.net/app_static/images/village/IconGov.png" :size="70" />
-              <Text :text="`${villageInfoLoader.content.value?.vipLevelText || '默认级别'}`" fontConfig="secondText" />  
-            </FlexRow>
-            <Text v-if="villageInfoLoader.content.value?.sizeLimit" :text="`存储空间:${villageInfoLoader.content.value?.sizeText || ''}`" :fontSize="22" fontConfig="secondText" />
-            <Text v-else text="无限存储空间" :fontSize="22" fontConfig="secondText" />
-          </FlexCol>
-        </FlexRow>
-        <FlexRow align="center" gap="gap.md">
-          <IconButton icon="help-filling" @click="navTo('/pages/article/details', { id: 7021, modelId: 18, showRecommend: false })" />
-          <Button 
-            icon="https://xy.wenlvti.net/app_static/images/village/IconUser.png" 
-            radius="radius.lgr" 
-            :padding="[10, 30]" 
-            backgroundColor="white" 
-            text="升级村社"
-            @click="upgradeRef?.show()"
-          />
-        </FlexRow>
-      </FlexRow>
+          </template>
+        </template>
+      </DynamicXNodeChild>
+
       
-      <!-- 图片 -->
-      <VillageGallery
-        v-if="villageInfoLoader.content.value"
-        ref="villageGalleryRef"
-        :villageId="villageStore.currentVillage?.id ?? 0" 
-        @goGallery="handleGoGallery"
-      />
-
-      <!-- 地址 -->
-      <FlexRow justify="space-between" align="center">
-        <FlexRow align="center" gap="gap.sm">
-          <Icon name="https://xy.wenlvti.net/app_static/images/village/IconMap.png" size="fontSize.md" />
-          <Text :text="villageInfoLoader.content.value?.address" fontConfig="contentText" fontSize="fontSize.sm" />
-        </FlexRow>
-        <Button size="mini" icon="picture" text="村社相册" @click="handleGoGallery" />
-      </FlexRow>
-      <VillageMiniMap 
-        v-if="villageInfoLoader.content.value"
-        :lonlat="{ 
-          longitude: villageInfoLoader.content.value.longitude, 
-          latitude: villageInfoLoader.content.value.latitude 
-        }" 
-        :currentNoticeContent="currentNoticeContent"
-      />
-
-      <FlexRow justify="space-between" align="center">
-        <FlexRow center gap="gap.lg" flexBasis="50%">
-          <Text text="村社排名" fontConfig="contentText" />
-          <Text text="No." fontConfig="lightTitle" />
-          <Text :text="villageInfoLoader.content.value?.rankText" fontConfig="primaryTitle" />
-        </FlexRow>
-        <FlexRow center gap="gap.lg" flexBasis="50%">
-          <Text text="村社等级" fontConfig="contentText" />
-          <Text :text="`${villageInfoLoader.content.value?.level}级`" fontConfig="primaryTitle" />
-        </FlexRow>
-      </FlexRow>
-
-      <FlexRow backgroundColor="background.tertiary" radius="radius.md" :padding="[30, 20]">
-        <Touchable direction="column" center gap="gap.sm" flexBasis="25%" @click="navTo('/pages/home/village/task/index')">
-          <Text text="乡源光" fontConfig="secondText" fontSize="fontSize.sm" />
-          <Text :text="villageInfoLoader.content.value?.light || '0'" fontConfig="importantTitle" />
-          <Button type="text" size="mini" text="做任务" @click="navTo('/pages/home/village/task/index')" />
-        </Touchable>
-        <Divider type="vertical" />
-        <FlexCol center gap="gap.sm" flexBasis="25%">
-          <Touchable direction="column" center @click="navTo('/pages/home/village/volunteer/list', { villageId: villageStore.currentVillage?.id ?? undefined })">
-            <Text text="乡源人数" fontConfig="contentText" fontSize="fontSize.sm" />
-            <Text :text="villageInfoLoader.content.value?.memberCount|| '0'" fontConfig="importantTitle" />
-          </Touchable>
-          <WxButton openType="share">
-            <Button type="text" size="mini" text="邀请加入" @click="navTo('/pages/home/village/task/index')" />
-          </WxButton>
-        </FlexCol>
-        <Divider type="vertical" />
-        <Touchable direction="column" center gap="gap.sm" flexBasis="25%" @click="navTo('/pages/home/village/follow/list', {
-          villageId: villageStore.currentVillage?.id ?? undefined,
-        })">
-          <Text text="关注人数" fontConfig="contentText" fontSize="fontSize.sm" />
-          <Text :text="villageInfoLoader.content.value?.followerCount|| '0'" fontConfig="importantTitle" />
-          <Height :size="36" />
-        </Touchable>
-        <Divider type="vertical" />
-        <Button 
-          :padding="0" 
-          type="text" 
-          size="small" 
-          textColor="text.title"
-          rightIcon="arrow-right" 
-          @click="handleGoNew()"
-        >
-          <FlexCol>
-            <Text text="新手" fontConfig="contentText" />
-            <Text text="上路" fontConfig="contentText" />
-          </FlexCol>
-        </Button>
-      </FlexRow>
-    </BackgroundBox>
-
-    <!-- 排行榜 -->
-    <HomeTitle 
-      title="排行榜" 
-      showMore 
-      @moreClicked="navTo('/pages/home/village/rank/volunteer', {
-        villageId: villageStore.currentVillage?.id ?? undefined,
-      })" 
-    />
-    <!-- <RoundTags v-model:active="rankActiveTag" :tags="['乡源果', '乡源光', '文化积分' ]" /> -->
-    <VillageUserRankList 
-      :list="villageUserRankListLoader.content.value ?? []" 
-      :scoreSuffix="rankActiveTag"
-      @goDetails="navTo('/pages/home/village/volunteer/detail', { id: $event.id })"
-    />
-
-    <!-- 魅力乡源 -->
-    <HomeTitle title="魅力乡源" showMore @moreClicked="handleGoCollect()">
-      <template #right>
-        <FlexRow align="center" gap="gap.md">
-          <FrameButton
-            text="管理" 
-            size="small" 
-            primary
-            @click="handleGoDigManage()"
-          />
-          <FrameButton
-            text="共编村史" 
-            icon="https://xy.wenlvti.net/app_static/images/village/IconHistory.png"
-            size="small"
-            @click="handleGoCollect()"
-          />
-          <Width :size="15" />
-        </FlexRow>
-      </template>
-    </HomeTitle>
-    <ProvideVar :vars="{
-      GridItemIconSize: 90,
-      GridItemBackgroundColor: 'transparent',
-      GridItemPaddingHorizontal: 0,
-      GridItemPaddingVertical: 8,
-    }">
-      <Grid :borderGrid="false" :mainAxisCount="4">
-        <GridItem title="村社概况" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeIntrod.png" touchable @click="handleGoCollect(11, '村社概况')" />
-        <GridItem title="自然风光" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeEnvirounment.png" touchable @click="handleGoCollect(13, '自然风光')" />
-        <GridItem title="历史沿革" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeHistory.png" touchable @click="handleGoCollect(2, '历史沿革')" />
-        <GridItem title="特色产业" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeIndustry.png" touchable @click="handleGoCollect(9, '特色产业'  )"    />
-        <GridItem title="文艺活动" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeActivity.png" touchable @click="handleGoCollect(12, '文艺活动')" />
-        <GridItem title="非遗展示" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeShow.png" touchable @click="handleGoCollect(10, '非遗展示')" />
-        <GridItem title="民俗风采" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeFolkloreVibe.png" touchable @click="handleGoCollect(8, '民俗风采')" />
-        <GridItem title="历史人物" icon="https://xy.wenlvti.net/app_static/images/village/IconGoods.png" touchable @click="handleGoCollect(7, '历史人物')" />
-      </Grid>
-    </ProvideVar>
-
-    <!-- 活力乡源 -->
-    <HomeTitle title="活力乡源" />
-    <ProvideVar :vars="{
-      GridItemIconSize: 90,
-      GridItemBackgroundColor: 'transparent',
-      GridItemPaddingHorizontal: 0,
-      GridItemPaddingVertical: 8,
-    }">
-      <Grid :borderGrid="false" :mainAxisCount="4">
-        <GridItem title="乡源荣光" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeHornor.png" touchable @click="handleGoCollect(23, '乡源荣光')" />
-        <GridItem title="乡源好物" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeGoods.png" touchable @click="navTo('/pages/home/village/goods/index', { villageId: villageStore.currentVillage?.id ?? undefined })" />
-        <GridItem title="乡源树" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeTree.png" touchable @click="emit('goTree')" />
-        <!-- <GridItem title="政贤连心" icon="https://xy.wenlvti.net/app_static/images/village/IconGovAffairs.png" touchable @click="navTo('/pages/home/village/gov/index')" /> -->
-        <GridItem title="互动游戏" icon="https://xy.wenlvti.net/app_static/images/village/IconLargeGame.png" touchable @click="navTo('/pages/home/village/games/index')" />
-      </Grid>
-    </ProvideVar>
-
-    <!-- 文脉乡源 -->
-    <HomeTitle title="文脉乡源">
-      <template #right>
-        <FlexRow align="center" gap="gap.md">
-          <BubbleTip
-            v-model:show="showManageTip"
-            position="bottom"
-            crossPosition="right"
-            arrowOffsetX="180rpx"
-            content="村社贴图太乱?点这里整理"
-            @contentClick="handleCloseManageTip(true)"
-            @close="handleCloseManageTip(false)"
-          >
-            <FrameButton
-              text="管理" 
-              size="small" 
-              primary
-              @click="handleGoOfficalManage()"
-            />
-          </BubbleTip>
-          <FrameButton 
-            text="乡源AI帮你写" 
-            size="small" 
-            icon="https://xy.wenlvti.net/app_static/images/village/IconLargeHistory.png"
-            @click="handleGoPublish()" 
-          />
-        </FlexRow>
-      </template>
-    </HomeTitle>
-    <RoundTags 
-      v-model:active="listActiveTag" 
-      :tags="listTags" 
-    />
-  </FlexCol>
-  <OfficialAccountPublishWrap
-    :topic="recommendTagName" 
-    :path="recommendPath"
-    @publishsuccess="onPublishSuccess"
-    @empty="isOfficialEmpty=true"
-  />
+
+
+    </template>
+  </DynamicX>
+
   <UpgradeDialog 
     ref="upgradeRef"
     v-if="villageStore.currentVillage"
@@ -416,6 +453,9 @@ import ChangeNickDialog from '../dialogs/ChangeNickDialog.vue';
 import StaffInfoDialog from '../dialogs/StaffInfoDialog.vue';
 import ShareQRDialog from '../dialogs/ShareQRDialog.vue';
 import FlexView from '@/components/layout/FlexView.vue';
+import DynamicX from '@/components/dynamic/DynamicX.vue';
+import type { DynamicXPage } from '@/components/dynamic/DynamicX';
+import DynamicXNodeChild from '@/components/dynamic/x/DynamicXNodeChild.vue';
 
 const emit = defineEmits<{
   (e: 'goTree'): void;
@@ -459,7 +499,7 @@ const villageInfoLoader = useSimpleDataLoader(async () => {
     sizeText: `${FormatUtils.formatSize(village?.storageUsed || 0)}/${FormatUtils.formatSize(village?.storageLimit || 0)}`,
     sizeLimit: (village?.storageLimit || 0) >= 0,
     level: village?.level || 1,
-    vipLevel: village?.vipLevel || '',
+    vipLevel: village?.vipLevel || 0,
     vipLevelText: village?.vipName ? `已升级 · ${village?.vipName}` : '',
     rankText: village?.rank.toString() || '',
     light: village?.light || 0,
@@ -485,7 +525,6 @@ const isStaff = computed(() => staffInfo.value?.type === 'staff');
 const shareQRDialog = ref<InstanceType<typeof ShareQRDialog>>();
 
 const descOpen = ref(false);
-const rankActiveTag = ref('文化积分');
 const listActiveTag = ref('广场');
 
 const villageUserRankListLoader = useSimpleDataLoader(async () => {
@@ -527,12 +566,6 @@ function handleCloseManageTip(goComp: boolean) {
   showManageTipTimeout.recordTime();
 }
 
-const listTags = ['广场', '美食', '美景', '故事', '老手艺', '老物件'];
-
-watch(rankActiveTag, () => {
-  villageUserRankListLoader.reload();
-});
-
 const recommendTagName = computed(() => {
   return '亮乡源·' + villageInfoLoader.content.value?.title + '·' + listActiveTag.value;
 });
@@ -647,6 +680,110 @@ const upgradeRef = ref<InstanceType<typeof UpgradeDialog>>();
 const villageGalleryRef = ref<InstanceType<typeof VillageGallery>>();
 const changeNickRef = ref<InstanceType<typeof ChangeNickDialog>>();
 
+const pageScriptGlobal = {
+  handleGoCollect,
+  getVillageId: () => villageStore.currentVillage?.id ?? 0,
+  navTo,
+  emit,
+};
+const page : DynamicXPage = {
+  name: 'VILLAGE',
+  type: 'page',
+  nodes: [
+    {
+      type: 'flex',
+      props: {
+        direction: 'column',
+        padding: [30,30,0,30],
+        gap: "gap.lg"
+      },
+      nodes: [
+        {
+          type: 'BackgroundBox',
+          props: {
+            color1: "#eecaa0",
+            color2: "white",
+            color2Position: "85%",
+            color3: "white",
+            radius: "radius.lg",
+            direction: "column",
+            padding: [35,30],
+            gap: "gap.lg",
+          },
+          nodes: [
+            {
+              type: 'Card:Basic',
+            },
+            {
+              type: 'Card:Level',
+            },
+            {
+              type: 'Card:Gallery',
+            },
+            {
+              type: 'Card:AddressAndMap',
+            },
+            {
+              type: 'Card:Static',
+            },
+          ],
+        },
+        {
+          type: 'Block:Rank',
+          props: {
+            title: '排行榜',
+          },
+        },
+        {
+          type: 'Block:Collect',
+          props: {
+            title: '魅力乡源',
+          },
+        },
+        {
+          type: 'Block:Games',
+          props: {
+            title: '活力乡源',
+            items: [
+              {
+                title: '乡源荣光',
+                icon: 'https://xy.wenlvti.net/app_static/images/village/IconLargeHornor.png',
+                handler: 'globalContext.handleGoCollect(23, \'乡源荣光\')',
+              },
+              {
+                title: '乡源好物',
+                icon: 'https://xy.wenlvti.net/app_static/images/village/IconLargeGoods.png',
+                handler: 'globalContext.navTo("/pages/home/village/goods/index", { villageId: globalContext.getVillageId() })',
+              },
+              {
+                title: '乡源树',
+                icon: 'https://xy.wenlvti.net/app_static/images/village/IconLargeTree.png',
+                handler: 'globalContext.emit("goTree")',
+              },
+              {
+                title: '互动游戏',
+                icon: 'https://xy.wenlvti.net/app_static/images/village/IconLargeGame.png',
+                handler: 'globalContext.navTo("/pages/home/village/games/index")',
+              },
+            ]
+          },
+        },
+        {
+          type: 'Block:Official',
+          props: {
+            title: '文脉乡源',
+            tags: [
+              '广场', '美食', '美景', 
+              '故事', 
+              '老手艺', '老物件'
+            ]
+          },
+        },
+      ],
+    },
+  ],
+};
+
 watch(() => villageStore.currentVillage, async () => {
   await waitTimeOut(100);
   await villageInfoLoader.reload();

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

@@ -0,0 +1,22 @@
+{
+  "blocks": [
+    {
+      "type": "mainBox",
+      "text": "",
+      "boxProps": {
+
+      },
+      "children": [
+        { "type": "mainBox:gallery" }
+      ]
+    },
+    {
+      "type": "volunteerRank",
+      "text": "排行榜"
+    },
+    {
+      "type": "text",
+      "text": "默认卡片内容"
+    }
+  ]
+}

+ 27 - 18
src/pages/index.vue

@@ -17,24 +17,33 @@
         align="left"
         :zIndex="200"
       />
-      <HomeIndex 
-        ref="homeIndex" 
-        v-show="tabIndex === 0"
-        :currentScrollTop="currentScrollTop"
-        @goVillage="tabIndex = 1" 
-        @goDig="tabIndex = 2" 
-      />
-      <VillageIndex 
-        v-if="tabIndex === 1"
-        ref="villageIndex" 
-        showSwitch
-        @goHome="tabIndex=0" 
-        @goMap="homeIndex?.goMap()"
-        @goDiscover="tabIndex = 3"
-      />
-      <DigIndex v-show="tabIndex === 2" />
-      <DiscoverIndex v-show="tabIndex === 3" @goVillage="tabIndex = 1" />
-      <UserIndex v-show="tabIndex === 4" @goSubmit="tabIndex = 2" />
+      <view v-show="tabIndex === 0">
+        <HomeIndex 
+          ref="homeIndex" 
+          :currentScrollTop="currentScrollTop"
+          @goVillage="tabIndex = 1" 
+          @goDig="tabIndex = 2" 
+        />
+      </view>
+      <view>
+        <VillageIndex 
+          v-if="tabIndex === 1"
+          ref="villageIndex" 
+          showSwitch
+          @goHome="tabIndex=0" 
+          @goMap="homeIndex?.goMap()"
+          @goDiscover="tabIndex = 3"
+        />
+      </view>
+      <view v-show="tabIndex === 2">
+        <DigIndex  />
+      </view>
+      <view v-show="tabIndex === 3">
+        <DiscoverIndex @goVillage="tabIndex = 1" />
+      </view>
+      <view v-show="tabIndex === 4">
+        <UserIndex @goSubmit="tabIndex = 2" />
+      </view>
       <TabBar
         v-model:selectedTabIndex="tabIndex"
         fixed

+ 35 - 0
src/pages/test/dynamic.vue

@@ -0,0 +1,35 @@
+<template>
+  <DynamicX
+    :page="page"
+  />
+</template>
+
+<script lang="ts" setup>
+import type { DynamicXPage } from '@/components/dynamic/DynamicX';
+import DynamicX from '@/components/dynamic/DynamicX.vue';
+
+
+
+const page : DynamicXPage = {
+  name: 'test',
+  type: 'page',
+  title: '动态页面',
+  nodes: [
+    {
+      type: 'flex',
+      props: {
+        direction: 'column',
+        align: 'center',
+        justify: 'center',
+      },
+      nodes: [
+        {
+          type: 'text',
+          text: '这是一个动态页面',
+        },
+      ],
+    },
+  ],
+};
+
+</script>