浏览代码

📦 传统村落对接

imengyu 3 天之前
父节点
当前提交
6cdda6da44

+ 19 - 4
src/api/CommonContent.ts

@@ -79,7 +79,12 @@ export class GetContentListParams extends DataModel<GetContentListParams> {
     this.keywords = val;
     return this; 
   }
+  setModelId(val: number) {
+    this.modelId = val;
+    return this; 
+  }
 
+  modelId ?: number;
   /**
    * 主体栏目id
    */
@@ -208,6 +213,14 @@ export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
         };
       return undefined;
     };
+    this._afterSolveServer = () => {
+      if (!this.image && this.images && this.images && this.images.length > 0  ) {
+        this.image = this.images[0]
+      }
+      if ((!this.images || this.images.length == 0) && this.image) {
+        this.images = [ this.image ]
+      }
+    }
   }
 
   id = 0;
@@ -217,7 +230,7 @@ export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
   title = '';
   region = 0;
   image = '';
-  images = [];
+  images = [] as string[];
   audio = '';
   video = '';
   desc = '';
@@ -234,6 +247,8 @@ export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
   isLike = false;
   isCollect = false;
   content = '';
+  value = '';
+  intro = '';
   publish_at = new Date();
 }
 
@@ -336,7 +351,7 @@ export class CommonContentApi extends AppServerRequestModule<DataModel> {
     return this.get('/content/content/getContentList', `${this.debugName} 模型内容列表`, {
       ...params.toServerSide(),
       ...querys,
-      model_id: this.modelId,
+      model_id: params.modelId || this.modelId,
       main_body_column_id: params.mainBodyColumnId || this.mainBodyColumnId,
       page,
       pageSize,
@@ -353,9 +368,9 @@ export class CommonContentApi extends AppServerRequestModule<DataModel> {
    * @param querys 额外参数
    * @returns 
    */
-  getContentDetail<T extends DataModel = GetContentDetailItem>(id: number, modelClassCreator: NewDataModel = GetContentDetailItem, querys?: QueryParams) {
+  getContentDetail<T extends DataModel = GetContentDetailItem>(id: number, modelId?: number, modelClassCreator: NewDataModel = GetContentDetailItem, querys?: QueryParams) {
     return this.get('/content/content/getContentDetail', `${this.debugName} (${id}) 内容详情`, {
-      model_id: this.modelId,
+      model_id: modelId ?? this.modelId,
       id,
       ...querys,
     }, modelClassCreator)

+ 92 - 0
src/api/village/VillageApi.ts

@@ -0,0 +1,92 @@
+import { CONVERTER_ADD_DEFAULT, DataModel, transformArrayDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+import ApiCofig from '@/common/config/ApiCofig';
+
+export class VillageListItem extends DataModel<VillageListItem> {
+  constructor() {
+    super(VillageListItem, "村落列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      images: { clientSide: 'forceArray' }
+    }
+    this._nameMapperServer = {
+      name: 'villageName',
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('At'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+    this._afterSolveServer = () => {
+      if (this.images && this.images && this.images.length > 0  ) {
+        this.image = this.images[0]
+      }
+    }
+  }
+
+  id !: number;
+  villageVolunteerId = null as number|null;
+  villageId = null as number|null;
+  claimReason = '';
+  status = '';
+  statusText = '';
+  createdAt = null as Date|null;
+  updatedAt = null as Date|null;
+  deleteAt = null as Date|null;
+  image = '';
+  images = [] as string[];
+  villageName = '';
+  volunteerName = '';
+}
+
+export class VillageMenuListItem extends DataModel<VillageMenuListItem> {
+  constructor() {
+    super(VillageMenuListItem, "村落菜单列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+    this._nameMapperServer = {
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('At'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+
+  }
+  name = '';
+  logo = '';
+}
+
+export class VillageApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+
+  async getVillageList() {
+    return (this.get('/village/village/getList', '村落列表', {
+    })) 
+      .then(res => transformArrayDataModel<VillageListItem>(VillageListItem, res.data2, `村落`, true))
+      .catch(e => { throw e });
+  }
+  async getVillageMenuList(id: number) {
+    return (this.get('/village/menu/getList', '村落菜单列表', {
+      village_id: id,
+    })) 
+      .then(res => transformArrayDataModel<VillageMenuListItem>(VillageMenuListItem, res.data2, `村落菜单`, true))
+      .catch(e => { throw e });
+  }
+
+  
+}
+
+export default new VillageApi();

二进制
src/assets/images/BackArrow.png


二进制
src/assets/images/CloseMini.png


+ 170 - 5
src/assets/scss/news.scss

@@ -2,17 +2,20 @@
 @use "sass:list";
 @use '@/assets/scss/colors.scss' as *;
 
+//List page
+
 .news-list {
   position: relative;
   display: flex;
   flex-direction: column;
 
+
   &.grid {
     .list {
       flex-direction: row;
       flex-wrap: wrap;
       justify-content: space-between;
-      align-items: center;
+      align-items: stretch;
       column-gap: 0;
     }
     .item {
@@ -23,7 +26,6 @@
       }
     }
   }
-
   .list {
     position: relative;
     display: flex;
@@ -38,34 +40,72 @@
     padding: 25px;
     border-radius: 6px;
     background-color: $box-color;
+    border: 1px solid $border-split-color;
     width: 100%;
 
+    &.row-type2 {
+      flex-wrap: wrap;
+
+      .TitleDescBlock h3 {
+        margin-top: 10px;
+      }
+
+      img {
+        width: 100%;
+        height: 300px;
+        margin-right: 0;
+      }
+    }
+    &.row-type3 {
+      img {
+        width: 270px;
+        height: 180px;
+      }
+    }
     &.empty {
       background-color: transparent;
+      border: none;
     }
 
-    &:hover { 
+    &:hover:not(.empty) { 
       background-color: $box-hover-color;
     }
-    &:active {
+    &:active:not(.empty) {
       transform: scale(0.95);
     }
 
+    .extra {
+      display: flex;
+      flex-direction: column;
+      flex-wrap: wrap;
+      margin-top: 15px;
+      font-size: 0.8rem;
+
+      .desc {
+        display: block;
+        min-width: 70px;
+        color: $text-second-color;
+      }
+    }
+
+
     img {
+      flex-shrink: 0;
       width: 320px;
       height: 180px;
       margin-right: 25px;
       border-radius: 5px;
       object-fit: cover;
+      background-color: $border-split-color;
     }
 
    
   }
 }
 
-
 @media (max-width: 768px) {
   .news-list {
+
     .item {
       display: flex;
       flex-direction: row;
@@ -73,6 +113,20 @@
       border-radius: 6px;
       background-color: $box-color;
 
+      &.row-type2 {
+        img {
+          width: 100%;
+          height: 250px;
+          margin-right: 0;
+        }
+      }
+      &.row-type3 {
+        img {
+          width: 170px;
+          height: 90px;
+        }
+      }
+
       img {
         width: 200px;
         height: 140px;
@@ -105,4 +159,115 @@
       }
     }
   }
+}
+
+//Detail page
+
+.news-detail {
+  color: $text-content-color;
+
+  h1 {
+    font-size: 1.8rem;
+    font-family: SourceHanSerifCNBold;
+    text-align: center;
+  }
+  .small-info {
+    text-align: center;
+    font-size: 0.75rem;
+    flex-wrap: nowrap;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+  .back-button {
+    width: 92px;
+    height: 92px;
+    border-radius: 50%;
+    text-align: center;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    display: flex;
+    background-color: $box-inset-color;
+    cursor: pointer;
+
+    &:hover {
+      background-color: $box-hover-color;
+    }
+
+    img { 
+      width: 25px;
+      height: 25px;
+      margin-bottom: 5px;
+    }
+    span {
+      font-size: 0.75rem;
+    }
+  }
+
+  .news-content {
+    position: relative;
+    min-height: 50vh;
+
+    img {
+      max-width: 100%;
+      text-align: center;
+      border-radius: 5px;
+    }
+
+    p > img {
+      display: block;
+      margin: 0 auto;
+    }
+  }
+
+  .info-list {
+    margin-top: 10px;
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    row-gap: 10px;
+    background-color: $box-color;
+    border-radius: 8px;
+    padding: 15px 20px;
+
+    .entry {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: wrap;
+      width: 50%;
+
+      .label {
+        width: 120px;
+        color: $text-content-second-color;
+      }
+      .value {
+        color: $text-color;
+      }
+    }
+
+    img {
+      width: 200px;
+      max-width: 100%;
+    }
+  }
+
+  .carousel {
+    border-radius: 8px;
+    overflow: hidden;
+
+    img {
+      width: 100%;
+      height: 50vh;
+      object-fit: contain;
+      background-color: $border-split-color;
+    }
+  }
+}
+
+@media (max-width: 768px) {
+  
+}
+@media (max-width: 540px) {
+  
 }

+ 300 - 0
src/components/content/CommonListBlock.vue

@@ -0,0 +1,300 @@
+<template>
+  <!-- 通用列表页详情 -->
+  <div class="content mb-2">
+    <!-- 搜素栏 -->
+    <div class="row mt-3 align-items-center">
+      <!-- 左栏 -->
+      <div class="col-sm-12 col-md-6 col-lg-6">
+        <!-- 分类 -->
+        <TagBar 
+          :tags="tagsData || []"
+          :margin="[30, 70]" 
+          v-model:selectedTag="selectedTag"
+        />
+        <!-- 标题 -->
+        <div v-if="showNav" class="nav-back-title">
+          <img src="@/assets/images/BackArrow.png" alt="返回" @click="back" />
+          <h2>{{ title }}</h2>
+        </div>
+      </div>
+      <!-- 右栏 -->
+      <div class="col-sm-12 col-md-6 col-lg-6 d-flex flex-row justify-content-end align-items-start" style="gap:5px">
+        <Dropdown
+          v-for="(drop, k) in dropDownNames" :key="k" 
+          :selectedValue="dropDownValues[k] || drop.defaultSelectedValue"
+          :options="drop.options" 
+          labelKey="name"
+          valueKey="id"
+          @update:selectedValue="(v) => handleChangeDropDownValue(k, v)"
+        />
+        <SimpleInput v-if="showSearch" v-model="searchText" placeholder="请输入关键词" @enter="handleSearch">
+          <template #suffix>
+            <IconSearch
+              class="search-icon"
+              src="@/assets/images/news/IconSearch.png"
+              alt="搜索" 
+              @click="newsLoader.loadData(undefined, true)"
+            />
+          </template>
+        </SimpleInput>
+      </div>
+    </div>
+  </div>
+  <div 
+    :class="[
+      'content', 
+      'news-list',
+      rowCount === 1 ? '' : 'grid',
+    ]"
+  >
+    <!-- 新闻列表 -->
+    <SimplePageContentLoader :loader="newsLoader">
+      <div class="list">
+        <div 
+          v-for="(item, k) in newsLoader.list.value"
+          :key="item.id"
+          :class="'item user-select-none main-clickable row-type'+rowType"
+          :style="{ width: rowWidth }"
+          @click="handleShowDetail(item)"
+        >
+          <img 
+            :src="item.image || defaultImage" alt="新闻图片" 
+          />
+          <TitleDescBlock
+            :title="item.title"
+            :desc="item.desc || item.title"
+            :date="DateUtils.formatDate(item.publish_at, DateUtils.FormatStrings.YearCommon)"
+          >
+            <template #addon>
+              <div v-if="item.addItems" class="extra">
+                <div 
+                  v-for="(addItem, k) in item.addItems" 
+                  :key="k" class="d-flex flex-row align-items-center">
+                  <span class="desc">{{ addItem.name }}:</span>
+                  <span>{{ addItem.text || '暂无' }}</span>
+                </div>
+              </div>
+            </template>
+          </TitleDescBlock>
+        </div>
+        <div 
+          v-for="count of placeholderItemCount"
+          :key="count"
+          class="item empty"
+          :style="{ width: rowWidth }"
+        />
+      </div>
+    </SimplePageContentLoader>
+  </div>
+  <!-- 分页 -->
+  <Pagination
+    v-model:currentPage="newsLoader.page.value"
+    :totalPages="newsLoader.totalPages.value"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, watch, type PropType } from 'vue';
+import { useSimplePagerDataLoader } from '@/composeable/SimplePagerDataLoader';
+import { usePageAction } from '@/composeable/PageAction';
+import DateUtils from '@/common/utils/DateUtils';
+import TagBar from '../content/TagBar.vue';
+import Dropdown from '../controls/Dropdown.vue';
+import SimpleInput from '../controls/SimpleInput.vue';
+import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
+import Pagination from '../controls/Pagination.vue';
+import TitleDescBlock from '../parts/TitleDescBlock.vue';
+import IconSearch from '../icons/IconSearch.vue';
+
+const { navTo, back } = usePageAction();
+
+export interface DropdownCommonItem {
+  id: number; 
+  name: string;
+}
+export interface DropDownNames {
+  options: (string|DropdownCommonItem)[],
+  defaultSelectedValue: number|string,
+}
+
+const props = defineProps({	
+  title: {
+    type: String,
+    default: '',
+  },
+  showNav: {
+    type: Boolean,
+    default: false,
+  },
+  prevPage: {
+    type: Object as PropType<{
+      title: string,
+      url?: string,
+    }>,
+    default: null,
+  },
+  dropDownNames: {
+    type: Object as PropType<DropDownNames[]>,
+    default: null,
+  },
+  showSearch: {
+    type: Boolean,
+    default: true,
+  },
+  tagsData: {
+    type: Object as PropType<{
+      id: number,
+      name: string,
+    }[]>,
+    default: null,
+  },
+  pageSize: {
+    type: Number,
+    default: 8,
+  },
+  rowCount: {
+    type: Number,
+    default: 2,
+  },
+  rowType: {
+    type: Number,
+    default: 1,
+  },
+  defaultSelectTag: {
+    type: Number,
+    default: 1,
+  },
+  load: {
+    type: Function as PropType<(
+      page: number, 
+      pageSize: number,
+      selectedTag: number,
+      searchText: string,
+      dropDownValues: number[],
+    ) => Promise<{
+      page: number,
+      total: number,
+      data: any[],
+    }>>,
+    required: true,
+  },
+  showDetail: {
+    type: Function as PropType<(item: any) => void>,
+    default: null,
+  },
+  /**
+   * 点击详情跳转页面路径
+   */
+  detailsPage: {
+    type: String,
+    default: '/news/detail'
+  },
+  /**
+   * 详情跳转页面参数
+   */
+  detailsParams: {
+    type: Object as PropType<Record<string, any>>,
+    default: () => ({})
+  },
+  defaultImage: {
+    type: String,
+    default: ''
+  },
+})
+
+const realRowCount = computed(() => {
+  if (window.innerWidth < 768) 
+    return 1;
+  return props.rowCount;
+});
+const rowWidth = computed(() => {
+  switch (realRowCount.value) {
+    case 2:
+      return `calc(50% - 25px)`;
+    case 3:
+      return `calc(33% - 25px)`;
+    case 4:
+      return `calc(25% - 25px)`;
+  }
+});
+const placeholderItemCount = computed(() => {
+  switch (realRowCount.value) {
+    case 2:
+    case 3:
+    case 4:
+      return newsLoader.list.value.length % realRowCount.value;
+  }
+  return 0;
+});
+const searchText = ref('');
+const dropDownValues = ref<any>([]);
+
+function handleSearch() {
+  newsLoader.loadData(undefined, true);
+}
+function handleChangeDropDownValue(index: number, value: number) {
+  dropDownValues.value[index] = value;
+  newsLoader.loadData(undefined, true);
+}
+function handleShowDetail(item: any) {
+  if (props.showDetail)
+    return props.showDetail(item);
+  navTo(props.detailsPage, { 
+    id: item.id,
+    ...props.detailsParams,
+  });
+}
+
+const newsLoader = useSimplePagerDataLoader(props.pageSize, (page, size) => props.load(
+  page, size, 
+  selectedTag.value, 
+  searchText.value,
+  dropDownValues.value,
+));
+
+//子分类
+const selectedTag = ref(props.defaultSelectTag);
+
+watch(selectedTag, () => {
+  newsLoader.loadData(undefined, true);
+})
+onMounted(() => {
+  newsLoader.loadData(undefined, true);
+});
+
+defineExpose({
+  reload() {
+    newsLoader.loadData(undefined, true);
+  }
+})
+</script>
+
+<style lang="scss">
+@use "@/assets/scss/colors";
+
+.nav-back-title {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-start;
+
+  h2 {
+    font-size: 20px;
+    font-family: SourceHanSerifCNBold;
+    margin: 0;
+  }
+  img { 
+    width: 25px;
+    height: 25px;
+    cursor: pointer;
+    margin-right: 10px;
+  }
+}
+.search-icon {
+  width: 25px;
+  height: 25px;
+  cursor: pointer;
+  color: colors.$primary-color;
+}
+</style>
+

+ 28 - 146
src/components/content/CommonListPage.vue

@@ -12,105 +12,20 @@
           <a-breadcrumb-item>{{ title }}</a-breadcrumb-item>
         </a-breadcrumb>
       </div>
-      <div class="content mb-2">
-        <!-- 搜素栏 -->
-        <div class="row mt-3">
-          <!-- 左栏 -->
-          <div class="col-sm-12 col-md-6 col-lg-6">
-            <!-- 分类 -->
-            <TagBar 
-              :tags="tagsData || []"
-              :margin="[30, 70]" 
-              v-model:selectedTag="selectedTag"
-            />
-          </div>
-          <!-- 右栏 -->
-          <div class="col-sm-12 col-md-6 col-lg-6 d-flex flex-row justify-content-end">
-            <Dropdown
-              v-for="(drop, k) in dropDownNames" :key="k" 
-              :selectedValue="dropDownValues[k] || drop.defaultSelectedValue"
-              :options="drop.options" 
-              @update:selectedValue="(v) => handleChangeDropDownValue(k, v)"
-            />
-            <SimpleInput v-if="showSearch" v-model="searchText" placeholder="请输入关键词" @enter="handleSearch">
-              <template #suffix>
-                <IconSearch
-                  class="search-icon"
-                  src="@/assets/images/news/IconSearch.png"
-                  alt="搜索" 
-                  @click="newsLoader.loadData(undefined, true)"
-                />
-              </template>
-            </SimpleInput>
-          </div>
-        </div>
-      </div>
-      <div 
-        :class="[
-          'content', 
-          'news-list',
-          rowCount === 1 ? '' : 'grid',
-        ]"
-      >
-        <!-- 新闻列表 -->
-        <SimplePageContentLoader :loader="newsLoader">
-          <div class="list">
-            <div 
-              v-for="(item, k) in newsLoader.list.value"
-              :key="item.id"
-              class="item user-select-none main-clickable"
-              :style="{ width: rowWidth }"
-              @click="navTo('/news/detail', { id: item.id })"
-            >
-              <img :src="item.image" alt="新闻图片" />
-              <TitleDescBlock
-                :title="item.title"
-                :desc="item.desc || item.title"
-                :date="DateUtils.formatDate(item.publish_at, DateUtils.FormatStrings.YearCommon)"
-                @click="handleShowDetail(item)"
-              />
-            </div>
-            <div 
-              v-for="count of placeholderItemCount"
-              :key="count"
-              class="item empty"
-              :style="{ width: rowWidth }"
-            />
-          </div>
-        </SimplePageContentLoader>
-      </div>
-      <!-- 分页 -->
-      <Pagination
-        v-model:currentPage="newsLoader.page.value"
-        :totalPages="newsLoader.totalPages.value"
-      />
+      <CommonListBlock v-bind="props"></CommonListBlock>
     </section>
   </div>
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref, watch, type PropType } from 'vue';
-import { useSimplePagerDataLoader } from '@/composeable/SimplePagerDataLoader';
+import { type PropType } from 'vue';
 import { usePageAction } from '@/composeable/PageAction';
-import DateUtils from '@/common/utils/DateUtils';
-import TagBar from '../content/TagBar.vue';
-import Dropdown from '../controls/Dropdown.vue';
-import SimpleInput from '../controls/SimpleInput.vue';
-import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
-import Pagination from '../controls/Pagination.vue';
-import TitleDescBlock from '../parts/TitleDescBlock.vue';
-import IconSearch from '../icons/IconSearch.vue';
+import CommonListBlock from './CommonListBlock.vue';
+import type { DropdownCommonItem, DropDownNames } from './CommonListBlock.vue';
 
-const { navTo, back } = usePageAction();
+export type { DropdownCommonItem, DropDownNames }
 
-export interface DropdownCommonItem {
-  value: number; 
-  title: string;
-}
-export interface DropDownNames {
-  options: (string|DropdownCommonItem)[],
-  defaultSelectedValue: number|string,
-}
+const { navTo, back } = usePageAction();
 
 const props = defineProps({	
   title: {
@@ -169,62 +84,29 @@ const props = defineProps({
     }>>,
     required: true,
   },
+  showDetail: {
+    type: Function as PropType<(item: any) => void>,
+    default: null,
+  },
+  /**
+   * 点击详情跳转页面路径
+   */
+  detailsPage: {
+    type: String,
+    default: '/news/detail'
+  },
+  /**
+   * 详情跳转页面参数
+   */
+  detailsParams: {
+    type: Object as PropType<Record<string, any>>,
+    default: () => ({})
+  },
+  defaultImage: {
+    type: String,
+    default: ''
+  },
 })
-
-const realRowCount = computed(() => {
-  if (window.innerWidth < 768) 
-    return 1;
-  return props.rowCount;
-});
-const rowWidth = computed(() => {
-  switch (realRowCount.value) {
-    case 2:
-      return `calc(50% - 25px)`;
-    case 3:
-      return `calc(33% - 25px)`;
-    case 4:
-      return `calc(25% - 25px)`;
-  }
-});
-const placeholderItemCount = computed(() => {
-  switch (realRowCount.value) {
-    case 2:
-    case 3:
-    case 4:
-      return newsLoader.list.value.length % realRowCount.value;
-  }
-  return 0;
-});
-const searchText = ref('');
-const dropDownValues = ref<any>([]);
-
-function handleSearch() {
-  newsLoader.loadData(undefined, true);
-}
-function handleChangeDropDownValue(index: number, value: number) {
-  dropDownValues.value[index] = value;
-  newsLoader.loadData(undefined, true);
-}
-function handleShowDetail(item: any) {
-  navTo('/news/detail', { id: item.id });
-}
-
-const newsLoader = useSimplePagerDataLoader(props.pageSize, (page, size) => props.load(
-  page, size, 
-  selectedTag.value, 
-  searchText.value,
-  dropDownValues.value,
-));
-
-//子分类
-const selectedTag = ref(props.defaultSelectTag);
-
-watch(selectedTag, () => {
-  newsLoader.loadData(undefined, true);
-})
-onMounted(() => {
-  newsLoader.loadData(undefined, true);
-});
 </script>
 
 <style lang="scss">

+ 34 - 0
src/components/content/ImageSwiper.vue

@@ -0,0 +1,34 @@
+<template>
+  <Carousel 
+    v-bind="carouselConfig"
+    @slide-end="(i) => $emit('switch', i)"
+  >
+    <Slide v-for="(slide, index) in items" :key="index">
+      <slot name="item" :index="index" :item="slide" />
+    </Slide>
+    <template #addons>
+      <Navigation />
+      <Pagination />
+    </template>
+  </Carousel>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+
+defineEmits([	
+  "switch"	
+])
+
+const props = defineProps({	
+  items : {
+    type: Object as PropType<Array<any>>,
+    default: () => ([]),
+  },
+})
+const carouselConfig =  {
+  wrapAround: true,
+  ...props,
+}
+</script>

+ 90 - 0
src/components/display/SimplePopup.vue

@@ -0,0 +1,90 @@
+<template>
+  <teleport to="body">
+    <!-- 遮罩层 -->
+    <div
+      v-if="isClose2 || isVisible" 
+      :class="[
+        'popup-overlay',
+        isVisible ? 'open' : '',
+        isClose2 ? 'close' : '',
+      ]" 
+      @click="close"
+    >
+      <!-- 弹出框内容 -->
+      <div class="popup-content" @click.stop>
+        <slot />
+      </div>
+    </div>
+  </teleport>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const emit = defineEmits([ 'change' ]);
+
+const isVisible = ref(false);
+const isClose2 = ref(false);
+
+const open = () => {
+  isClose2.value = true;
+  setTimeout(() => {
+    isClose2.value = false;
+    isVisible.value = true;
+    emit('change', true);
+  }, 100);
+};
+const close = () => {
+  isClose2.value = true;
+  isVisible.value = false;
+  setTimeout(() => {
+    isClose2.value = false;
+    emit('change', false);
+  }, 300);
+};
+
+defineExpose({
+  open,
+  close
+});
+</script>
+
+<style lang="scss">
+@use "@/assets/scss/colors";
+
+.popup-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 300;
+  opacity: 0;
+  transition: all 0.3s ease-in-out;
+
+  &.open {
+    opacity: 1;
+
+    .popup-content {
+      opacity: 1;
+      transform: scale(1);
+    }
+  }
+  &.close {
+    pointer-events: none;
+
+    .popup-content {
+      opacity: 0;
+    }
+  }
+}
+.popup-content {
+  position: relative;
+  transition: all 0.3s ease-in-out;
+  transform: scale(0.9);
+}
+</style>

+ 105 - 0
src/components/parts/ContentDialog.vue

@@ -0,0 +1,105 @@
+<template>
+  <!-- 带一个关闭按钮的弹窗 -->
+  <SimplePopup
+    ref="popupContent"
+    @change="(show: boolean) => emit('update:show', show)"
+  >
+    <img 
+      :src="CloseMini"
+      class="content-dialog-close"
+      @click="() => emit('update:show', false)"
+    />
+    <div :class="[
+      'd-flex',
+      'flex-col',
+      'content-dialog',
+      small ? 'small' : '',
+      light ? 'light' : '',
+    ]">
+      <slot />
+    </div>
+  </SimplePopup>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+import CloseMini from '@/assets/images/CloseMini.png';
+import SimplePopup from '../display/SimplePopup.vue';
+
+
+const props = defineProps({	
+  show : {
+    type: Boolean,
+    default: false,
+  },
+  small : {
+    type: Boolean,
+    default: false,
+  },
+  light : {
+    type: Boolean,
+    default: false, 
+  }
+})
+
+const emit = defineEmits([	
+  "update:show"	
+])
+
+const popupContent = ref()
+
+watch(() => props.show, (v) => {
+  if(v) {
+    popupContent.value.open()
+  } else {
+    popupContent.value.close()
+  }
+})
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+@use '@/assets/scss/scroll.scss' as *;
+
+.content-dialog {
+  width: 85vw;
+  height: 80vh;
+  overflow: hidden; 
+  border-radius: 10px;
+  background-color: $box-color;
+  border: 1px solid $border-split-color;
+  padding: 25px 33px;
+  pointer-events: all;
+  overflow-y: scroll;
+
+  @include pc-fix-scrollbar();
+
+  &.small {
+    width: 45vw;
+  }
+}
+.content-dialog-close {
+  position: absolute;
+  top: -30px;
+  right: -30px;
+  width: 40px;
+  height: 40px;
+  pointer-events: all;
+  cursor: pointer;
+}
+
+@media (max-width: 540px) {
+  
+  .content-dialog-close {
+    right: -6px;
+    top: -40px;
+  }
+  .content-dialog {
+    width: 97vw;
+
+    &.small {
+      width: 97vw; 
+    }
+  }
+}
+</style>

+ 32 - 7
src/components/parts/LeftRightBox.vue

@@ -7,7 +7,8 @@
         :title="title"
         :desc="desc" 
         :descLines="descLines"
-        more
+        :showExpand="showExpand"
+        :more="showMore"
         @moreClick="emit('moreClick')"
       />
     </div>
@@ -17,7 +18,8 @@
         :title="title"
         :desc="desc"
         :descLines="descLines"
-        more
+        :showExpand="showExpand"
+        :more="showMore"
         @moreClick="emit('moreClick')"
       />
       <img v-else :src="image" alt="image" />
@@ -29,11 +31,34 @@
 import TitleDescBlock from './TitleDescBlock.vue';
 
 defineProps({	
-  title : String,
-  desc: String,
-  image: String,
-  descLines: Number,
-  left: Boolean,	
+  title : {
+    type: String,
+    default: '',
+  },
+  desc: {
+    type: String,
+    default: '',
+  },
+  image: {
+    type: String,
+    default: '',
+  },
+  descLines: {
+    type: Number,
+    default: 3,
+  },
+  left: {
+    type: Boolean,
+    default: false,
+  },	
+  showExpand: {
+    type: Boolean,
+    default: true,
+  },
+  showMore: {
+    type: Boolean,
+    default: true,
+  },
 })
 
 const emit = defineEmits([	

+ 73 - 20
src/components/parts/TitleDescBlock.vue

@@ -2,27 +2,54 @@
   <div class="TitleDescBlock">
     <h3>{{ title }}</h3>
     <span v-if="date" class="time">{{ date }}</span>
-    <p>{{ desc }}</p>
+    <p :class="expand?'expand':''">{{ desc }}</p>
+    <slot name="addon" />
 
-    <div v-if="more" class="more" @click="emit('moreClick')">
-      更多
-      <img src="@/assets/images/IconArrowRight.png" alt="更多" />
+    <div class="footer">
+      <div v-if="showExpand" :class="'expand'+(expand?' on':'')" @click="expand=!expand">
+        {{expand?'折叠':'展开'}}
+        <img src="@/assets/images/IconArrowRight.png" />
+      </div>
+      <div v-if="more" class="more" @click="emit('moreClick')">
+        更多
+        <img src="@/assets/images/IconArrowRight.png" alt="更多" />
+      </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
+import { ref } from 'vue';
+
 const props = defineProps({	
-  title : String,
-  desc: String,
+  title : {
+    type: String,
+    default: '',
+  },
+  desc: {
+    type: String,
+    default: '',
+  },
   descLines: {
     type: Number,
     default: 3,
   },
-  more: Boolean,
-  date: String,
+  more: {
+    type: Boolean,
+    default: false,
+  },
+  showExpand: {
+    type: Boolean,
+    default: false,
+  },
+  date: {
+    type: String,
+    default: '',
+  },
 })
 
+const expand = ref(false)
+
 const emit = defineEmits([	
   "moreClick"	
 ])
@@ -39,7 +66,7 @@ const emit = defineEmits([
   h3 {
     color: $text-content-color;
     font-size: 1.2rem;
-    margin: 0;
+    margin-top: 0;
     margin-bottom: 8px;
   }
 
@@ -55,27 +82,53 @@ const emit = defineEmits([
   p {
     display: -webkit-box;
     -webkit-box-orient: vertical;
-    -webkit-line-clamp: 4;
-    line-clamp: 4;
+    -webkit-line-clamp: 6;
+    line-clamp: 6;
     margin: 0;
     overflow: hidden;
     text-overflow: ellipsis;
+
+    &.expand {
+      -webkit-line-clamp: 100;
+      line-clamp: 100;
+    }
   }
 
-  .more {
+  .footer {
     display: flex;
     flex-direction: row;
-    align-items: center;
-    justify-content: flex-end;
-    color: $text-content-second-color;
-    font-size: 0.85rem;
-    margin-top: 25px;
-    cursor: pointer;
+    align-items: center; 
+    justify-content: space-between;
 
-    &:hover {
-      color: $primary-color;
+    > div {
+      display: flex;
+      flex-direction: row;
+      align-items: center; 
+      color: $text-content-second-color;
+      font-size: 0.85rem;
+      margin-top: 25px;
+      cursor: pointer;
+    
+      &:hover {
+        color: $primary-color;
+      }
     }
+  }
 
+  .expand {
+    img {
+      width: 16px;
+      height: 16px;
+      transform: rotate(90deg);
+    }
+    &.on {
+      img {
+        transform: rotate(270deg); 
+      }
+    }
+  }
+
+  .more {
     img {
       width: 16px;
       height: 16px;

+ 72 - 0
src/router/index.ts

@@ -168,6 +168,78 @@ const router = createRouter({
       name: 'inheritor',
       component: () => import('../views/InheritorView.vue'),
     },
+        {
+      path: '/inheritor/inheritor',
+      name: 'InheritorList',
+      component: () => import('../views/inheritor/inheritor.vue'),
+    },
+    {
+      path: '/inheritor/products',
+      name: 'InheritorProducts',
+      component: () => import('../views/inheritor/products.vue'),
+    },
+    {
+      path: '/inheritor/projects',
+      name: 'InheritorProjects',
+      component: () => import('../views/inheritor/projects.vue'),
+    },
+    {
+      path: '/inheritor/seminar',
+      name: 'InheritorSeminar',
+      component: () => import('../views/inheritor/seminar.vue'),
+    },
+    {
+      path: '/inheritor/unmoveable',
+      name: 'InheritorUnmoveable',
+      component: () => import('../views/inheritor/unmoveable.vue'),
+    },
+    {
+      path: '/inheritor/area',
+      name: 'InheritorArea',
+      component: () => import('../views/inheritor/area.vue'),
+    },
+    {
+      path: '/inheritor/heritage',
+      name: 'InheritorHeritage',
+      component: () => import('../views/inheritor/heritage.vue'),
+    },
+    {
+      path: '/inheritor/artifact-detail',
+      name: 'artifact-detail',
+      component: () => import('../views/details/ArtifactDetailView.vue'),
+    },
+    {
+      path: '/inheritor/moveable',
+      name: 'InheritorMoveable',
+      component: () => import('../views/inheritor/moveable.vue'),
+    },
+    {
+      path: '/inheritor/activity',
+      name: 'InheritorActivity',
+      component: () => import('../views/inheritor/activity.vue'),
+    },
+    {
+      path: '/village/index',
+      name: 'VillageList',
+      component: () => import('../views/village/index.vue'),
+      children: [
+        {
+          path: 'content',
+          name: 'VillageContent',
+          component: () => import('../views/village/content.vue'),
+        },
+        {
+          path: 'list',
+          name: 'VillageList2',
+          component: () => import('../views/village/list.vue'),
+        },
+        {
+          path: 'detail',
+          name: 'VillageDetail',
+          component: () => import('../views/village/detail.vue'),
+        },
+      ]
+    },
     {
       path: '/404',
       name: 'NotFound',

+ 69 - 46
src/views/InheritorView.vue

@@ -40,8 +40,10 @@
         </div>
         <LeftRightBox 
           title="自然遗产"
-          desc="闽南地区总人口约一千余万,河南地区包括泉州、厦门、州三个地级市以及龙岩市的新罗区和漳平市。闽南地区的泉州港在未元时期是世界第一大港,闽南人分布广泛,海内外使用闽南方言的人很多,不少被闽南人影响的当地民族和马来人也会使用闽南语。闽南这个词..---闽南方言。明末时,闽南发生大旱,郑芝龙....."
+          :desc="overviewsLoader.content.value?.[0]"
           :image="Image9"
+          :showExpand="false"
+          @moreClick="navTo('/inheritor/heritage')"
         />
       </div>
     </section>
@@ -54,9 +56,11 @@
         </div>
         <LeftRightBox 
           title="历史风貌区"
-          desc="闽南地区总人口约一千余万,河南地区包括泉州、厦门、州三个地级市以及龙岩市的新罗区和漳平市。闽南地区的泉州港在未元时期是世界第一大港,闽南人分布广泛,海内外使用闽南方言的人很多,不少被闽南人影响的当地民族和马来人也会使用闽南语。闽南这个词..---闽南方言。明末时,闽南发生大旱,郑芝龙....."
+          :desc="overviewsLoader.content.value?.[1]"
           :image="Image11"
+          :showExpand="false"
           left
+          @moreClick="navTo('/inheritor/area')"
         />
       </div>
     </section>
@@ -69,9 +73,11 @@
         </div>
         <LeftRightBox 
           title="传统村落"
-          desc="闽南地区总人口约一千余万,河南地区包括泉州、厦门、州三个地级市以及龙岩市的新罗区和漳平市。闽南地区的泉州港在未元时期是世界第一大港,闽南人分布广泛,海内外使用闽南方言的人很多,不少被闽南人影响的当地民族和马来人也会使用闽南语。闽南这个词..---闽南方言。明末时,闽南发生大旱,郑芝龙....."
+          :desc="overviewsLoader.content.value?.[2]"
           :image="Image10"
+          :showExpand="false"
           left
+          @moreClick="navTo('/village/index')"
         />
       </div>
     </section>
@@ -81,18 +87,21 @@
       <div class="content">
         <div class="title left-right">
           <h2>法律法规</h2>
-          <div class="small-more">
+          <div class="small-more" @click="navTo('/introduction/policy')">
             <span>更多信息</span>
             <img src="@/assets/images/index/ButtonMore.png" alt="更多" />
           </div>
         </div>
-        <ImageTextSmallBlock
-          v-for="(item, index) in lawsData"
-          :key="index"
-          :title="item.title"
-          :image="item.image"
-          :date="item.date"
-        />
+        <SimplePageContentLoader :loader="lawsData">
+          <ImageTextSmallBlock
+            v-for="(item, index) in lawsData.content.value"
+            :key="index"
+            :title="item.title"
+            :image="item.image"
+            :date="item.date"
+            @click="navTo('/news/detail', { id: item.id })"
+          />
+        </SimplePageContentLoader>
       </div>
     </section>
 
@@ -127,6 +136,16 @@ import Image11 from '@/assets/images/inheritor/Image11.jpg'
 import LeftRightBox from '@/components/parts/LeftRightBox.vue';
 import ThreeImageList from '@/components/parts/ThreeImageList.vue';
 import ImageTextSmallBlock from '@/components/parts/ImageTextSmallBlock.vue';
+import { usePageAction } from '@/composeable/PageAction';
+import { useSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import PolicyContent from '@/api/introduction/PolicyContent';
+import { GetColumListParams, GetContentListParams } from '@/api/CommonContent';
+import DateUtils from '@/common/utils/DateUtils';
+import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
+import { NO_CONTENT_STRING } from '@/common/ConstStrings';
+import IndexContent from '@/api/introduction/IndexContent';
+
+const { navTo } = usePageAction();
 
 const carouselConfig = {
   itemsToShow: 1,
@@ -138,31 +157,36 @@ const list1 = [
     title: '非遗项目',
     desc: '让文化因传承而永存',
     image: Image1,
+    onClick: () => navTo('/inheritor/projects'),
   },
   {
     title: '非遗传承人',
     desc: '让文化因传承而永存',
     image: Image2,
+    onClick: () => navTo('/inheritor/inheritor'),
   },
   {
     title: '非遗产品',
     desc: '让文化因传承而永存',
     image: Image3,
+    onClick: () => navTo('/inheritor/products'),
   },
   {
     title: '非遗活动',
     desc: '让文化因传承而永存',
     image: Image4,
+    onClick: () => navTo('/inheritor/activity'),
   },
   {
     title: '非遗传习所',
     desc: '让文化因传承而永存',
     image: Image5,
+    onClick: () => navTo('/inheritor/seminar'),
   },
   {
-    title: '非遗管理',
-    desc: '让文化因传承而永存',
-    image: Image6,
+    title: '',//'非遗管理',
+    desc: '',//'让文化因传承而永存',
+    image: '',//Image6,
   }
 ]
 const list2 = [
@@ -170,11 +194,13 @@ const list2 = [
     title: '不可移动文物',
     desc: '让文化因传承而永存',
     image: Image7,
+    onClick: () => navTo('/inheritor/unmoveable'),
   },
   {
     title: '可移动文物',
     desc: '让文化因传承而永存',
     image: Image8,
+    onClick: () => navTo('/inheritor/moveable'),
   },
   {
     title: '',
@@ -183,38 +209,35 @@ const list2 = [
   },
 ]
 
-const lawsData = [
-  {
-    title: '《中华人民共和国非遗法》',
-    date: '2025-04-17',
-    image: LawsTest,
-  },
-  {
-    title: '厦门市闽南文化生态保护区建设办法',
-    date: '2025-04-17',
-    image: LawsTest,
-  },
-  {
-    title: '《中华人民共和国非遗法》',
-    date: '2025-04-17',
-    image: LawsTest,
-  },
-  {
-    title: '厦门市闽南文化生态保护区建设办法',
-    date: '2025-04-17',
-    image: LawsTest,
-  },
-  {
-    title: '《中华人民共和国非遗法》',
-    date: '2025-04-17',
-    image: LawsTest,
-  },
-  {
-    title: '厦门市闽南文化生态保护区建设办法',
-    date: '2025-04-17',
-    image: LawsTest,
-  },
-]
+const lawsData = useSimpleDataLoader(async () => 
+  (await PolicyContent.getContentList(new GetContentListParams(), 1, 8))
+    .list?.map(item => ({
+      id: item.id,
+      title: item.title,
+      image: item.image || LawsTest,
+      date: DateUtils.formatDate(item.publish_at, DateUtils.FormatStrings.YearCommon),
+    }))
+)
+const overviewsLoader = useSimpleDataLoader(async () => {
+  return [
+    (await IndexContent.getColumList(
+      new GetColumListParams()
+        .setModelId(17)
+        .setMainBodyColumnId(310)
+    )).list[0]?.overview || NO_CONTENT_STRING,
+    (await IndexContent.getColumList(
+      new GetColumListParams()
+        .setModelId(17)
+        .setMainBodyColumnId(286)
+    )).list[0]?.overview || NO_CONTENT_STRING,
+    (await IndexContent.getColumList(
+      new GetColumListParams()
+        .setModelId(17)
+        .setMainBodyColumnId(235)
+    )).list[0]?.overview || NO_CONTENT_STRING,
+  ]
+});
+
 </script>
 
 <style lang="scss">

+ 32 - 78
src/views/NewsDetailView.vue

@@ -18,10 +18,30 @@
               <span class="small-info">浏览量:{{ newsLoader.content.value.views }}</span>
             </div>
           </div>
+   
+          <!-- 轮播 -->
+          <Carousel 
+            :itemsToShow="1"
+            wrapAround
+            :autoPlay="5000"
+            class="carousel"
+          >
+            <Slide v-for="(image, key) in newsLoader.content.value.images" :key="key">
+              <img :src="image" />
+            </Slide>
+            <template #addons>
+              <Navigation />
+              <Pagination />
+            </template>
+          </Carousel>
 
           <SimpleRichHtml 
             class="news-content"
-            :contents="[newsLoader.content.value.content]" 
+            :contents="[
+              newsLoader.content.value.intro,
+              newsLoader.content.value.value,
+              newsLoader.content.value.content,
+            ]" 
           />
 
           <div class="row d-flex justify-content-center">
@@ -49,6 +69,7 @@
 </template>
 
 <script setup lang="ts">
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
 import type { GetContentDetailItem } from '@/api/CommonContent';
 import NewsIndexContent from '@/api/news/NewsIndexContent';
 import DateUtils from '@/common/utils/DateUtils';
@@ -59,20 +80,24 @@ import { useSimpleDataLoader } from '@/composeable/SimpleDataLoader';
 import { useRouter } from 'vue-router';
 
 const router = useRouter();
-const newsLoader = useSimpleDataLoader<GetContentDetailItem, { id: number }>(async (p) => {
+const newsLoader = useSimpleDataLoader<GetContentDetailItem, { 
+  id: number,
+  modelId: number,
+}>(async (p) => {
   if (!p)
     throw new Error('参数错误');
-  return (await NewsIndexContent.getContentDetail<GetContentDetailItem>(p.id));
+  return (await NewsIndexContent.getContentDetail<GetContentDetailItem>(p.id, p.modelId ? p.modelId : undefined));
 }, false)
 
 useLoadQuerys({
-  id: 0
-}, async ({ id }) => {
-  if (id <= 0) {
+  id: 0,
+  modelId: 0,
+}, async (p) => {
+  if (p.id <= 0) {
     router.push({ name: 'NotFound' });
     return;
   }
-  newsLoader.loadData({ id });
+  newsLoader.loadData(p);
 })
 
 function back() {
@@ -80,74 +105,3 @@ function back() {
 }
 </script>
 
-<style lang="scss">
-@use '@/assets/scss/colors.scss' as *;
-
-.news-detail {
-  color: $text-content-color;
-
-  h1 {
-    font-size: 1.8rem;
-    font-family: SourceHanSerifCNBold;
-    text-align: center;
-  }
-  .small-info {
-    text-align: center;
-    font-size: 0.75rem;
-    flex-wrap: nowrap;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-  }
-  .back-button {
-    width: 92px;
-    height: 92px;
-    border-radius: 50%;
-    text-align: center;
-    flex-direction: column;
-    justify-content: center;
-    align-items: center;
-    display: flex;
-    background-color: $box-inset-color;
-    cursor: pointer;
-
-    &:hover {
-      background-color: $box-hover-color;
-    }
-
-    img { 
-      width: 25px;
-      height: 25px;
-      margin-bottom: 5px;
-    }
-    span {
-      font-size: 0.75rem;
-    }
-  }
-
-  .news-content {
-    position: relative;
-    min-height: 50vh;
-
-    img {
-      max-width: 100%;
-      text-align: center;
-      border-radius: 5px;
-    }
-
-    p > img {
-      display: block;
-      margin: 0 auto;
-    }
-  }
-}
-
-
-@media (max-width: 768px) {
-  
-}
-@media (max-width: 540px) {
-  
-}
-</style>
-

+ 120 - 0
src/views/details/ArtifactDetailView.vue

@@ -0,0 +1,120 @@
+<template>
+  <!-- 文物详情页 -->
+  <div class="main-background">
+    <div class="nav-placeholder"></div>
+    <!-- 新闻 -->
+    <section class="main-section main-background main-background-type0 small-h">
+      <SimplePageContentLoader :loader="loader">
+        <div v-if="loader.content.value" class="content news-detail">
+          <h1>{{ loader.content.value.title }}</h1>
+          <p class="small-info">
+            {{ loader.content.value.address }} 
+          </p>
+
+          <!-- 轮播 -->
+          <Carousel 
+            :itemsToShow="1"
+            wrapAround
+            :autoPlay="5000"
+            class="carousel"
+          >
+            <Slide v-for="(image, key) in loader.content.value.images" :key="key">
+              <img :src="image" />
+            </Slide>
+            <template #addons>
+              <Navigation />
+              <Pagination />
+            </template>
+          </Carousel>
+
+          <div class="info-list">
+            <div class="entry">
+              <div class="label">开放时间:</div>
+              <div class="value">{{ loader.content.value.openStatusText }}</div>
+            </div>
+            <div class="entry">
+              <div class="label">年代:</div>
+              <div class="value">{{ loader.content.value.age }}</div>
+            </div>
+            <div class="entry">
+              <div class="label">文物类型:</div>
+              <div class="value">{{ loader.content.value.crTypeText }}</div>
+            </div>
+            <div class="entry">
+              <div class="label">所属区域:</div>
+              <div class="value">{{ loader.content.value.regionText }}</div>
+            </div>
+            <div class="entry">
+              <div class="label">级别:</div>
+              <div class="value">{{ loader.content.value.levelText }}</div>
+            </div>
+            <div class="entry">
+              <div class="label">保护范围:</div>
+              <div class="value">
+                <SimpleRichHtml 
+                  :contents="[loader.content.value.protectedArea as string]" 
+                />
+              </div>
+            </div>
+          </div>
+
+          <SimpleRichHtml 
+            class="news-content"
+            :contents="[
+              loader.content.value.intro,
+              loader.content.value.value,
+              loader.content.value.content,
+            ]" 
+          />
+
+          <div class="row d-flex justify-content-center">
+            <div class="back-button" @click="back">
+              <img src="@/assets/images/news/IconBack.png" />
+              <span>返回列表</span>
+            </div>
+          </div>
+        </div>
+      </SimplePageContentLoader>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+import type { GetContentDetailItem } from '@/api/CommonContent';
+import UnmoveableContent from '@/api/inheritor/UnmoveableContent';
+import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
+import SimpleRichHtml from '@/components/display/SimpleRichHtml.vue';
+import { useLoadQuerys } from '@/composeable/PageQuerys';
+import { useSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { useRouter } from 'vue-router';
+
+const router = useRouter();
+const loader = useSimpleDataLoader<
+  GetContentDetailItem, 
+  { id: number }
+>(async (params) => {
+  if (!params)
+    throw new Error("!params");
+  return await UnmoveableContent.getContentDetail(params.id);
+});
+
+useLoadQuerys({
+  id: 0
+}, async ({ id }) => {
+  if (id <= 0) {
+    router.push({ name: 'NotFound' });
+    return;
+  }
+  loader.loadData({ id });
+})
+
+function back() {
+  router.back();
+}
+</script>
+
+<style lang="scss">
+
+</style>
+

+ 63 - 0
src/views/inheritor/activity.vue

@@ -0,0 +1,63 @@
+<template>
+  <!-- 文化传承 - 非遗活动 -->
+   <CommonListPage
+    :title="'非遗活动'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="[]"
+    :showSearch="true"
+    :pageSize="8"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0].id"
+  />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { GetContentListParams } from '@/api/CommonContent';
+import CustomContent from '@/api/introduction/CustomContent';
+
+async function loadDetail(id: number, item: any) {
+  return await CustomContent.getContentDetail(id);
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await CustomContent.getContentList(new GetContentListParams().setSelfValues({
+    mainBodyColumnId: selectedTag,
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title,
+        desc: item.desc,
+        image: item.image,
+      };
+    }),
+  }
+}
+
+//子分类
+const tagsData = ref([
+  { id: 290, name: '全部' },
+  { id: 291, name: '闽南音乐' },
+  { id: 187, name: '讲古' },
+  { id: 292, name: '方言' },
+  { id: 293, name: '民俗习俗' },
+]);
+</script>
+
+<style>
+</style>
+

+ 48 - 0
src/views/inheritor/area.vue

@@ -0,0 +1,48 @@
+<template>
+  <!-- 文化传承 - 历史风貌区 -->
+  <CommonListPage
+    :title="'历史风貌区'"
+    :prevPage="{ title: '文化传承' }"
+    :dropDownNames="[]"
+    :showSearch="true"
+    :pageSize="8"
+    :load="loadData"
+    :detailsParams="{ modelId: 17 }"
+  />
+</template>
+
+<script setup lang="ts">
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await CommonContent.getContentList(new GetContentListParams()
+    .setKeywords(searchText)
+    .setModelId(17)
+    .setMainBodyColumnId(286)
+  , page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title,
+        desc: item.desc,
+        image: item.image,
+      };
+    }),
+  }
+}
+</script>
+
+<style>
+</style>
+

+ 48 - 0
src/views/inheritor/heritage.vue

@@ -0,0 +1,48 @@
+<template>
+  <!-- 文化传承 - 自然遗产 -->
+  <CommonListPage
+    :title="'自然遗产'"
+    :prevPage="{ title: '文化传承' }"
+    :dropDownNames="[]"
+    :showSearch="true"
+    :pageSize="8"
+    :load="loadData"
+    :detailsParams="{ modelId: 17 }"
+  />
+</template>
+
+<script setup lang="ts">
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await CommonContent.getContentList(new GetContentListParams()
+    .setKeywords(searchText)
+    .setModelId(17)
+    .setMainBodyColumnId(310)
+  , page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title,
+        desc: item.desc,
+        image: item.image,
+      };
+    }),
+  }
+}
+</script>
+
+<style>
+</style>
+

+ 100 - 0
src/views/inheritor/inheritor.vue

@@ -0,0 +1,100 @@
+<template>
+  <!-- 文化传承 -  -->
+   <CommonListPage
+    :title="'非遗传承人'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0].id"
+  />
+</template>
+
+<script setup lang="ts">
+import { GetContentListParams } from '@/api/CommonContent';
+import InheritorContent from '@/api/inheritor/InheritorContent';
+import IndexContent from '@/api/introduction/IndexContent';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+import { onMounted, ref } from 'vue';
+
+async function loadDetail(id: number, item: any) {
+  item = await InheritorContent.getContentDetail(id);
+  item.content = item.content || item.intro as string;
+  item.addItems = [
+    { name: '传承项目', text: item.ichName },
+    { name: '非遗成就', text: item.prize },
+  ];
+  return item;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await InheritorContent.getContentList(new GetContentListParams().setSelfValues({
+    ichType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title,
+        desc: item.desc,
+        image: item.image,
+        addItems: [
+          { name: '传承项目', text: item.ichName },
+          { name: '非遗成就', text: item.prize },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+//子分类
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await IndexContent.getCategoryList(4)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await IndexContent.getCategoryList(2)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await IndexContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>
+

+ 104 - 0
src/views/inheritor/moveable.vue

@@ -0,0 +1,104 @@
+<template>
+  <!-- 文化传承 - 可移动文物 -->
+  <CommonListPage
+    :title="'可移动文物'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :rowType="2"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0].id"
+    detailsPage="/inheritor/artifact-detail"
+  />
+</template>
+
+<script setup lang="ts">
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import MoveableContent from '@/api/inheritor/MoveableContent';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+import { onMounted, ref } from 'vue';
+
+async function loadDetail(id: number, item: any) {
+  const res = await MoveableContent.getContentDetail(id);
+  res.addItems = [
+    { name: '地理位置', text: item.code, span: 12 },
+    { name: '建筑时间', text: item.ichTypeText, span: 12 },
+    { name: '保护级别', text: item.levelText, span: 12 },
+  ];
+  return res;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await MoveableContent.getContentList(new GetContentListParams().setSelfValues({
+    crType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title ?? '!!title!!',
+        desc: item.desc,
+        image: item.image,
+        addItems: [
+          { name: '地理位置', text: item.code, span: 12 },
+          { name: '建筑时间', text: item.ichTypeText, span: 12 },
+          { name: '保护级别', text: item.declarationRegion, span: 12 },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+//子分类
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await CommonContent.getCategoryList(3)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(2)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>
+
+

+ 100 - 0
src/views/inheritor/products.vue

@@ -0,0 +1,100 @@
+<template>
+  <!-- 文化传承 - 非遗产品 -->
+  <CommonListPage
+    :title="'非遗产品'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0].id"
+  />
+</template>
+
+<script setup lang="ts">
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import ProductsContent from '@/api/inheritor/ProductsContent';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+import { onMounted, ref } from 'vue';
+
+async function loadDetail(id: number, item: any) {
+  const res = await ProductsContent.getContentDetail(id);
+  res.addItems = [
+    { name: '产地', text: res.regionText, span: 12 },
+    { name: '特点', text: res.keywords, span: 12 },
+    { name: '艺术分类', text: res.ichTypeText, span: 12 },
+  ];
+  return res;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await ProductsContent.getContentList(new GetContentListParams().setSelfValues({
+    ichType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title ?? '!!title!!',
+        desc: item.desc,
+        image: item.image,
+        addItems: [
+          { name: '产地', text: item.regionText, span: 12 },
+          { name: '特点', text: item.keywords?.join(' '), span: 12 },
+          { name: '艺术分类', text: item.ichTypeText, span: 12 },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+//子分类
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await CommonContent.getCategoryList(4)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(2)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>
+

+ 106 - 0
src/views/inheritor/projects.vue

@@ -0,0 +1,106 @@
+<template>
+  <!-- 文化传承 - 非遗项目 -->
+  <CommonListPage
+    :title="'非遗项目'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0].id"
+  />
+</template>
+
+<script setup lang="ts">
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import ProjectsContent from '@/api/inheritor/ProjectsContent';
+import DateUtils from '@/common/utils/DateUtils';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+import { onMounted, ref } from 'vue';
+
+async function loadDetail(id: number, item: any) {
+  const res = await ProjectsContent.getContentDetail(id);
+  res.addItems = [
+    { name: '非遗编号', text: res.code, span: 12 },
+    { name: '非遗类别', text: res.ichTypeText, span: 12 },
+    { name: '申报地区', text: res.declarationRegion, span: 12 },
+    { name: '非遗级别', text: res.levelText, span: 12 },
+    { name: '批准时间', text: DateUtils.formatDate(res.approveTime as Date, DateUtils.FormatStrings.YearChanese), span: 12 },
+    { name: '流行地区', text: res.popularRegion, span: 12 },
+  ];
+  return res;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+  const res = await ProjectsContent.getContentList(new GetContentListParams().setSelfValues({
+    ichType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title ?? '!!title!!',
+        desc: item.desc,
+        image: item.image,
+        addItems: [
+          { name: '非遗编号', text: item.code, span: 12 },
+          { name: '非遗类别', text: item.ichTypeText, span: 12 },
+          { name: '申报地区', text: item.declarationRegion, span: 12 },
+          { name: '非遗级别', text: item.levelText, span: 12 },
+          { name: '批准时间', text: DateUtils.formatDate(item.approveTime as Date, DateUtils.FormatStrings.YearChanese), span: 12 },
+          { name: '流行地区', text: item.popularRegion, span: 12 },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+//子分类
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await CommonContent.getCategoryList(4)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(2)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>
+

+ 100 - 0
src/views/inheritor/seminar.vue

@@ -0,0 +1,100 @@
+<template>
+  <!-- 文化传承 - 非遗传习所 -->
+  <CommonListPage
+    :title="'非遗传习所'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0].id"
+  />
+</template>
+
+<script setup lang="ts">
+import SeminarContent from '@/api/inheritor/SeminarContent';
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import { onMounted, ref } from 'vue';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+
+async function loadDetail(id: number, item: any) {
+  const res = await SeminarContent.getContentDetail(id);
+  res.addItems = [
+    { name: '地理位置', text: res.address, span: 12 },
+    { name: '建筑时间', text: res.age, span: 12 },
+    { name: '保护级别', text: res.levelText, span: 12 },
+  ];
+  return res;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await SeminarContent.getContentList(new GetContentListParams().setSelfValues({
+    crType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title ?? '!!title!!',
+        desc: item.desc,
+        image: item.image,
+        addItems: [
+          { name: '地理位置', text: item.address, span: 12 },
+          { name: '建筑时间', text: item.age, span: 12 },
+          { name: '保护级别', text: item.levelText, span: 12 },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+
+//子分类
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await CommonContent.getCategoryList(3)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(2)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>

+ 104 - 0
src/views/inheritor/unmoveable.vue

@@ -0,0 +1,104 @@
+<template>
+  <!-- 文化传承 - 不可移动文物 -->
+  <CommonListPage
+    :title="'不可移动文物'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :rowType="2"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0].id"
+    detailsPage="/inheritor/artifact-detail"
+  />
+</template>
+
+<script setup lang="ts">
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import { onMounted, ref } from 'vue';
+import UnmoveableContent from '@/api/inheritor/UnmoveableContent';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+
+async function loadDetail(id: number, item: any) {
+  const res = await UnmoveableContent.getContentDetail(id);
+  res.addItems = [
+    { name: '地理位置', text: res.address, span: 12 },
+    { name: '建筑时间', text: res.age, span: 12 },
+    { name: '保护级别', text: res.levelText, span: 12 },
+  ];
+  return res;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await UnmoveableContent.getContentList(new GetContentListParams().setSelfValues({
+    crType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title ?? '!!title!!',
+        desc: item.desc,
+        image: item.image,
+        addItems: [
+          { name: '地理位置', text: item.address, span: 12 },
+          { name: '建筑时间', text: item.age, span: 12 },
+          { name: '保护级别', text: item.levelText, span: 12 },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+
+//子分类
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await CommonContent.getCategoryList(3)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(2)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>
+
+

+ 3 - 0
src/views/introduction/policy.vue

@@ -5,14 +5,17 @@
     :prevPage="{ title: '文化概况' }"
     :dropDownNames="[]"
     :pageSize="8"
+    :rowCount="1"
     :load="loadData"
     :loadDetail="loadDetail"
+    :defaultImage="LawsImage"
   />
 </template>
 
 <script setup lang="ts">
 import { GetContentListParams } from '@/api/CommonContent';
 import PolicyContent from '@/api/introduction/PolicyContent';
+import LawsImage from '@/assets/images/inheritor/LawsTest.jpg'
 
 async function loadDetail(id: number, item: any) {
   return await PolicyContent.getContentDetail(id);

+ 61 - 0
src/views/village/content.vue

@@ -0,0 +1,61 @@
+<template>
+  <SimplePageContentLoader :loader="newsLoader">
+    <div class="d-flex flex-column details"> 
+      <div class="nav-back-title">
+        <img :src="BackArrow" @click="back" />
+        <h2>{{ newsLoader.content.value?.title }}</h2>
+      </div>
+      <SimpleRichHtml 
+        class="news-content mt-3"
+        :contents="[
+          newsLoader.content.value?.intro ?? '',
+          newsLoader.content.value?.value ?? '',
+          newsLoader.content.value?.content ?? '',
+        ]" 
+      />
+    </div>
+  </SimplePageContentLoader>
+</template>
+
+<script setup lang="ts">
+import { watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { useLoadQuerys } from '@/composeable/PageQuerys';
+import BackArrow from '@/assets/images/BackArrow.png';
+import CommonContent, { GetContentDetailItem } from '@/api/CommonContent';
+import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
+import SimpleRichHtml from '@/components/display/SimpleRichHtml.vue';
+
+const router = useRouter();
+const route = useRoute();
+
+function back() {
+  router.back();
+}
+
+watch(() => route.query.id, (newVal) => {
+  newsLoader.loadData(undefined);
+});
+
+const newsLoader = useSimpleDataLoader<GetContentDetailItem, { id: number, modelId: number }>(async (p) => {
+  if (!p)
+    throw new Error('参数错误');
+  return (await CommonContent.getContentDetail<GetContentDetailItem>(p.id, p.modelId ? p.modelId : undefined));
+}, false)
+
+useLoadQuerys({
+  id: 0,
+  modelId: 0,
+}, async (p) => {
+  if (p.id <= 0) {
+    router.push({ name: 'NotFound' });
+    return;
+  }
+  newsLoader.loadData(p);
+})
+</script>
+
+<style>
+</style>
+

+ 173 - 0
src/views/village/detail.vue

@@ -0,0 +1,173 @@
+<template>
+  <div class="d-flex flex-column village-details">
+    <!-- 轮播图 -->
+    <ImageSwiper
+      v-if="data.images && data.images.length > 0"
+      :items="data.images"
+      :autoplay="2500"
+      style="height:300px"
+    >
+      <template #item="{ item }">
+        <img class="swiper-slide" :src="item" />
+      </template>
+    </ImageSwiper>
+    <img 
+      v-else 
+      :src="data.image" 
+      class="swiper-slide"
+    />
+    <div class="mt-3" />
+    <div class="d-flex flex-col content-box">
+      <SimpleRichHtml
+        v-if="data.overview"
+        :contents="[ data.overview ]"
+        :tag-style="{
+          a: 'text-decoration: underline ; color: #fff;',
+          p: 'color: #fff; margin-bottom: 20px;',
+          img: 'border-radius: 10px;'
+        }"
+      />
+      <span v-else>无内容,请添加内容!</span>
+    </div>
+    <div class="mt-3" />
+    <div class="d-flex flex-row flex-wrap">
+      <div
+        v-for="(i, k) in tagsData"
+        :key="k"
+        class="tag-item"
+        @click="handleGoDetail(i)"
+      >
+        <img :src="i.image" />
+        <span>{{ i.title }}</span>
+      </div>
+    </div>
+    <div class="mt-3" />
+    <div class="map-container">
+      <el-amap
+        style="width: 100%;"
+        :center="center"
+        :zoom="zoom"
+        @init="handleInit"
+      />
+    </div>
+    <div class="mt-3" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import SimpleRichHtml from '@/components/display/SimpleRichHtml.vue';
+import router from '@/router';
+import { useRoute } from 'vue-router';
+import { ref, watch } from 'vue';
+import VillageApi from '@/api/village/VillageApi';
+import ImageSwiper from '@/components/content/ImageSwiper.vue';
+
+const tagsData = ref<{ image: string, title: string }[]>([]);
+const route = useRoute();
+const data = ref({
+  image: '',
+  images: [],
+  overview: '',
+  longitude: "",
+  latitude: '',
+  modelId: 0,
+  mainBodyColumnId: 0,
+})
+const zoom = ref(12);
+const center = ref([121.59996, 31.197646]);
+let map: any = null;
+
+function handleInit(mapRef: any) {
+  map = mapRef;
+}
+
+watch(route, () => {
+  setTimeout(() => {
+    loadInfo();
+  }, 500);
+}, { immediate: true })
+
+async function loadInfo() {
+  const id = Number(route.query.id);
+  data.value = {
+    ...data.value,
+    ...JSON.parse(localStorage.getItem('VillageTemp') || '{}'),
+  };
+
+  if (data.value.longitude && data.value.latitude) {
+    center.value = [Number(data.value.longitude), Number(data.value.latitude)];
+  } else {
+    center.value = [118.850895, 28.982787];
+  }
+  map?.add(new AMap.Marker({
+    position: center.value as [number, number]
+  }));
+
+  const menu = await VillageApi.getVillageMenuList(id);
+
+  tagsData.value = menu.filter((i) => i.platform == 1).map((item, index) => {
+    return {
+      title: item.name,
+      image: item.logo,
+      ...item,
+    };
+  });
+}
+function handleGoDetail(item: any) {
+  router.push({
+    name: 'VillageList2',
+    query: { 
+      id: item.id,
+      model_id: item.modelId,
+      main_body_column_id: item.mainBodyColumnId,
+    }, 
+  })
+}
+
+</script>
+    
+<style lang="scss">
+@use "@/assets/scss/colors";
+
+.village-details {
+  .swiper-slide {
+    width: 100%;
+    height: 400px;
+    object-fit: cover;
+  }
+  .tag-item {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 13%;
+    cursor: pointer;
+
+    img {
+      width: 40px;
+      height: 40px;
+    }
+  }
+  .content-box {
+    width: 100%;
+    padding: 20px;
+    background-color: colors.$box-color;
+    border: 1px solid colors.$border-split-color;
+    box-shadow: 0 0 10px 5px colors.$box-dark-trans-color2;
+    border-radius: 8px;
+  }
+  .map-container {
+    position: relative;
+    width: 100%;
+    height: 250px;
+    overflow: hidden;
+    border-radius: 8px;
+    flex-shrink: 0;
+
+    .el-vue-amap-container {
+      width: 100%;
+    }
+  }
+}
+
+</style>

+ 117 - 0
src/views/village/index.vue

@@ -0,0 +1,117 @@
+<template>
+  <!-- 传统村落 -->
+   <CommonListPage
+    :title="'传统村落'"
+    :dropDownNames="[]"
+    :pageSize="8"
+    :rowCount="2"
+    :rowType="2"
+    :load="loadData"
+    :showDetail="showDetail"
+  />
+    <!-- :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0].id" -->
+  <ContentDialog v-model:show="popupContentShow" light :small="smallDialog">
+    <RouterView></RouterView>
+  </ContentDialog>
+</template>
+
+<script setup lang="ts">
+import router from '@/router';
+import { computed, ref } from 'vue';
+import { useRoute } from 'vue-router';
+import VillageApi from '@/api/village/VillageApi';
+import ContentDialog from '@/components/parts/ContentDialog.vue';
+
+const route = useRoute();
+const popupContentShow = ref(false);
+const smallDialog = computed(() => {
+  return route.name === 'VillageList2' || route.name === 'VillageDetail'; 
+})
+
+async function showDetail(item: any) {
+  popupContentShow.value = true;
+  localStorage.setItem('VillageTemp', JSON.stringify(item));
+
+  setTimeout(() => {
+    router.push({
+      name: 'VillageDetail',
+      query: { id: item.id }, 
+    })
+  }, 200);
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await VillageApi.getVillageList();
+
+  return { 
+    page: page,
+    total: res.length,
+    data: res.map((item, index) => {
+      return {
+        title: item.villageName,
+        desc: item.ichLevelText,
+        ...item,
+        addItems: [],
+      };
+    }),
+  }
+}
+
+//子分类
+const tagsData = ref([
+  { id: 38, name: '全部' },
+  { id: 39, name: '传统技艺' },
+  { id: 40, name: '传统舞蹈' },
+  { id: 41, name: '曲艺' },
+  { id: 42, name: '传统美术' },
+  { id: 43, name: '传统音乐' },
+  { id: 44, name: '民俗' },
+  { id: 45, name: '传统医药' },
+  { id: 46, name: '传统体育与杂技' },
+  { id: 47, name: '民间文学' },
+]);
+</script>
+
+
+<style lang="scss">
+
+.shadow1 {
+  background: linear-gradient( 180deg, #F1F1F1 0%, #FFFFFF 100%);
+  border-radius: 12px 12px 12px 12px;
+  border: 1px solid #FFFFFF;
+  box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.08);
+}
+.details {
+  color: #333;
+
+  .item {
+    cursor: pointer;
+    margin-bottom: 15px;
+  }
+
+  .nana-simple-input {
+    background-color: #fff;
+    border-color: #eee;
+  }
+
+  .page-button {
+    background-color: #ddd;
+    color: #333;
+
+    &.enable {
+      background-color: #fff;
+      color: #000;
+      border-color: #333;
+    }
+  }
+
+}
+</style>
+

+ 70 - 0
src/views/village/list.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="details w-100">
+    <!-- 传统村落 -->
+    <CommonListBlock
+      ref="list"
+      :title="'传统村落'"
+      showNav
+      :dropDownNames="[]"
+      :pageSize="8"
+      :rowCount="1"
+      :rowType="3"
+      :load="loadData"
+      :showDetail="showDetail"
+    />
+    <!-- :defaultSelectTag="tagsData[0].id" -->
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import CommonListBlock from '@/components/content/CommonListBlock.vue';
+
+const router = useRouter();
+const route = useRoute();
+const list = ref();
+
+async function showDetail(item: any) {
+  router.push({ name: 'VillageContent', query: { id: item.id } });
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await CommonContent.getContentList(new GetContentListParams()
+    .setModelId(Number(route.query.model_id))
+    .setMainBodyColumnId(Number(route.query.main_body_column_id))
+    .setKeywords(searchText)
+  , page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title,
+        desc: item.desc,
+        image: item.image,
+        addItems: [
+          { name: '传承项目', text: item.title },
+        ],
+      };
+    }),
+  }
+}
+
+watch(route, (newVal) => {
+  list.value.reload();
+})
+</script>
+
+<style>
+</style>
+