Sfoglia il codice sorgente

对接通用页面

快乐的梦鱼 1 settimana fa
parent
commit
2f63bdb305

+ 6 - 8
src/api/BaseAppServerRequestModule.ts

@@ -1,14 +1,14 @@
 import ApiCofig from "@/common/config/ApiCofig";
 import AppCofig, { isDev } from "@/common/config/AppCofig";
-import { useAuthStore } from "@/stores/auth";
+import { RegShared } from "@/common/RegShared";
 import { 
   RequestCoreInstance, RequestApiError, StringUtils, appendGetUrlParams, 
   appendPostParams, RequestResponse, RequestOptions, 
   type RequestApiInfoStruct, RequestApiResult, 
   type RequestApiErrorType, 
   defaultResponseDataGetErrorInfo, defaultResponseDataHandlerCatch, WebFetchImplementer } from "@imengyu/imengyu-utils";
-import { logError, type DataModel, type KeyValue, type NewDataModel } from "@imengyu/js-request-transform";
-import Modal from "@/components/Modal.vue";
+import { type DataModel, type KeyValue, type NewDataModel } from "@imengyu/js-request-transform";
+import { Modal } from "ant-design-vue";
 
 /**
  * 说明:业务相关的请求模块
@@ -58,7 +58,6 @@ export function reportError<T extends DataModel>(instance: RequestCoreInstance<T
     Modal.error({
       title: '抱歉',
       content: errMsg,
-      showCancel: false,
     });
   }
 }
@@ -71,11 +70,10 @@ export class BaseAppServerRequestModule<T extends DataModel> extends RequestCore
     //请求拦截器
     this.config.requestInterceptor = (url, req) => {
       //获取store中的token,追加到头;
-      const autoStore = useAuthStore();
       if (StringUtils.isNullOrEmpty((req.headers as KeyValue).token as string)) {
-        if (!StringUtils.isNullOrEmpty(autoStore.token)) {
-          req.headers['token'] = autoStore.token;
-          req.headers['__token__'] = autoStore.token;
+        if (!StringUtils.isNullOrEmpty(RegShared.token)) {
+          req.headers['token'] = RegShared.token;
+          req.headers['__token__'] = RegShared.token;
         }
       }
       if (req.method == 'GET') {

+ 2 - 2
src/api/CommonContent.ts

@@ -228,7 +228,7 @@ export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
     };
     this._afterSolveServer = () => {
       if (!this.image && this.images && this.images && this.images.length > 0  ) {
-        this.image = this.images[0]
+        this.image = this.images[0]!
       }
       if ((!this.images || this.images.length == 0) && this.image) {
         this.images = [ this.image ]
@@ -264,7 +264,7 @@ export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
   isLike = false;
   isCollect = false;
   content = '';
-  publish_at = new Date();
+  publishAt = new Date();
   associationMeList = [] as {
     id: number,
     title: string,

+ 9 - 0
src/common/utils/CommonUtils.ts

@@ -1,3 +1,12 @@
+export function formatDate(val?: Date | string | number): string {
+  if (!val) return ''
+  const d = val instanceof Date ? val : new Date(val)
+  if (isNaN(d.getTime())) return ''
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${day}`
+}
 export function stringNumberOrUndefined(val?: any) : undefined|number {
   if (val) {
     const v = Number(val);

+ 0 - 166
src/components/Modal.vue

@@ -1,166 +0,0 @@
-<template>
-  <Teleport to="body">
-    <Transition name="modal-fade">
-      <div v-if="show" class="modal-overlay" @mousedown.self="onMaskClick">
-        <div class="modal-box" :style="boxStyle">
-          <div class="modal-header" :class="{'hide-title': title === ''}">
-            <div class="modal-title">
-              <slot name="title">{{ title }}</slot>
-            </div>
-          </div>
-          <div class="modal-body">
-            <slot />
-          </div>
-          <div v-if="$slots.footer" class="modal-footer">
-            <slot name="footer" />
-          </div>
-        </div>
-      </div>
-    </Transition>
-  </Teleport>
-</template>
-
-<script lang="ts">
-import { ModalApi } from './modalApi';
-
-export default {
-  name: 'Modal',
-  ...ModalApi,
-};
-</script>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-
-const props = withDefaults(defineProps<{
-  title?: string;
-  show: boolean;
-  showClose?: boolean;
-  width?: string | number;
-  height?: string | number;
-  maskClosable?: boolean;
-}>(), {
-  title: '',
-  showClose: true,
-  width: 480,
-  height: 'auto',
-  maskClosable: true,
-});
-
-const emit = defineEmits<{
-  (e: 'update:show', value: boolean): void;
-  (e: 'close'): void;
-}>();
-
-const boxStyle = computed(() => ({
-  width: toCssSize(props.width),
-  height: toCssSize(props.height),
-}));
-
-function toCssSize(value: string | number) {
-  return typeof value === 'number' ? `${value}px` : value;
-}
-
-function onClose() {
-  emit('update:show', false);
-  emit('close');
-}
-
-function onMaskClick() {
-  if (props.maskClosable) {
-    onClose();
-  }
-}
-</script>
-
-<style scoped lang="scss">
-.modal-overlay {
-  position: fixed;
-  inset: 0;
-  z-index: 1000;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: rgba(0, 0, 0, 0.2);
-  backdrop-filter: blur(4px);
-}
-
-.modal-box {
-  display: flex;
-  flex-direction: column;
-  max-width: calc(100vw - 40px);
-  max-height: calc(100vh - 40px);
-  overflow: hidden;
-  border-radius: var(--border-radius);
-  border: 1px solid var(--color-border-light);
-  background: color-mix(in srgb, var(--color-surface) 80%, transparent);
-  backdrop-filter: blur(24px);
-  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
-  color: var(--color-text);
-}
-
-.modal-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  padding: 12px 16px;
-  border-bottom: 1px solid var(--color-border-light);
-  flex-shrink: 0;
-
-  &.hide-title {
-    position: absolute;
-    top: 0;
-    right: 0;
-    z-index: 1000;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    border-bottom: none
-  }
-}
-
-.modal-title {
-  flex: 1;
-  min-width: 0;
-  font-size: 16px;
-  font-weight: 500;
-  color: var(--color-text);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.modal-body {
-  flex: 1;
-  min-height: 0;
-  padding: 16px;
-  overflow-y: auto;
-  display: flex;
-  flex-direction: column;
-}
-
-.modal-footer {
-  flex-shrink: 0;
-  padding: 12px 16px;
-  border-top: 1px solid var(--color-border-light);
-}
-
-.modal-fade-enter-active,
-.modal-fade-leave-active {
-  transition: opacity 0.2s ease-in-out;
-
-  .modal-box {
-    transition: transform 0.2s ease-in-out;
-  }
-}
-
-.modal-fade-enter-from,
-.modal-fade-leave-to {
-  opacity: 0;
-
-  .modal-box {
-    transform: scale(0.96);
-  }
-}
-</style>

+ 3 - 3
src/components/SimplePageContentLoader.vue

@@ -17,7 +17,7 @@
     <slot />
   </template>
   <div
-    v-if="showEmpty || loader?.loadStatus.value == 'nomore'"
+    v-if="showEmpty || (loader?.loadStatus.value == 'nomore' || loader?.loadStatus.value == 'empty')"
     style="min-height: 200rpx"
     class="empty"
   >
@@ -69,12 +69,12 @@ onMounted(() => {
 });
 
 function handleRetry() {
-  props.loader.loadData(undefined);
+  props.loader.load(true);
 }
 function handleLoad() {
   if (loaded.value) 
     return;
   loaded.value = true;
-  props.loader.loadData(undefined);
+  props.loader.load(true);
 }
 </script>

+ 0 - 137
src/components/modalApi.ts

@@ -1,137 +0,0 @@
-import { defineComponent, h, onMounted, render, ref, type VNode } from 'vue';
-import Modal from './Modal.vue';
-import Button from './Button.vue';
-import Icon from '@/components/Icon.vue';
-
-export type ModalConfirmType = 'info' | 'success' | 'warning' | 'error' | 'confirm';
-
-export interface ModalConfirmOptions {
-  title?: string;
-  content?: string | VNode;
-  okText?: string;
-  cancelText?: string;
-  width?: string | number;
-  maskClosable?: boolean;
-  showCancel?: boolean;
-  onOk?: () => void | Promise<void>;
-  onCancel?: () => void;
-}
-
-const typeConfig: Record<ModalConfirmType, { icon: string; color: string }> = {
-  info: { icon: 'icon-info-fill', color: '#1677ff' },
-  success: { icon: 'icon-round-check-fill', color: '#00c49f' },
-  warning: { icon: 'icon-warn-fill', color: '#ff8b2c' },
-  error: { icon: 'icon-round-close-fill', color: '#ff4d4f' },
-  confirm: { icon: 'icon-question-fill', color: '#ff8b2c' },
-};
-
-const ModalHolder = defineComponent({
-  name: 'ModalConfirmHolder',
-  props: {
-    type: { type: String, required: true },
-    options: { type: Object, required: true },
-    onDestroy: { type: Function, required: true },
-  },
-  setup(props) {
-    const show = ref(false);
-    const okLoading = ref(false);
-    const opt = props.options as ModalConfirmOptions;
-    const cfg = typeConfig[props.type as ModalConfirmType];
-
-    onMounted(() => {
-      show.value = true;
-    });
-
-    function destroy() {
-      (props.onDestroy as () => void)();
-    }
-
-    function handleClose() {
-      show.value = false;
-    }
-
-    function handleUpdateShow(value: boolean) {
-      show.value = value;
-      if (!value) {
-        setTimeout(destroy, 200);
-      }
-    }
-
-    function handleOk() {
-      const result = opt.onOk?.();
-      if (result && typeof (result as Promise<void>).then === 'function') {
-        okLoading.value = true;
-        Promise.resolve(result)
-          .then(handleClose)
-          .catch(() => {})
-          .finally(() => {
-            okLoading.value = false;
-          });
-      } else {
-        handleClose();
-      }
-    }
-
-    function handleCancel() {
-      opt.onCancel?.();
-      handleClose();
-    }
-
-    return () =>
-      h(
-        Modal,
-        {
-          show: show.value,
-          'onUpdate:show': handleUpdateShow,
-          title: opt.title,
-          width: opt.width,
-          maskClosable: opt.maskClosable,
-        },
-        {
-          default: () =>
-            h('div', { class: 'flex items-start gap-3' }, [
-              h(Icon, { name: cfg.icon, size: '1.5rem', color: cfg.color, class: 'flex-shrink-0 mt-0.5' }),
-              opt.content
-                ? h(
-                    'div',
-                    { class: 'flex-1 text-sm whitespace-pre-wrap break-words' },
-                    typeof opt.content === 'string' ? opt.content : opt.content,
-                  )
-                : null,
-            ]),
-          footer: () =>
-            h('div', { class: 'flex flex-row justify-end items-center gap-2' }, [
-              opt.showCancel
-                ? h(Button, { onClick: handleCancel }, { default: () => opt.cancelText ?? '取消' })
-                : null,
-              h(
-                Button,
-                { type: 'primary', loading: okLoading.value, onClick: handleOk },
-                { default: () => opt.okText ?? '确定' },
-              ),
-            ]),
-        },
-      );
-  },
-});
-
-function openConfirm(type: ModalConfirmType, options: ModalConfirmOptions = {}) {
-  const container = document.createElement('div');
-  document.body.appendChild(container);
-
-  const destroy = () => {
-    render(null, container);
-    container.remove();
-  };
-
-  const vm = h(ModalHolder, { type, options, onDestroy: destroy });
-  render(vm, container);
-}
-
-export const ModalApi = {
-  info: (options?: ModalConfirmOptions) => openConfirm('info', { showCancel: false, ...options }),
-  success: (options?: ModalConfirmOptions) => openConfirm('success', { showCancel: false, ...options }),
-  warning: (options?: ModalConfirmOptions) => openConfirm('warning', { showCancel: false, ...options }),
-  error: (options?: ModalConfirmOptions) => openConfirm('error', { showCancel: false, ...options }),
-  confirm: (options?: ModalConfirmOptions) => openConfirm('confirm', { showCancel: true, ...options }),
-};

+ 2 - 2
src/composeable/LoaderCommon.ts

@@ -1,9 +1,9 @@
 import type { Ref } from "vue";
 
-export type LoaderLoadType = 'loading' | 'finished' | 'nomore' | 'error';
+export type LoaderLoadType = 'loading' | 'finished' | 'nomore' | 'error' | 'empty' | '';
 
 export interface ILoaderCommon<P> {
   loadError: Ref<string>;
   loadStatus: Ref<LoaderLoadType>;
-  loadData: (params?: P, refresh?: boolean) => Promise<void>;
+  load: (refresh?: boolean, params?: P) => Promise<void>;
 }

+ 0 - 8
src/composeable/PagerDefine.ts

@@ -1,8 +0,0 @@
-import type { Ref } from "vue";
-
-export interface PagerLoaderStateControl {
-  load: () => Promise<void>;
-  loading: Ref<boolean>;
-  empty: Ref<boolean>;
-  error: Ref<string>;
-}

+ 2 - 2
src/composeable/SimpleDataLoader.ts

@@ -28,7 +28,7 @@ export function useSimpleDataLoader<T, P = any>(
       const res = (await loader(params ?? lastParams)) as T;
       content.value = res;
       if (Array.isArray(res) && emptyIfArrayEmpty && (res as any[]).length === 0)
-        loadStatus.value = 'nomore';
+        loadStatus.value = 'empty';
       else
         loadStatus.value = 'finished';
       loadError.value = '';
@@ -52,7 +52,7 @@ export function useSimpleDataLoader<T, P = any>(
     content,
     loadStatus,
     loadError,
-    loadData,
+    load: (refresh, params?: P) => loadData(params),
     getLastParams: () => lastParams,
   }
 }

+ 55 - 25
src/composeable/SimplePagerData.ts

@@ -1,5 +1,31 @@
 import { formatError } from "@imengyu/imengyu-utils";
-import { watch, ref, computed, type Ref } from "vue"
+import { watch, ref, computed, type Ref, onMounted } from "vue"
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+
+export interface ISimplePagerLoader<T> extends ILoaderCommon<T> {
+  /**
+   * 数据
+   */
+  data: Ref<T[]>;
+  /**
+   * 总页数
+   */
+  totalPages: Ref<number>;
+  /**
+   * 是否为空
+   */
+  empty: Ref<boolean>;
+  /**
+   * 当前页码
+   */
+  page: Ref<number>;
+  /**
+   * 总数据条数
+   */
+  total: Ref<number>;
+  next: () => Promise<void>;
+  prev: () => Promise<void>;
+}
 
 /**
  * 简单分页数据封装。
@@ -27,18 +53,19 @@ import { watch, ref, computed, type Ref } from "vue"
 export function useSimplePagerData<T>(
   pageSize: number|Ref<number>, 
   loader: (page: number, pageSize: number) => Promise<{ 
-    data: T[], 
-    page: number,
+    list: T[], 
+    page?: number,
     total: number,
-  }>
-) {
+  }>,
+  loadWhenMounted: boolean = true,
+) : ISimplePagerLoader<T> {
   const data = ref<T[]>([]);
   const page = ref(0);
   const total = ref(0);
   const totalPages = computed(() => Math.ceil(total.value / (typeof pageSize === 'number' ? pageSize : pageSize.value)));
-  const loading = ref(false);
   const empty = computed(() => data.value.length === 0);
-  const error = ref('');
+  const loadError = ref('');
+  const loadStatus = ref<LoaderLoadType>('');
 
   watch(page, async () => {
     await load();
@@ -49,20 +76,21 @@ export function useSimplePagerData<T>(
    * @returns 
    */
   async function load() {
-    if (loading.value) 
+    if (loadStatus.value === 'loading')
       return;
-    loading.value = true;
+    loadStatus.value = 'loading';
     try {
-      error.value = '';
+      loadError.value = '';
       const res = await loader(page.value, typeof pageSize === 'number' ? pageSize : pageSize.value);
-      page.value = res.page;
+      if (res.page !== undefined)
+        page.value = res.page;
       total.value = res.total;
-      data.value = res.data;
+      data.value = res.list;
+      loadStatus.value = data.value.length > 0 ? (res.total > 0 ? 'finished' : 'nomore') : 'empty';
     } catch (e) {
       console.error(e);
-      error.value = formatError(e);
-    } finally {
-      loading.value = false;
+      loadError.value = formatError(e);
+      loadStatus.value = 'error';
     }
   }
   /**
@@ -84,24 +112,30 @@ export function useSimplePagerData<T>(
     await load();
   }
 
+  onMounted(() => {
+    if (loadWhenMounted)
+      load();
+  })
+
   return {
+    /**
+     * 加载错误信息
+     * 当加载失败时,该值不为空。
+     */
+    loadError,
+    loadStatus,
     load,
     next,
     prev,
     /**
      * 数据
      */
-    data,
+    data: data as any,
     /**
      * 是否为空
      */
     empty,
     /**
-     * 加载错误信息
-     * 当加载失败时,该值不为空。
-     */
-    error,
-    /**
      * 当前页码
      */
     page,
@@ -113,9 +147,5 @@ export function useSimplePagerData<T>(
      * 总页数
      */
     totalPages,
-    /**
-     * 是否正在加载
-     */
-    loading,
   }
 }

+ 4 - 0
src/router/index.ts

@@ -52,4 +52,8 @@ const router = createRouter({
   ],
 })
 
+router.onError((error) => {
+  console.error(error)
+})
+
 export default router

+ 5 - 0
src/stores/auth.ts

@@ -1,4 +1,5 @@
 import UserApi, { LoginResult, UserInfo } from "@/api/auth/UserApi";
+import { RegShared } from "@/common/RegShared";
 import { defineStore } from "pinia"
 
 const STORAGE_KEY = 'authInfo';
@@ -19,12 +20,14 @@ export const useAuthStore = defineStore('auth', {
           throw 'no storage';
         const authInfo = JSON.parse(res);
         this.token = authInfo.token;
+        RegShared.token = authInfo.token;
         this.userId = authInfo.userId;
         this.userInfo = authInfo.userInfo;
         this.expireAt = authInfo.expireAt;
         this.userMainBodyId = authInfo.userMainBodyId;
       } catch (error) {
         this.token = '';
+        RegShared.token = '';
         this.userId = 0;
         this.userInfo = null;
         this.userMainBodyId = 0;
@@ -41,6 +44,7 @@ export const useAuthStore = defineStore('auth', {
     },
     async loginResultHandle(loginResult: LoginResult) {
       this.token = loginResult.userInfo.token;
+      RegShared.token = loginResult.userInfo.token;
       this.userId = loginResult.userInfo.id;
       this.userInfo = loginResult.userInfo;
       this.userMainBodyId = loginResult.userInfo.mainBodyId as number;
@@ -58,6 +62,7 @@ export const useAuthStore = defineStore('auth', {
     },
     async logout() {
       this.token = '';
+      RegShared.token = '';
       this.userId = 0;
       this.userInfo = null;
       this.userMainBodyId = 0;

+ 37 - 27
src/views/generic-content/GenericContentView.vue

@@ -2,43 +2,53 @@
 import { computed } from 'vue'
 import { useRoute } from 'vue-router'
 import DashboardLayout from '@/components/DashboardLayout.vue'
+import SimplePageContentLoader from '@/components/SimplePageContentLoader.vue'
+import { formatDate } from '@/common/utils/CommonUtils'
 
-import { mockGenericContent } from './data'
+import { useSimpleDataLoader } from '@/composeable/SimpleDataLoader.ts'
+import CommonContent, { GetContentDetailItem } from '@/api/CommonContent.ts'
 
 const route = useRoute()
 
 // 输入参数:modelId / id
-const modelId = computed(() => route.query.modelId as string | undefined)
-const id = computed(() => route.query.id as string | undefined)
+const modelId = computed(() => Number(route.query.modelId) || 0)
+const id = computed(() => Number(route.query.id) || 0)
 
-// 后续替换为接口调用:根据 modelId / id 拉取详情
-const content = mockGenericContent
+const loader = useSimpleDataLoader<GetContentDetailItem>(() =>
+  CommonContent.getContentDetail(id.value, modelId.value),
+)
+
+const content = computed(() => loader.content.value)
+const date = computed(() => formatDate(content.value?.publishAt))
+const author = computed(() => content.value?.from || '')
 </script>
 
 <template>
   <DashboardLayout subtitle="通用内容" footer-middle="通用内容页 · 数据驾驶舱">
-    <article class="mx-auto mt-5 max-w-4xl">
-      <section class="border-b border-[#7f4034]/70 pb-5">
-        <h1 class="text-[30px] font-bold leading-snug tracking-[.16em] text-[#fff0bd]">
-          {{ content.title }}
-        </h1>
-        <p class="mt-3 text-sm text-[#d8b45e]">{{ content.subtitle }}</p>
-        <div class="mt-4 flex items-center gap-4 text-xs text-[#c6a18c]">
-          <span class="flex items-center gap-1">
-            <Icon icon="solar:user-id-linear" width="14" />
-            {{ content.author }}
-          </span>
-          <span class="flex items-center gap-1">
-            <Icon icon="solar:calendar-mark-linear" width="14" />
-            {{ content.date }}
-          </span>
-        </div>
-      </section>
+    <SimplePageContentLoader :loader="loader">
+      <article v-if="content" class="mx-auto mt-5 max-w-4xl">
+        <section class="border-b border-[#7f4034]/70 pb-5">
+          <h1 class="text-[30px] font-bold leading-snug tracking-[.16em] text-[#fff0bd]">
+            {{ content.title }}
+          </h1>
+          <p v-if="content.desc" class="mt-3 text-sm text-[#d8b45e]">{{ content.desc }}</p>
+          <div class="mt-4 flex items-center gap-4 text-xs text-[#c6a18c]">
+            <span v-if="author" class="flex items-center gap-1">
+              <Icon icon="solar:user-id-linear" width="14" />
+              {{ author }}
+            </span>
+            <span v-if="date" class="flex items-center gap-1">
+              <Icon icon="solar:calendar-mark-linear" width="14" />
+              {{ date }}
+            </span>
+          </div>
+        </section>
 
-      <section
-        class="generic-content mt-5 space-y-4 text-sm leading-7 text-[#e4c9ad]"
-        v-html="content.content"
-      ></section>
-    </article>
+        <section
+          class="generic-content mt-5 space-y-4 text-sm leading-7 text-[#e4c9ad]"
+          v-html="content.content"
+        ></section>
+      </article>
+    </SimplePageContentLoader>
   </DashboardLayout>
 </template>

+ 0 - 35
src/views/generic-content/data.ts

@@ -1,35 +0,0 @@
-/**
- * 通用内容页静态数据
- *
- * 说明:以下均为静态示例数据,用于前端页面搭建。
- * 后续对接后端时,请根据入参 modelId / id 调用详情接口,
- * 将 `mockGenericContent` 替换为接口返回数据,
- * 类型定义(interface)可保持不变,作为接口数据结构的约定。
- */
-
-export interface GenericContent {
-  id: string
-  /** 标题 */
-  title: string
-  /** 副标题 */
-  subtitle: string
-  /** 作者 */
-  author: string
-  /** 日期 */
-  date: string
-  /** 文章内容(富文本 HTML) */
-  content: string
-}
-
-export const mockGenericContent: GenericContent = {
-  id: '1',
-  title: '歌仔戏传承人在漳州古城开课',
-  subtitle: '传统戏剧 · 国家级非遗代表性项目',
-  author: '漳州日报',
-  date: '2026-08-18',
-  content: `
-    <p>歌仔戏是闽南地区最具代表性的地方戏曲之一,以其婉转唱腔与生活化表演深受群众喜爱。近年来,漳州市持续推进歌仔戏的保护与传承工作,推动代表性传承人走进古城、走进社区,开展常态化传习授课。</p>
-    <p>本次在漳州古城开设的传习课堂,邀请国家级代表性传承人现场教学,围绕经典唱段、身段表演与唱腔韵味进行系统传授,吸引了大批年轻学员与戏曲爱好者参与。</p>
-    <p>下一步,相关部门将继续完善传承人档案与数字影像采集,让歌仔戏这一珍贵文化遗产在新时代焕发新的生机。</p>
-  `,
-}

+ 29 - 29
src/views/generic-list/GenericListView.vue

@@ -2,23 +2,30 @@
 import { computed } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import DashboardLayout from '@/components/DashboardLayout.vue'
+import SimplePageContentLoader from '@/components/SimplePageContentLoader.vue'
 
 import GenericListItem from './components/GenericListItem.vue'
-import { mockGenericList, type GenericListItem as GenericListItemData } from './data'
+import { useSimplePagerData } from '@/composeable/SimplePagerData.ts'
+import CommonContent, { GetContentListParams, GetContentListItem } from '@/api/CommonContent.ts'
 
 const route = useRoute()
 const router = useRouter()
 
-// 输入参数:columId / mainBodyId / modelId / parentId
-const columnId = computed(() => route.query.columnId as string | undefined)
-const mainBodyId = computed(() => route.query.mainBodyId as string | undefined)
-const modelId = computed(() => route.query.modelId as string | undefined)
-const parentId = computed(() => route.query.parentId as string | undefined)
-
-// 后续替换为接口调用:根据 columId / mainBodyId / modelId / parentId 拉取列表
-const items = computed(() => mockGenericList)
-
-function onItemClick(item: GenericListItemData) {
+// 输入参数:modelId / mainBodyColumnId
+const modelId = computed(() => Number(route.query.modelId) || 0)
+const mainBodyColumnId = computed(() => Number(route.query.mainBodyColumnId) || 0)
+
+const loader = useSimplePagerData<GetContentListItem>(10, async (page, pageSize) => {
+  return await CommonContent.getContentList(
+    new GetContentListParams()
+      .setModelId(modelId.value)
+      .setMainBodyColumnId(mainBodyColumnId.value),
+    page,
+    pageSize,
+  )
+})
+
+function onItemClick(item: GetContentListItem) {
   router.push({
     path: '/generic-content',
     query: { modelId: modelId.value, id: item.id },
@@ -32,25 +39,18 @@ function onItemClick(item: GenericListItemData) {
       <div>
         <h1 class="mt-2 text-[30px] font-bold tracking-[.16em] text-[#fff0bd]">通用列表</h1>
       </div>
-      <div class="pb-1 text-xs text-[#c6a18c]">共 {{ items.length }} 条</div>
-    </section>
-
-    <section class="mt-4 border border-[#704033] bg-[#2b1514]/75 p-2 text-[11px] text-[#c6a18c]">
-      <span class="mr-4">columId:{{ columnId || '-' }}</span>
-      <span class="mr-4">mainBodyId:{{ mainBodyId || '-' }}</span>
-      <span class="mr-4">modelId:{{ modelId || '-' }}</span>
-      <span>parentId:{{ parentId || '-' }}</span>
-    </section>
-
-    <section class="mt-4 grid grid-cols-2 gap-3">
-      <GenericListItem v-for="item in items" :key="item.id" :item="item" @click="onItemClick" />
+      <div class="pb-1 text-xs text-[#c6a18c]">共 {{ loader.total.value }} 条</div>
     </section>
 
-    <p
-      v-if="items.length === 0"
-      class="mt-4 border-l-2 border-[#d0734e] bg-[#4a211d] px-3 py-2 text-xs text-[#f0d7a2]"
-    >
-      暂无数据。
-    </p>
+    <SimplePageContentLoader :loader="loader">
+      <section class="mt-4 grid grid-cols-2 gap-3">
+        <GenericListItem
+          v-for="item in loader.data.value"
+          :key="item.id"
+          :item="item"
+          @click="onItemClick"
+        />
+      </section>
+    </SimplePageContentLoader>
   </DashboardLayout>
 </template>

+ 13 - 7
src/views/generic-list/components/GenericListItem.vue

@@ -1,9 +1,15 @@
 <script setup lang="ts">
-import type { GenericListItem } from '../data'
+import { computed } from 'vue'
+import { formatDate } from '@/common/utils/CommonUtils'
+import type { GetContentListItem } from '@/api/CommonContent'
 
-defineProps<{ item: GenericListItem }>()
+const props = defineProps<{ item: GetContentListItem }>()
 
-defineEmits<{ (e: 'click', item: GenericListItem): void }>()
+defineEmits<{ (e: 'click', item: GetContentListItem): void }>()
+
+const subtitle = computed(() => props.item.desc || props.item.tags?.join(' · ') || '')
+const date = computed(() => formatDate(props.item.publishAt))
+const author = computed(() => props.item.from || '')
 </script>
 
 <template>
@@ -22,15 +28,15 @@ defineEmits<{ (e: 'click', item: GenericListItem): void }>()
     </span>
     <span class="flex min-w-0 flex-1 flex-col justify-center">
       <span class="block truncate font-bold text-[#f3dba1]">{{ item.title }}</span>
-      <span class="mt-1 block truncate text-[11px] text-[#d8b45e]">{{ item.subtitle }}</span>
+      <span v-if="subtitle" class="mt-1 block truncate text-[11px] text-[#d8b45e]">{{ subtitle }}</span>
       <span class="mt-2 flex items-center gap-4 text-[11px] text-[#c6a18c]">
         <span class="flex items-center gap-1">
           <Icon icon="solar:calendar-mark-linear" width="13" />
-          {{ item.date }}
+          {{ date }}
         </span>
-        <span class="flex items-center gap-1">
+        <span v-if="author" class="flex items-center gap-1">
           <Icon icon="solar:user-id-linear" width="13" />
-          {{ item.author }}
+          {{ author }}
         </span>
       </span>
     </span>

+ 0 - 73
src/views/generic-list/data.ts

@@ -1,73 +0,0 @@
-/**
- * 通用列表页静态数据
- *
- * 说明:以下均为静态示例数据,用于前端页面搭建。
- * 后续对接后端时,请根据入参 columnId / mainBodyId / modelId / parentId
- * 调用列表接口,将 `mockGenericList` 替换为接口返回数据,
- * 类型定义(interface)可保持不变,作为接口数据结构的约定。
- */
-
-export interface GenericListItem {
-  id: string
-  /** 标题 */
-  title: string
-  /** 副标题 */
-  subtitle: string
-  /** 图片 */
-  image: string
-  /** 日期 */
-  date: string
-  /** 作者 */
-  author: string
-}
-
-export const mockGenericList: GenericListItem[] = [
-  {
-    id: '1',
-    title: '歌仔戏传承人在漳州古城开课',
-    subtitle: '传统戏剧 · 国家级非遗代表性项目',
-    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/fec813433056449f94fa9a0c6344ce11.jpg',
-    date: '2026-08-18',
-    author: '漳州日报',
-  },
-  {
-    id: '2',
-    title: '漳州木版年画数字化采集完成',
-    subtitle: '传统美术 · 国家级非遗代表性项目',
-    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/a1eb77553860452b88762b67954a1bec.jpg',
-    date: '2026-08-15',
-    author: '文化遗产保护中心',
-  },
-  {
-    id: '3',
-    title: '八宝印泥制作技艺口述史整理启动',
-    subtitle: '传统技艺 · 老字号档案',
-    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/781e0f6f33674d8bbd86b466c2228bed.jpg',
-    date: '2026-08-12',
-    author: '非遗科',
-  },
-  {
-    id: '4',
-    title: '田螺坑土楼群建筑本体巡查纪实',
-    subtitle: '文化空间 · 传统村落保护',
-    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/c53c5c35918d4cd88b31ca68702c240c.jpg',
-    date: '2026-08-09',
-    author: '南靖县文体旅局',
-  },
-  {
-    id: '5',
-    title: '东山歌册田野调查阶段性成果',
-    subtitle: '民间文学 · 省级非遗代表性项目',
-    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/2a7798eae2754c6e87282c5677fc0aa8.jpg',
-    date: '2026-08-05',
-    author: '东山县文化馆',
-  },
-  {
-    id: '6',
-    title: '漳州元宵民俗档案辑录发布',
-    subtitle: '民俗档案 · 闽南文化档案',
-    image: 'https://modao.cc/agent-py/media/generated_images/2026-08-12/932c7ab061d045578f20f63e0defc2f5.jpg',
-    date: '2026-08-02',
-    author: '档案中心',
-  },
-]

+ 46 - 10
src/views/protection-overview/ProtectionOverviewView.vue

@@ -1,10 +1,3 @@
-<script setup lang="ts">
-import DashboardLayout from '@/components/DashboardLayout.vue'
-
-import OverviewCard from './components/OverviewCard.vue'
-import { overviewCards } from './data'
-</script>
-
 <template>
   <DashboardLayout subtitle="" footer-middle="保护概况 · 数字驾驶舱" footer-right="更新于 2026-08-19">
     <section class="mt-5 flex items-end justify-between border-b border-[#7f4034]/70 pb-4">
@@ -13,8 +6,51 @@ import { overviewCards } from './data'
       </div>
     </section>
 
-    <section class="mt-5 grid grid-cols-2 gap-4">
-      <OverviewCard v-for="card in overviewCards" :key="card.id" :card="card" />
-    </section>
+    <SimplePageContentLoader :loader="loader">
+      <section class="mt-5 grid grid-cols-2 gap-4">
+        <OverviewCard
+          v-for="card in loader.data.value"
+          :key="card.id"
+          :card="{
+            id: card.id,
+            title: card.title,
+            desc: card.desc,
+            image: card.image,
+            tags: card.tags,
+          }"
+          @click="onCardClick(card)"
+        />
+      </section>
+    </SimplePageContentLoader>
   </DashboardLayout>
 </template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router'
+import DashboardLayout from '@/components/DashboardLayout.vue'
+import OverviewCard from './components/OverviewCard.vue'
+import SimplePageContentLoader from '@/components/SimplePageContentLoader.vue';
+import { useSimplePagerData } from '@/composeable/SimplePagerData.ts';
+import CommonContent, { GetContentListParams, GetContentListItem } from '@/api/CommonContent.ts';
+
+const router = useRouter()
+
+// 保护区概况主体模型 id
+const MODEL_ID = 14
+
+const loader = useSimplePagerData(8, async (page, pageSize) => {
+  return await CommonContent.getContentList(new GetContentListParams()
+    .setModelId(MODEL_ID)
+    .setMainBodyColumnId(233),
+    page,
+    pageSize
+  );
+})
+
+function onCardClick(card: GetContentListItem) {
+  router.push({
+    path: '/generic-list',
+    query: { modelId: MODEL_ID, mainBodyColumnId: card.id },
+  })
+}
+</script>

+ 20 - 9
src/views/protection-overview/components/OverviewCard.vue

@@ -1,38 +1,49 @@
 <script setup lang="ts">
-import type { OverviewCard } from '../data'
+defineProps<{
+  card: {
+    id: number
+    title: string
+    image?: string
+    badge?: string
+    corner?: string
+    tags?: string[]
+    desc?: string
+  }
+}>()
 
-defineProps<{ card: OverviewCard }>()
+defineEmits<{ (e: 'click'): void }>()
 </script>
 
 <template>
   <article
-    class="group overflow-hidden border border-[#7f4034] bg-[#351817]/85 shadow-[inset_0_0_28px_rgba(194,77,54,.12)] transition hover:border-[#c98755]"
+    class="group cursor-pointer overflow-hidden border border-[#7f4034] bg-[#351817]/85 shadow-[inset_0_0_28px_rgba(194,77,54,.12)] transition hover:border-[#c98755]"
+    @click="$emit('click')"
   >
-    <div class="relative h-44 overflow-hidden border-b border-[#7f4034]" :class="card.headerBgClass">
+    <div class="relative h-44 overflow-hidden border-b border-[#7f4034] bg-[#51271f]">
       <img
-        :alt="card.imageAlt"
         class="h-full w-full object-cover opacity-80"
         :src="card.image"
       />
       <div
+        v-if="card.badge"
         class="absolute left-4 top-4 border border-[#f0c77b]/70 bg-[#3b1b18]/80 px-3 py-1 text-xs text-[#ffe7aa]"
       >
         {{ card.badge }}
       </div>
-      <span class="absolute bottom-3 right-4 text-xs tracking-[.2em] text-[#ffe1a0]">
+      <span v-if="card.corner" class="absolute bottom-3 right-4 text-xs tracking-[.2em] text-[#ffe1a0]">
         {{ card.corner }}
       </span>
     </div>
 
     <div class="p-5">
       <div class="flex items-start gap-3">
-        <span class="mt-1 h-8 w-1 shrink-0" :class="card.accentClass"></span>
+        <span class="mt-1 h-8 w-1 shrink-0 bg-[#c6533d]"></span>
         <div>
           <h2 class="mt-1 text-xl font-bold text-[#f5df9e]">{{ card.title }}</h2>
         </div>
       </div>
-      <p class="mt-4 text-sm leading-7 text-[#c8a289]">{{ card.desc }}</p>
-      <div class="mt-4 flex gap-2 text-xs">
+      <p v-if="card.desc" class="mt-4 text-sm leading-7 text-[#c8a289]">{{ card.desc }}</p>
+      <div v-if="card.tags" class="mt-4 flex gap-2 text-xs">
         <span
           v-for="(tag, i) in card.tags"
           :key="tag"