ソースを参照

📦 接口对接首页,新闻页

imengyu 4 日 前
コミット
e1cf6c49f3
共有65 個のファイルを変更した1882 個の追加397 個の削除を含む
  1. 7 0
      package-lock.json
  2. 1 0
      package.json
  3. 30 6
      src/App.vue
  4. 323 0
      src/api/CommonContent.ts
  5. 12 0
      src/api/NotConfigue.ts
  6. 218 0
      src/api/RequestModules.ts
  7. 24 0
      src/api/Test.ts
  8. 68 0
      src/api/auth/UserApi.ts
  9. 10 0
      src/api/communicate/CommunicateContent.ts
  10. 10 0
      src/api/communicate/CommunicateNewsContent.ts
  11. 10 0
      src/api/fusion/CalendarContent.ts
  12. 10 0
      src/api/fusion/DemoSiteContent.ts
  13. 10 0
      src/api/fusion/FashionContent.ts
  14. 10 0
      src/api/fusion/ProductContent.ts
  15. 10 0
      src/api/fusion/RouteContent.ts
  16. 10 0
      src/api/fusion/ScenicSpotContent.ts
  17. 10 0
      src/api/fusion/ShowPlaceContent.ts
  18. 10 0
      src/api/inheritor/ActivityContent.ts
  19. 10 0
      src/api/inheritor/ArtifactProtectContent.ts
  20. 10 0
      src/api/inheritor/InheritorContent.ts
  21. 19 0
      src/api/inheritor/MoveableContent.ts
  22. 10 0
      src/api/inheritor/ProductsContent.ts
  23. 10 0
      src/api/inheritor/ProjectsContent.ts
  24. 10 0
      src/api/inheritor/ProtectListContent.ts
  25. 10 0
      src/api/inheritor/SeminarContent.ts
  26. 10 0
      src/api/inheritor/UnitContent.ts
  27. 20 0
      src/api/inheritor/UnmoveableContent.ts
  28. 10 0
      src/api/introduction/BulidingContent.ts
  29. 10 0
      src/api/introduction/CharacterContent.ts
  30. 10 0
      src/api/introduction/CustomContent.ts
  31. 10 0
      src/api/introduction/FeatureContent.ts
  32. 10 0
      src/api/introduction/HistoryContent.ts
  33. 39 0
      src/api/introduction/IndexContent.ts
  34. 10 0
      src/api/introduction/LanguageContent.ts
  35. 10 0
      src/api/introduction/PolicyContent.ts
  36. 10 0
      src/api/introduction/SeaContent.ts
  37. 10 0
      src/api/introduction/VictualsContent.ts
  38. 10 0
      src/api/news/NewsFolkActivitiesContent.ts
  39. 10 0
      src/api/news/NewsIndexContent.ts
  40. 10 0
      src/api/news/NewsOfficialActivitiesContent.ts
  41. 10 0
      src/api/news/NewsPeoplesStageContent.ts
  42. 10 0
      src/api/research/DiscussContent.ts
  43. 10 0
      src/api/research/ExpertContent.ts
  44. 10 0
      src/api/research/IndexTeamsContent.ts
  45. 10 0
      src/api/research/ProjectContent.ts
  46. 10 0
      src/api/research/ResultContent.ts
  47. 10 0
      src/api/research/TeamsContent.ts
  48. 1 0
      src/common/utils/DateUtils.ts
  49. 79 0
      src/components/content/SimplePageContentLoader.vue
  50. 4 4
      src/components/controls/Dropdown.vue
  51. 2 1
      src/components/controls/SimpleInput.vue
  52. 98 0
      src/components/error/ErrorReporter.vue
  53. 28 0
      src/components/error/ErrorReporterIs.ts
  54. 9 0
      src/composeable/LoaderCommon.ts
  55. 38 0
      src/composeable/PageQuerys.ts
  56. 0 8
      src/composeable/PagerDefine.ts
  57. 57 0
      src/composeable/SimpleDataLoader.ts
  58. 0 120
      src/composeable/SimplePagerData.ts
  59. 124 0
      src/composeable/SimplePagerDataLoader.ts
  60. 7 0
      src/main.ts
  61. 1 1
      src/router/index.ts
  62. 74 0
      src/stores/auth.ts
  63. 111 130
      src/views/HomeView.vue
  64. 56 38
      src/views/NewsDetailView.vue
  65. 72 89
      src/views/NewsView.vue

+ 7 - 0
package-lock.json

@@ -14,6 +14,7 @@
         "bootstrap": "^5.3.0",
         "mitt": "^3.0.1",
         "pinia": "^3.0.1",
+        "tslib": "^2.8.1",
         "vue": "^3.5.13",
         "vue-router": "^4.5.0",
         "vue3-carousel": "^0.15.0"
@@ -3817,6 +3818,12 @@
         "node": ">=6"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
     "node_modules/typescript": {
       "version": "5.8.3",
       "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz",

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
     "bootstrap": "^5.3.0",
     "mitt": "^3.0.1",
     "pinia": "^3.0.1",
+    "tslib": "^2.8.1",
     "vue": "^3.5.13",
     "vue-router": "^4.5.0",
     "vue3-carousel": "^0.15.0"

+ 30 - 6
src/App.vue

@@ -1,14 +1,38 @@
 <template>
-  <NavBar />
-  <main>
-    <RouterView />
-  </main>
-  <Footer />
+  <a-config-provider
+    :theme="{
+      token: {
+        colorPrimary: '#bd4b36',
+      },
+    }"
+  >
+    <NavBar />
+    <main>
+      <RouterView />
+    </main>
+    <Footer />
+  </a-config-provider>
 </template>
 
 <script setup lang="ts">
-import { RouterLink, RouterView } from 'vue-router'
+import { onMounted, watch } from 'vue';
+import { RouterView, useRoute } from 'vue-router'
 import NavBar from './components/NavBar.vue';
 import Footer from './components/Footer.vue';
+import { useAuthStore } from './stores/auth';
 
+const authStore = useAuthStore();
+
+onMounted(() => {
+  authStore.loadLoginState();
+});
+
+const route = useRoute();
+
+watch(route, () => {
+  window.scrollTo({
+    top: 0,
+    behavior: 'instant'
+  })
+});
 </script>

+ 323 - 0
src/api/CommonContent.ts

@@ -0,0 +1,323 @@
+import { DataModel, transformArrayDataModel, type KeyValue, type NewDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from './RequestModules';
+import type { QueryParams } from '@/common/request/utils/AllType';
+import { transformSomeToArray } from '@/common/request/utils/Utils';
+
+export class GetColumListParams extends DataModel<GetColumListParams> {
+  
+  public constructor() {
+    super(GetColumListParams);
+    this.setNameMapperCase('Camel', 'Snake');
+  }
+
+  /**
+   * 	主体栏目id
+   */
+  mainBodyColumnId: number|number[] = 0;
+  /**
+   * 标志:hot=热门,recommend=推荐,top=置顶
+   */
+  flag ?: 'hot'|'recommend'|'top';
+  /**
+   * 内容数量,默认4
+   */
+  size = 4;
+}
+export class GetContentListParams extends DataModel<GetContentListParams> {
+  
+  public constructor() {
+    super(GetContentListParams);
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      ids: {
+        customToServerFn: (val) => (val as number[]).join(','),
+        customToClientFn: (val) => (val as string).split(',').map((item) => parseInt(item)),
+      },
+    }
+  }
+
+  /**
+   * 主体栏目id
+   */
+  mainBodyColumnId: number|number[] = 0;
+  /**
+   * 标志:hot=热门,recommend=推荐,top=置顶
+   */
+  flag ?: 'hot'|'recommend'|'top';
+  /**
+   * 内容id(逗号隔开)如:3 或者 1,2,3
+   */
+  ids?: number[];
+  /**
+   * 类型:1=文章,2=音频,3=视频,4=相册
+   */
+  type?: 1|2|3|4;
+  /**
+   * 内容数量,默认4
+   */
+  size = 4;
+  /**
+   * 关键字查询
+   */
+  keywords?: string;
+
+}
+
+export class GetColumContentList extends DataModel<GetColumContentList> {
+  constructor() {
+    super(GetColumContentList, "主体栏目列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      name: { clientSide: 'string', serverSide: 'string', clientSideRequired: true },
+      content_list: { 
+        clientSide: 'array',
+        clientSideRequired: true,
+        clientSideChildDataModel: GetContentListItem,
+      },
+    }
+  }
+
+  name = '';
+  overview = '';
+}
+export class GetContentListItem extends DataModel<GetContentListItem> {
+  constructor() {
+    super(GetContentListItem, "内容列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      mainBodyColumnId: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      title: { clientSide: 'string', serverSide: 'string', clientSideRequired: true },
+      isGuest: { clientSide: 'boolean', serverSide: 'number' },
+      isLogin: { clientSide: 'boolean', serverSide: 'number' },
+      isComment: { clientSide: 'boolean', serverSide: 'number' },
+      isLike: { clientSide: 'boolean', serverSide: 'number' },
+      isCollect: { clientSide: 'boolean', serverSide: 'number' },
+      latitude: { clientSide: 'number', serverSide: 'number' },
+      longitude: { clientSide: 'number', serverSide: 'number' },
+      publish_at: { clientSide: 'date', serverSide: 'string' },
+      flag: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      tags: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      keywords: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      type: { clientSide: 'number', serverSide: 'number' },
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Time'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+  }
+  id = 0;
+  mainBodyColumnId = 0;
+  latitude = 0;
+  longitude = 0;
+  mapX = '';
+  mapY = '';
+  from = '';
+  modelId = 0;
+  title = '!title';
+  typeText = '';
+  region = 0;
+  image = '';
+  thumbnail = '';
+  desc = '!desc';
+  content = '!content';
+  type = 0;
+  keywords ?: string[];
+  flag ?: string[];
+  tags ?: string[];
+  views = 0;
+  comments = 0;
+  likes = 0;
+  collects = 0;
+  dislikes = 0;
+  district = '';
+  publish_at = new Date();
+}
+export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
+  constructor() {
+    super(GetContentDetailItem, "内容详情");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      title: { clientSide: 'string', serverSide: 'string', clientSideRequired: true },
+      content: { clientSide: 'string', serverSide: 'string' },
+      isGuest: { clientSide: 'boolean', serverSide: 'number' },
+      isLogin: { clientSide: 'boolean', serverSide: 'number' },
+      isComment: { clientSide: 'boolean', serverSide: 'number' },
+      isLike: { clientSide: 'boolean', serverSide: 'number' },
+      isCollect: { clientSide: 'boolean', serverSide: 'number' },
+      publish_at: { clientSide: 'date', serverSide: 'string' },
+      flag: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      tags: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      type: { clientSide: 'number', serverSide: 'number' },
+    }
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Time'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+  }
+
+  id = 0;
+  from = '';
+  modelId = 0;
+  type = 0;
+  title = '';
+  region = 0;
+  image = '';
+  images = [];
+  audio = '';
+  video = '';
+  desc = '';
+  flag ?: string[];
+  tags ?: string[];
+  views = 0;
+  comments = 0;
+  likes = 0;
+  collects = 0;
+  dislikes = 0;
+  isLogin = false;
+  isGuest = false;
+  isComment = false;
+  isLike = false;
+  isCollect = false;
+  content = '';
+  publish_at = new Date();
+}
+
+export class CategoryListItem extends DataModel<CategoryListItem> {
+  constructor() {
+    super(CategoryListItem, "分类列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      pid: { clientSide: 'number', serverSide: 'number' },
+      haschild: { clientSide: 'boolean', serverSide: 'number' },
+    }
+  }
+
+  id !: number;
+  pid !: number;
+  title = '';
+  status = 'normal';
+  weight = 0;
+  spacer = '';
+  haschild = false;
+  children?: CategoryListItem[];
+}
+
+export class CommonContentApi extends AppServerRequestModule<DataModel> {
+
+  constructor(modelId: number, debugName: string, mainBodyColumnId?: number) {
+    super();
+    this.modelId = modelId;
+    this.mainBodyColumnId = mainBodyColumnId;
+    this.debugName = debugName;
+  }
+
+  protected mainBodyColumnId?: number;
+  protected modelId: number;
+  protected debugName: string;
+
+  /**
+   * 获取分类列表
+   * @param type 根级类型:1=区域、2=级别、3=文物类型、4=非遗类型、42=事件类型
+   * @param withself 是否返回包含自己:true=是,false=否 ,默认false
+   * @returns 
+   */
+  async getCategoryList(
+    type?: number,
+    withself?: boolean,
+  ) {
+    return (this.get('/content/category/getCategoryList', '获取分类列表', {
+      type,
+      is_tree: false,
+      withself,
+    }))
+      .then(res => transformArrayDataModel<CategoryListItem>(CategoryListItem, res.data2, `获取分类列表`, true))
+      .catch(e => { throw e });
+  }
+  /**
+   * 用于获取某一个分类需要用的子级
+   * @param pid 父级
+   * @returns 
+   */
+  async getCategoryChildList(pid?: number) {
+    return (this.get('/content/category/getCategoryOnlyChildList', '获取分类子级列表', {
+      pid,
+    }))
+      .then(res => transformArrayDataModel<CategoryListItem>(
+        CategoryListItem, 
+        transformSomeToArray(res.data2), 
+        `获取分类列表`, 
+        true
+      ))
+      .catch(e => { throw e });
+  }
+  /**
+   * 主体栏目列表
+   * @param params 参数 
+   * @param querys 额外参数
+   * @returns 
+   */
+  getColumList<T extends DataModel = GetColumContentList>(params: GetColumListParams, modelClassCreator: NewDataModel = GetColumContentList, querys?: QueryParams) {
+    return this.get('/content/content/getMainBodyColumnContentList', `${this.debugName} 主体栏目列表`, {
+      model_id: this.modelId,
+      ...params.toServerSide(),
+      ...querys,
+    })
+      .then(res => ({
+        list: transformArrayDataModel<T>(modelClassCreator, res.data2.list, `${this.debugName} 主体栏目列表`, true),
+        total: res.data2.total as number,
+      }))
+      .catch(e => { throw e });
+  }
+  /**
+   * 模型内容列表
+   * @param params 参数
+   * @param page 页码
+   * @param pageSize 页大小
+   * @param querys 额外参数
+   * @returns 
+   */
+  getContentList<T extends DataModel = GetContentListItem>(params: GetContentListParams, page: number, pageSize: number = 10, modelClassCreator: NewDataModel = GetContentListItem, querys?: QueryParams) {
+    return this.get('/content/content/getContentList', `${this.debugName} 模型内容列表`, {
+      ...params.toServerSide(),
+      ...querys,
+      model_id: this.modelId,
+      main_body_column_id: params.mainBodyColumnId || this.mainBodyColumnId,
+      page,
+      pageSize,
+    })
+      .then(res => ({ 
+        list: transformArrayDataModel<T>(modelClassCreator, res.data2.list, `${this.debugName} 模型内容列表`, true),
+        total: res.data2.total as number,
+      }))
+      .catch(e => { throw e });
+  }
+  /**
+   * 内容详情
+   * @param id id 
+   * @param querys 额外参数
+   * @returns 
+   */
+  getContentDetail<T extends DataModel = GetContentDetailItem>(id: number, modelClassCreator: NewDataModel = GetContentDetailItem, querys?: QueryParams) {
+    return this.get('/content/content/getContentDetail', `${this.debugName} (${id}) 内容详情`, {
+      model_id: this.modelId,
+      id,
+      ...querys,
+    }, modelClassCreator)
+      .then(res => res.data as T)
+      .catch(e => { throw e });
+  }
+}
+
+export default new CommonContentApi(0, '默认通用内容');

+ 12 - 0
src/api/NotConfigue.ts

@@ -0,0 +1,12 @@
+import { DataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from './RequestModules';
+
+export class CommonApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+    this.config.modelClassCreator = DataModel;
+  }
+}
+
+export default new CommonApi();

+ 218 - 0
src/api/RequestModules.ts

@@ -0,0 +1,218 @@
+
+/**
+ * 这里写的是业务相关的:
+ * * 请求数据处理函数。
+ * * 自定义请求模块。
+ * * 自定义错误报告处理函数。
+ */
+
+import AppCofig from "@/common/config/AppCofig";
+import ApiCofig from "@/common/config/ApiCofig";
+import RequestApiConfig from "../common/request/core/RequestApiConfig";
+import fetchImplementer from "@/common/request/implementer/WebFetch";
+import { RequestApiError, RequestApiResult, type RequestApiErrorType } from "../common/request/core/RequestApiResult";
+import { RequestCoreInstance, RequestOptions } from "../common/request/core/RequestCore";
+import { defaultResponseDataGetErrorInfo, defaultResponseDataHandlerCatch } from "../common/request/core/RequestHandler";
+import { logError } from "@/components/error/ErrorReporterIs";
+import type { DataModel, KeyValue, NewDataModel } from "@imengyu/js-request-transform";
+import { isNullOrEmpty, appendGetUrlParams, appendPostParams, checkIfStringAllEnglish } from "@/common/request/utils/Utils";
+import { useAuthStore } from "@/stores/auth";
+import { Modal } from "ant-design-vue";
+
+/**
+ * 不报告错误的 code
+ */
+const notReportErrorCode = [401] as number[];
+const notReportMessages = [
+  /请授权绑定手机号/g,
+] as RegExp[];
+function matchNotReportMessage(str: string) {
+  for (let i = 0; i < notReportMessages.length; i++) {
+    if (notReportMessages[i].test(str))
+      return true;
+  }
+  return false;
+}
+
+//请求拦截器
+function requestInceptor(url: string, req: RequestOptions) {
+  //获取store中的token,追加到头;
+  const autoStore = useAuthStore();
+  if (isNullOrEmpty((req.header as KeyValue).token as string)) {
+    req.header['token'] = autoStore.token;
+    req.header['__token__'] = autoStore.token;
+  }
+  if (req.method == 'GET') {
+    //追加GET参数
+    url = appendGetUrlParams(url, 'main_body_id', ApiCofig.mainBodyId);
+  } else {
+    req.data = appendPostParams(req.data,'main_body_id', ApiCofig.mainBodyId);
+  }
+  return { newUrl: url, newReq: req };
+}
+//响应数据处理函数
+function responseDataHandler<T extends DataModel>(response: Response, req: RequestOptions, resultModelClass: NewDataModel|undefined, instance: RequestCoreInstance<T>, apiName: string | undefined): Promise<RequestApiResult<T>> {
+  return new Promise<RequestApiResult<T>>((resolve, reject) => {
+    const method = req.method || 'GET';
+    response.json().then((json) => {
+      if (response.ok) {
+        if (!json) {
+          reject(new RequestApiError(
+            'businessError',
+            '后端未返回数据',
+            '',
+            response.status,
+            null,
+            null,
+            req,
+            apiName,
+            response.url
+          ));
+          return;
+        }
+
+        //code == 0 错误
+        if (json.code === 0) {
+          handleError();
+          return;
+        }
+
+        //处理后端的数据
+        let message = '未知错误';
+        let data = {} as any;
+
+        //后端返回格式不统一,所以在这里处理格式
+        if (typeof json.data === 'object') {
+          data = json.data;
+          message = json.data?.msg || response.statusText;
+        }
+        else {
+          //否则返回上层对象
+          data = json;
+          message = json.msg || response.statusText;
+        }
+
+        resolve(new RequestApiResult(
+          resultModelClass ?? instance.config.modelClassCreator,
+          json?.code || response.status,
+          message,
+          data,
+          json
+        ));
+      }
+      else {
+        handleError();
+      }
+
+      function handleError() {
+        let errType : RequestApiErrorType = 'unknow';
+        let errString = '';
+        let errCodeStr = '';
+
+        if (typeof json.message === 'string') 
+          errString = json.message;
+        if (typeof json.msg === 'string') 
+          errString += json.msg;
+
+        if (checkIfStringAllEnglish(errString))
+          errString = '服务器返回:' + errString;
+
+        //错误处理
+        if (errString) {
+          //如果后端有返回错误信息,则收集错误信息并返回
+          errType = 'businessError';
+          if (typeof json.data === 'object' && json.data?.errmsg) {
+            errString += '\n' + json.data.errmsg;
+          }
+          if (typeof json.errors === 'object') {
+            for (const key in json.errors) {
+              if (Object.prototype.hasOwnProperty.call(json.errors, key)) {
+                errString += '\n' + json.errors[key];
+              }
+            }
+          }
+        } else {
+          const res = defaultResponseDataGetErrorInfo(response, json);
+          errType = res.errType;
+          errString = res.errString;
+          errCodeStr = res.errCodeStr;
+        }
+
+        reject(new RequestApiError(
+          errType,
+          errString,
+          errCodeStr,
+          response.status,
+          null,
+          null,
+          req,
+          apiName,
+          response.url
+        ));
+      }
+    }).catch((err) => {
+      //错误统一处理
+      defaultResponseDataHandlerCatch(method, req, response, null, err, apiName, response.url, reject, instance);
+    });
+  });
+}
+//错误报告处理
+function responseErrReoprtInceptor<T extends DataModel>(instance: RequestCoreInstance<T>, response: RequestApiError) {
+  return (
+    (response.errorType !== 'businessError' && response.errorType !== 'networkError') ||
+    notReportErrorCode.indexOf(response.code) >= 0 ||
+    matchNotReportMessage(response.errorMessage) === true
+  );
+}
+
+//错误报告处理
+export function reportError<T extends DataModel>(instance: RequestCoreInstance<T>, response: RequestApiError | Error) {
+  if (import.meta.env.DEV) {
+    if (response instanceof RequestApiError) {
+      logError({
+        message: `请求错误 ${response.apiName} : ${response.errorMessage}`,
+        detail: response.toString() +
+          '\r\n请求接口:' + response.apiName +
+          '\r\n请求地址:' + response.apiUrl +
+          '\r\n请求参数:' + JSON.stringify(response.rawRequest) +
+          '\r\n返回参数:' + JSON.stringify(response.rawData) +
+          '\r\n状态码:' + response.code +
+          '\r\n信息:' + response.errorCodeMessage,
+        type: 'error',
+      });
+    } else {
+      logError({
+        message: '错误报告 代码错误',
+        detail: response?.stack || ('' + response),
+        type: 'error',
+      });
+    }
+  } else {    
+    let errMsg = '';
+    if (response instanceof RequestApiError)
+      errMsg = response.errorMessage + '。';
+      
+    errMsg += '服务出现了异常,请稍后重试或联系客服。';
+    errMsg += '版本:' + AppCofig.version;
+
+    Modal.error({
+      title: '抱歉',
+      content: errMsg,
+    });
+}
+}
+
+/**
+ * App服务请求模块
+ */
+export class AppServerRequestModule<T extends DataModel> extends RequestCoreInstance<T> {
+  constructor() {
+    super(fetchImplementer);
+    this.config.baseUrl = RequestApiConfig.getConfig().BaseUrl;
+    this.config.errCodes = []; //
+    this.config.requestInceptor = requestInceptor;
+    this.config.responseDataHandler = responseDataHandler;
+    this.config.responseErrReoprtInceptor = responseErrReoprtInceptor;
+    this.config.reportError = reportError;
+  }
+}

+ 24 - 0
src/api/Test.ts

@@ -0,0 +1,24 @@
+import { DataModel } from '@imengyu/js-request-transform';
+import { DefaultRequestModule } from '../common/request';
+
+export class Test extends DataModel {
+  public constructor() {
+    super();
+    this._convertTable = {
+    };
+  }
+}
+
+export class TestApi extends DefaultRequestModule<Test> {
+
+  constructor() {
+    super();
+    this.config.modelClassCreator = Test;
+  }
+
+  getDataTest() {
+    return this.get('http://127.0.0.1', 'getDataTest');
+  }
+}
+
+export default new TestApi();

+ 68 - 0
src/api/auth/UserApi.ts

@@ -0,0 +1,68 @@
+import { DataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+
+
+export class LoginResult extends DataModel<LoginResult> {
+  constructor() {
+    super(LoginResult, "登录结果");
+    this._convertTable = {
+      userInfo: { clientSide: 'object', clientSideChildDataModel: UserInfo },
+    };
+    this._nameMapperServer = {
+      'userinfo': 'userInfo',
+      'mainBodyUserInfo': 'userInfo',
+    }
+    this._afterSolveServer = () => {
+      if (this.mainBodyUserInfo) {
+        this.userInfo.token = this.mainBodyUserInfo.token;
+      }
+    };
+  }
+  userInfo !:UserInfo;
+  mainBodyUserInfo?:UserInfo;
+}
+export class UserInfo extends DataModel<UserInfo> {
+  constructor() {
+    super(UserInfo, "用户信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+  }
+
+  expiresIn = 0;
+  id = 0;
+  userId = 0;
+  mobile = '';
+  nickname = '';
+  avatar = '';
+  username = '';
+  token = '';
+}
+
+export class UserApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+  async loginAdmin(data?: {
+    account: string,
+    password: string,
+  }) {
+    const form = new FormData();
+    form.append('account', data?.account || '');
+    form.append('password', data?.password || '');
+    return (await this.post('/user/adminLogin', form, '登录', undefined, LoginResult)).data as LoginResult;
+  }
+  async getUserInfo(main_body_user_id: number) {
+    return (await this.post('/content/main_body_user/getMainBodyUser', {
+      main_body_user_id,
+    }, '获取用户信息', undefined, UserInfo)).data as UserInfo;
+  }
+  async refresh() {
+    return (await this.post('/content/main_body_user/refreshUser', {
+    }, '刷新用户', undefined, LoginResult)).data as LoginResult;
+  }
+}
+
+export default new UserApi();

+ 10 - 0
src/api/communicate/CommunicateContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class CommunicateContentApi extends CommonContentApi {
+
+  constructor() {
+    super(16, "文化交流");
+  }
+}
+
+export default new CommunicateContentApi();

+ 10 - 0
src/api/communicate/CommunicateNewsContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class CommunicateNewsContentApi extends CommonContentApi {
+
+  constructor() {
+    super(18, "文化交流", 229);
+  }
+}
+
+export default new CommunicateNewsContentApi();

+ 10 - 0
src/api/fusion/CalendarContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class CalendarContentApi extends CommonContentApi {
+
+  constructor() {
+    super(4, "闽南节庆日历", 272);
+  }
+}
+
+export default new CalendarContentApi();

+ 10 - 0
src/api/fusion/DemoSiteContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class DemoSiteContentApi extends CommonContentApi {
+
+  constructor() {
+    super(9, "文旅融合示范点", 48);
+  }
+}
+
+export default new DemoSiteContentApi();

+ 10 - 0
src/api/fusion/FashionContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class FashionContentApi extends CommonContentApi {
+
+  constructor() {
+    super(9, "闽南时尚", 279);
+  }
+}
+
+export default new FashionContentApi();

+ 10 - 0
src/api/fusion/ProductContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ProductContentApi extends CommonContentApi {
+
+  constructor() {
+    super(9, "文创产品", 48);
+  }
+}
+
+export default new ProductContentApi();

+ 10 - 0
src/api/fusion/RouteContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class RouteContentApi extends CommonContentApi {
+
+  constructor() {
+    super(17, "文化旅游路线", 274);
+  }
+}
+
+export default new RouteContentApi();

+ 10 - 0
src/api/fusion/ScenicSpotContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ScenicSpotContentApi extends CommonContentApi {
+
+  constructor() {
+    super(17, "闽南文化景区", 273);
+  }
+}
+
+export default new ScenicSpotContentApi();

+ 10 - 0
src/api/fusion/ShowPlaceContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ShowPlaceContentApi extends CommonContentApi {
+
+  constructor() {
+    super(17, "闽南文化展示场所", 280);
+  }
+}
+
+export default new ShowPlaceContentApi();

+ 10 - 0
src/api/inheritor/ActivityContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ActivityContentApi extends CommonContentApi {
+
+  constructor() {
+    super(16, "传承活动");
+  }
+}
+
+export default new ActivityContentApi();

+ 10 - 0
src/api/inheritor/ArtifactProtectContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ArtifactProtectContentApi extends CommonContentApi {
+
+  constructor() {
+    super(1, "文物保护", 1);
+  }
+}
+
+export default new ArtifactProtectContentApi();

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

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class InheritorContentApi extends CommonContentApi {
+
+  constructor() {
+    super(7, "非遗保护名录-非遗传承人", 38);
+  }
+}
+
+export default new InheritorContentApi();

+ 19 - 0
src/api/inheritor/MoveableContent.ts

@@ -0,0 +1,19 @@
+import type { QueryParams } from '@/common/request/utils/AllType';
+import type { DataModel, NewDataModel } from '@imengyu/js-request-transform';
+import { CommonContentApi, GetColumContentList, GetColumListParams, GetContentListItem, GetContentListParams } from '../CommonContent';
+
+export class MoveableContentApi extends CommonContentApi {
+
+  constructor() {
+    super(1, "文物保护-可移动文物");
+  }
+
+  getContentList<T extends DataModel = GetContentListItem>(params: GetContentListParams, page: number, pageSize?: number, modelClassCreator?: NewDataModel, querys?: QueryParams): Promise<{ list: T[]; total: number; }> {
+    return super.getContentList(params, page, pageSize, modelClassCreator, { 
+      ...querys,
+      movable: 1
+    });
+  }
+}
+
+export default new MoveableContentApi();

+ 10 - 0
src/api/inheritor/ProductsContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ProductsContentApi extends CommonContentApi {
+
+  constructor() {
+    super(2, "非遗保护名录-非遗产品", 295);
+  }
+}
+
+export default new ProductsContentApi();

+ 10 - 0
src/api/inheritor/ProjectsContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ProjectsContentApi extends CommonContentApi {
+
+  constructor() {
+    super(2, "非遗保护名录-非遗代表性项目", 258);
+  }
+}
+
+export default new ProjectsContentApi();

+ 10 - 0
src/api/inheritor/ProtectListContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ProtectListContentApi extends CommonContentApi {
+
+  constructor() {
+    super(2, "非遗保护名录");
+  }
+}
+
+export default new ProtectListContentApi();

+ 10 - 0
src/api/inheritor/SeminarContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class SeminarContentApi extends CommonContentApi {
+
+  constructor() {
+    super(17, "非遗保护名录-非遗传习中心(所)", 221);
+  }
+}
+
+export default new SeminarContentApi();

+ 10 - 0
src/api/inheritor/UnitContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class UnitContentApi extends CommonContentApi {
+
+  constructor() {
+    super(17, "非遗保护名录-非遗保护单位", 259);
+  }
+}
+
+export default new UnitContentApi();

+ 20 - 0
src/api/inheritor/UnmoveableContent.ts

@@ -0,0 +1,20 @@
+import type { QueryParams } from '@/common/request/utils/AllType';
+import type { DataModel, NewDataModel } from '@imengyu/js-request-transform';
+import { CommonContentApi, GetColumContentList, GetColumListParams, GetContentListItem, GetContentListParams } from '../CommonContent';
+
+export class UnmoveableContentApi extends CommonContentApi {
+
+  constructor() {
+    super(1, "文物保护-不可移动文物");
+  }
+
+
+  getContentList<T extends DataModel = GetContentListItem>(params: GetContentListParams, page: number, pageSize?: number, modelClassCreator?: NewDataModel, querys?: QueryParams): Promise<{ list: T[]; total: number; }> {
+    return super.getContentList(params, page, pageSize, modelClassCreator, { 
+      ...querys,
+      movable: 0
+    });
+  }
+}
+
+export default new UnmoveableContentApi();

+ 10 - 0
src/api/introduction/BulidingContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class BulidingContentApi extends CommonContentApi {
+
+  constructor() {
+    super(3, "闽南文化-建筑文化", 252);
+  }
+}
+
+export default new BulidingContentApi();

+ 10 - 0
src/api/introduction/CharacterContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class CharacterContentApi extends CommonContentApi {
+
+  constructor() {
+    super(7, "闽南文化-历史人物", 244);
+  }
+}
+
+export default new CharacterContentApi();

+ 10 - 0
src/api/introduction/CustomContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class CustomContentApi extends CommonContentApi {
+
+  constructor() {
+    super(4, "闽南文化-民间习俗");
+  }
+}
+
+export default new CustomContentApi();

+ 10 - 0
src/api/introduction/FeatureContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class FeatureContentApi extends CommonContentApi {
+
+  constructor() {
+    super(3, "闽南文化-艺术特色");
+  }
+}
+
+export default new FeatureContentApi();

+ 10 - 0
src/api/introduction/HistoryContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class HistoryContentApi extends CommonContentApi {
+
+  constructor() {
+    super(14, "闽南文化-历史和地理背景", 233);
+  }
+}
+
+export default new HistoryContentApi();

+ 39 - 0
src/api/introduction/IndexContent.ts

@@ -0,0 +1,39 @@
+import { DataModel } from '@imengyu/js-request-transform';
+import { CommonContentApi } from '../CommonContent';
+
+export class IndexStats extends DataModel<IndexStats> {
+  constructor() {
+    super(IndexStats, "内容详情");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      crData: { clientSide: 'forceArray' },
+      minnanCr: { clientSide: 'forceArray' },
+      historyData: { clientSide: 'forceArray' },
+      inheritorData: { clientSide: 'forceArray' },
+      ichData: { clientSide: 'forceArray' },
+      ichCenter: { clientSide: 'forceArray' },
+    }
+  }
+
+  crData: any;
+  minnanCr: any;
+  historyData: any;
+  inheritorData: any;
+  ichData: any;
+  ichCenter: any;
+}
+
+export class IndexContentApi extends CommonContentApi {
+
+  constructor() {
+    super(3, "闽南文化概况", 288);
+  }
+
+  async getStats() {
+    return (await this.get('/volunteer/statistics/webData', '闽南文化首页数据统计', {
+    }, IndexStats)).data as IndexStats
+  }
+
+}
+
+export default new IndexContentApi();

+ 10 - 0
src/api/introduction/LanguageContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class LanguageContentApi extends CommonContentApi {
+
+  constructor() {
+    super(5, "闽南文化-闽南方言", 235);
+  }
+}
+
+export default new LanguageContentApi();

+ 10 - 0
src/api/introduction/PolicyContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class PolicyContentApi extends CommonContentApi {
+
+  constructor() {
+    super(13, "政策法规");
+  }
+}
+
+export default new PolicyContentApi();

+ 10 - 0
src/api/introduction/SeaContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class SeaContentApi extends CommonContentApi {
+
+  constructor() {
+    super(3, "闽南文化-海洋文化", 252);
+  }
+}
+
+export default new SeaContentApi();

+ 10 - 0
src/api/introduction/VictualsContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class VictualsContentApi extends CommonContentApi {
+
+  constructor() {
+    super(3, "闽南文化-饮食文化", 253);
+  }
+}
+
+export default new VictualsContentApi();

+ 10 - 0
src/api/news/NewsFolkActivitiesContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class NewsFolkActivitiesContentApi extends CommonContentApi {
+
+  constructor() {
+    super(18, "资讯动态-民俗庆典活动", 299);
+  }
+}
+
+export default new NewsFolkActivitiesContentApi();

+ 10 - 0
src/api/news/NewsIndexContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class NewsIndexContentApi extends CommonContentApi {
+
+  constructor() {
+    super(18, "资讯动态");
+  }
+}
+
+export default new NewsIndexContentApi();

+ 10 - 0
src/api/news/NewsOfficialActivitiesContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class NewsOfficialActivitiesContentApi extends CommonContentApi {
+
+  constructor() {
+    super(18, "资讯动态-官方活动", 228);
+  }
+}
+
+export default new NewsOfficialActivitiesContentApi();

+ 10 - 0
src/api/news/NewsPeoplesStageContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class NewsPeoplesStageContentApi extends CommonContentApi {
+
+  constructor() {
+    super(18, "资讯动态-百姓舞台", 298);
+  }
+}
+
+export default new NewsPeoplesStageContentApi();

+ 10 - 0
src/api/research/DiscussContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class DiscussContentApi extends CommonContentApi {
+
+  constructor() {
+    super(19, "理论研讨", 266);
+  }
+}
+
+export default new DiscussContentApi();

+ 10 - 0
src/api/research/ExpertContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ExpertContentApi extends CommonContentApi {
+
+  constructor() {
+    super(7, "专家学者", 263);
+  }
+}
+
+export default new ExpertContentApi();

+ 10 - 0
src/api/research/IndexTeamsContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class IndexTeamsContentApi extends CommonContentApi {
+
+  constructor() {
+    super(17, "研究团队", 264);
+  }
+}
+
+export default new IndexTeamsContentApi();

+ 10 - 0
src/api/research/ProjectContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ProjectContentApi extends CommonContentApi {
+
+  constructor() {
+    super(19, "研究项目", 265);
+  }
+}
+
+export default new ProjectContentApi();

+ 10 - 0
src/api/research/ResultContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class ResultContetApi extends CommonContentApi {
+
+  constructor() {
+    super(19, "研究成果", 269);
+  }
+}
+
+export default new ResultContetApi();

+ 10 - 0
src/api/research/TeamsContent.ts

@@ -0,0 +1,10 @@
+import { CommonContentApi } from '../CommonContent';
+
+export class TeamsContentApi extends CommonContentApi {
+
+  constructor() {
+    super(17, "研究团队", 264);
+  }
+}
+
+export default new TeamsContentApi();

+ 1 - 0
src/common/utils/DateUtils.ts

@@ -385,6 +385,7 @@ function getDayGap(y: number, m: number, d: number) {
 const DateUtils = {
   FormatStrings: {
     YearChanese: "YYYY年MM月dd日",
+    YearCommon: "YYYY-MM-dd HH:mm:ss",
   },
   /**
    * 日期加上指定天数

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

@@ -0,0 +1,79 @@
+<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"
+  >
+    <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>

+ 4 - 4
src/components/controls/Dropdown.vue

@@ -59,9 +59,9 @@ const selectedLabel = computed(() => {
 });
 const isDropdownOpen = ref(false);
 
-function selectOption(option: string) {
+function selectOption(option: any) {
   isDropdownOpen.value = false;
-  emit('update:selectedValue', option);
+  emit('update:selectedValue', option[props.valueKey]);
 }
 
 </script>
@@ -115,8 +115,8 @@ function selectOption(option: string) {
   border: 1px solid $primary-color;
   z-index: 20;
 
-  scroll-view {
-    max-height: 50%;
+  .nana-scroll-view {
+    max-height: 50vh;
   }
 
   .option {

+ 2 - 1
src/components/controls/SimpleInput.vue

@@ -16,6 +16,7 @@
       @input="(e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value)"
       @focus="handleFocus"
       @blur="handleBlur"
+      @keydown.enter="emit('search')"
     />
     <div class="suffix">
       <slot name="suffix"/>
@@ -38,7 +39,7 @@ defineProps({
 })
 
 const focusState = ref(false)
-const emit = defineEmits([ 'update:modelValue', 'focus', 'blur' ])
+const emit = defineEmits([ 'update:modelValue', 'focus', 'blur', 'search' ])
 
 function handleFocus() {
   focusState.value = true;

+ 98 - 0
src/components/error/ErrorReporter.vue

@@ -0,0 +1,98 @@
+<template>
+  <div class="nana-error-report-container">
+    <Transition name="nana-scale-transform">
+      <div v-if="messageItems.length > 0" class="nana-error-report" @click="showError">
+        <img :src="ErrorIcon" />
+        <span>{{ errorCount }}</span>
+        <img :src="WarningIcon" /> 
+        <span>{{ warningCount }}</span>
+      </div>
+    </Transition>
+  </div>
+  <a-modal v-model:open="showModal" title="错误报告" width="60%" height="80%">
+    <a-alert
+      v-for="(item, index) in messageItems"
+      :key="index"
+      :message="item.message"
+      :type="item.type"
+      :description="item.detail"
+    />
+    <template #footer>
+      <a-button @click="messageItems = [];showModal = false">清空</a-button>
+      <a-button @click="showModal = false">关闭</a-button>
+    </template>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import { onMounted, provide, ref } from 'vue';
+import WarningIcon from '../AlertIcons/warning.svg';
+import ErrorIcon from '../AlertIcons/error.svg';
+
+export interface ErrorReportItem {
+  time?: number,
+  message: string,
+  detail?: string,
+  type: 'error' | 'warning' | 'info',
+}
+export interface ErrorReportRef {
+  logError(info: ErrorReportItem): void;
+}
+
+const messageItems = ref<ErrorReportItem[]>([]);
+const errorCount = ref(0);
+const warningCount = ref(0);
+const showModal = ref(false);
+
+function logError(info: ErrorReportItem) {
+  messageItems.value.push(info);
+  if (info.type === 'error') 
+    errorCount.value++;
+  if (info.type === 'warning') 
+    warningCount.value++;
+}
+function showError() {
+  showModal.value = true;
+}
+
+provide<ErrorReportRef>("ErrorReporter", {
+  logError,
+});
+
+onMounted(() => {
+  (window as any).$error = logError;
+})
+</script>
+
+<style lang="scss">
+.nana-error-report-container {
+  position: fixed;
+  top: 2rem;
+  left: 0;
+  right: 0;
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  pointer-events: none;
+  z-index: 100;
+
+  img {
+    width: 1rem;
+    height: 1rem;
+  }
+  span {
+    color: #333;
+  }
+}
+.nana-error-report {
+  display: flex;
+  flex-direction: row;
+  align-items: center; 
+  width: auto;
+  gap: 0.5rem;
+  background-color: #fff;
+  padding: 0.6rem;
+  border-radius: 0.5rem;
+  pointer-events: all;
+}
+</style>

+ 28 - 0
src/components/error/ErrorReporterIs.ts

@@ -0,0 +1,28 @@
+import { inject } from "vue";
+import type { ErrorReportItem, ErrorReportRef } from "./ErrorReporter.vue";
+import { RequestApiError } from "@/common/request/core/RequestApiResult";
+
+export function useErrorReporter() {
+  const r = inject<ErrorReportRef>("ErrorReporter");
+  if (!r)
+    throw new Error("ErrorReporter is not provided"); 
+  return r;
+}
+
+export function formatError(err: any) {
+  if (err instanceof RequestApiError) 
+    return err.toStringDetail();
+  if (err instanceof Error) 
+    return err.message + '\n' + err.stack;
+  return '' + err;
+}
+export function logError(info: ErrorReportItem) {
+  (window as any).$error?.(info);
+}
+export function logErrorSimple(err: any, message: string) { 
+  logError({
+    type: 'error',
+    detail: formatError(err),
+    message,
+  });
+}

+ 9 - 0
src/composeable/LoaderCommon.ts

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

+ 38 - 0
src/composeable/PageQuerys.ts

@@ -0,0 +1,38 @@
+import { nextTick, onMounted, ref, watch, type Ref } from "vue";
+import { useRoute } from "vue-router";
+
+export function useLoadQuerys<T extends Record<string, any>>(
+  defaults: T, 
+  afterLoad?: (querys: T) => void
+) {
+
+  const querys = ref<T>(defaults) as Ref<T>; 
+  const route = useRoute();
+
+  function loadQuerys() {
+    const _querys = route.query;
+    if (_querys) {
+      for (const key in querys.value) {
+        if (typeof defaults[key] === 'number')
+          (querys.value as Record<string, any>)[key] = Number(_querys[key]); 
+        else
+          querys.value[key] = _querys[key] as any;
+      }
+    }
+    afterLoad?.(querys.value);
+  }
+
+  watch(route, () => {
+    loadQuerys();
+  });
+
+  onMounted(() => {
+    nextTick(() => {
+      loadQuerys();
+    });
+  });
+
+  return {
+    querys,
+  }
+}

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

+ 57 - 0
src/composeable/SimpleDataLoader.ts

@@ -0,0 +1,57 @@
+import { onMounted, ref, type Ref } from "vue";
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+
+export interface ISimpleDataLoader<T, P> extends ILoaderCommon<P> {
+  content: Ref<T|null>;
+  getLastParams: () => P | undefined;
+}
+
+export function useSimpleDataLoader<T, P = any>(
+  loader: (params?: P) => Promise<T>,
+  loadWhenMounted = true,
+  emptyIfArrayEmpty = true,
+)  : ISimpleDataLoader<T, P>
+ {
+
+  const content = ref<T|null>(null) as Ref<T|null>;
+  const loadStatus = ref<LoaderLoadType>('loading');
+  const loadError = ref('');
+
+  let lastParams: P | undefined;
+
+  async function loadData(params?: P) {
+    if (params)
+      lastParams = params;
+    loadStatus.value = 'loading';
+    try {
+      const res = (await loader(params ?? lastParams)) as T;
+      content.value = res;
+      if (Array.isArray(res) && emptyIfArrayEmpty && (res as any[]).length === 0)
+        loadStatus.value = 'nomore';
+      else
+        loadStatus.value = 'finished';
+      loadError.value = '';
+    } catch(e) {
+      loadError.value = '' + e;
+      loadStatus.value = 'error';
+      console.log(e);
+      
+    }
+  }
+
+  onMounted(() => {
+    if (loadWhenMounted) {
+      setTimeout(() => {
+        loadData();
+      }, (0.5 + Math.random()) * 500);
+    }
+  })
+
+  return {
+    content,
+    loadStatus,
+    loadError,
+    loadData,
+    getLastParams: () => lastParams,
+  }
+}

+ 0 - 120
src/composeable/SimplePagerData.ts

@@ -1,120 +0,0 @@
-import { watch, ref, computed } from "vue"
-
-/**
- * 简单分页数据封装。
- * 
- * 该封装了分页数据的加载、分页、上一页、下一页等功能。当页码发生变化时,会自动调用加载函数。
- * 简单分页同时只能显示一页数据,重新加载会覆盖之前的数据。
- * 
- * 使用示例:
- * ```ts
- * const { data, page, total, loading } = useSimplePagerData(10, async (page, pageSize) => {
- *   const res = await fetch(`/api/data?page=${page}&pageSize=${pageSize}`);
- *   const data = await res.json();  
- *   return {
- *     data,
- *     page: res.page,
- *     total: res.total,
- *   };
- * });
- * ```
- *
- * @param pageSize 一页的数量
- * @param loader 加载函数
- * @returns 
- */
-export function useSimplePagerData<T>(
-  pageSize: number, 
-  loader: (page: number, pageSize: number) => Promise<{ 
-    data: T[], 
-    page: number,
-    total: number,
-  }>
-) {
-  const data = ref<T[]>([]);
-  const page = ref(0);
-  const total = ref(0);
-  const totalPages = computed(() => Math.ceil(total.value / pageSize));
-  const loading = ref(false);
-  const empty = computed(() => data.value.length === 0);
-  const error = ref('');
-
-  watch(page, async () => {
-    await load();
-  });
-
-  /**
-   * 加载数据
-   * @returns 
-   */
-  async function load() {
-    if (loading.value) 
-      return;
-    loading.value = true;
-    try {
-      error.value = '';
-      const res = await loader(page.value, pageSize);
-      page.value = res.page;
-      total.value = res.total;
-      data.value = res.data;
-    } catch (e) {
-      console.error(e);
-      error.value = '' + e;
-    } finally {
-      loading.value = false;
-    }
-  }
-  /**
-   * 下一页
-   */
-  async function next() {
-    if (page.value > total.value)
-      return;
-    page.value++;
-    await load();
-  }
-  /**
-   * 上一页
-   */
-  async function prev() {
-    if (page.value <= 1)
-      return;   
-    page.value--;
-    await load();
-  }
-
-  return {
-    load,
-    next,
-    prev,
-    /**
-     * 数据
-     */
-    data,
-    /**
-     * 是否为空
-     */
-    empty,
-    /**
-     * 加载错误信息
-     * 当加载失败时,该值不为空。
-     */
-    error,
-    /**
-     * 当前页码
-     */
-    page,
-    /**
-     * 总数据条数
-     */
-    total,
-    /**
-     * 总页数
-     */
-    totalPages,
-    /**
-     * 是否正在加载
-     */
-    loading,
-  }
-}

+ 124 - 0
src/composeable/SimplePagerDataLoader.ts

@@ -0,0 +1,124 @@
+import { watch, ref, computed, type Ref } from "vue"
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+
+export interface ISimplePageListLoader<T, P> extends ILoaderCommon<P> {
+  list: Ref<T[]>;
+  page: Ref<number>;
+  next: () => Promise<void>;
+  prev: () => Promise<void>;
+  total: Ref<number>;
+  totalPages: Ref<number>;
+}
+
+/**
+ * 简单分页数据封装。
+ * 
+ * 该封装了分页数据的加载、分页、上一页、下一页等功能。当页码发生变化时,会自动调用加载函数。
+ * 简单分页同时只能显示一页数据,重新加载会覆盖之前的数据。
+ * 
+ * 使用示例:
+ * ```ts
+ * const { data, page, total, loading } = useSimplePagerDataLoader(10, async (page, pageSize) => {
+ *   const res = await fetch(`/api/data?page=${page}&pageSize=${pageSize}`);
+ *   const data = await res.json();  
+ *   return {
+ *     data,
+ *     page: res.page,
+ *     total: res.total,
+ *   };
+ * });
+ * ```
+ *
+ * @param pageSize 一页的数量
+ * @param loader 加载函数
+ * @returns 
+ */
+export function useSimplePagerDataLoader<T, P = any>(
+  pageSize: number, 
+  loader: (page: number, pageSize: number, params?: P) => Promise<{
+    data: T[],
+    total: number,
+  }>)  : ISimplePageListLoader<T, P>
+{
+  const page = ref(0);
+  const list = ref<T[]>([]) as Ref<T[]>;
+  const total = ref(0);
+  const totalPages = computed(() => Math.ceil(total.value / pageSize));
+  const loadStatus = ref<LoaderLoadType>('loading');
+  const loadError = ref('');
+  
+  watch(page, async () => {
+    await loadData(lastParams, false);
+  });
+
+  let lastParams: P | undefined;
+  let loading = false;
+
+  async function loadData(params?: P, refresh: boolean = false) {
+    if (loading) 
+      return;
+    if (params)
+      lastParams = params;
+    if (refresh) {
+      page.value = 1;
+    }
+    list.value = []; 
+    loadStatus.value = 'loading';
+    loading = true;
+
+    try {
+      const res = (await loader(page.value, pageSize, lastParams));
+      list.value = list.value.concat(res.data);
+      total.value = res.total;
+      loadStatus.value = res.data.length > 0 ? 'finished' : 'nomore';
+      loadError.value = '';
+      loading = false;
+    } catch(e) {
+      loadError.value = '' + e;
+      loadStatus.value = 'error';
+      loading = false;
+    }
+  }
+  /**
+   * 下一页
+   */
+  async function next() {
+    if (page.value > total.value)
+      return;
+    page.value++;
+    await loadData(lastParams, false);
+  }
+  /**
+   * 上一页
+   */
+  async function prev() {
+    if (page.value <= 1)
+      return;   
+    page.value--;
+    await loadData(lastParams, false);
+  }
+
+  return {
+    loadData,
+    next,
+    prev,
+    /**
+     * 数据
+     */
+    list,
+    /**
+     * 当前页码
+     */
+    page,
+    /**
+     * 总数据条数
+     */
+    total,
+    /**
+     * 总页数
+     */
+    totalPages,
+    loadError,
+    loadStatus,
+  }
+}

+ 7 - 0
src/main.ts

@@ -3,6 +3,7 @@ import 'bootstrap/dist/css/bootstrap.css'
 import 'bootstrap/dist/css/bootstrap-grid.css'
 import 'bootstrap/dist/css/bootstrap-utilities.css'
 import './assets/scss/main.scss'
+import 'ant-design-vue/dist/reset.css';
 import 'vue3-carousel/carousel.css'
 import '@vuemap/vue-amap/dist/style.css'
 
@@ -11,7 +12,9 @@ import { createPinia } from 'pinia'
 
 import App from './App.vue'
 import router from './router'
+import Antd from 'ant-design-vue';
 import VueAMap, {initAMapApiLoader} from '@vuemap/vue-amap';
+import { registryConvert } from './common/ConvertRgeistry'
 
 initAMapApiLoader({
   key: '212b86dc49a5116a34e945d31da7ad14',
@@ -23,6 +26,10 @@ const app = createApp(App)
 
 app.use(createPinia())
 app.use(VueAMap)
+app.use(Antd)
 app.use(router)
 
 app.mount('#app')
+
+
+registryConvert();

+ 1 - 1
src/router/index.ts

@@ -25,7 +25,7 @@ const router = createRouter({
     },
     {
       path: '/news/detail',
-      name: 'newsdetail',
+      name: 'news-detail',
       component: () => import('../views/NewsDetailView.vue'),
     },
     {

+ 74 - 0
src/stores/auth.ts

@@ -0,0 +1,74 @@
+import UserApi, { LoginResult, UserInfo } from "@/api/auth/UserApi";
+import { defineStore } from "pinia"
+
+const STORAGE_KEY = 'authInfo';
+
+export const useAuthStore = defineStore('auth', {
+  state: () => ({
+    token: '',
+    expireAt: 0,
+    userId: 0,
+    userInfo: null as null|UserInfo,
+  }),
+  actions: {
+    async loadLoginState() {
+      try {
+        const res = localStorage.getItem(STORAGE_KEY);
+        if (!res)
+          throw 'no storage';
+        const authInfo = JSON.parse(res);
+        this.token = authInfo.token;
+        this.userId = authInfo.userId;
+        this.expireAt = authInfo.expireAt;
+
+        // 检查token是否过期,如果快要过期,则刷新token
+        if (Date.now() > this.expireAt + 1000 * 3600 * 5) {
+          const refreshResult = await UserApi.refresh();
+          this.loginResultHandle(refreshResult);
+          this.userInfo = refreshResult.userInfo;
+        } else {
+          this.userInfo = await UserApi.getUserInfo(this.userId);
+        }
+      } catch (error) {
+        this.token = '';
+        this.userId = 0;
+        this.userInfo = null;
+
+        console.log('loadLoginState', error);
+      }
+    },
+    async loginAdmin(account: string, password: string) {
+      const loginResult = await UserApi.loginAdmin({
+        account,
+        password,
+      })
+      this.loginResultHandle(loginResult);
+    },
+    async loginResultHandle(loginResult: LoginResult) {
+      this.token = loginResult.userInfo.token;
+      this.userId = loginResult.userInfo.id;
+      this.userInfo = loginResult.userInfo;
+      this.expireAt = loginResult.userInfo.expiresIn + Date.now();
+
+      localStorage.setItem(STORAGE_KEY, 
+        JSON.stringify({ 
+          token: this.token, 
+          userId: this.userId ,
+          expireAt: this.expireAt,
+        }) 
+      );
+    },
+    async logout() {
+      this.token = '';
+      this.userId = 0;
+      this.userInfo = null;
+
+      localStorage.removeItem(STORAGE_KEY);
+    }
+  },
+  getters: {
+    isLogged(state) {
+      return state.token != '' && state.userId != 0
+    },
+  },
+})

+ 111 - 130
src/views/HomeView.vue

@@ -24,21 +24,23 @@
       <div class="content">
         <div class="title left-right">
           <h2>最新资讯动态</h2>
-          <div class="small-more">
-            <span>更多展览信息</span>
+          <div class="small-more" @click="router.push({ name: 'news' })">
+            <span>更多动态信息</span>
             <img src="@/assets/images/index/ButtonMore.png" alt="更多" />
           </div>
         </div>
 
-        <Carousel ref="carousel2Ref" v-bind="carousel2Config">
-          <Slide v-for="(item, index) in newsData" :key="index">
-            <ImageTitleBlock 
-              :image="item.image"
-              :title="item.title"
-              :desc="item.type"
-            />
-          </Slide>
-        </Carousel>
+        <SimplePageContentLoader :loader="newsData">
+          <Carousel ref="carousel2Ref" v-bind="carousel2Config">
+            <Slide v-for="(item, index) in newsData.content.value" :key="index">
+              <ImageTitleBlock 
+                :image="item.image"
+                :title="item.title"
+                :desc="item.typeText"
+              />
+            </Slide>
+          </Carousel>
+        </SimplePageContentLoader>
 
         <div class="simple-carousel2-left-right">
           <div @click="carousel2Ref?.prev()">←</div>
@@ -54,22 +56,24 @@
           <h2>数据统计</h2>
         </div>
 
-        <Carousel ref="carousel3Ref" v-bind="carousel3Config">
-          <Slide v-for="(stat,key) in statsData" :key="key">
-            <div :class="`main-card-box type${stat.type}`">
-              <h4>{{ stat.title }}</h4>
-              <div class="descs">
-                <div v-for="(data, key2) in stat.datas" :key="key2">
-                  <h5>{{ data.title }}</h5>
-                  <p>{{ data.value }}</p>
+        <SimplePageContentLoader :loader="statsData">
+          <Carousel ref="carousel3Ref" v-bind="carousel3Config">
+            <Slide v-for="(stat,key) in statsData.content.value" :key="key">
+              <div :class="`main-card-box type${stat.type}`">
+                <h4>{{ stat.title }}</h4>
+                <div class="descs">
+                  <div v-for="(data, key2) in stat.datas" :key="key2">
+                    <h5>{{ data.title }}</h5>
+                    <p>{{ data.value }}</p>
+                  </div>
                 </div>
               </div>
-            </div>
-          </Slide>
-          <template #addons>
-            <Navigation />
-          </template>
-        </Carousel>
+            </Slide>
+            <template #addons>
+              <Navigation />
+            </template>
+          </Carousel>
+        </SimplePageContentLoader>
       </div>
     </section>
 
@@ -95,8 +99,15 @@
 <script setup lang="ts">
 import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
 import { onMounted, ref } from 'vue';
-import Placeholder1 from '@/assets/images/placeholder/Midium.jpg';
+import { useRouter } from 'vue-router';
+import { useSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { GetContentListParams, type GetContentListItem } from '@/api/CommonContent';
+import NewsIndexContent from '@/api/news/NewsIndexContent';
 import ImageTitleBlock from '@/components/parts/ImageTitleBlock.vue';
+import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
+import IndexContent from '@/api/introduction/IndexContent';
+
+const router = useRouter();
 
 const carouselConfig = {
   itemsToShow: 1,
@@ -125,110 +136,80 @@ onMounted(() => {
   } 
 })
 
-const newsData = [
-  {
-    title: '2024年厦门市非物质文化遗产保护工作队伍培训班成功举',
-    type: '地市动态',
-    date: '2024-09-01',
-    image: Placeholder1,
-  },
-  {
-    title: '2024年厦门市非物质文化遗产保护工作队伍培训班成功举',
-    type: '地市动态',
-    date: '2024-09-01',
-    image: Placeholder1,
-  },
-  {
-    title: '2024年厦门市非物质文化遗产保护工作队伍培训班成功举',
-    type: '地市动态',
-    date: '2024-09-01'
-  },
-  {
-    title: '2024年厦门市非物质文化遗产保护工作队伍培训班成功举',
-    type: '地市动态',
-    date: '2024-09-01',
-    image: Placeholder1,
-  },
-  {
-    title: '2024年厦门市非物质文化遗产保护工作队伍培训班成功举',
-    type: '地市动态',
-    date: '2024-09-01',
-    image: Placeholder1,
-  },
-  {
-    title: '2024年厦门市非物质文化遗产保护工作队伍培训班成功举',
-    type: '地市动态',
-    date: '2024-09-01',
-    image: Placeholder1,
-  },
-];
-const statsData = [
-  {
-    title: '非遗项目',
-    type: '1',
-    datas: [
-      { title: '国家级', value: 15  },
-      { title: '省级', value: 48  },
-      { title: '市级', value: 47  },
-      { title: '区级', value: 66  },
-    ]
-  },
-  {
-    title: '传承人',
-    type: '2',
-    datas: [
-      { title: '国家级', value: 15  },
-      { title: '省级', value: 48  },
-      { title: '市级', value: 47  },
-      { title: '区级', value: 66  },
-    ]
-  },
-  {
-    title: '不可移动文物',
-    type: '3',
-    datas: [
-      { title: '思明区', value: 15  },
-      { title: '湖里区', value: 48  },
-      { title: '鼓浪屿', value: 47  },
-      { title: '海沧区', value: 66  },
-      { title: '集美区', value: 48  },
-      { title: '同安区', value: 47  },
-      { title: '翔安区', value: 66  },
-    ]
-  },
-  {
-    title: '闽南文化重要相关文物古迹',
-    type: '2',
-    datas: [
-      { title: '国家重点', value: 15  },
-      { title: '省级', value: 48  },
-      { title: '市级', value: 47  },
-    ]
-  },
-  {
-    title: '重要相关历史风貌区',
-    type: '1',
-    datas: [
-      { title: '世界文化遗产', value: 15  },
-      { title: '历史文化街区', value: 48  },
-      { title: '传统村落', value: 47  },
-      { title: '历史地段', value: 48  },
-    ]
-  },
-  {
-    title: '传习中心',
-    type: '3',
-    datas: [
-      { title: '思明区', value: 15  },
-      { title: '湖里区', value: 48  },
-      { title: '鼓浪屿', value: 47  },
-      { title: '海沧区', value: 66  },
-      { title: '集美区', value: 48  },
-      { title: '同安区', value: 47  },
-      { title: '翔安区', value: 66  },
-    ]
-  },
-]
+const newsData = useSimpleDataLoader<GetContentListItem[]>(async () => {
+  return (await NewsIndexContent.getContentList(new GetContentListParams().setSelfValues({
+    flag: 'recommend',
+  }), 1, 6)).list
+});
+
+const statsData = useSimpleDataLoader(async () => {
+  const data = (await IndexContent.getStats());
+  console.log(data);
+  
+  return [
+    {
+      title: '非遗项目',
+      type: '1',
+      datas: data.ichData.map((item: any) => {
+        return {
+          title: item.level_text,
+          value: item.total
+        }
+      })
+    },
+    {
+      title: '传承人',
+      type: '2',
+      datas: data.inheritorData.map((item: any) => {
+        return {
+          title: item.title,
+          value: item.total
+        }
+      })
+    },
+    {
+      title: '不可移动文物',
+      type: '3',
+      datas: data.crData.map((item: any) => {
+        return {
+          title: item.title,
+          value: item.total
+        }
+      })
+    },
+    {
+      title: '闽南文化重要相关文物古迹',
+      type: '2',
+      datas: data.minnanCr.map((item: any) => {
+        return {
+          title: item.title,
+          value: item.total
+        }
+      })
+    },
+    {
+      title: '重要相关历史风貌区',
+      type: '1',
+      datas: data.historyData.map((item: any) => {
+        return {
+          title: item.title,
+          value: item.total
+        }
+      })
+    },
+    {
+      title: '传习中心',
+      type: '3',
+      datas: data.ichCenter.map((item: any) => {
+        return {
+          title: item.title,
+          value: item.total
+        }
+      })
+    },
+  ]
+
+});
 </script>
 
 <style lang="scss">

+ 56 - 38
src/views/NewsDetailView.vue

@@ -4,42 +4,44 @@
     <div class="nav-placeholder"></div>
     <!-- 新闻 -->
     <section class="main-section main-background main-background-type0">
-      <div class="content news-detail">
-        <h1>{{ newsData.title }}</h1>
-        <div class="row p-2 p-md-3 p-lg-4">
-          <div class="col-12 col-md-4 col-lg-4 col-xl-4 d-flex justify-content-center">
-            <span class="small-info">作者:厦门市文化和旅游局</span>
+      <SimplePageContentLoader :loader="newsLoader">
+        <div v-if="newsLoader.content.value" class="content news-detail">
+          <h1>{{ newsLoader.content.value.title }}</h1>
+          <div class="row p-2 p-md-3 p-lg-4">
+            <div class="col-12 col-md-4 col-lg-4 col-xl-4 d-flex justify-content-center">
+              <span class="small-info">作者:{{ newsLoader.content.value.author }}</span>
+            </div>
+            <div class="col-12 col-md-4 col-lg-4 col-xl-4 d-flex justify-content-center">
+              <span class="small-info">时间:{{ DateUtils.formatDate(newsLoader.content.value.publish_at, DateUtils.FormatStrings.YearCommon) }}</span>
+            </div>
+            <div class="col-12 col-md-4 col-lg-4 col-xl-4 d-flex justify-content-center">
+              <span class="small-info">浏览量:{{ newsLoader.content.value.views }}</span>
+            </div>
           </div>
-          <div class="col-12 col-md-4 col-lg-4 col-xl-4 d-flex justify-content-center">
-            <span class="small-info">时间:2022/12/16 9:19:20</span>
-          </div>
-          <div class="col-12 col-md-4 col-lg-4 col-xl-4 d-flex justify-content-center">
-            <span class="small-info">浏览量:2104</span>
-          </div>
-        </div>
 
-        <SimpleRichHtml 
-          class="news-content"
-          :content="newsData.content" 
-        />
+          <SimpleRichHtml 
+            class="news-content"
+            :contents="[newsLoader.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 class="row pt-3 pt-md-4 pt-lg-5">
-          <div class="col-12 col-md-6 col-lg-6 col-xl-6 d-flex justify-content-start">
-            <span class="small-info">上一篇:作者:厦门市文化和旅游局</span>
+          <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 class="col-12 col-md-6 col-lg-6 col-xl-6 d-flex justify-content-end">
-            <span class="small-info">下一篇:时间:2022/12/16 9:19:20</span>
+          
+          <div class="row pt-3 pt-md-4 pt-lg-5">
+            <div class="col-12 col-md-6 col-lg-6 col-xl-6 d-flex justify-content-start">
+              <span class="small-info">上一篇:????</span>
+            </div>
+            <div class="col-12 col-md-6 col-lg-6 col-xl-6 d-flex justify-content-end">
+              <span class="small-info">下一篇:????</span>
+            </div>
           </div>
-        </div>
 
-      </div>
+        </div>
+      </SimplePageContentLoader>
     </section>
 
 
@@ -47,15 +49,31 @@
 </template>
 
 <script setup lang="ts">
+import type { GetContentDetailItem } from '@/api/CommonContent';
+import NewsIndexContent from '@/api/news/NewsIndexContent';
+import DateUtils from '@/common/utils/DateUtils';
+import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
 import SimpleRichHtml from '@/components/display/SimpleRichHtml.vue';
-import router from '@/router';
-import { onMounted, ref } from 'vue';
-
-const newsData = {
-  title: '2024年“中国青年节”活动通知',
-  desc: '2024年“中国青年节”活动通知',
-  content: ``,
-}
+import { useLoadQuerys } from '@/composeable/PageQuerys';
+import { useSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { useRouter } from 'vue-router';
+
+const router = useRouter();
+const newsLoader = useSimpleDataLoader<GetContentDetailItem, { id: number }>(async (p) => {
+  if (!p)
+    throw new Error('参数错误');
+  return (await NewsIndexContent.getContentDetail<GetContentDetailItem>(p.id));
+}, false)
+
+useLoadQuerys({
+  id: 0
+}, async ({ id }) => {
+  if (id <= 0) {
+    router.push({ name: 'NotFound' });
+    return;
+  }
+  newsLoader.loadData({ id });
+})
 
 function back() {
   router.back();

ファイルの差分が大きいため隠しています
+ 72 - 89
src/views/NewsView.vue