Переглянути джерело

📦 我的提交基础信息查看

快乐的梦鱼 3 тижнів тому
батько
коміт
0a52b33db7

+ 3 - 0
src/api/inheritor/InheritorContent.ts

@@ -131,7 +131,10 @@ export class SeminarInfo extends DataModel<IchInfo> {
   content = '' as string|null;
   mapX = '' as string|null;
   mapY = '' as string|null;
+  longitude = '' as string|null;
+  latitude = '' as string|null;
   address = '' as string;
+
   featuresType = 0 as number;
   contact = '' as string;
   ichSiteType = '' as string;

+ 33 - 1
src/components/NavBar.vue

@@ -47,7 +47,16 @@
     <div class="group">
       <RouterLink to="/inheritor">我的</RouterLink>
     </div>
-    <div></div>
+    
+    <a-dropdown v-if="authStore.isLogged" :trigger="['click']">
+      <a-image :src="IconUser" class="right-button" :preview="false" />
+      <template #overlay>
+        <a-menu>
+          <a-menu-item key="3" @click="logout">退出登录</a-menu-item>
+        </a-menu>
+      </template>
+    </a-dropdown>
+    <div v-else></div>
   </nav>
 </template>
 
@@ -55,7 +64,10 @@
 import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import IconMenu from './icons/IconMenu.vue';
+import IconUser from '@/assets/images/IconUser.png';
 import { TITLE } from '@/common/ConstStrings';
+import { useAuthStore } from '@/stores/auth';
+import { Modal } from 'ant-design-vue';
 
 const router = useRouter();
 const route = useRoute();
@@ -72,6 +84,20 @@ function onScroll() {
   scrollValue.value = window.scrollY;
 }
 
+const authStore = useAuthStore();
+
+function logout() {
+  Modal.confirm({
+    title: '确认退出登录吗?',
+    okText: '确认',
+    okType: 'danger',
+    onOk: async () => {
+      await authStore.logout();
+      router.push('/');
+    }
+  })
+}
+
 onMounted(() => {
   window.addEventListener('scroll', onScroll);
 });
@@ -172,6 +198,12 @@ nav.main {
       }
     }
   }
+
+  .right-button {
+    width: 30px;
+    height: 30px;
+    cursor: pointer;
+  }
 }
 
 .mobile-menu {

+ 142 - 0
src/components/content/CommonCatalog.vue

@@ -0,0 +1,142 @@
+<script setup lang="ts">
+import SimpleScrollView from '../display/SimpleScrollView.vue';
+import { ref, watch, type PropType } from 'vue';
+
+export interface CatalogItem {
+  title: string,
+  level: number,
+  scrollPos: number, 
+  anchor: string,
+}
+
+const emit = defineEmits([	
+  "goToItem"	
+])
+const props = defineProps({	
+  items: {
+    type: Object as PropType<CatalogItem[]>,
+    default: () => []
+  },
+  scrollContainer: {
+    type: Object as PropType<HTMLElement|null>,
+    default: () => null,
+  },
+})
+
+const activeIndex = ref(-1);
+
+function handlerContainerScroll(e: Event) {
+  const container = e.target as HTMLElement;
+  const scrollTop = container.scrollTop;
+
+  activeIndex.value = 0;
+  for (let i = props.items.length - 1; i >= 0; i--) {
+    const item = props.items[i];
+    if (scrollTop >= item.scrollPos) {
+      activeIndex.value = i;
+      break;
+    }
+  }
+}
+function handlerItemClick(item: CatalogItem) {
+  if (item.anchor) {
+    const el = document.getElementById(item.anchor);
+    if (el) {
+      el.scrollIntoView({ behavior: 'smooth' });
+    }
+  }
+  emit('goToItem', item);
+}
+
+watch(() => props.scrollContainer, (newVal, oldVal) => {
+  if (oldVal && oldVal instanceof HTMLElement)
+    oldVal.removeEventListener('scroll', handlerContainerScroll);
+  if (newVal && newVal instanceof HTMLElement)
+    newVal.addEventListener('scroll', handlerContainerScroll);
+}, { immediate: true });
+
+</script>
+
+<template>
+  <SimpleScrollView class="nana-catalog" :scrollY="true">
+    <div>
+      <div 
+        v-for="(item, index) in props.items"
+        :key="index"
+        :class="[
+          'nana-catalog-item',
+          `level-${item.level}`,
+          activeIndex === index ? 'active' : '',
+        ]"
+        @click="handlerItemClick(item)"
+      >
+        {{ item.title }}
+      </div>
+    </div>
+  </SimpleScrollView>
+</template>
+
+<style lang="scss">
+.nana-catalog {
+  position: relative; 
+  margin-left: 0.5rem;
+
+  > div {
+    display: flex;
+    flex-direction: column;
+  }
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    width: 1px;
+    background-color: var(--nana-text-6);
+  }
+
+  .nana-catalog-item {
+    position: relative;
+    padding: 0.4rem 0.8rem;
+    font-size: 1rem;
+    color: var(--nana-text-6);
+    user-select: none;
+    cursor: pointer;
+
+    &.active {
+      font-weight: bold; 
+      color: var(--nana-text-1);
+
+      &::after {
+        content: '';
+        position: absolute;
+        top: calc(50% - 6px);
+        left: 0;
+        border: 8px solid transparent;
+        border-left: 8px solid var(--nana-text-1);
+      }
+    }
+    &.level-1 {
+      font-size: 1.2rem;
+      padding-left: 1rem;
+    }
+    &.level-3,
+    &.level-4,
+    &.level-5 {
+      font-size: 0.8rem;
+      padding-left: 1.2rem;
+      
+      &::before {
+        content: '·';
+        display: inline-block;
+        padding-right: 0.6rem;
+      }
+    }
+    &.level-6 {
+      font-size: 0.7rem;
+      padding-left: 1.6rem;
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,391 @@
+<template>
+  <!-- 通用列表页详情 -->
+  <div v-show="show" >
+    <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="router.back()" />
+            <h2>{{ title }}</h2>
+          </div>
+          <!-- 标题 -->
+          <div v-if="showTotal" class="nav-back-title">
+            共有 {{ newsLoader.total }} 个{{ title }}
+          </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]"
+            :options="drop.options" 
+            labelKey="name"
+            valueKey="id"
+            style="max-width: 150px"
+            @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>
+          <button class="tab-button" v-if="showTableSwitch" @click="tableListShow=!tableListShow">
+            ▼ 清单
+          </button>
+        </div>
+      </div>
+    </div>
+    <div 
+      :class="[
+        'content', 
+        'news-list',
+        rowCount === 1 ? '' : 'grid',
+      ]"
+    >
+      <!-- 新闻列表 -->
+      <SimplePageContentLoader :loader="newsLoader">
+        <div v-if="tableListShow" class="table-list">
+          <table>
+            <thead>
+              <tr>
+                <th>序号</th>
+                <th>{{ tableSwitchOptions.title ?? '标题'}}</th>
+                <th v-for="(t, k) in newsLoader.list.value[0]?.addItems || []" :key="k">{{ t.name }}</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(item, k) in newsLoader.list.value" :key="item.id">
+                <td>{{ (newsLoader.page.value - 1) * 100 + k + 1 }}</td>
+                <td>{{ item.title }}</td>
+                <td v-for="(t, k) in item.addItems || []" :key="k">{{ t.text }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+        <div v-else 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)"
+          >
+            <a class="d-none" :href="router.resolve({ path: props.detailsPage, query: { id: item.id }}).href" />
+            <img
+              :src="item.image || defaultImage" alt="新闻图片" 
+            />
+            <TitleDescBlock
+              :title="item.title"
+              :desc="item.desc"
+            >
+              <template #addon>
+                <div v-if="item.bottomTags" class="tags">
+                  <div
+                    v-for="(tag, k) in item.bottomTags"
+                    :key="k"
+                    :class="tag ? '' : 'd-none'"
+                  >{{ tag }}</div>
+                </div>
+                <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"
+                    :class="[
+                      addItem.text ? '' : 'd-none',
+                    ]"
+                  >
+                    <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"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, watch, type PropType } from 'vue';
+import { useSSrSimplePagerDataLoader } from '@/composeable/SimplePagerDataLoader';
+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';
+
+export interface DropdownCommonItem {
+  id: number; 
+  name: string;
+}
+export interface DropDownNames {
+  options: (string|DropdownCommonItem)[],
+  label?: string,
+  defaultSelectedValue: number|string,
+}
+
+const tableListShow = ref(false);
+
+const props = defineProps({	
+  title: {
+    type: String,
+    default: '',
+  },
+  show: {
+    type: Boolean,
+    default: true,
+  },
+  showTableSwitch: {
+    type: Boolean,
+    default: false,
+  },
+  tableSwitchOptions: {
+    type: Object,
+    default: () => ({}), 
+  },
+  showNav: {
+    type: Boolean,
+    default: false,
+  },
+  showTotal: {
+    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,
+  },
+  subName: {
+    type: String,
+    default: '',
+  },
+  /**
+   * 点击详情跳转页面路径
+   */
+  detailsPage: {
+    type: String,
+    default: '/news/detail'
+  },
+  /**
+   * 详情跳转页面参数
+   */
+  detailsParams: {
+    type: Object as PropType<Record<string, any>>,
+    default: () => ({})
+  },
+  defaultImage: {
+    type: String,
+    default: 'https://mncdn.wenlvti.net/app_static/minnan/EmptyImage.png'
+  },
+})
+
+const router = useRouter();
+
+const realRowCount = computed(() => {
+  if (import.meta.client)
+    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);
+  router.push({ 
+    path: props.detailsPage,
+    query: {
+      id: item.id,
+    }
+  });
+}
+
+
+//子分类
+const selectedTag = ref(props.defaultSelectTag);
+const pageSize = ref(props.pageSize);
+const route = useRoute();
+
+const newsLoader = await useSSrSimplePagerDataLoader(route.fullPath + '/list' + props.subName, Number(route.query.page || 1), pageSize, (page, size) => props.load(
+  page, size, 
+  selectedTag.value, 
+  searchText.value,
+  dropDownValues.value,
+));
+
+watch(() => props.defaultSelectTag, (v) => {
+  selectedTag.value = v;
+})
+watch(() => props.dropDownNames, () => {
+  loadDropValues();
+})
+watch(selectedTag, () => {
+  newsLoader.loadData(undefined, true);
+})
+watch(tableListShow, (v) => {
+  pageSize.value = v ? 100 : props.pageSize;
+  newsLoader.loadData(undefined, true);
+})
+
+function loadDropValues() {
+  dropDownValues.value = [];
+  if (props.dropDownNames)
+    for (const element of props.dropDownNames)
+      dropDownValues.value.push(element.defaultSelectedValue);
+  newsLoader.loadData(undefined, true);
+}
+
+onMounted(() => {
+  setTimeout(() => {
+    loadDropValues();
+  }, 600);
+})
+watch(route, () => {
+  loadDropValues();
+})
+
+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>
+

+ 130 - 0
src/components/content/CommonListPage.vue

@@ -0,0 +1,130 @@
+<template>
+  <!-- 资讯详情页 -->
+  <div class="main-background">
+    <div class="nav-placeholder"></div>
+    <!-- 新闻 -->
+    <section class="main-section main-background main-background-type0 small-h">
+      <div class="content mb-2">
+        <!-- 路径 -->
+        <a-breadcrumb>
+          <a-breadcrumb-item><a href="javascript:;" @click="navTo('/')">首页</a></a-breadcrumb-item>
+          <a-breadcrumb-item v-if="prevPage"><a href="javascript:;" @click="prevPage.url ? navTo(prevPage.url) : back()">{{ prevPage.title }}</a></a-breadcrumb-item>
+          <a-breadcrumb-item>{{ title }}</a-breadcrumb-item>
+        </a-breadcrumb>
+      </div>
+      <CommonListBlock v-bind="props"></CommonListBlock>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { type PropType } from 'vue';
+import { usePageAction } from '@/composeable/PageAction';
+import CommonListBlock from './CommonListBlock.vue';
+import type { DropdownCommonItem, DropDownNames } from './CommonListBlock.vue';
+
+export type { DropdownCommonItem, DropDownNames }
+
+const { navTo, back } = usePageAction();
+
+const props = defineProps({	
+  title: {
+    type: String,
+    default: '',
+  },
+  prevPage: {
+    type: Object as PropType<{
+      title: string,
+      url?: string,
+    }>,
+    default: null,
+  },
+  dropDownNames: {
+    type: Object as PropType<DropDownNames[]>,
+    default: null,
+  },
+  showSearch: {
+    type: Boolean,
+    default: true,
+  },
+  showTableSwitch: {
+    type: Boolean,
+    default: false, 
+  },
+  tableSwitchOptions: {
+    type: Object,
+    default: () => ({}), 
+  },
+  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: 'https://mn.wenlvti.net/app_static/minnan/EmptyImage.png'
+  },
+})
+</script>
+
+<style lang="scss">
+@use "@/assets/scss/colors";
+
+.search-icon {
+  width: 25px;
+  height: 25px;
+  cursor: pointer;
+  color: colors.$primary-color;
+}
+</style>
+

+ 56 - 0
src/components/content/ImageGrid.vue

@@ -0,0 +1,56 @@
+<template>
+  <div class="w-100 d-flex flex-row flex-wrap" :style="{ gap: gap }">
+    <slot 
+      name="item"
+      v-for="(v, k) in data"
+      :key="k"
+      :item="v"
+      :index="k"
+      :width="`calc(${100 / rowCount}% - ${gap})`"
+      :height="imageHeight"
+      :url="imagekey ? v[imagekey] : v" 
+    >
+      <a-image
+        :src="imagekey ? v[imagekey] : v" 
+        :style="{ 
+          width: `calc(${100 / rowCount}% - ${gap})`,
+          height: imageHeight,
+          borderRadius: '5px',
+          objectFit: 'cover',
+        }"
+        @click="()=>emit('itemClick', v)"
+      />
+    </slot>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+
+defineProps({	
+  rowCount : {
+    type: Number,
+    default: 3,
+  },
+  imagekey : {
+    type: String,
+    default: undefined,
+  },
+  imageHeight : {
+    type: String,
+    default: undefined,
+  },
+  gap: {
+    type: String,
+    default: '10px',
+  },
+  data: {
+    type: Object as PropType<any[]>,
+    default: null,
+  },
+})
+
+const emit = defineEmits([	
+  "itemClick"	
+])
+</script>

+ 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>

+ 80 - 0
src/components/content/SimplePageContentLoader.vue

@@ -0,0 +1,80 @@
+<template>
+  <div
+    v-if="loader?.loadStatus.value == 'loading'"
+    style="min-height: 200rpx;display: flex;justify-content: center;align-items: center;"
+  >
+    <a-spin tip="加载中" />
+  </div>
+  <div
+    v-else-if="loader?.loadStatus.value == 'error'"
+    style="min-height: 200rpx"
+  >
+    <a-empty :description="loader.loadError.value" >
+      <a-button  @click="handleRetry">重试</a-button>
+    </a-empty>
+  </div>
+  <template v-else-if="loader?.loadStatus.value == 'finished' || loader?.loadStatus.value == 'nomore'">
+    <slot />
+  </template>
+  <div
+    v-if="showEmpty || loader?.loadStatus.value == 'nomore'"
+    style="min-height: 200rpx"
+    class="empty"
+  >
+    <a-empty :description="emptyView?.text ?? '暂无数据'">
+      <a-button
+        v-if="emptyView?.button"
+        @click="emptyView?.buttonClick ?? handleRetry"
+      >
+        {{emptyView?.buttonText ?? '刷新'}}
+      </a-button>
+    </a-empty>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref, type PropType } from 'vue';
+import type { ILoaderCommon } from '../../composeable/LoaderCommon';
+
+const props = defineProps({	
+  loader: {
+    type: Object as PropType<ILoaderCommon<any>>,
+    default: null,
+  },
+  autoLoad: {
+    type: Boolean,
+    default: false, 
+  },
+  showEmpty: {
+    type: Boolean,
+    default: false, 
+  },
+  emptyView: {
+    type: Object as PropType<{
+      text: string,
+      buttonText: string,
+      button: boolean,
+      buttonClick: () => void,
+    }>,
+    default: null,
+  },
+})
+
+const loaded = ref(false);
+
+onMounted(() => {
+  loaded.value = false;
+  if (props.autoLoad)
+    handleLoad(); 
+});
+
+function handleRetry() {
+  props.loader.loadData(undefined);
+}
+function handleLoad() {
+  if (loaded.value) 
+    return;
+  loaded.value = true;
+  props.loader.loadData(undefined);
+}
+</script>

+ 56 - 0
src/components/content/SimplePointedMap.vue

@@ -0,0 +1,56 @@
+<template>
+  <div :style="{ width, height }">
+    <el-amap
+      style="width: 100%"
+      :center="center"
+      :zoom="zoom"
+      @init="handleInit"
+      v-bind="$attrs"
+    >
+      <el-amap-marker :position="center" />
+    </el-amap>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref, watch } from 'vue';
+
+const props = defineProps({
+  latitude: {
+    type: [Number, String],
+    default: 0
+  },
+  longitude: {
+    type: [Number, String],
+    default: 0
+  },
+  width: {
+    type: [Number, String],
+    default: '100%'
+  },
+  height: {
+    type: [Number, String],
+    default: '300px'
+  }
+});
+
+const zoom = ref(12);
+const center = ref([121.59996, 31.197646]);
+let map: any = null;
+
+function handleInit(mapRef: any) {
+  map = mapRef;
+}
+function loadMaker() {
+  if (!map || !props.longitude || !props.latitude) 
+    return;
+  center.value = [Number(props.longitude), Number(props.latitude)];
+}
+
+watch(() => [props.latitude, props.longitude], () => {
+  loadMaker();
+});
+onMounted(() => {
+  loadMaker();
+});
+</script>

+ 68 - 0
src/components/content/TagBar.vue

@@ -0,0 +1,68 @@
+<template>
+  <!-- 单选标签选择按钮条,可显示一行标签,然后高亮选中项 -->
+  <div class="d-flex flex-row flex-wrap">
+    <div
+      :class="[
+        'tag-button',
+        { 'active': tag.id === selectedTag },
+      ]"
+      v-for="tag in tags"
+      :key="tag.id"
+      @click="emit('update:selectedTag', tag.id ?? tag)"
+    >
+      {{ tag.name?? tag }}
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+
+defineProps({	
+  /**
+   * 标签列表
+   */
+  tags: {
+    type: Object as PropType<Array<{
+      id: number|string,
+      name: string,
+    }>>,
+    required: true,
+  },
+  /**
+   * 选中的标签,可双向绑定
+   */
+  selectedTag: {
+    type: [Number,String],
+    default: null,
+  }
+})
+
+const emit = defineEmits([	
+  "update:selectedTag"	
+])
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+.tag-button {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background-color: $box-inset-color;
+  color: $text-color;
+  padding: 10px 15px;
+  margin-right: 8px;
+  cursor: pointer;
+  user-select: none;
+
+  &:hover {
+    background-color: $box-hover-color;
+  }
+  &:active, &.active {
+    color: $text-color-light;
+    background-color: $primary-color;
+  }
+}
+</style>

+ 2 - 2
src/pages/index.vue

@@ -7,10 +7,10 @@
       <p></p>
       
       <RouterLink v-if="authStore.isLogged" to="/inheritor">
-        <a-button size="large" type="primary">我的</a-button>
+        <a-button size="large" type="primary">我的非遗提交信息</a-button>
       </RouterLink>
       <RouterLink v-else to="/login">
-        <a-button size="large" type="primary">登录</a-button>
+        <a-button size="large" type="primary">登录</a-button>
       </RouterLink>
     </div>
   </div>

+ 82 - 3
src/pages/inheritor.vue

@@ -1,7 +1,8 @@
 <template>
   <!-- 传承人提交首页 -->
   <div class="about main-background main-background-type0">
-    <div class="nav-placeholder"></div>
+    <div class="nav-placeholder">
+    </div>
     <!-- 表单 -->
     <section class="main-section ">
       <div class="content">
@@ -12,26 +13,96 @@
         <a-tabs v-model:activeKey="activeKey" centered>
           <a-tab-pane key="1" tab="非遗项目">
             <EmptyToRecord title="非遗项目" :model="ichData">
-              <a-descriptions v-if="ichData" bordered>
+              <a-descriptions class="mt-3" title="非遗项目信息" v-if="ichData" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
                 <a-descriptions-item label="标题"><ShowValueOrNull :value="ichData.title" /></a-descriptions-item>
-                <a-descriptions-item label="类别"><ShowValueOrNull :value="ichData.ichTypeText" /></a-descriptions-item>
+                <a-descriptions-item label="简介" :span="3"><SimpleRichHtml :contents="[ ichData.intro ]" /></a-descriptions-item>
+                <a-descriptions-item label="描述" :span="3"><SimpleRichHtml :contents="[ ichData.description ]" /></a-descriptions-item>
                 <a-descriptions-item label="类别"><ShowValueOrNull :value="ichData.ichTypeText" /></a-descriptions-item>
                 <a-descriptions-item label="级别"><ShowValueOrNull :value="ichData.levelText" /></a-descriptions-item>
                 <a-descriptions-item label="级别"><ShowValueOrNull :value="ichData.levelText" /></a-descriptions-item>
                 <a-descriptions-item label="批次"><ShowValueOrNull :value="ichData.batchText" /></a-descriptions-item>
                 <a-descriptions-item label="区域"><ShowValueOrNull :value="ichData.regionText" /></a-descriptions-item>
                 <a-descriptions-item label="保护单位"><ShowValueOrNull :value="ichData.unit" /></a-descriptions-item>
+                <a-descriptions-item v-if="ichData.image" label="图片">
+                  <a-image :src="ichData.image" style="max-width:300px;" />
+                </a-descriptions-item>
+                <a-descriptions-item v-if="ichData.video" label="视频">
+                  <video controls :src="ichData.video" style="max-width:300px;" />
+                </a-descriptions-item>
+                <a-descriptions-item v-if="ichData.audio" label="音频">
+                  <audio controls :src="ichData.audio" style="max-width:300px;" />
+                </a-descriptions-item>
+                <a-descriptions-item label="类型"><ShowValueOrNull :value="ichData.typeText" /></a-descriptions-item>
+                <a-descriptions-item v-if="ichData.latitude && ichData.longitude" label="地图">
+                  <SimplePointedMap :longitude="ichData.longitude" :latitude="ichData.latitude" :zoom="15" height="300px"  />
+                </a-descriptions-item>
               </a-descriptions>
             </EmptyToRecord>
           </a-tab-pane>
           <a-tab-pane key="2" tab="传承人">
             <EmptyToRecord title="传承人" :model="inheritorData">
+              <a-descriptions class="mt-3" title="传承人信息" v-if="inheritorData" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
+                <a-descriptions-item label="名字"><ShowValueOrNull :value="inheritorData.title" /></a-descriptions-item>
+                <a-descriptions-item v-if="inheritorData.image" label="头像">
+                  <a-avatar :src="inheritorData.image" size="large" />
+                </a-descriptions-item>
+                <a-descriptions-item label="传承人等级"><ShowValueOrNull :value="inheritorData.levelText" /></a-descriptions-item>
+                <a-descriptions-item label="传承人批次"><ShowValueOrNull :value="inheritorData.batchText" /></a-descriptions-item>
+                <a-descriptions-item label="别称"><ShowValueOrNull :value="inheritorData.alsoName" /></a-descriptions-item>
+                <a-descriptions-item label="时代"><ShowValueOrNull :value="inheritorData.age" /></a-descriptions-item>
+                <a-descriptions-item label="出生地"><ShowValueOrNull :value="inheritorData.birthplace" /></a-descriptions-item>
+                <a-descriptions-item label="民族"><ShowValueOrNull :value="inheritorData.nation" /></a-descriptions-item>
+                <a-descriptions-item label="出生日期"><ShowValueOrNull :value="inheritorData.dateBirth" /></a-descriptions-item>
+                <a-descriptions-item label="逝世日期"><ShowValueOrNull :value="inheritorData.deathBirth" /></a-descriptions-item>
+                <a-descriptions-item label="单位"><ShowValueOrNull :value="inheritorData.unit" /></a-descriptions-item>
+                <a-descriptions-item label="区域"><ShowValueOrNull :value="inheritorData.regionText" /></a-descriptions-item>
 
+                <a-descriptions-item label="简介" :span="3"><SimpleRichHtml :contents="[ inheritorData.intro ]" /></a-descriptions-item>
+                <a-descriptions-item label="描述" :span="3"><SimpleRichHtml :contents="[ inheritorData.content! ]" /></a-descriptions-item>
+                <a-descriptions-item label="奖项/成就" :span="3"><SimpleRichHtml :contents="[ inheritorData.prize ]" /></a-descriptions-item>
+                      
+                <a-descriptions-item v-if="inheritorData.typicalImages" label="代表性图片">
+                  <ImageGrid :data="inheritorData.typicalImages" />
+                </a-descriptions-item>
+                <a-descriptions-item v-if="inheritorData.video" label="视频">
+                  <video controls :src="inheritorData.video" style="max-width:300px;" />
+                </a-descriptions-item>
+                <a-descriptions-item v-if="inheritorData.audio" label="音频">
+                  <audio controls :src="inheritorData.audio" style="max-width:300px;" />
+                </a-descriptions-item>
+              </a-descriptions>
             </EmptyToRecord>
           </a-tab-pane>
           <a-tab-pane key="3" tab="传习所">
             <EmptyToRecord title="传习所" :model="seminarData">
+              <a-descriptions class="mt-3" title="传习所信息" v-if="seminarData" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
+                <a-descriptions-item label="标题"><ShowValueOrNull :value="seminarData.title" /></a-descriptions-item>
+                <a-descriptions-item label="简介" :span="3"><SimpleRichHtml :contents="[ seminarData.desc as string ]" /></a-descriptions-item>
+                <a-descriptions-item label="介绍" :span="3"><SimpleRichHtml :contents="[ seminarData.content as string ]" /></a-descriptions-item>
+                <a-descriptions-item v-if="seminarData.latitude && seminarData.longitude" label="地图">
+                  <SimplePointedMap :longitude="seminarData.longitude" :latitude="seminarData.latitude" :zoom="15" height="300px"  />
+                </a-descriptions-item>
+                <a-descriptions-item label="地址"><ShowValueOrNull :value="seminarData.address" /></a-descriptions-item>
+                <a-descriptions-item label="批次"><ShowValueOrNull :value="seminarData.batchText" /></a-descriptions-item>
+                <a-descriptions-item label="级别"><ShowValueOrNull :value="seminarData.levelText" /></a-descriptions-item>
+                
+                <a-descriptions-item label="联系人"><ShowValueOrNull :value="seminarData.contact" /></a-descriptions-item>
+                <a-descriptions-item label="联系电话"><ShowValueOrNull :value="seminarData.mobile" /></a-descriptions-item>
+                
+                <a-descriptions-item label="单位类型"><ShowValueOrNull :value="seminarData.ichSiteTypeText" /></a-descriptions-item>
+                <a-descriptions-item label="单位"><ShowValueOrNull :value="seminarData.unit" /></a-descriptions-item>
+                <a-descriptions-item v-if="seminarData.image" label="图片">
+                  <a-image :src="seminarData.image" style="max-width:300px;" />
+                </a-descriptions-item>
+                <a-descriptions-item v-if="seminarData.video" label="视频">
+                  <video controls :src="seminarData.video" style="max-width:300px;" />
+                </a-descriptions-item>
+                <a-descriptions-item v-if="seminarData.audio" label="音频">
+                  <audio controls :src="seminarData.audio" style="max-width:300px;" />
+                </a-descriptions-item>
+                <a-descriptions-item label="类型"><ShowValueOrNull :value="seminarData.typeText" /></a-descriptions-item>
 
+              </a-descriptions>
             </EmptyToRecord>
           </a-tab-pane>
         </a-tabs>
@@ -43,15 +114,23 @@
 <script setup lang="ts">
 import type { IchInfo, InheritorInfo, SeminarInfo } from '@/api/inheritor/InheritorContent';
 import InheritorContent from '@/api/inheritor/InheritorContent';
+import ImageGrid from '@/components/content/ImageGrid.vue';
+import SimplePointedMap from '@/components/content/SimplePointedMap.vue';
+import SimpleRichHtml from '@/components/display/SimpleRichHtml.vue';
 import ShowValueOrNull from '@/components/dynamicf/Display/ShowValueOrNull.vue';
 import EmptyToRecord from '@/components/parts/EmptyToRecord.vue';
+import { useAuthStore } from '@/stores/auth';
+import { Modal } from 'ant-design-vue';
 import { onMounted, ref } from 'vue';
+import { useRouter } from 'vue-router';
 
 const activeKey = ref('1');
 const ichData = ref<IchInfo>();
 const inheritorData = ref<InheritorInfo>();
 const seminarData = ref<SeminarInfo>();
 
+
+
 onMounted(() => {
   InheritorContent.getIchInfo().then(data => {
     ichData.value = data;