Prechádzať zdrojové kódy

⚙️ 动态调整核心

快乐的梦鱼 1 mesiac pred
rodič
commit
e4bf9d4c86
26 zmenil súbory, kde vykonal 614 pridanie a 118 odobranie
  1. 2 0
      src/App.vue
  2. 21 10
      src/pages/article/common/CommonContent.ts
  3. 3 1
      src/pages/article/common/CommonListPage.ts
  4. 19 3
      src/pages/article/common/CommonListPage.vue
  5. 1 1
      src/pages/article/common/DetailTabPage.vue
  6. 2 1
      src/pages/article/data/CommonCategoryBlocks.ts
  7. 21 32
      src/pages/article/data/CommonCategoryBlocks.vue
  8. 40 26
      src/pages/article/data/CommonCategoryDetail.vue
  9. 0 1
      src/pages/article/data/CommonCategoryDetailContentBlocks.vue
  10. 34 0
      src/pages/article/data/CommonCategoryDynamicData.ts
  11. 292 0
      src/pages/article/data/CommonCategoryDynamicEvax.ts
  12. 5 0
      src/pages/article/data/CommonCategoryList.vue
  13. 5 1
      src/pages/article/data/CommonCategoryListBlock.vue
  14. 4 9
      src/pages/article/data/defines/Details.ts
  15. 17 2
      src/pages/article/data/editor/components/ArrayEditor.vue
  16. 72 12
      src/pages/article/data/editor/components/DataSolveEditor.vue
  17. 2 2
      src/pages/article/data/editor/components/KeyValueEditor.vue
  18. 9 8
      src/pages/article/data/editor/components/ValueEditor.vue
  19. 13 3
      src/pages/article/data/editor/editors/DetailPropsEditor.vue
  20. 2 0
      src/pages/article/data/editor/subpart/NestCategoryEditorItem.vue
  21. 1 2
      src/pages/article/details.vue
  22. 1 1
      src/pages/inhert/map/index.vue
  23. 0 1
      src/pages/parts/Box2LineImageRightShadow.vue
  24. 0 1
      src/pages/parts/Box2LineLargeImageUserShadow.vue
  25. 47 0
      src/pages/parts/Grid4Item.vue
  26. 1 1
      src/pages/video/details.vue

+ 2 - 0
src/App.vue

@@ -5,6 +5,7 @@ import { configTheme } from './components/theme/ThemeDefine';
 import { LogUtils } from '@imengyu/imengyu-utils';
 import { useCommonCategoryGlobalLoader } from './pages/article/data/CommonCategoryGlobalLoader';
 import { useAppConfiguration } from './api/system/useAppConfiguration';
+import AppCofig from './common/config/AppCofig';
 
 const TAG = 'App';
 const authStore = useAuthStore();
@@ -46,6 +47,7 @@ configTheme(false, (theme, darkTheme) => {
   theme.colorConfigs.background.primary = '#ffcfc6';
   theme.colorConfigs.background.warning = '#f5ebe0';
   theme.colorConfigs.background.page = '#f6f2e7';
+  theme.varOverrides.ImageDefaultImage = AppCofig.defaultImage;
   return [theme, darkTheme];
 });
 </script>

+ 21 - 10
src/pages/article/common/CommonContent.ts

@@ -2,6 +2,7 @@ import { GetContentListItem, GetContentListParams } from "@/api/CommonContent";
 import { navTo } from "@/components/utils/PageAction";
 import { type IHomeCommonCategoryListTabListDataSolve } from "../data/CommonCategoryDefine";
 import { DateUtils } from "@imengyu/imengyu-utils";
+import { doEvaluateDynamicDataExpression } from "../data/CommonCategoryDynamicEvax";
 
 /**
  * 通用内容首页小列表控制代码组合
@@ -52,27 +53,27 @@ export function resolveCommonContentGetPageDetailUrlAuto(item: GetContentListIte
 }
 
 const resolveCommonContentData = {
-  'none': (item: GetContentListItem[]) => item,
-  'common': (item: GetContentListItem[]) => {
+  'none': (item: any[]) => item,
+  'common': (item: any[]) => {
     item.forEach(it => {
       it.bottomTags = it.bottomTags || [];
       it.bottomTags = (it.bottomTags as string[]).concat(it.keywords?.length ? it.keywords as string[] : [ it.mainBodyColumnName ]);
     })
     return item;
   },
-  'date': (item: GetContentListItem[]) => {
+  'date': (item: any[]) => {
     item.forEach(p => {
       p.desc = DateUtils.formatDate(p.publishAt, 'YYYY-MM-dd') + (p.desc ? '\n' : '') + (p.desc || '');
     })
     return item;
   },
-  'form': (item: GetContentListItem[]) => {
+  'form': (item: any[]) => {
     item.forEach(p => {
       p.desc = `来源:${p.from || '暂无'}` + ' ' + (p.desc || '');
     })
     return item;
   },
-  'ich': (item: GetContentListItem[]) => {
+  'ich': (item: any[]) => {
     item.forEach(it => {
       it.bottomTags = (it.bottomTags as string[] || []).concat([
         it.levelText as string, 
@@ -83,7 +84,7 @@ const resolveCommonContentData = {
     });
     return item;
   },
-  'inheritor': (item: GetContentListItem[]) => {
+  'inheritor': (item: any[]) => {
     item.forEach(it => {
       it.bottomTags = (it.bottomTags as string[] || []).concat([
         it.age as string, 
@@ -91,16 +92,26 @@ const resolveCommonContentData = {
     });
     return item;
   },
-  'inheritor-deadbox': (item: GetContentListItem[]) => {
+  'inheritor-deadbox': (item: any[]) => {
     item.forEach(it => {
       it.titleBox = Boolean(it.deathBirth);
     });
     return item;
   },
-} as Record<string, (item: GetContentListItem[]) => GetContentListItem[]>
+} as Record<string, (item: any[]) => any[]>
 
-export function resolveCommonContentSolveProps(res: GetContentListItem[], dataSolve: IHomeCommonCategoryListTabListDataSolve[]) {
-  for (const solve of dataSolve)
+export function resolveCommonContentSolveProps(res: any[], dataSolve: IHomeCommonCategoryListTabListDataSolve[]) {
+  for (const solve of dataSolve) {
+    if (solve.startsWith('dynamic:')) {
+      const dynamicExpression = solve.substring(8);
+      res = doEvaluateDynamicDataExpression(dynamicExpression, {
+        sourceData: {
+          main: res,
+          customData: {},
+        },
+      });
+    }
     res = resolveCommonContentData[solve]?.(res) || res;
+  }
   return res;
 }

+ 3 - 1
src/pages/article/common/CommonListPage.ts

@@ -1,10 +1,12 @@
 export type CommonListPageItemType = 'image-large-2'|'image-large'|'article-common'
   |'article-common-2'|'article-character'|'article-character-2'
-  |'simple-text'|'simple-text-2';
+  |'simple-text'|'simple-text-2'|'image-small'|'image-small-2';
 
 export const CommonListPageItemTypeOptions = [
   { value: 'image-large-2', label: '大图2列 (image-large-2)' },
   { value: 'image-large', label: '大图 (image-large)' },
+  { value: 'image-small', label: '小图 (image-small)' },
+  { value: 'image-small-2', label: '小图2列 (image-small-2)' },
   { value: 'article-common', label: '普通文章 (article-common)' },
   { value: 'article-common-2', label: '普通文章2列 (article-common-2)' },
   { value: 'article-character', label: '人物文章 (article-character)' },

+ 19 - 3
src/pages/article/common/CommonListPage.vue

@@ -128,7 +128,22 @@
             :badge="item.badge"
             @click="goDetails(item, item.id)"
           />
-
+          <Grid4Item 
+            v-else-if="itemType.startsWith('image-small')"
+            :title="item.title"
+            :image="item.image"
+            :classNames="itemType.endsWith('-2') ? 'half' : 'full'"
+            @click="goDetails(item, item.id)"
+          />
+          <Box2LineRightShadow
+            v-else
+            :key="i" 
+            :title="item.title"
+            :titleBox="item.titleBox"
+            :desc="item.desc"
+            :tags="(item.bottomTags as string[])"
+            @click="goDetails(item, item.id)"
+          />
         </view>
         <view v-if="itemType.endsWith('-2') && listLoader.list.value.length % 2 != 0" class="width-1-2" />
       </view>
@@ -146,15 +161,16 @@ import SimplePageListLoader from '@/common/components/SimplePageListLoader.vue';
 import Box2LineLargeImageUserShadow from '@/pages/parts/Box2LineLargeImageUserShadow.vue';
 import Box2LineImageRightShadow from '@/pages/parts/Box2LineImageRightShadow.vue';
 import SimpleDropDownPicker, { type SimpleDropDownPickerItem } from '@/common/components/SimpleDropDownPicker.vue';
-import AppCofig from '@/common/config/AppCofig';
 import Tabs from '@/components/nav/Tabs.vue';
 import SearchBar from '@/components/form/SearchBar.vue';
 import Empty from '@/components/feedback/Empty.vue';
 import { resolveCommonContentGetPageDetailUrlAuto } from './CommonContent';
 import type { CommonListPageItemType } from './CommonListPage';
+import Grid4Item from '@/pages/parts/Grid4Item.vue';
+import Box2LineRightShadow from '@/pages/parts/Box2LineRightShadow.vue';
 
 function getImage(item: any) {
-  return item.thumbnail || item.image || AppCofig.defaultImage
+  return item.thumbnail || item.image
 }
 function getItemClass(index: number) {
   return props.itemType.endsWith('-2') ? (index % 2 != 0 ? 'ml-1' : 'mr-1') : ''

+ 1 - 1
src/pages/article/common/DetailTabPage.vue

@@ -79,7 +79,7 @@ const emit = defineEmits([
 
 const tabCurrentIndex = ref(0);
 const tabCurrentId = computed(() => {
-  return props.tabs[tabCurrentIndex.value]?.id ?? -1;
+  return props.tabs.filter((item) => item.visible)[tabCurrentIndex.value]?.id ?? -1;
 })
 
 const emptyContent = computed(() => {

+ 2 - 1
src/pages/article/data/CommonCategoryBlocks.ts

@@ -5,7 +5,7 @@ export interface CategoryDefine extends Omit<IHomeCommonCategoryListTabNestCateg
   title: string;
   showTitle: boolean;
   content: IHomeCommonCategoryDynamicData|IHomeCommonCategoryDynamicDataDetailContent|null;
-  type?: 'article'|'large-image2'|'horizontal-large'|'large-image'|'large-grid2'|'small-grid2'|'simple-article'|'simple-text'
+  type?: 'article'|'large-image2'|'horizontal-large'|'large-image'|'large-grid2'|'small-grid'|'small-grid2'|'simple-article'|'simple-text'
     |'default'
     |'CalendarBlock'|'StatsBlock'|'MapBlock'|'AudioBlock'
     |undefined;
@@ -23,6 +23,7 @@ export const CommonCategoryBlockType : {
   { type: 'large-image2', label: '大图2列' },
   { type: 'large-grid2', label: '大图2列' },
   { type: 'small-grid2', label: '小图2列' },
+  { type: 'small-grid', label: '小图' },
   { type: 'horizontal-large', label: '横排大图' },
   { type: 'CalendarBlock', label: '预制块:日历块' },
   { type: 'StatsBlock', label: '预制块:统计块' },

+ 21 - 32
src/pages/article/data/CommonCategoryBlocks.vue

@@ -115,28 +115,28 @@
             </FlexRow>
           </template>
           <template v-else-if="category.type === 'small-grid2'">
-            <view class="d-flex flex-row justify-between flex-wrap">
-              <view 
+            <FlexRow wrap justify="space-between" align="stretch">
+              <Grid4Item 
                 v-for="(tab, k) in category.data.content.value"
                 :key="k"
-                class="grid4-item position-relative mb-3"
+                :title="tab.title"
+                :image="tab.thumbnail || tab.image"
+                classNames="half"
                 @click="category.detailsPage(tab)"
-              >
-                <text 
-                  class="tag bg-mask-white color-primary radius-l p-1 position-absolute size-s text-lines-1"
-                >
-                  {{ tab.title }}
-                </text> 
-                <Image
-                  width="100%"
-                  :height="250"
-                  :radius="15"
-                  :defaultImage="AppCofig.defaultImage"
-                  :src="tab.thumbnail || tab.image"
-                  mode="aspectFit"
-                />
-              </view>  
-            </view>
+              />
+            </FlexRow>
+          </template>
+          <template v-else-if="category.type === 'small-grid'">
+            <FlexCol :gap="10" align="center">
+              <Grid4Item 
+                v-for="(tab, k) in category.data.content.value"
+                :key="k"
+                :title="tab.title"
+                :image="tab.thumbnail || tab.image"
+                classNames="full"
+                @click="category.detailsPage(tab)"
+              />
+            </FlexCol>
           </template>
           <template v-else-if="category.type === 'simple-text'">
             <FlexCol>
@@ -211,6 +211,7 @@ import type { CategoryDefine } from './CommonCategoryBlocks';
 import { CommonCategoryDynamicDataSerializedApi, doGetDynamicListDataParams, type IHomeCommonCategoryDynamicDataDetailContent } from './CommonCategoryDynamicData';
 import AudioBlock from '@/pages/blocks/AudioBlock.vue';
 import { toast } from '@/components/utils/DialogAction';
+import Grid4Item from '@/pages/parts/Grid4Item.vue';
 
 const props = defineProps({
   /**
@@ -377,16 +378,4 @@ function loadCategoryDatas() {
 
 watch(categoryDatas, loadCategoryDatas);
 onMounted( loadCategoryDatas);
-</script>
-
-<style lang="scss">
-.grid4-item {
-  width: 320rpx;
-
-  .tag {
-    top: 2rpx; 
-    right: 2rpx;
-    z-index: 20;
-  }
-}
-</style>
+</script>

+ 40 - 26
src/pages/article/data/CommonCategoryDetail.vue

@@ -136,10 +136,10 @@ import { computed, onMounted, ref, watch } from 'vue';
 import { navTo } from '@/components/utils/PageAction';
 import { injectAppConfiguration } from '@/api/system/useAppConfiguration';
 import { injectCommonCategory } from './CommonCategoryGlobalLoader';
-import { doLoadDynamicCategoryDataMergeTypeGetColumns } from './CommonCategoryDynamicData';
+import { doLoadDynamicCategoryDataMergeTypeGetColumns, doSerializeInternalVar } from './CommonCategoryDynamicData';
 import { formatError, StringUtils, waitTimeOut } from '@imengyu/imengyu-utils';
 import { getIsDevtoolsPlatform } from '@/common/utils/MpVersions';
-import type { IHomeCommonCategoryDefine, IHomeCommonCategoryListTabNestCategoryItemDefine } from './CommonCategoryDefine';
+import type { IHomeCommonCategoryDefine, IHomeCommonCategoryListTabListDataSolve, IHomeCommonCategoryListTabNestCategoryItemDefine } from './CommonCategoryDefine';
 import type { IHomeCommonCategoryDetailDefine, IHomeCommonCategoryDetailTabItemDefine } from './defines/Details';
 import type { DetailTabPageProps } from '../common/DetailTabPage';
 import type { CategoryDefine } from './CommonCategoryBlocks';
@@ -157,6 +157,8 @@ import CommonContent, { GetContentDetailItem, GetContentListParams } from '@/api
 import CommonCategoryDetailContentBlocks from './CommonCategoryDetailContentBlocks.vue';
 import ImageGrid from '@/pages/parts/ImageGrid.vue';
 import CommonCategoryListBlock from './CommonCategoryListBlock.vue';
+import { doEvaluateDynamicCompareExpression, doEvaluateDynamicDataExpression } from './CommonCategoryDynamicEvax';
+import { resolveCommonContentSolveProps } from '../common/CommonContent';
 
 export interface CommonCategoryDetailProps extends DetailTabPageProps {
   /**
@@ -179,6 +181,10 @@ export interface CommonCategoryDetailProps extends DetailTabPageProps {
    * 是否显示死亡框
    */
   showDeadBox?: boolean;
+  /**
+   * 内容处理
+   */
+  dataSolve?: IHomeCommonCategoryListTabListDataSolve[];
 }
 export interface CommonCategoryDetailIntroBlocksDesc {
   type: string;
@@ -209,12 +215,14 @@ const currentCommonCategoryContentDefine = ref<IHomeCommonCategoryDetailDefine>(
 const commonCategory = injectCommonCategory();
 
 const tabDefines = ref<IHomeCommonCategoryDetailTabItemDefine[]>([]);
+const tabVisibles = ref<boolean[]>([]);
 const tabRenderDefines = computed(() => {
   const result = {} as Record<number, RenderTabDefine>;
   try {
     tabDefines.value.forEach((item, i) => {
       const renderItem : RenderTabDefine = {
         ...item,
+        visible: tabVisibles.value[i],
         id: i,
       };
       function loadNestCategoryData(items: IHomeCommonCategoryListTabNestCategoryItemDefine[]) {
@@ -306,7 +314,6 @@ async function loadPageConfig() {
             const subListKey = keys[2];
             if (subListKey) {
               const list = content?.[subListKey];
-              console.log('aaaa', list);
               if (Array.isArray(list) && list.length > 0) {
                 switch(list[0].type) {
                   default:
@@ -321,6 +328,17 @@ async function loadPageConfig() {
             }
             break;
           }
+          case 'expression': {
+            result = doEvaluateDynamicDataExpression(keys[2], {
+              sourceData: {
+                main: content,
+                customData: {
+                  customTabNameIdMap: currentCommonCategoryContentDefine.value?.props.customTabNameIdMap || {},
+                },
+              },
+            });
+            break;
+          }
           case 'idMap': {
             if (currentCommonCategoryContentDefine.value?.props.customTabNameIdMap) {
               result = currentCommonCategoryContentDefine.value.props.customTabNameIdMap[content.id];
@@ -367,14 +385,19 @@ function onLoaded(d: any) {
   content.value = d;
 }
 async function loadTabVisible(tabs: IHomeCommonCategoryDetailTabItemDefine[], d: any) {
-  for (const tab of tabs) {
+  if (!d)
+    return;
+  for (let i = 0; i < tabs.length; i++) {
+    const tab = tabs[i];
     const v = d[tab.key];
     let check = true
     let visibleCheckBy = 'auto';
     let visibleCheckKeys = [] as string[];
+    let visibleCheckExpression = '';
     if (tab.visibleVia && tab.visibleVia.includes(':')) {
       visibleCheckKeys = tab.visibleVia.split(':');
       visibleCheckBy = visibleCheckKeys[0];
+      visibleCheckExpression = tab.visibleVia.substring(visibleCheckKeys[0].length + 1);
     }
     switch (visibleCheckBy) {
       case 'auto': {
@@ -393,26 +416,13 @@ async function loadTabVisible(tabs: IHomeCommonCategoryDetailTabItemDefine[], d:
         }
         break;
       }
-      case 'contentProps': {
-        for (let i = 1; i < visibleCheckKeys.length; i++) {
-          let key = visibleCheckKeys[i];
-          let keyMatchProps2 = '';
-          if (key.includes(',')) {
-            const arr = key.split(',');
-            key = arr[0];
-            keyMatchProps2 = arr[1];
-          }
-          const value = d[key];
-          switch (keyMatchProps2) {
-            case 'array0': check = Array.isArray(value) && value.length > 0; break;
-            case 'array1': check = Array.isArray(value) && value.length > 1; break;
-            case 'noZero': check = Number(value) !== 0 && !isNaN(Number(value)); break;
-            case 'trim': check = String(value).trim() !== ''; break;
-            default: check = Boolean(value); break;
-          }
-          if (!check)
-            break;
-        }
+      case 'expression': {
+        check = doEvaluateDynamicCompareExpression(visibleCheckExpression, {
+          sourceData: {
+            main: d,
+            customData: {},
+          },
+        });
         break;
       }
       case 'idMap': {
@@ -422,17 +432,21 @@ async function loadTabVisible(tabs: IHomeCommonCategoryDetailTabItemDefine[], d:
         break;
       }
     }
-    tab.visible = tab.visible !== false && check;
+    tabVisibles.value[i] = tab.visible !== false && check;
   }
 }
 async function load(id: number) {
   if (isNaN(id) || id <= 0)
     throw new Error("请输入ID。如果正在测试,可在后台复制ID");
-  return await CommonContent.getContentDetail(
+  let res = await CommonContent.getContentDetail(
     id, 
     undefined, 
     props.pageQuerys.modelId && Number(props.pageQuerys.modelId) > 0 ? Number(props.pageQuerys.modelId) : undefined
   );
+  if (currentCommonCategoryContentDefine.value?.props.dataSolve) {
+    res = resolveCommonContentSolveProps([ res ], currentCommonCategoryContentDefine.value.props.dataSolve)[0];
+  }
+  return res;
 }
 function handleGoToVr(vr: string) {
   navTo('/pages/article/web/ewebview', { url: vr })

+ 0 - 1
src/pages/article/data/CommonCategoryDetailContentBlocks.vue

@@ -2,7 +2,6 @@
   <template v-if="define.type === 'map'">
     <!-- 地理位置单元 -->
     <view class="d-flex flex-col mt-3 mb-2">
-    <HomeTitle title="地理位置" />
       <map id="map"
         class="w-100 height-350 mt-3"
         :latitude="content.latitude"

+ 34 - 0
src/pages/article/data/CommonCategoryDynamicData.ts

@@ -256,4 +256,38 @@ export function doGetDynamicListDataParams(data: IHomeCommonCategoryDynamicData)
     modelId,
     mainBodyColumnId,
   };
+}
+
+export function doSerializeInternalVar(string: string) {
+  let result = {
+    prased: false,
+    value: undefined as any,
+  } 
+  switch (string) {
+    case 'TYPE_ARTICLE': result.value = GetContentListParams.TYPE_ARTICLE; result.prased = true; break;
+    case 'TYPE_ARCHIVE': result.value = GetContentListParams.TYPE_ARCHIVE; result.prased = true; break;
+    case 'TYPE_AUDIO': result.value = GetContentListParams.TYPE_AUDIO; result.prased = true; break;
+    case 'TYPE_IMAGE': result.value = GetContentListParams.TYPE_IMAGE; result.prased = true; break;
+    case 'TYPE_VIDEO': result.value = GetContentListParams.TYPE_VIDEO; result.prased = true; break; 
+    default: {
+      if (string.includes('.')) {
+        const arr = string.split('.');
+        const obj = arr[0];
+        const type = arr[1] || '';
+        const key = arr[2] || '';
+        switch (type) {
+          case 'serializedApi': 
+            result.value = (CommonCategoryDynamicDataSerializedApi({
+              type: 'serializedApi',
+              name: obj,
+            }) as unknown as Record<string, any>)?.[key];
+            result.prased = true;
+            break;
+          default: break;
+        }
+      }
+      break;
+    }
+  }
+  return result;
 }

+ 292 - 0
src/pages/article/data/CommonCategoryDynamicEvax.ts

@@ -0,0 +1,292 @@
+import { DataObjectUtils } from "@imengyu/js-request-transform";
+import { doSerializeInternalVar } from "./CommonCategoryDynamicData";
+import { DateUtils } from "@imengyu/imengyu-utils";
+
+export interface IDynamicCompareExpressionContext {
+  sourceData: {
+    main: Record<string, any>,
+    customData: Record<string, Record<string, any>>,
+  },
+}
+
+function parseMapExpression(mapExpression: string) {
+  const arr = mapExpression.split('#');
+  const map = {} as Record<string, any>;
+  for (let i = 0; i < arr.length; i += 2) {
+    map[arr[i]] = arr[i + 1];
+  }
+  return map;
+}
+
+/**
+ * 评估动态取值表达式
+ * 
+ * 取值格式: 下方表达式中以 [R/RW/W] 代表槽位:读取、读写、写入
+ * [K#key] : 从sourceData中取值,键值为key
+ * [M#MAP] : 处理映射,格式为:key#value#key#value#...
+ * [R]     : 从上一步表达式中结果
+ * [value] : 静态数据,可选 type#value 格式,type为类型,value为值,默认 string
+ * 
+ * 格式:
+ * (最后一位表示是否存储结果至上一步表达式结果)
+ * A,[R]           [Y]: 从sourceData中取值,键值为key
+ * M,[R],[M#MAP]   [Y]: 从customData中取值,并使用MAP中的数据映射,MAP为自定义数据映射
+ * R               [Y]: 从上一步表达式中结果
+ * S,[W],[R]       [N]: 执行设置操作,R为从上一步表达式中结果,value为设置值
+ * ST,[key],[R]    [N]: 暂存变量:设置
+ * RT,[key],[W]    [Y]: 暂存变量:取值
+ * CT,[key]        [N]: 暂存变量:删除
+ * OP,[R],[OP],[R] [Y]: 执行操作,A为操作数,OP为操作符,B为操作数
+ * 
+ * 多个表达式用 / 分隔,会按顺序依次评估,并返回最后一个表达式的结果
+ * @param expression 
+ * @returns 
+ */
+export function doEvaluateDynamicDataExpression(expressions: string, context: IDynamicCompareExpressionContext) {
+  const expressionArr = expressions.split('/');
+  const tempData = new Map<string, any>();
+  
+  function evaluateExpression(expression: string, prevResult: any) : any {
+    const arr = expression.split(','); 
+    if (arr.length < 2)
+      return undefined;
+    const type = arr[0];
+    const key = arr[1];
+
+    function accessDynamicKeyOrPrevResult(key: string, write: boolean, setValue?: any) {
+      //基础取值
+      if (key ==='R') {
+        if (write) {
+          prevResult = setValue;
+          return undefined;
+        }
+        return prevResult;
+      }
+      else if (key.startsWith('M#')) {
+        if (write) {
+          console.error('doEvaluateDynamicDataExpression: M#MAP is not supported in write mode');
+          return undefined;
+        }
+        return parseMapExpression(key.substring(2));
+      }
+      else if (key.startsWith('K#')) {
+        key = key.substring(2);
+        const data = context.sourceData as Record<string, any>;
+        
+        //对象访问
+        if (write) {
+          return DataObjectUtils.accessObjectByString(data, key, true);
+        } else {
+          const preSerialize = doSerializeInternalVar(key);
+          if (preSerialize.prased)
+            return preSerialize.value;
+          else if (typeof data !== 'object') {
+            if (key)
+              throw new Error('doEvaluateDynamicDataExpression: data is not an object: ' + key);
+            return data;
+          }
+          return DataObjectUtils.accessObjectByString(data, key, false);
+        }
+      } 
+      else {
+        //原始值
+        if (write) 
+          throw new Error('doEvaluateDynamicDataExpression: is not writable: ' + key);
+
+        const preSerialize = doSerializeInternalVar(key);
+        if (preSerialize.prased)
+          return preSerialize.value;
+        let type = 'string';
+        let dat = '';
+        const sp = key.split('#');
+        if (sp.length > 1) {
+          type = sp[0];
+          dat = sp[1];
+        }
+        switch (type) {
+          case 'string': return dat as string;
+          case 'number': return Number(dat);
+          case 'boolean': return Boolean(dat);
+          case 'date': return new Date(dat);
+          case 'array': return dat.split(',');
+          case 'object': return JSON.parse(dat);
+          default: throw new Error('doEvaluateDynamicDataExpression: unknown type: ' + type);
+        }
+      }
+    }
+
+    switch (type) {
+      case 'A': 
+        return accessDynamicKeyOrPrevResult(key, false);
+      case 'M': {
+        if (arr.length < 3)
+          return undefined;
+        const map = accessDynamicKeyOrPrevResult(arr[2], false);
+        return map[accessDynamicKeyOrPrevResult(key, false)];
+      }
+      case 'R': return prevResult;
+      case 'S': {
+        if (arr.length < 3)
+          return undefined;
+        accessDynamicKeyOrPrevResult(
+          accessDynamicKeyOrPrevResult(arr[1], false), 
+          true,
+          accessDynamicKeyOrPrevResult(arr[2], false)
+        );
+      }
+      case 'ST': {
+        if (arr.length < 3)
+          return undefined;
+        tempData.set(arr[1], accessDynamicKeyOrPrevResult(arr[2], false));
+        return undefined;
+      }
+      case 'RT': {
+        if (arr.length < 3)
+          return undefined;
+        return tempData.get(arr[1]);
+      }
+      case 'CT': {
+        if (arr.length < 2)
+          return undefined;
+        tempData.delete(arr[1]);
+        return undefined;
+      }
+      case 'OP': {
+        if (arr.length < 4)
+          return undefined;
+        const a = accessDynamicKeyOrPrevResult(arr[1], false);
+        const op = arr[2];
+        const b = accessDynamicKeyOrPrevResult(arr[3], false);
+        switch (op) {
+          case '+': return a + b;
+          case '-': return a - b;
+          case '*': return a * b;
+          case '/': return a / b;
+          case '%': return a % b;
+          case '**': return a ** b;
+          case '==': return a == b;
+          case '!=': return a != b;
+          case '>': return a > b;
+          case '>=': return a >= b;
+          case '<': return a < b;
+          case '<=': return a <= b;
+          case '&&': return a && b;
+          case '||': return a || b;
+          case '!': return !a;
+          default: {
+            if (op.startsWith('arr#')) {
+              if (Array.isArray(a)) {
+                switch (op) {
+                  case 'pop': return a.pop();
+                  case 'shift': return a.shift();
+                  case 'unshift': return a.unshift(b);
+                  case 'join': return a.join(b);
+                  case 'push': return a.push(b);
+                }
+              }
+              else
+                throw new Error('doEvaluateDynamicDataExpression: push is not an array: ' + a);
+            } else if (op.startsWith('obj#')) {
+              if (typeof a === 'object' && a !== null) {
+                switch (op) {
+                  case 'keys': return Object.keys(a);
+                  case 'values': return Object.values(a);
+                  case 'entries': return Object.entries(a);
+                }
+              }
+            } else if (op.startsWith('str#')) {
+              if (typeof a === 'string') {
+                switch (op) {
+                  case 'split': return a.split(b);
+                  case 'replace': return a.replace(b, arr[3]);
+                }
+              }
+            } else if (op.startsWith('num#')) {
+              if (typeof a === 'number') {
+                switch (op) {
+                  case 'round': return Math.round(a);
+                  case 'ceil': return Math.ceil(a);
+                  case 'floor': return Math.floor(a);
+                }
+              }
+            } else if (op.startsWith('date#')) {
+              if (a instanceof Date) {
+                switch (op) {
+                  case 'format': return DateUtils.formatDate(a, b as string);
+                  case 'getYear': return a.getFullYear();
+                  case 'getMonth': return a.getMonth();
+                  case 'getDate': return a.getDate();
+                  case 'getDay': return a.getDay();
+                  case 'getHours': return a.getHours();
+                  case 'getMinutes': return a.getMinutes();
+                  case 'getSeconds': return a.getSeconds();
+                  case 'getMilliseconds': return a.getMilliseconds();
+                }
+              }
+            }
+            throw new Error('doEvaluateDynamicDataExpression: unknown operator ' + op);
+          }
+        }
+      }
+    }
+    return undefined;
+  }
+  
+  let result = null;
+  for (const expression of expressionArr)
+    result = evaluateExpression(expression, result);
+
+  //console.log('doEvaluateDynamicDataExpression: ', expressions, 'result: ', result);
+  return result;
+}
+/**
+ * 评估动态比较表达式
+ * 
+ * 格式:
+ * 取值:符号:值
+ * 
+ * @param expression 
+ * @returns 
+ */
+export function doEvaluateDynamicCompareExpression(expression: string, context: IDynamicCompareExpressionContext) : boolean {
+  const arr = expression.split(':');
+  if (arr.length !== 3)
+    return false;
+  const key = arr[0];
+  const symbol = arr[1];
+  const value = doEvaluateDynamicDataExpression(arr[2], context);
+  const data = doEvaluateDynamicDataExpression(key, context);
+  const negate = symbol.charAt(0) === 'n';
+  switch (symbol) {
+    case 'nempty':
+    case 'empty': {
+      let compareValue = data === undefined || data === null || data === '';
+      if (Array.isArray(data))
+        compareValue = data.length === 0;
+      return negate ? !compareValue : compareValue;
+    }
+    case 'eq': 
+    case 'neq': {
+      let compareValue = data == value;
+      return negate ? !compareValue : compareValue;
+    }
+    case 'gt':
+    case 'gte':
+    case 'lt':
+    case 'lte': {
+      let compareValue = false;
+      if (symbol.startsWith('g'))
+        compareValue = data > value;
+      if (symbol.endsWith('e'))
+        compareValue = data >= value;
+      if (symbol.startsWith('l'))
+        compareValue = data < value;
+      if (symbol.startsWith('l'))
+        compareValue = data <= value;
+      return negate ? !compareValue : compareValue;
+    }
+    default: {
+      throw new Error('doEvaluateDynamicCompareExpression: unknown symbol: ' + symbol);
+    }
+  }
+}

+ 5 - 0
src/pages/article/data/CommonCategoryList.vue

@@ -25,6 +25,8 @@ import FlexCol from '@/components/layout/FlexCol.vue';
 import LoadingPage from '@/components/display/loading/LoadingPage.vue';
 import Button from '@/components/basic/Button.vue';
 import CommonCategoryListBlock from './CommonCategoryListBlock.vue';
+import { getIsDevtoolsPlatform } from '@/common/utils/MpVersions';
+import { waitTimeOut } from '@imengyu/imengyu-utils';
 
 /**
  * 动态通用内容 - 通用列表页
@@ -51,6 +53,9 @@ const props = defineProps({
 });
 
 async function loadPageConfig() {
+  if (getIsDevtoolsPlatform()) {
+    await waitTimeOut(1000);
+  }
   if (!props.pageConfigName) {
     errorMessage.value = '配置有误';
     return;

+ 5 - 1
src/pages/article/data/CommonCategoryListBlock.vue

@@ -4,10 +4,11 @@
     :startTabIndex="pageStartTab"
     :hasPadding="hasPadding"
     :hasBg="hasBg"
-    v-bind="currentCommonCategoryDefine.content.props as any || undefined"
+    v-bind="currentCommonCategoryContentDefine.props as any || undefined"
     :title="currentCommonCategoryDefine.title || undefined"
     :load="loadData"
     :tabs="tabs"
+    :itemType="itemType"
     :dropDownNames="dropdownNames"
     :showListTabIds="showListTabIds"
     :detailsPage="detailsPage"
@@ -216,6 +217,9 @@ const detailsPage = computed(() => {
   }
   return props.currentCommonCategoryContentDefine?.props.detailsPage || undefined;
 });
+const itemType = computed(() => {
+  return props.currentCommonCategoryContentDefine?.props.itemType || 'article-common';
+});
 const detailsParams = ref<Record<string, any>>({});
 const currentParentData = ref<any[]>([]);
 

+ 4 - 9
src/pages/article/data/defines/Details.ts

@@ -21,15 +21,7 @@ export interface IHomeCommonCategoryDetailDefine {
      */
     customTabNameIdMap?: Record<string, string>,
     /**
-     * 自定义标签页动态可见性判断ID映射
-     * * auto: 自动判断
-     * * contentProps: 内容属性判断 格式:contentProps:key1:key2:key3 (keys可包含逗号,逗号后为匹配属性)
-     * * * array0: 数组长度为0
-     * * * array1: 数组长度为1
-     * * * trim: 字符串长度不为0
-     * * * default: 布尔值
-     * * * noZero: 数值不为0或nan
-     * * idMap: 标签页ID映射判断 格式:id:categoy (category为visibleVia中定义的分类ID)
+     * 自定义标签页动态可见性判断ID映射 {d.id}:{visibleVia[1]}
      */
     customTabVisibleViaIdMap?: Record<string, boolean>,
   },
@@ -67,6 +59,9 @@ export interface IHomeCommonCategoryDetailTabItemBaseDefine {
   width?: number,
   /**
    * 标签页动态可见性判断方式
+     * * auto: 自动判断
+     * * expression: 表达式判断 格式参考 doEvaluateDynamicCompareExpression
+     * * idMap: 标签页ID映射判断 格式:id:categoy (category为visibleVia中定义的分类ID)
    */
   visibleVia?: string,
 

+ 17 - 2
src/pages/article/data/editor/components/ArrayEditor.vue

@@ -12,6 +12,8 @@
           :forceOneLevel="forceOneLevel"
           class="array-item-input"
         />
+        <ArrowUpOutlined title="上移" @click="upItem(index)" />
+        <ArrowDownOutlined title="下移" @click="downItem(index)" />
         <a-popconfirm
           title="确定要删除这个项吗?"
           ok-text="确认"
@@ -23,7 +25,7 @@
             danger 
             class="item-remove"
           >
-            删除
+            <DeleteOutlined title="删除" />
           </a-button>
         </a-popconfirm>
       </div>
@@ -36,8 +38,9 @@
 
 <script setup lang="ts">
 import { ref, watch } from 'vue';
-import { PlusOutlined } from '@ant-design/icons-vue';
+import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
 import ValueEditor from './ValueEditor.vue';
+import { ArrayUtils } from '@imengyu/imengyu-utils';
 
 // 定义props
 const props = defineProps<{
@@ -80,6 +83,18 @@ const addItem = () => {
   updateArray();
 };
 
+// 上移项
+const upItem = (index: number) => {
+  ArrayUtils.upData(localArray.value, index);
+  updateArray();
+};
+
+// 下移项
+const downItem = (index: number) => {
+  ArrayUtils.downData(localArray.value, index);
+  updateArray();
+};
+
 // 删除项
 const removeItem = (index: number) => {
   localArray.value.splice(index, 1);

+ 72 - 12
src/pages/article/data/editor/components/DataSolveEditor.vue

@@ -1,16 +1,37 @@
 <template>
-  <a-select
-    v-model:value="localValue"
-    mode="multiple"
-    style="width: 100%"
-    :options="dataSolveOptions"
-    placeholder="可选"
-    @change="updateValue"
-  />
+  <div class="data-solve-editor">
+    <a-select
+      v-model:value="localValue"
+      mode="multiple"
+      style="width: 100%"
+      :allowClear="true"
+      :filterOption="false"
+      :options="dataSolveOptions"
+      placeholder="可选"
+      @change="updateValue"
+    />
+    <a-button type="primary" title="添加自定义表达式" :icon="h(PlusOutlined)" @click="openAddModal" />
+    <a-modal
+      v-model:open="addModalVisible"
+      title="添加自定义表达式"
+      ok-text="添加"
+      destroy-on-close
+      @ok="confirmAddCustom"
+      @cancel="closeAddModal"
+    >
+      <a-input
+        v-model:value="customExprInput"
+        placeholder="请输入表达式, 请参考源码设置"
+        allow-clear
+        @press-enter="confirmAddCustom"
+      />
+    </a-modal>
+  </div>
 </template>
 
 <script setup lang="ts">
-import { ref, watch } from 'vue';
+import { h, ref, watch } from 'vue';
+import { PlusOutlined } from '@ant-design/icons-vue';
 
 const props = defineProps<{
   modelValue?: string[];
@@ -19,7 +40,7 @@ const emit = defineEmits<{
   (e: 'update:modelValue', value: string[]): void;
 }>();
 
-const dataSolveOptions = [
+const dataSolveOptions = ref([
   { value: 'none', label: '无' },
   { value: 'ich', label: '传承相关' },
   { value: 'common', label: '通用' },
@@ -27,9 +48,11 @@ const dataSolveOptions = [
   { value: 'form', label: '来源' },
   { value: 'inheritor-deadbox', label: '传承人死亡盒子' },
   { value: 'inheritor', label: '年代' },
-];
+]);
 
 const localValue = ref<string[]>(props.modelValue || []);
+const addModalVisible = ref(false);
+const customExprInput = ref('');
 
 watch(
   () => props.modelValue,
@@ -42,4 +65,41 @@ watch(
 const updateValue = () => {
   emit('update:modelValue', localValue.value);
 };
-</script>
+
+function openAddModal() {
+  customExprInput.value = '';
+  addModalVisible.value = true;
+}
+
+function closeAddModal() {
+  addModalVisible.value = false;
+  customExprInput.value = '';
+}
+
+function confirmAddCustom() {
+  const value = customExprInput.value?.trim();
+  if (!value) {
+    return Promise.reject(new Error('请输入表达式'));
+  }
+  const exists = dataSolveOptions.value.some((opt) => opt.value === value);
+  if (!exists) {
+    dataSolveOptions.value.push({ value, label: value });
+  }
+  if (!localValue.value.includes(value)) {
+    localValue.value = [...localValue.value, value];
+    updateValue();
+  }
+  closeAddModal();
+}
+</script>
+
+<style scoped>
+.data-solve-editor {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+.data-solve-editor .ant-select {
+  flex: 1;
+}
+</style>

+ 2 - 2
src/pages/article/data/editor/components/KeyValueEditor.vue

@@ -35,7 +35,7 @@
             @confirm="removeItem(item.key)"
           >
             <a-button type="text" danger class="item-remove">
-              删除
+              <DeleteOutlined />
             </a-button>
           </a-popconfirm>
         </div>
@@ -52,7 +52,7 @@
 
 <script setup lang="ts">
 import { ref, computed, watch } from 'vue';
-import { PlusOutlined } from '@ant-design/icons-vue';
+import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
 import ValueEditor from './ValueEditor.vue';
 import { RandomUtils } from '@imengyu/imengyu-utils';
 

+ 9 - 8
src/pages/article/data/editor/components/ValueEditor.vue

@@ -51,19 +51,19 @@
       </div>
     </div>
     <div class="value-editor-type-selector">
-      <a-dropdown @select="changeType">
+      <a-dropdown>
         <a-button type="text" class="type-select-button">
           {{ type }}
           <down-outlined />
         </a-button>
         <template #overlay>
           <a-menu>
-            <a-menu-item key="string">字符串</a-menu-item>
-            <a-menu-item key="number">数字</a-menu-item>
-            <a-menu-item key="boolean">布尔</a-menu-item>
-            <a-menu-item key="object" v-if="!forceOneLevel">对象</a-menu-item>
-            <a-menu-item key="array" v-if="!forceOneLevel">数组</a-menu-item>
-            <a-menu-item key="null">null</a-menu-item>
+            <a-menu-item key="string" @click="changeType('string')">字符串</a-menu-item>
+            <a-menu-item key="number" @click="changeType('number')">数字</a-menu-item>
+            <a-menu-item key="boolean" @click="changeType('boolean')">布尔</a-menu-item>
+            <a-menu-item key="object" v-if="!forceOneLevel" @click="changeType('object')">对象</a-menu-item>
+            <a-menu-item key="array" v-if="!forceOneLevel" @click="changeType('array')">数组</a-menu-item>
+            <a-menu-item key="null" @click="changeType('null')">null</a-menu-item>
           </a-menu>
         </template>
       </a-dropdown>
@@ -186,7 +186,6 @@ const changeType = (newType: LocalItemType) => {
     default:
       newValue = '';
   }
-  
   emit('update:modelValue', newValue);
 };
 </script>
@@ -197,6 +196,8 @@ const changeType = (newType: LocalItemType) => {
   display: flex;
   align-items: center;
   gap: 8px;
+  border-radius: 4px;
+  border: 1px dashed #e8e8e8;
 }
 
 .value-editor-content {

+ 13 - 3
src/pages/article/data/editor/editors/DetailPropsEditor.vue

@@ -19,6 +19,9 @@
       <a-form-item label="显示死亡框">
         <a-checkbox v-model:checked="props.props.showDeadBox"  />
       </a-form-item>
+      <a-form-item label="内容处理">
+        <DataSolveEditor v-model="props.props.dataSolve" />
+      </a-form-item>
     </a-form>
 
     <a-collapse v-model:activeKey="activeKeys" class="props-collapse">
@@ -109,6 +112,9 @@
                 <a-form-item label="数据键 key" help="用于根据数据判断当前 TAB 是否显示,如果需要一直显示,可填写 “id“">
                   <a-input v-model:value="tab.key" placeholder="对应内容数据键" />
                 </a-form-item>
+                <a-form-item label="标签页名称ID映射">
+                  <KeyValueEditor v-model:value="props.props.customTabNameIdMap" />
+                </a-form-item>
                 <a-form-item label="TAB 宽度" help="TAB 宽度,单位:像素(推荐130~300之间)">
                   <a-input-number v-model:value="tab.width" style="width: 100%" />
                 </a-form-item>
@@ -117,6 +123,12 @@
                     默认可见
                   </a-checkbox>
                 </a-form-item>
+                <a-form-item label="动态可见判断">
+                  <a-input v-model:value="tab.visibleVia" placeholder="动态可见判断表达式,请参考源码设置,默认auto:自动判断" />
+                </a-form-item>
+                <a-form-item label="可见性判断ID映射">
+                  <KeyValueEditor v-model:value="props.props.customTabVisibleViaIdMap" />
+                </a-form-item>
 
                 <template v-if="tab.type === 'list'">
                   <a-divider>列表配置</a-divider>
@@ -136,9 +148,6 @@
             </a-collapse-panel>
           </a-collapse>
         </div>
-        <a-form-item label="自定义标签页名称ID映射">
-          <KeyValueEditor v-model:value="props.props.customTabNameIdMap" />
-        </a-form-item>
         <div class="section-footer">
           <a-button style="flex: 4;" type="dashed" block size="small" @click="addTab">+ 添加 Tab</a-button>
           <a-button style="flex: 2;" type="dashed" block size="small" @click="pasteTab">粘贴</a-button>
@@ -163,6 +172,7 @@ import KeyValueEditor from '../components/KeyValueEditor.vue';
 import { ArrowUpOutlined, ArrowDownOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons-vue';
 import { ArrayUtils } from '@imengyu/imengyu-utils';
 import { message } from 'ant-design-vue';
+import DataSolveEditor from '../components/DataSolveEditor.vue';
 
 const props = defineProps<{
   props: IHomeCommonCategoryDetailDefine['props'];

+ 2 - 0
src/pages/article/data/editor/subpart/NestCategoryEditorItem.vue

@@ -79,6 +79,8 @@
       <a-form-item label="数据源">
         <DynamicDataEditor v-model="cat.data" />
       </a-form-item>
+    </template>
+    <template v-if="!isBlockType">
       <a-form-item label="加载数量">
         <a-input-number v-model:value="cat.count" placeholder="加载数量,默认:4" :min="1" style="width: 100%" />
       </a-form-item>

+ 1 - 2
src/pages/article/details.vue

@@ -21,7 +21,6 @@
               height="500rpx"
               :radius="15"
               :src="loader.content.value.image"
-              :defaultImage="AppCofig.defaultImage"
               mode="widthFix"
             />
             <view v-else class="height-150"></view>
@@ -95,7 +94,7 @@
               titleColor="title-text"
               v-for="item in recommendListLoader.content.value"
               :key="item.id"
-              :image="item.thumbnail || item.image || AppCofig.defaultImage"
+              :image="item.thumbnail || item.image"
               :title="item.title"
               :desc="item.desc"
               :wideImage="true"

+ 1 - 1
src/pages/inhert/map/index.vue

@@ -162,7 +162,7 @@ const listLoader = useSimplePageListLoader(50, async (page, pageSize) => {
       id: p.id,
       longitude: Number(p.longitude),
       latitude: Number(p.latitude),
-      iconPath: p.thumbnail || p.image || AppCofig.defaultImage,
+      iconPath: p.thumbnail || p.image,
       width: 40,
       height: 40,
       joinCluster: true,

+ 0 - 1
src/pages/parts/Box2LineImageRightShadow.vue

@@ -20,7 +20,6 @@
           :height="150"
           :radius="15"
           :src="image"
-          :defaultImage="AppCofig.defaultImage"
           mode="aspectFit"
         />
       </slot>

+ 0 - 1
src/pages/parts/Box2LineLargeImageUserShadow.vue

@@ -20,7 +20,6 @@
       :height="300"
       :radius="15"
       :src="image" 
-      :defaultImage="AppCofig.defaultImage"
       mode="aspectFit" 
     />
     <image 

+ 47 - 0
src/pages/parts/Grid4Item.vue

@@ -0,0 +1,47 @@
+<template>
+  <view 
+    :class="['grid4-item position-relative mb-3', classNames]"
+    @click="emit('click')"
+  >
+    <text class="tag bg-mask-white color-primary radius-l p-1 position-absolute size-s text-lines-1">
+      {{ title }}
+    </text> 
+    <Image
+      width="100%"
+      :height="250"
+      :radius="15"
+      :src="image"
+      mode="aspectFit"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import Image from '@/components/basic/Image.vue';
+
+const props = defineProps<{
+  title: string,
+  image: string,
+  classNames: string,
+}>();
+
+const emit = defineEmits([ 'click' ]);
+</script>
+
+<style lang="scss">
+.grid4-item {
+
+  &.half {
+    width: 320rpx;
+  }
+  &.full {
+    width: 710rpx;
+  }
+
+  .tag {
+    top: 2rpx; 
+    right: 2rpx;
+    z-index: 20;
+  }
+}
+</style>

+ 1 - 1
src/pages/video/details.vue

@@ -39,7 +39,7 @@
               titleColor="title-text"
               v-for="item in recommendListLoader.content.value"
               :key="item.id"
-              :image="item.thumbnail || item.image || AppCofig.defaultImage"
+              :image="item.thumbnail || item.image"
               :title="item.title"
               :desc="item.desc"
               :tags="item.bottomTags"