imengyu недель назад: 2
Родитель
Сommit
5979cd5b0f
40 измененных файлов с 3413 добавлено и 8 удалено
  1. 1 0
      package-lock.json
  2. 1 0
      package.json
  3. BIN
      src/assets/images/BgLong.png
  4. BIN
      src/assets/images/DropDownArrow.png
  5. BIN
      src/assets/images/news/Banner.jpg
  6. BIN
      src/assets/images/news/IconSearch.png
  7. 14 1
      src/assets/scss/colors.scss
  8. 78 5
      src/assets/scss/main.scss
  9. 98 0
      src/common/ConvertRgeistry.ts
  10. 8 0
      src/common/EventBus.ts
  11. 9 0
      src/common/config/ApiCofig.ts
  12. 20 0
      src/common/config/AppCofig.ts
  13. 59 0
      src/common/request/core/RequestApiConfig.ts
  14. 172 0
      src/common/request/core/RequestApiResult.ts
  15. 398 0
      src/common/request/core/RequestCore.ts
  16. 130 0
      src/common/request/core/RequestHandler.ts
  17. 7 0
      src/common/request/core/RequestImplementer.ts
  18. 103 0
      src/common/request/implementer/Uniapp.ts
  19. 44 0
      src/common/request/implementer/WebFetch.ts
  20. 33 0
      src/common/request/index.ts
  21. 52 0
      src/common/request/utils/AllType.ts
  22. 84 0
      src/common/request/utils/Utils.ts
  23. 41 0
      src/common/utils/ArrayUtils.ts
  24. 104 0
      src/common/utils/CheckUtils.ts
  25. 294 0
      src/common/utils/CommonUtils.ts
  26. 460 0
      src/common/utils/DateUtils.ts
  27. 214 0
      src/common/utils/StringUtils.ts
  28. 27 2
      src/components/NavBar.vue
  29. 57 0
      src/components/container/SimpleScrollView.vue
  30. 65 0
      src/components/controls/Check.vue
  31. 5 0
      src/components/controls/CheckIcon.vue
  32. 139 0
      src/components/controls/Dropdown.vue
  33. 5 0
      src/components/controls/DropdownIcon.vue
  34. 123 0
      src/components/controls/Pagination.vue
  35. 110 0
      src/components/controls/SimpleInput.vue
  36. 103 0
      src/composeable/PageAction.ts
  37. 8 0
      src/composeable/PagerDefine.ts
  38. 120 0
      src/composeable/SimplePagerData.ts
  39. 5 0
      src/router/index.ts
  40. 222 0
      src/views/NewsView.vue

+ 1 - 0
package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.0.0",
       "dependencies": {
         "bootstrap": "^5.3.0",
+        "mitt": "^3.0.1",
         "pinia": "^3.0.1",
         "vue": "^3.5.13",
         "vue-router": "^4.5.0",

+ 1 - 0
package.json

@@ -12,6 +12,7 @@
   },
   "dependencies": {
     "bootstrap": "^5.3.0",
+    "mitt": "^3.0.1",
     "pinia": "^3.0.1",
     "vue": "^3.5.13",
     "vue-router": "^4.5.0",

BIN
src/assets/images/BgLong.png


BIN
src/assets/images/DropDownArrow.png


BIN
src/assets/images/news/Banner.jpg


BIN
src/assets/images/news/IconSearch.png


+ 14 - 1
src/assets/scss/colors.scss

@@ -1,4 +1,17 @@
 $primary-color: #bd4b36;
+$primary-dark-color: #aa6052;
 $text-color: #333;
+$text-color-light: #fff;
 $text-second-color: #6d6d6d;
-$selection-max-width: 1250px;
+$text-second-color-light: #ddd;
+$selection-max-width: 1250px;
+
+$border-default-color: $primary-dark-color;
+$border-active-color: $primary-color;
+$border-dark-color: #fff;
+
+$box-dark-trans-color2: rgba(#000, 0.22);
+$box-dark-trans-color: rgba(#000, 0.55);
+$box-color: #fff;
+$box-inset-color: rgba(#FFFDF9, 0.33);
+$box-hover-color: rgba(#FFFDF9, 0.88);

+ 78 - 5
src/assets/scss/main.scss

@@ -12,17 +12,24 @@ html {
   color: $text-color;
 }
 main {
-
+  position: relative;
 }
 
 //Header
 
+$large-banner-height: 600px;
+$small-banner-height: 445px;
+
 .main-header-box {
   position: relative;
   width: 100%;
-  min-height: 600px;
+  min-height: $large-banner-height;
   background-color: $primary-color;
 
+  &.small {
+    min-height: $small-banner-height;
+  }
+
   img {
     position: absolute;
     top: 0;
@@ -40,6 +47,56 @@ main {
   transform: translate(-50%, -50%);
   text-align: center;
 }
+.main-header-title {
+  font-family: SourceHanSerifCNBold;
+  color: $text-color-light;
+
+  h1 {
+    font-size: 3rem;
+    margin: 0;
+    margin-bottom: 16px;
+  }
+  h2 {
+    font-size: 2.5rem;
+    margin: 0;
+    margin-bottom: 16px;
+  }
+  p {
+    font-size: 1.5rem;
+    margin: 0;
+    margin-bottom: 24px;
+  }
+}
+.main-header-tab {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+
+  .list {
+    margin: 0 auto;
+    max-width: $selection-max-width;
+    background-color: $box-dark-trans-color2;
+    backdrop-filter: blur(5px);
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: flex-start;
+
+    > div {
+      min-width: 300px;
+      height: 56px;
+      color: $text-color-light;
+      text-align: center;
+      line-height: 56px;
+
+      &.active {
+        background-color: $primary-color;
+        height: 60px;
+      }
+    }
+  }
+}
 
 //Utitles
 
@@ -48,14 +105,17 @@ main {
   background-repeat: repeat;
   background-position: center;
 
+  &-type0 {
+    background-image: url('@/assets/images/BgLong.png');
+  }
   &-type1 {
-    background-image: url('@/assets/images/Bg1.png');;
+    background-image: url('@/assets/images/Bg1.png');
   }
   &-type2 {
-    background-image: url('@/assets/images/Bg2.png');;
+    background-image: url('@/assets/images/Bg2.png');
   }
   &-type3 {
-    background-image: url('@/assets/images/index/IntrodRight.png');;
+    background-image: url('@/assets/images/index/IntrodRight.png');
   }
 }
 
@@ -114,6 +174,19 @@ main {
   position: relative;
   padding: 120px 100px;
 
+  &.absolute {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    z-index: 10;
+  }
+  &.fit-small-header {
+    height: $small-banner-height;
+  }
+  &.light {
+    color: $text-color-light;
+  }
 
   h2 {
     display: flex;

+ 98 - 0
src/common/ConvertRgeistry.ts

@@ -0,0 +1,98 @@
+import { 
+  DATA_MODEL_ERROR_REQUIRED_KEY_MISSING, 
+  DATA_MODEL_ERROR_TRY_CONVERT_BAD_TYPE, 
+  DATA_MODEL_ERROR_PRINT_SOURCE,
+  DATA_MODEL_ERROR_ARRAY_REQUIRED_KEY_MISSING,
+  DATA_MODEL_ERROR_ARRAY_IS_NOT_ARRAY,
+  DATA_MODEL_ERROR_MUST_PROVIDE_SIDE,
+  DATA_MODEL_ERROR_REQUIRED_KEY_NULL,
+  DataConverter, 
+  DataErrorFormatUtils, 
+  defaultDataErrorFormatHandler 
+} from "@imengyu/js-request-transform";
+
+function setErrorFormatter() {
+  DataErrorFormatUtils.setFormatHandler((error, data) => {
+    switch (error) {
+      case DATA_MODEL_ERROR_REQUIRED_KEY_MISSING: 
+        return `字段 ${data.sourceKey} 必填但未提供。 来源 ${data.source}; 对象 ${data.objectName} ${data.serverKey ? ('服务器应传字段: ' + data.serverKey) : ''}`;
+      case DATA_MODEL_ERROR_TRY_CONVERT_BAD_TYPE:
+        return `尝试将 ${data.sourceType} 转换为 ${data.targetType}。`;   
+      case DATA_MODEL_ERROR_PRINT_SOURCE:
+        return `来源: ${data.objectName}.`;
+      case DATA_MODEL_ERROR_ARRAY_REQUIRED_KEY_MISSING:
+        return `转换数组模型失败: 需要的字段 ${data.sourceKey} 未提供。`
+      case DATA_MODEL_ERROR_ARRAY_IS_NOT_ARRAY:
+        return `转换数组模型失败: 需要的字段 ${data.sourceKey} 不是数组类型。`
+      case DATA_MODEL_ERROR_MUST_PROVIDE_SIDE:
+        return `转换字段 ${data.key} 失败: 必须提供 ${data.direction} 侧数据。`;
+      case DATA_MODEL_ERROR_REQUIRED_KEY_NULL:
+        return `转换字段 ${data.key} 失败: 必填字段 ${data.key} 未提供或者为 null。`;
+    }
+    return defaultDataErrorFormatHandler(error, data);
+  });
+}
+
+export function registryConvert() {
+  setErrorFormatter();
+  DataConverter.registerConverter({
+    key: 'SplitCommaArray',
+    targetType: 'splitCommaArray',
+    converter: (source, key, type) => {
+      if (typeof source === 'string') 
+        return {
+          success: true,
+          result: source?.split(',') || [],
+        }
+      return {
+        success: false,
+        convertFailMessage: `[${key}] 不是字符串类型`,
+      };
+    }
+  })
+  DataConverter.registerConverter({
+    key: 'CommaArrayMerge',
+    targetType: 'commaArrayMerge',
+    converter: (source, key, type) => {
+      if (source instanceof Array) 
+        return {
+          success: true,
+          result: source?.join(',') || '',
+        }
+      return {
+        success: false,
+        convertFailMessage: `[${key}] 不是数组类型`,
+      };
+    }
+  })
+  DataConverter.registerConverter({
+    key: 'ForceArray',
+    targetType: 'forceArray',
+    converter: (source, key, type) => {
+      if (source instanceof Array) 
+        return {
+          success: true,
+          result: source,
+        }
+      if (typeof source === 'object' && source !== null) {
+        const arr = []
+        for (const key in source) {
+          arr.push((source as Record<string, any>)[key])
+        }
+        return {
+          success: true,
+          result: arr,
+        }
+      }
+      if (typeof source === 'string')
+        return {
+          success: true,
+          result: source.split(','), 
+        }
+      return {
+        success: false,
+        convertFailMessage: `[${key}] 不是数组类型`,
+      };
+    }
+  })
+}

+ 8 - 0
src/common/EventBus.ts

@@ -0,0 +1,8 @@
+import mitt from 'mitt'
+
+export type EventBusOnPageBackData = { name: string, data: any }
+type Events = {
+  pageActionListenOnPageBack: EventBusOnPageBackData,
+}
+
+export const EventBus = mitt<Events>();

+ 9 - 0
src/common/config/ApiCofig.ts

@@ -0,0 +1,9 @@
+
+/**
+ * 说明:后端接口配置
+ */
+export default {
+  serverDev: 'https://mn.wenlvti.net/api',
+  serverProd: 'https://mn.wenlvti.net/api',
+  mainBodyId: 1,
+}

+ 20 - 0
src/common/config/AppCofig.ts

@@ -0,0 +1,20 @@
+
+/**
+ * 说明:应用静态配置
+ */
+export default {
+  version: '0.0.1',
+}
+
+/**
+ * 图炫地图配置
+ */
+export function configAiMap() {
+  aimap.accessToken = 'UFJGhyFdSzvm0ZbecYglp6CssgnDK7PZ';
+  aimap.baseApiUrl = 'https://location-dev.newayz.com';
+}
+
+/**
+ * 是否是开发环境
+ */
+export const isDev = import.meta.env.DEV;

+ 59 - 0
src/common/request/core/RequestApiConfig.ts

@@ -0,0 +1,59 @@
+/**
+ * 请求的默认配置
+ *
+ * 说明:
+ *  此处提供的是请求中的默认配置。
+ *
+ * Author: imengyu
+ * Date: 2022/03/25
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+import ApiCofig from "@/common/config/ApiCofig";
+import { isDev } from "@/common/config/AppCofig";
+import type { KeyValue } from "@imengyu/js-request-transform/dist/DataUtils";
+
+interface ApiConfigInterface {
+  /**
+   * 默认转换日期的格式
+   */
+  DataDateFormat: string,
+  /**
+  * 所有请求默认携带的header
+  */
+  DefaultHeader: KeyValue,
+  /**
+  * 是否在在控制台上打印出请求信息
+  */
+  EnableApiRequestLog: boolean,
+  /**
+  * 是否在每一个请求都在控制台上打印出休息数据
+  */
+  EnableApiDataLog: boolean,
+  /**
+   * 基础请求地址
+   */
+  BaseUrl: string;
+}
+
+const defaultConfig = {
+  BaseUrl: isDev ? ApiCofig.serverDev : ApiCofig.serverProd,
+  DataDateFormat: 'YYYY-MM-DD HH:mm:ss',
+  DefaultHeader: {},
+  EnableApiRequestLog: true,
+  EnableApiDataLog: false,
+} as ApiConfigInterface;
+
+let config = defaultConfig;
+
+/**
+ * 请求中的默认配置
+ */
+const RequestApiConfig = {
+  getConfig() : ApiConfigInterface { return config; },
+  setConfig(newConfig: ApiConfigInterface): void { config = newConfig; },
+};
+
+export default RequestApiConfig;

+ 172 - 0
src/common/request/core/RequestApiResult.ts

@@ -0,0 +1,172 @@
+/**
+ * API 返回结构体定义
+ *
+ * 功能介绍:
+ *    这里定义了API返回数据的基本结构体,分为正常结果和错误结果。
+ *
+ * Author: imengyu
+ * Date: 2020/09/28
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+import { DataModel, type NewDataModel } from "@imengyu/js-request-transform";
+import type { KeyValue } from "@imengyu/js-request-transform/dist/DataUtils";
+
+/**
+ * API 的返回结构体
+ */
+export class RequestApiResult<T extends DataModel> {
+  public code = 0;
+  public message = '';
+  public data: T|KeyValue|null = null;
+  /**
+   * 无类型数据
+   */
+  public data2: any = null;
+  public raw: any = null;
+
+  public constructor(c: NewDataModel|null, code? : number, message? : string, data?: Record<string, unknown>|null, rawData?: Record<string, unknown>|null) {
+    if (typeof code !== 'undefined')
+      this.code = code;
+    if (typeof message !== 'undefined')
+      this.message = message;
+
+    //转换数据
+    if (typeof data !== 'undefined' && c)
+      this.data = new c().fromServerSide(data as KeyValue) as T;//转换data
+    else if (typeof rawData !== 'undefined' && c)
+      this.data = new c().fromServerSide(rawData as KeyValue) as T;//如果data为空则转换rawData
+    else
+      this.data = data as KeyValue as T; //原始数据
+    if (typeof rawData !== 'undefined')
+      this.raw = rawData;
+    else
+      this.raw = this.data;
+    this.data2 = this.data;
+  }
+
+  /**
+   * 使用另一个数据实例克隆当前结果
+   * @param model 另一个数据
+   * @returns
+   */
+  public cloneWithOtherModel<U extends DataModel>(model: U) : RequestApiResult<U> {
+    return new RequestApiResult(
+      null,
+      this.code,
+      this.message,
+      model.keyValue(),
+      this.raw
+    );
+  }
+  /**
+   * 转为纯JSON格式
+   * @returns
+   */
+  public keyValueData() : KeyValue {
+    return (this.data instanceof DataModel ? this.data?.keyValue() : this.data) || {};
+  }
+  /**
+   * 转为字符串表达形式
+   * @returns
+   */
+  public toString() : string {
+    return `${this.code} ${this.message} data: ${JSON.stringify(this.data)} raw: ` + JSON.stringify(this.raw);
+  }
+}
+
+/**
+ * 指示这个错误发生的类型
+ */
+export type RequestApiErrorType = 'networkError'|'statusError'|'serverError'|'businessError'|'scriptError'|'unknow';
+
+/**
+ * API 的错误信息
+ */
+export class RequestApiError {
+
+  /**
+   * 本次请求错误的 API 名字
+   */
+  public apiName = '';
+  /**
+   * 本次请求错误的 API URL
+   */
+  public apiUrl = '';
+  /**
+   * 指示这个错误发生的类型
+   * * networkError:网络连接错误
+   * * statusError:状态错误(返回了400-499错误状态码)
+   * * serverError:服务器错误(返回了500-599错误状态码)
+   * * businessError:业务错误(状态码200,但是自定义判断条件失败)
+   * * scriptError:脚本错误(通常是代码异常被catch)
+   */
+  public errorType : RequestApiErrorType = 'unknow';
+  /**
+   * 错误信息
+   */
+  public errorMessage: string;
+  /**
+   * code的错误信息
+   */
+  public errorCodeMessage: string;
+  /**
+   * 错误代号
+   */
+  public code = 0;
+  /**
+   * 本次请求的返回数据
+   */
+  public data: KeyValue|null = null;
+  /**
+   * 本次请求的原始返回数据
+   */
+  public rawData: KeyValue|null = null;
+  /**
+   * 本次请求的原始参数
+   */
+  public rawRequest: RequestInit|null = null;
+
+  public constructor(
+    errorType: RequestApiErrorType,
+    errorMessage = '',
+    errorCodeMessage = '',
+    code = 0,
+    data: KeyValue|null = null,
+    rawData: unknown|null = null,
+    rawRequest: RequestInit|null = null,
+    apiName = '',
+    apiUrl = ''
+  ) {
+    this.errorType = errorType;
+    this.errorMessage = errorMessage;
+    this.errorCodeMessage = errorCodeMessage;
+    this.code = code;
+    this.data = data;
+    this.apiName = apiName;
+    this.apiUrl = apiUrl;
+    this.rawData = rawData as KeyValue;
+    this.rawRequest = rawRequest as KeyValue;
+  }
+
+  /**
+   * 转为详情格式
+   * @returns
+   */
+  public toStringDetail() {
+    return `请求${this.apiName}错误 ${this.errorMessage} (${this.errorType}) ${this.code}(${this.errorCodeMessage})\n` +
+      `url: ${this.apiUrl}\n` +
+      `data: ${JSON.stringify(this.data)}\n` +
+      `rawData: ${JSON.stringify(this.rawData)}\n` +
+      `rawRequest: ${JSON.stringify(this.rawRequest)}\n`;
+  }
+  /**
+   * 转为字符串表达形式
+   * @returns
+   */
+  public toString(): string {
+    return this.errorMessage;
+  }
+}

+ 398 - 0
src/common/request/core/RequestCore.ts

@@ -0,0 +1,398 @@
+import RequestApiConfig from './RequestApiConfig';
+import { DataModel, type NewDataModel } from '@imengyu/js-request-transform';
+import { isNullOrEmpty, stringHashCode } from '../utils/Utils';
+import { RequestApiError, RequestApiResult } from './RequestApiResult';
+import { defaultResponseDataHandler, defaultResponseErrorHandler } from './RequestHandler';
+import type { HeaderType, QueryParams, TypeSaveable } from '../utils/AllType';
+import type { KeyValue } from '@imengyu/js-request-transform/dist/DataUtils';
+import type { RequestImplementer } from './RequestImplementer';
+
+/**
+ * API 请求核心
+ *
+ * 功能介绍:
+ *    本类是对 fetch 的封装,提供了基本的请求功能。
+ *
+ * Author: imengyu
+ * Date: 2022/03/28
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+/**
+ * 请求配置体
+ */
+export interface RequestCoreConfig<T extends DataModel> {
+  /**
+   * 基础URL
+   */
+  baseUrl: string;
+  /**
+   * 错误代码字符串数据
+   */
+  errCodes: { [index: number]: string };
+  /**
+   * 默认携带header
+   */
+  defaultHeader: HeaderType,
+  /**
+   * 超时时间 ms
+   */
+  timeout: number,
+  /**
+   * 请求拦截
+   */
+  requestInceptor?: (url: string, req: RequestOptions) => { newUrl: string, newReq: RequestOptions };
+  /**
+   * 响应拦截
+   */
+  responseInceptor?: (response: Response) => Response;
+  /**
+   * 错误报告拦截。如果返回true,则不进行错误报告
+   */
+  responseErrReoprtInceptor?: (instance: RequestCoreInstance<T>, err: RequestApiError) => boolean;
+  /**
+   * 错误报告函数
+   */
+  reportError?: (instance: RequestCoreInstance<T>, err: RequestApiError|Error) => void;
+
+  /**
+   * 自定义数据处理函数
+   */
+  responseDataHandler?: (response: Response, req: RequestOptions, resultModelClass: NewDataModel|undefined, instance: RequestCoreInstance<T>, apiName: string|undefined) => Promise<RequestApiResult<T>>;
+  /**
+   * 自定义错误处理函数
+   */
+  responseErrorHandler?: (err: Error, instance: RequestCoreInstance<T>, apiName: string|undefined) => RequestApiError;
+  /**
+   * 类自定义创建函数
+   */
+  modelClassCreator: ModelClassCreatorDefine<T>|null;
+}
+
+type ModelClassCreatorDefine<T> = (new () => T);
+
+export interface RequestCacheConfig {
+  /**
+   * 缓存保存时间,毫秒。超过时间后再请求时会发请求
+   */
+  cacheTime: number,
+  /**
+   * 是否启用缓存
+   */
+  cacheEnable: boolean,
+}
+
+export interface RequestCacheStorage {
+  time: number,
+  data: TypeSaveable
+}
+
+export class RequestOptions {
+  /**
+   * 请求的参数
+   */
+  data?: string | object | ArrayBuffer | FormData;
+  /**
+  * 设置请求的 header,header 中不能设置 Referer。
+  */
+  header?: any;
+  /**
+  * 默认为 GET
+  * 可以是:OPTIONS,GET,HEAD,POST,PUT,DELETE,TRACE,CONNECT
+  */
+  method?: 'OPTIONS' | 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'TRACE' | 'CONNECT';
+  /**
+  * 超时时间
+  */
+  timeout?: number;
+  /**
+  * 如果设为json,会尝试对返回的数据做一次 JSON.parse
+  */
+  dataType?: string;
+  /**
+  * 设置响应的数据类型。合法值:text、arraybuffer
+  */
+  responseType?: string;
+  /**
+  * 验证 ssl 证书
+  */
+  sslVerify?: boolean;
+  /**
+  * 跨域请求时是否携带凭证
+  */
+  withCredentials?: boolean;
+  /**
+  * DNS解析时优先使用 ipv4
+  */
+  firstIpv4?: boolean;
+}
+/**
+ * API 请求核心实例类,本类是对 fetch 的封装,提供了基本的请求功能。
+ */
+export class RequestCoreInstance<T extends DataModel> {
+
+  constructor(implementer: RequestImplementer) {
+    this.implementer = implementer;
+  }
+
+  /**
+   * 当前请求实例的请求配置项
+   */
+  config : RequestCoreConfig<T> = {
+    baseUrl: '',
+    errCodes: {},
+    timeout: 10000,
+    defaultHeader: RequestApiConfig.getConfig().DefaultHeader as HeaderType,
+    modelClassCreator: null,
+    responseDataHandler: defaultResponseDataHandler,
+    responseErrorHandler: defaultResponseErrorHandler,
+  };
+
+  /**
+   * 请求实现类
+   */
+  implementer: RequestImplementer;
+
+  /**
+   * 检查是否需要报告错误
+   */
+  checkShouldReportError(err: RequestApiError) {
+    if (typeof this.config.responseErrReoprtInceptor === 'function')
+      return this.config.responseErrReoprtInceptor(this, err) !== true;
+    return true;
+  }
+  /**
+   * 报告错误
+   * @param err 错误
+   */
+  reportError(err: RequestApiError|Error) {
+    if (this.checkShouldReportError(err as RequestApiError)) {
+      if (typeof this.config.reportError === 'function')
+        this.config.reportError(this, err);
+    }
+  }
+  /**
+   * 在配置中查找错误代码的说明文字
+   * @param code 错误代码
+   * @returns 说明文字,如果找不到,返回 undefined
+   */
+  findErrCode(code: number) : string|undefined {
+    return this.config.errCodes[code];
+  }
+
+  /**
+   * 合并URL
+   */
+  makeUrl(url: string, querys?: QueryParams) {
+    let finalUrl = '';
+    if (url.indexOf('http') === 0)
+      finalUrl = url; //绝对地址
+    else
+      finalUrl = this.config.baseUrl + url;
+    //处理query
+    if (querys) {
+      let i = finalUrl.indexOf('?') > 0 ? 1 : 0;
+      for (const key in querys) {
+        if (typeof querys[key] === 'undefined' || querys[key] === null)
+          continue;
+        finalUrl += i === 0 ? '?' : '&';
+        if (typeof querys[key] === 'object')
+          finalUrl += `${key}=` + encodeURIComponent(JSON.stringify(querys[key]));
+        else
+          finalUrl += `${key}=` + '' + querys[key];
+        i++;
+      }
+    }
+    return finalUrl;
+  }
+  //合并默认Header参数
+  private mergerDefaultHeader(header: Record<string, unknown>) {
+    const myHeaders = {} as Record<string, unknown>;
+    for (const key in this.config.defaultHeader)
+      myHeaders[key] = this.config.defaultHeader[key];
+    if (header) {
+      for (const key in header) 
+        myHeaders[key] = header[key];
+    }
+    return myHeaders;
+  }
+  /**
+   * 合并两个Header参数
+   * @param header 合并目标
+   * @param newHeader 新的Header
+   * @returns 合并后的Header
+   */
+  mergerHeaders(header: Record<string, unknown>, newHeader: Record<string, unknown>) {
+    if (!newHeader)
+      return header;
+    if (!header)
+      return newHeader;
+    for (const key in newHeader)
+      header[key] = newHeader[key];
+    return header;
+  }
+
+  //检查缓存参数
+  private checkCacheTime(cache?: RequestCacheConfig) {
+    return cache && cache.cacheEnable && cache.cacheTime || 0;
+  }
+  //请求缓存处理
+  private solveCache(url: string, req: RequestOptions, cache: RequestCacheConfig|undefined, callback: (cacheTime: number, cacheKey: string, res: TypeSaveable) => void) {
+    const cacheTime = req.method === 'GET' ? this.checkCacheTime(cache) : 0;
+    let requestHash = '';
+    if (cacheTime > 0) {
+      requestHash = "RequestCache" + stringHashCode(url + req.method);
+      //获取数据
+      this.implementer.getCache(requestHash).then((cacheData) => {
+        if (!cacheData) {
+          callback(cacheTime, requestHash, null);
+          return;
+        }
+        //没有过期
+        if (cacheData.time < new Date().getTime()) {
+          callback(cacheTime, requestHash, cacheData.time);
+          return;
+        }
+        callback(cacheTime, requestHash, null);
+      }).catch(() => {
+        callback(cacheTime, requestHash, null);
+      }); 
+    } else
+      callback(cacheTime, requestHash, null);
+  }
+
+  /**
+   * 通用的请求包装方法
+   * @param url 请求URL
+   * @param req 请求参数
+   * @param apiName 名称,用于日志和调试
+   * @returns 返回 Promise
+   */
+  request(url: string, req: RequestOptions,  apiName: string, modelClassCreator: NewDataModel|undefined, cache?: RequestCacheConfig) : Promise<RequestApiResult<T>> {
+    return new Promise<RequestApiResult<T>>((resolve, reject) => {
+      //附加请求头
+      req.header = this.mergerDefaultHeader(req.header);
+      
+      //拦截器
+      if (this.config.requestInceptor) {
+        const { newUrl, newReq } = this.config.requestInceptor(url, req);
+        url = newUrl;
+        req = newReq;
+      }
+      if (req.data instanceof FormData) {
+        req.header['Content-Type'] = 'multipart/form-data';
+      } else if (typeof req.data === 'object' || req.data === undefined) {
+        req.header['Content-Type'] = 'application/json';
+      }
+
+      if (RequestApiConfig.getConfig().EnableApiRequestLog)
+        console.log(`[API Debugger] Q > ${apiName} [${req.method || 'GET'}] ` + url, req.data);
+
+      //缓存处理
+      this.solveCache(url, req, cache, (cacheTime, cacheKey, cacheRes) => {
+
+        //有缓存数据,则直接返回
+        if (cacheRes) {
+          if (RequestApiConfig.getConfig().EnableApiRequestLog)
+            console.log(`[API Debugger] C > ${apiName} (${cacheKey}/${cacheTime})`, ( RequestApiConfig.getConfig().EnableApiDataLog ? cacheRes.toString() : ''));
+          resolve(cacheRes as unknown as RequestApiResult<T>);
+          return;
+        }
+
+        //发送请求并且处理响应数据
+        this.requestAndResponse(url, req, apiName, modelClassCreator, (result) => {
+          //保存缓存
+          if (cacheTime > 0) {
+            this.implementer.setCache(cacheKey, {
+              time: new Date().getTime() + cacheTime,
+              data: result as unknown as TypeSaveable,
+            });
+          }
+        }).then((d) => {
+          resolve(d);
+        }).catch((e) => {
+          reject(e);
+        });
+      });
+    });
+  }
+
+  //发送请求并且处理
+  private requestAndResponse(url: string, req: RequestOptions, apiName: string, resultModelClass: NewDataModel|undefined, saveCache?: (result: unknown) => void) {
+    return new Promise<RequestApiResult<T>>((resolve, reject) => {
+      //发起请求
+      this.implementer.doRequest(url, req, this.config.timeout).then((res) => {
+        //响应拦截
+        if (this.config.responseInceptor)
+          res = this.config.responseInceptor(res);
+
+        if (this.config.responseDataHandler) {
+          //处理数据
+          this.config.responseDataHandler(res, req, resultModelClass, this, apiName).then((result) => {
+            //尝试保存缓存
+            saveCache && saveCache(result);
+            //处理数据
+            try {
+              if (RequestApiConfig.getConfig().EnableApiRequestLog)
+                console.log(`[API Debugger] R > ${apiName} (${res.status}/${result.code})`);
+              //返回
+              resolve(result);
+            } catch (e) {
+              //捕获处理代码的异常
+              console.error('[API Debugger] E > Catch exception in promise : ' + e + ((e as Error).stack ? ('\n' + (e as Error).stack) : ''));
+              reject(new RequestApiError('scriptError', '代码异常,请检查:' + e, '脚本异常', -1, null, e as unknown as KeyValue, req, apiName));
+            }
+          }).catch((e) => {
+            reject(e);
+          });
+        }
+        else
+          reject(new RequestApiError('scriptError', 'This RequestCoreInstance is not configured with responsedatahandler and cannot convert data! ', '脚本异常', -1, null, null, req, apiName));
+      }).catch((err) => {
+        reject(this.config.responseErrorHandler ? this.config.responseErrorHandler(err, this, apiName) : err);
+      });
+    });
+  }
+
+  /**
+   * GET 请求
+   * @param url 请求URL
+   * @param querys 请求URL参数
+   * @param cache 缓存参数
+   */
+  get(url: string, apiName: string, querys?: QueryParams, modelClassCreator?: NewDataModel, cache?: RequestCacheConfig, headers?: KeyValue) {
+    return this.request(this.makeUrl(url, querys), { method: 'GET', header: headers }, apiName, modelClassCreator, cache);
+  }
+  /**
+   * POST 请求
+   * @param url 请求URL
+   * @param data 请求Body参数
+   * @param querys 请求URL参数
+   * @param cache 缓存参数
+   */
+  post(url: string, data: KeyValue|FormData, apiName: string, querys?: QueryParams, modelClassCreator?: NewDataModel, cache?: RequestCacheConfig, headers?: KeyValue) {
+    return this.request(this.makeUrl(url, querys), { method: 'POST', data, header: headers }, apiName, modelClassCreator, cache);
+  }
+  /**
+   * PUT 请求
+   * @param url 请求URL
+   * @param data 请求Body参数
+   * @param querys 请求URL参数
+   * @param cache 缓存参数
+   */
+  put(url: string, data: KeyValue, apiName: string,querys?: QueryParams,  modelClassCreator?: NewDataModel, cache?: RequestCacheConfig, headers?: KeyValue) {
+    return this.request(this.makeUrl(url, querys), { method: 'PUT', data, header: headers }, apiName, modelClassCreator, cache);
+  }
+  /**
+   * DELETE 请求
+   * @param url 请求URL
+   * @param data 请求Body参数
+   * @param querys 请求URL参数
+   * @param cache 缓存参数
+   */
+  delete(url: string, data: KeyValue, apiName: string, querys?: QueryParams, modelClassCreator?: NewDataModel, cache?: RequestCacheConfig, headers?: KeyValue) {
+    return this.request(this.makeUrl(url, querys), { method: 'DELETE', data, header: headers }, apiName, modelClassCreator, cache);
+  }
+}

+ 130 - 0
src/common/request/core/RequestHandler.ts

@@ -0,0 +1,130 @@
+import ApiConfig from "./RequestApiConfig";
+import { DataModel, type NewDataModel } from "@imengyu/js-request-transform";
+import { RequestApiError, type RequestApiErrorType, RequestApiResult } from "./RequestApiResult";
+import { RequestCoreInstance, RequestOptions } from "./RequestCore";
+
+/**
+ * 请求错误与数据处理函数
+ *
+ * 这里写的是请求中的 数据处理函数 与 错误默认处理函数。
+ *
+ * 业务相关的自定义数据处理函数,请单独在RequestModules中写明。
+ *
+ * Author: imengyu
+ * Date: 2022/03/28
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+//默认的请求数据处理函数
+export function defaultResponseDataHandler<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) => {
+      //情况1,有返回数据
+      if (response.ok) {
+        if (ApiConfig.getConfig().EnableApiRequestLog)
+          console.log(`[API Debugger] Request [${method}] ` + response.url + ' success (' + response.status + ') ' + (ApiConfig.getConfig().EnableApiDataLog ? JSON.stringify(json) : ''));
+
+        //情况1-1,请求成功,状态码200-299
+        resolve(new RequestApiResult(resultModelClass ?? instance.config.modelClassCreator, response.status, json.message, json.data, json));
+      } else {
+        if (ApiConfig.getConfig().EnableApiRequestLog)
+          console.log(`[API Debugger] Request [${method}] ${response.url} Got error from server : ` + json.message + ' (' + json.code + ') ' + (ApiConfig.getConfig().EnableApiDataLog ? JSON.stringify(json) : ''));
+
+        //情况1-2,请求失败,状态码>299
+        const err = new RequestApiError('statusError', json.message, '状态码异常', json.code || response.status, json.data, json, req, apiName, response.url);
+
+        //错误报告
+        if (instance.checkShouldReportError(err))
+          instance.reportError(err);
+
+        reject(err);
+      }
+    }).catch((err) => {
+      //错误统一处理
+      defaultResponseDataHandlerCatch(method, req, response, null, err, apiName, response.url, reject, instance);
+    });
+  });
+}
+export function defaultResponseDataGetErrorInfo(response: Response, err: any) {
+  let errString = (response.status > 299) ? ('返回了状态码' + response.status + '。\n') : '';
+  let errType : RequestApiErrorType = 'statusError';
+  let errCodeStr = '状态码:' + response.status;
+  if (err instanceof Error && response.status < 299) {
+    errString = '代码错误: ' + err.message;
+    errType = 'scriptError';
+  } else {
+    if (('' + err).indexOf('JSON Parse error') >= 0)
+      errString += '处理JSON结构失败,可能后端没有返回正确的JSON格式。\n';
+
+    //情况2,没有返回数据
+    //错误状态码的处理
+    switch (response.status) {
+      case 400:
+        errCodeStr = '错误的请求';
+        errString += errCodeStr + ' \n[提示:请检查传入参数是否正确]';
+        errType = 'statusError';
+        break;
+      case 401:
+        errCodeStr = '未登录。可能登录已经过期,请重新登录';
+        errString += errCodeStr;
+        errType = 'statusError';
+        break;
+      case 405:
+        errCodeStr = 'HTTP方法不被允许';
+        errString += errCodeStr + ' \n[提示:这可能是调用接口是不正确造成的]';
+        errType = 'statusError';
+        break;
+      case 404:
+        errCodeStr = '返回404未找到';
+        errString += errCodeStr + ' \n[提示:后端检查下到底有没有提供这个API?]';
+        errType = 'statusError';
+        break;
+      case 500:
+        errCodeStr = '服务异常,请稍后重试';
+        errString += errCodeStr + ' \n[故障提示:这可能是后端服务出现了异常]';
+        errType = 'serverError';
+        break;
+      case 502:
+        errCodeStr = '无效网关,请反馈此错误';
+        errString += errCodeStr + ' \n[故障提示:请检查服务器与软件状态]';
+        errType = 'serverError';
+        break;
+      case 503:
+        errCodeStr = '服务暂时不可用';
+        errString += errCodeStr + ' \n[故障提示:请检查服务器状态]';
+        errType = 'serverError';
+        break;
+    }
+  }
+
+  return {errString, errType, errCodeStr};
+}
+//默认的请求数据处理函数
+export function defaultResponseDataHandlerCatch<T extends DataModel>(method: string, req: RequestOptions, response: Response, data: any, err: any, apiName: string|undefined, apiUrl: string, reject: (reason?: any) => void, instance: RequestCoreInstance<T>) {
+  if (ApiConfig.getConfig().EnableApiRequestLog) {
+    console.log(`[API Debugger] E > ${apiName} ` + err + ' status: ' + response.status);
+    if (err instanceof Error)
+      console.log(err.stack);
+  }
+
+  
+  const {errString, errType, errCodeStr} = defaultResponseDataGetErrorInfo(response, err);
+  const errObj = new RequestApiError(errType, errString, errCodeStr, response.status, null, data, req, apiName, apiUrl);
+
+  //错误报告
+  if (instance.checkShouldReportError(errObj))
+    instance.reportError(errObj);
+  reject(errObj);
+}
+
+//默认的请求错误处理函数
+export function defaultResponseErrorHandler(err: Error) : RequestApiError {
+  if (err instanceof Error)
+    console.error('[API Debugger] Error : ' + err + (err.stack ? ('\n' + err.stack) : ''));
+  else
+    console.error('[API Debugger] Error : ' + JSON.stringify(err));
+  return new RequestApiError('unknow', '' + JSON.stringify(err));
+}

+ 7 - 0
src/common/request/core/RequestImplementer.ts

@@ -0,0 +1,7 @@
+import type { RequestCacheStorage, RequestOptions } from "./RequestCore";
+
+export interface RequestImplementer {
+  getCache(key: string): Promise<RequestCacheStorage|null>;
+  setCache(key: string, value: RequestCacheStorage|null): Promise<void>;
+  doRequest(url: string, init?: RequestOptions, timeout?: number): Promise<Response>;
+}

+ 103 - 0
src/common/request/implementer/Uniapp.ts

@@ -0,0 +1,103 @@
+import type { RequestCacheStorage, RequestOptions } from "../core/RequestCore";
+import type { RequestImplementer } from "../core/RequestImplementer";
+import { isNullOrEmpty } from "../utils/Utils";
+
+export class Response {
+  public constructor(url: string, data: unknown, options: {
+    headers: Record<string, unknown>,
+    status: number,
+  }, errMsg: string) {
+    this.errMsg = errMsg;
+    this.data = data;
+    this.url = url;
+    this.status = options.status;
+    this.headers = options.headers;
+    this.ok = options.status >= 200 && options.status <= 399;
+  }
+
+  headers: Record<string, unknown>;
+  ok: boolean;
+  status: number;
+  errMsg: string;
+  url: string;
+  data: unknown;
+
+  json() : Promise<any> {
+    return new Promise<any>((resolve, reject) => {
+      if (typeof this.data === 'undefined' || isNullOrEmpty(this.data)) {
+        resolve({});
+        return;
+      }
+      if (typeof this.data === 'object') {
+        resolve(this.data);
+        return;
+      }
+      let data = null;
+      
+      if (typeof this.data === 'string') {
+        try {
+          data = JSON.parse(this.data);
+        } catch(e) {
+          console.log('json error: ' + e,  this.data);
+          
+          reject(e);
+        }
+      } else {
+        data = this.data;
+      }
+
+      resolve(data);
+    })
+  }
+}
+
+const uniappImplementer : RequestImplementer = {
+  getCache: function (key: string) {
+    return new Promise<RequestCacheStorage|null>((resolve, reject) => {
+      uni.getStorage({
+        key: key,
+        success: (res) => {
+          resolve(res.data ? JSON.parse(res.data) as RequestCacheStorage : null);
+        },
+        fail: (res) => {
+          resolve(null);
+        }
+      });
+    });
+  },
+  setCache: async function (key: string, value: RequestCacheStorage|null) {
+    return new Promise<void>((resolve, reject) => {
+      uni.setStorage({
+        key: key,
+        data: JSON.stringify(value),
+        success: (res) => {
+          resolve();
+        },
+        fail: (res) => {
+          resolve();
+        }
+      });
+    });
+  },
+  doRequest: function (url: string, init?: RequestOptions, timeout?: number): Promise<Response> {
+    return new Promise<Response>((resolve, reject) => {
+      uni.request({
+        url: url,
+        timeout: timeout,
+        ...init,
+        success(res) {
+          const response = new Response(url, res.data, {
+            headers: res.header,
+            status: res.statusCode,
+          }, 'success');
+          resolve(response);
+        },
+        fail(res) {
+          reject(res);
+        },
+      })
+    });
+  }
+};
+
+export default uniappImplementer;

+ 44 - 0
src/common/request/implementer/WebFetch.ts

@@ -0,0 +1,44 @@
+import type { RequestCacheStorage, RequestOptions } from "../core/RequestCore";
+import type { RequestImplementer } from "../core/RequestImplementer";
+
+const fetchImplementer : RequestImplementer = {
+  getCache: async function (key: string) {
+    const v = localStorage.getItem(key);
+    return v ? JSON.parse(v) as RequestCacheStorage : null;
+  },
+  setCache: async function (key: string, value: RequestCacheStorage|null) {
+    localStorage.setItem(key, JSON.stringify(value));
+  },
+  doRequest: function (url: string, init?: RequestOptions, timeout?: number): Promise<Response> {
+    // 创建 AbortController 实例
+    const controller = new AbortController();
+    const { signal } = controller;
+
+    // 设置超时逻辑
+    const timeoutId = setTimeout(() => {
+      controller.abort(); // 超时后取消请求
+    }, timeout);
+
+    let body : string|FormData|undefined;
+    if (init?.data instanceof FormData)
+      body = init.data; 
+    else if (typeof init?.data === 'object') {
+      body = JSON.stringify(init.data); 
+    }
+
+    // 发起 fetch 请求
+    const response = fetch(url, { 
+      headers: init?.header,
+      method: init?.method,
+      body,
+      signal 
+    });
+
+    // 请求完成后清除超时
+    response.finally(() => clearTimeout(timeoutId));
+    return response
+  }
+};
+
+
+export default fetchImplementer;

+ 33 - 0
src/common/request/index.ts

@@ -0,0 +1,33 @@
+import type { DataModel } from "@imengyu/js-request-transform";
+import { RequestCoreInstance } from "./core/RequestCore";
+
+/**
+ * 基础请求模块
+ *
+ * 说明:
+ *  此处提供的是一个默认请求模块,演示了如何写自己的请求模块,
+ *  你可以参照这个类来写你自己的请求模块,并添加拦截器、错误处理、数据处理等等功能。
+ *
+ * Author: imengyu
+ * Date: 2022/03/25
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+
+/**
+ * 基础请求模块
+ * @deprecated 请使用 AuthServerRequestModule 或 AppServerRequestModule
+ */
+export class DefaultRequestModule<T extends DataModel> extends RequestCoreInstance<T> {
+  constructor() {
+    super();
+    this.config.requestInceptor = (url, req) => {
+      //登录相关Token添加
+      return { newUrl: url, newReq: req };
+    };
+  }
+}
+
+export default new DefaultRequestModule<DataModel>();

+ 52 - 0
src/common/request/utils/AllType.ts

@@ -0,0 +1,52 @@
+/**
+ * 请求工具所使用的类定义
+ *
+ * Author: imengyu
+ * Date: 2022/03/25
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+/**
+ * 空的结构
+ */
+export type TypeEmpty = Record<string, never>;
+
+/**
+ * 可保存数据
+ */
+export type TypeSaveable =
+  | TypeEmpty
+  | string
+  | number
+  | null
+  | undefined
+  | bigint
+  | boolean;
+/**
+ * 可保存数据
+ */
+export type TypeAll =
+  | TypeEmpty
+  | unknown
+  | object
+  | undefined
+  | string
+  | bigint
+  | number
+  | boolean;
+
+/**
+ * URL参数
+ */
+export interface QueryParams {
+  /**
+   * URL参数
+   */
+  [index: string]: TypeAll;
+}
+
+export interface HeaderType {
+  [key: string]: string;
+}

+ 84 - 0
src/common/request/utils/Utils.ts

@@ -0,0 +1,84 @@
+/**
+ * 请求工具所使用的工具函数
+ *
+ * 功能介绍:
+ *  提供了一些处理工具函数,方便使用。
+ *
+ * Author: imengyu
+ * Date: 2022/03/25
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+/* eslint-disable no-bitwise */
+import type { KeyValue } from "@imengyu/js-request-transform/dist/DataUtils";
+import type { TypeSaveable } from "./AllType";
+
+export function isNullOrEmpty(val: unknown) {
+  return !val || typeof val === 'undefined' || val === '';
+}
+export function simpleClone<T>(obj: T) : T {
+  let temp: KeyValue|Array<KeyValue>|null = null;
+  if (obj instanceof Array) {
+    temp = obj.concat();
+  }
+  else if (typeof obj === 'object') {
+    temp = {} as KeyValue;
+    for (const item in obj) {
+      const val = (obj as unknown as KeyValue)[item];
+      if (val === null) temp[item] = null;
+      else (temp as KeyValue)[item] = simpleClone(val) as TypeSaveable;
+    }
+  } else {
+    temp = obj as unknown as KeyValue;
+  }
+  return temp as unknown as T;
+}
+/**
+ * 计算字符串的哈希值
+ * @param {string} str
+ */
+export function stringHashCode(str: string) {
+  return '' + (str.split("").reduce(function(a, b) {
+    a = (a << 5) - a + b.charCodeAt(0);
+    return (a & a);
+  }, 0));
+}
+
+export function checkIfStringAllEnglish(str: string) {
+  return /^[\x00-\x7F]+$/.test(str)
+}
+
+export function appendGetUrlParams(url: string, key: string, value: any) {
+  if (!url.includes(`?${key}`) && !url.includes(`&${key}`)) {
+    if (url.includes('?'))
+      url = url + '&' + key + '=' + value;
+    else
+      url = url + '?' + key + '=' + value;
+  }
+  return url;
+}
+export function appendPostParams(source: any, key: string, value: any) {
+  if (source instanceof FormData && !source.has(key))
+    source.append(key, value);
+  else if (typeof source === 'object' && source[key] === undefined)
+    source = { ...source, [key]: value };
+  return source;
+}
+
+export function transformSomeToArray(source: any) {
+  if (typeof source === 'string') 
+    return source.split(','); 
+  if (typeof source === 'object') {
+    if (source instanceof Array)
+      return source; 
+    else {
+      const arr = [];
+      for (const key in source)
+        arr.push(source[key]);
+      return arr;
+    }
+  }
+  return source;
+}

+ 41 - 0
src/common/utils/ArrayUtils.ts

@@ -0,0 +1,41 @@
+function remove<T>(array: T[], item: T) {
+  let index = array.indexOf(item);
+  if (index >= 0) {
+    array.splice(index, 1);
+    return true;
+  }
+  return false;
+}
+function removeAt<T>(array: T[], index: number) {
+  if (index >= 0) {
+    array.splice(index, 1);
+    return true;
+  }
+  return false;
+}
+function insert<T>(array: T[], i: number, item: T) {
+  if (i > array.length) {
+    array.push(item);
+    return array;
+  }
+  return array.splice(i, 0, item);
+}
+function contains<T>(array: T[], item: T) {
+  return array.indexOf(item) >= 0;
+}
+function clear<T>(array: T[]) {
+  return array.splice(0, array.length);
+}
+function addOnce<T>(array: T[], item: T) {
+  if (array.indexOf(item) >= 0) return array.length;
+  else return array.push(item);
+}
+
+export default {
+  addOnce,
+  clear,
+  contains,
+  insert,
+  removeAt,
+  remove,
+};

+ 104 - 0
src/common/utils/CheckUtils.ts

@@ -0,0 +1,104 @@
+/**
+ * Author: imengyu 2021-10-16
+ * 
+ * 检查工具类,此类提供了一些方法用于检查用户输入字符串是否满足要求。
+ */
+
+/**
+ * 检查用户输入字符串是否是合法身份证号
+ * @param {string} str 输入字符串
+ * @returns {boolean} 返回结果
+ */
+function checkIsCardNumber(str: string) {
+  return /^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$|^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$/.test(str);
+}
+
+/**
+ * 检查用户输入字符串是否是合法中文名字
+ * @param {string} str 
+ * @returns {boolean} 返回结果
+ */
+function checkIsChineseName(str: string) {
+  return /[\u4e00-\u9fa5]{2,5}/.test(str);
+}
+
+/**
+ * 检查用户输入字符串是否是中国手机号
+ * @param {string} str 
+ * @returns {boolean} 返回结果
+ */
+function checkIsChinesePhoneNumber(str: string) {
+  return /^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$/.test(str); 
+}
+
+/**
+ * 检查用户输入字符串是否是网址
+ * @param {string} str
+ */
+function checkIsUrl(str: string) {
+  return /^(http|https):\/\/[a-zA-Z0-9]+\.[a-zA-Z0-9]+[\/=\?%\-&_~`@[\]\':+!]*([^<>\"\"])*$/.test(str); 
+}
+
+function checkIsImageFile(str: string) {
+  return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(str); 
+}
+/**
+ * 检查用户输入字符串是否为空
+ * @param {string} str 
+ * @returns {boolean} 返回结果
+ */
+function checkIsNotEmpty(str: string) {
+  return typeof str === 'string' && str != '';
+}
+
+/**
+ * 检查用户输入字符串是否为空(同样检查空格)
+ * @param {string} str 
+ * @returns {boolean} 返回结果
+ */
+function checkIsNotEmptyAndSpace(str: string) {
+  return typeof str === 'string' && str != '' && str.trim() != '';
+}
+
+/**
+ * 比较版本号
+ * @param v1 
+ * @param v2 
+ * @returns 
+ */
+function compareVersion(v1: any, v2: any) {
+  v1 = v1.split('.');
+  v2 = v2.split('.');
+  const len = Math.max(v1.length, v2.length)
+
+  while (v1.length < len) {
+    v1.push('0')
+  }
+  while (v2.length < len) {
+    v2.push('0')
+  }
+
+  for (let i = 0; i < len; i++) {
+    const num1 = parseInt(v1[i])
+    const num2 = parseInt(v2[i])
+
+    if (num1 > num2) {
+      return 1
+    } else if (num1 < num2) {
+      return -1
+    }
+  }
+
+  return 0
+}
+
+export default {
+  checkIsNotEmpty,
+  checkIsNotEmptyAndSpace,
+  checkIsCardNumber,
+  checkIsChineseName,
+	checkIsChinesePhoneNumber,
+  checkIsUrl,
+  checkIsImageFile,
+  compareVersion,
+}

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

@@ -0,0 +1,294 @@
+import StringUtils from "./StringUtils";
+
+/**
+ * 说明:通用工具类
+ */
+
+/* eslint-disable no-undefined */
+/**
+ * 空的结构
+ */
+export type TypeEmpty = Record<string, never>;
+
+/**
+  * 可保存数据
+  */
+export type TypeSaveable = TypeEmpty|string|number|null|undefined|bigint|boolean;
+/**
+  * 可保存数据
+  */
+// eslint-disable-next-line @typescript-eslint/ban-types
+export type TypeAll = TypeEmpty|unknown|object|undefined|string|bigint|number|boolean;
+
+/**
+* 任意JSON数据
+*/
+export interface KeyValue {
+   [index: string]: TypeSaveable;
+}
+
+/**
+ * 数字补0,如果数字转为字符串后不足 `n` 位,则在它前面加 `0`
+ * @param num 数字
+ * @param n 指定字符串位数
+ * @returns 字符串
+ */
+function pad(num: number|string, n: number) : string {
+  let str = num.toString();
+  let len = str.length;
+  while (len < n) {
+    str = "0" + num;
+    len++;
+  }
+  return str;
+}
+
+/**
+ * 数字保留n位小数
+ * @param num 数字
+ * @param n 保留小数位数 
+ */
+function fixedNumber(num: number, n: number) : number {
+  return n > 0 ? parseFloat(num.toFixed(n)) : Math.round(num);
+}
+
+/**
+ * 检查是否定义
+ * @param obj 
+ */
+function isDefined(obj: TypeAll) : boolean {
+  return typeof obj !== 'undefined';
+}
+/**
+ * 字符串判空
+ * @param str 字符串
+ */
+function isNullOrEmpty(str: string|null|undefined|unknown) : boolean {
+  return StringUtils.isNullOrEmpty(str as string);
+}
+/**
+ * 判断是否定义并且不为 `null`
+ * @param v 要判断的数值
+ */
+function isDefinedAndNotNull(v: TypeAll) : boolean {
+  return v != null && typeof v != 'undefined';
+}
+/**
+ * 生成随机字符串
+ * @param len 随机字符串长度
+ * @returns 随机字符串
+ */
+function randomString(len?: number) : string {
+  len = len || 32;
+  const $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
+  const maxPos = $chars.length;
+  let pwd = '';
+  for (let i = 0; i < len; i++) {
+    pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
+  }
+  return pwd;
+}
+
+/**
+ * 生成随机字符串
+ * @param len 随机字符串长度
+ * @returns 随机字符串
+ */
+function randomNumberString(len?: number) : string {
+  len = len || 32;
+  const $chars = '0123456789';
+  const maxPos = $chars.length;
+  let pwd = '';
+  for (let i = 0; i < len; i++) {
+    pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
+  }
+  return pwd;
+}
+
+/**
+ * 生成指定范围之内的随机数
+ * @param minNum 最小值
+ * @param maxNum 最大值
+ */
+function genRandom(minNum: number, maxNum: number) : number {
+  return Math.floor(Math.random()*(maxNum-minNum+1)+minNum); 
+}
+/**
+ * 生成不重复随机字符串
+ * @param randomLength 字符长度
+ */
+function genNonDuplicateID(randomLength: number) : string {
+  let idStr = Date.now().toString(36)
+  idStr += Math.random().toString(36).substr(3,randomLength)
+  return idStr
+}
+/**
+ * 生成不重复随机字符串
+ * @param randomLength 字符长度
+ */
+function genNonDuplicateIDHEX(randomLength: number) : string {
+  const idStr = genNonDuplicateID(randomLength);
+  return StringUtils.strToHexCharCode(idStr, false).substr(idStr.length - randomLength, randomLength);
+}
+
+function getDeviceUid() : string {
+  let uid = localStorage.getItem('DeviceUid');
+  if(!uid || uid === '') {
+    uid = genNonDuplicateID(16);
+    localStorage.setItem('DeviceUid', uid);
+  }
+  return uid;
+}
+
+/**
+ * 深克隆对象,数组
+ * @param obj 要克隆的对象
+ * @param deepArray 是否要深度克隆数组里的每个对象
+ */
+function clone(obj: TypeAll, deepArray = false): KeyValue|Array<KeyValue>|null {
+  let temp: KeyValue|Array<KeyValue>|null = null;
+  if (obj instanceof Array) {
+    if(deepArray) temp = (obj as KeyValue[]).map<KeyValue>((item) => clone(item, deepArray) as KeyValue)
+    else temp = obj.concat();
+  }
+  else if (typeof obj === 'object') {
+    temp = new Object() as KeyValue;
+    for (const item in obj) {
+      const val = (obj as KeyValue)[item];
+      if(val === null) temp[item] = null;
+      else (temp as KeyValue)[item] = clone(val, deepArray) as TypeSaveable;
+    }
+  }else 
+    temp = obj as KeyValue;
+  return temp;
+}
+
+/* eslint-disable */
+
+/**
+ * 深克隆对象
+ * @param obj 要克隆的对象(不能是数组,数组请使用 `clone` 方法)
+ * @param deepArray 是否要深度克隆数组属性里的每个对象
+ */
+function cloneObject<T>(obj: T, deepArray = false): T {
+  let temp = {};
+  for (const item in obj) {
+    const val = obj[item];
+    if(val === null) (temp as KeyValue)[item] = null;
+    else if (typeof val === 'object') (temp as KeyValue)[item] = clone(val, deepArray) as TypeSaveable;
+    else (temp as KeyValue)[item] = val as any;
+  }
+  return temp as T;
+}
+
+/**
+ * 合并两个对象(仅合并一级属性)
+ * @param obj1 
+ * @param obj2 
+ */
+function mergeObject(obj1 : Record<string, unknown>, obj2 : Record<string, unknown>) : Record<string, unknown> { 
+  for(const k in obj2)
+    obj1[k] = obj2[k];
+  return obj1;
+}
+/**
+ * 合并多个对象(仅合并一级属性)
+ * @param objs
+ */
+function mergeObjects(...objs : Record<string, unknown>[]) : Record<string, unknown> { 
+  if(objs.length < 1) 
+    return {};
+  if(objs.length < 2) 
+    return objs[0];
+  const o = objs[0];
+  for(let i = objs.length - 1; i > 0; i--)
+    mergeObject(o, objs[i]);
+  return o;
+}
+
+/* eslint-enable */
+
+export default {
+  pad,
+  isDefined,
+  isNullOrEmpty,
+  isDefinedAndNotNull,
+  /**
+   * 如果字符串为空,则返回undefined,否则返回字符串
+   * @param val 
+   */
+  emptyToUndefined(val: string) : undefined|string {
+    // eslint-disable-next-line no-undefined
+    return isNullOrEmpty(val) ? undefined : val;
+  },
+  /**
+   * 如果数字为null或小于等于0,则返回undefined,否则返回数字
+   * @param val 
+   */
+  zeroOrNullToUndefined(val?: number|null) : undefined|number {
+    // eslint-disable-next-line no-undefined
+    return (!val || val == 0) ? undefined : val;
+  },
+  clone,
+  /**
+   * 将源对象每个属性都复制到目标对象(不管目标对象有没有对应属性)
+   * @param setObj 目标对象
+   * @param sourceObj 源对象
+   */
+  cloneValue(setObj: { [index: string]: TypeAll}, sourceObj: { [index:  string]: TypeAll}) : void {
+    if(!setObj || !sourceObj) return;
+    Object.keys(setObj).forEach(function(key){
+      if(typeof sourceObj[key] != 'undefined') {
+        setObj[key] = sourceObj[key];
+      }
+    });
+  },
+  cloneObject,
+  /**
+   * 检查数组中是否全部是空字符串或null
+   * @param arr 要检查的数组
+   * @returns 
+   */
+  isArrayAllNullOrEmpty(arr : Array<unknown>) : boolean {
+    if(!arr)
+      return true;
+    for (let i = arr.length - 1; i >= 0; i--) {
+      if(arr[i] !== null && arr[i] !== "") {
+        return false;
+      }
+    }
+    return true;
+  },
+  /**
+   * 检查数组中是否有空值或字符串
+   * @param arr 要检查的数组
+   * @returns 
+   */
+  isArrayContainsNullOrEmpty(arr : Array<unknown>) : boolean {
+    if(!arr)
+      return false;
+    for (let i = arr.length - 1; i >= 0; i--) {
+      if(isNullOrEmpty(arr[i])) {
+        return true;
+      }
+    }
+    return false;
+  },
+  randomString,
+  randomNumberString,
+  getDeviceUid,
+  genRandom,
+  genNonDuplicateID,
+  genNonDuplicateIDHEX,
+  mergeObject,
+  mergeObjects,
+  fixedNumber,
+  /**
+   * 等待延时
+   */
+  waitTimeOut(timeOut: number) : Promise<void> {
+    return new Promise<void>((resolve) => {
+      setTimeout(() => resolve(), timeOut);
+    });
+  },
+}

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

@@ -0,0 +1,460 @@
+import StringUtils from "./StringUtils";
+
+/**
+ * 说明:日期相关工具类
+ */
+
+/**
+ * 获取年份的中文表达形式
+ * @param {Number} year
+ */
+function getChineseYear(year: number) {
+  let arr1 = new Array('零', '一', '二', '三', '四', '五', '六', '七', '八', '九');
+  if (!year || isNaN(year)) {
+    return "零";
+  }
+  let english = year.toString().split("")
+  let result = "";
+  for (let i = 0; i < english.length; i++) {
+    let des_i = english.length - 1 - i;
+    result = result;
+    let arr1_index = english[des_i];
+    result = arr1[parseInt(arr1_index)] + result;
+  }
+  result = result.replace(/零(千|百|十)/g, '零').replace(/十零/g, '十');
+  result = result.replace(/零+/g, '零');
+  result = result.replace(/零亿/g, '亿').replace(/零万/g, '万');
+  result = result.replace(/亿万/g, '亿');
+  result = result.replace(/零+$/, '')
+  result = result.replace(/^一十/g, '十');
+  return result;
+}
+
+/**
+ * 获取某年的某月共多少天
+ * @param {number} year 
+ * @param {number} month
+ * @returns {number}
+ */
+function getMonthDays(year: number, month: number) {
+  switch (month + 1) {
+    case 1:
+    case 3:
+    case 5:
+    case 7:
+    case 8:
+    case 10:
+    case 12:
+      return 31;
+    case 4:
+    case 6:
+    case 9:
+    case 11:
+      return 30;
+    case 2:
+      return (year % 4 == 0 && year % 100 !== 0 || year % 400 == 0) ? 29 : 28;
+  }
+}
+
+/**
+ * 获取某一天(年月日)是星期几
+ * @param {number} year 
+ * @param {number} month 
+ * @param {number} date 
+ * @returns {number}
+ */
+function getDayWeekday(year: number, month: number, date: number) {
+  const dateNow = new Date(year, month - 1, date)
+  // 0-6, 0 is sunday
+  return dateNow.getDay()
+}
+
+/**
+ * 获取某一天所在周的日期
+ * @param {Date} date 
+ * @returns {{
+ * 	theDay: boolean,
+ * 	date: Date,
+ * 	today: boolean
+ * }[]}
+ */
+function getWeekDatesForDate(date: Date) {
+  const timeStamp = date.getTime()
+  const currentDay = date.getDay()
+  let dates = []
+  for (var i = 0; i < 7; i++) {
+    const _i = i - (currentDay + 6) % 7
+    const _isToday = _i === 0
+    const _date = new Date(timeStamp + 24 * 60 * 60 * 1000 * _i)
+    dates.push({
+      theDay: _isToday,  // 只是指当前查询的时间,在那一周的哪一天. 并不是指查询的这一天是否是今天
+      date: _date,
+      today: isToday(_date) // 是否是今天
+    })
+  }
+  return dates
+}
+
+/**
+ * 获取当前周的日期
+ * @returns 
+ */
+function getWeekDates() {
+  const new_Date = new Date()
+  return getWeekDatesForDate(new_Date)
+}
+
+/**
+ * 获取某一天所在周的日期
+ * @param {number} year 
+ * @param {number} month 
+ * @param {number} date 
+ * @returns 
+ */
+function getWeekDatesForYMD(year: number, month: number, date: number) {
+  const dateNow = new Date(year, month - 1, date)
+  return getWeekDatesForDate(dateNow)
+}
+
+/**
+ * 获取 开始日期 之后 第n周 的 日期
+ * @param {string} start 
+ * @param {number} diff 
+ * @returns 
+ */
+function getDatesAfterWeeks(start: string, diff: number) {
+  const _arr = start.replace(/-/g, '/').split('/')
+  const y = _arr[0]
+  const m = _arr[1]
+  const d = _arr[2]
+  const _start = new Date(parseInt(y), parseInt(m) - 1, parseInt(d))
+  const timeStamp = _start.getTime()
+  const date = new Date(timeStamp + diff * 7 * 24 * 60 * 60 * 1000)
+  return getWeekDatesForDate(date)
+}
+
+/**
+ * 获取当前是开始日期之后的第几周,如果大于total,则表示已经结束过时,如果小于0,则表示start还没有到来。
+ * 
+ * 我们这里需要注意的是,比如 开始日期是星期四(没有给出星期一的日期),下周的星期一应该是第2周,而不是还在第一周。(我们这里不仅仅只是差值周数计算,还需要受实际周的限制)
+ * 不能只是计算差值,如果给出的开始日期是星期一的,差值计算正确,如果不是,则需要考虑开始日期的星期
+ * @param {string} start 
+ * @param {number} total 
+ * @returns 
+ */
+function getCurrentWeekNumber(start: string, total: number) {
+  const _arr = start.replace(/-/g, '/').split('/')
+  const y = _arr[0]
+  const m = _arr[1]
+  const d = _arr[2]
+  const _start = new Date(parseInt(y), parseInt(m) - 1, parseInt(d))
+  let _timestamp = _start.getTime()
+  let day = _start.getDay() // 星期几
+  day = (day + 6) % 7  // 将星期几转化为距离星期一多少天
+  // 我们将开始时间修正到那一周的星期一
+  // 这里我们将星期天作为最后一天,星期一作为第一天
+  _timestamp = _timestamp - day * (24 * 60 * 60 * 1000)
+  // current
+  const dt = new Date()
+  const _y = dt.getFullYear()
+  const _m = dt.getMonth()
+  const _d = dt.getDate()
+  const today = new Date(_y, _m, _d)
+  const todayStamp = today.getTime()
+  const diff = todayStamp - _timestamp
+  if (diff < 0) {
+    // start还没有开始,未来返回-1
+    return -1
+  }
+  const weekStamp = 7 * 24 * 60 * 60 * 1000
+  let weekDiff = Math.floor(diff / weekStamp)
+  const more = diff % weekStamp
+  // if (more >= 24 * 60 * 60 * 1000) {
+  // weekDiff += 1
+  // }
+  // wo always need to plus 1 for weekDiff
+  const weekNumber = weekDiff + 1
+  if (weekNumber > total) {
+    // 已经过期
+    return -2
+  }
+  return weekNumber
+}
+
+/**
+ * 查询某日期是否是今天
+ * @param date 
+ * @returns 
+ */
+function isToday(date: Date) {
+  const dt = new Date();
+  const y = dt.getFullYear(); // 年
+  const _y = date.getFullYear();
+  const m = dt.getMonth(); // 月份从0开始的
+  const _m = date.getMonth();
+  const d = dt.getDate(); //日
+  const _d = date.getDate();
+  return (_y + '-' + _m + '-' + _d) === (y + '-' + m + '-' + d);
+}
+
+/**
+ * 获取 某年某月某日 是在 那一月 的第几周
+ * @param year 
+ * @param month 
+ * @param date 
+ * @returns {number}
+ */
+function getMonthWeek(year: number, month: number, date: number) {
+  /*  
+      month = 6 - w = 当前周的还有几天过完(不算今天)  
+      year + month 的和在除以7 就是当天是当前月份的第几周  
+  */
+  let dateNow = new Date(year, month - 1, date);
+  let w = dateNow.getDay(); //星期数
+  let d = dateNow.getDate();
+  return Math.ceil((d + 6 - w) / 7);
+}
+
+/**
+ * 获取 某年某月某日 是在 那一年 的第几周
+ * @param year 
+ * @param month 
+ * @param date 
+ * @returns 
+ */
+function getYearWeek(year: number, month: number, date: number) {
+  /*  
+      dateNow是当前日期 
+      dateFirst是当年第一天  
+      dataNumber是当前日期是今年第多少天  
+      用dataNumber + 当前年的第一天的周差距的和在除以7就是本年第几周
+  */
+  let dateNow = new Date(year, month - 1, date);
+  let dateFirst = new Date(year, 0, 1);
+  let dataNumber = Math.round((dateNow.valueOf() - dateFirst.valueOf()) / 86400000);
+  return Math.ceil((dataNumber + ((dateFirst.getDay() + 1) - 1)) / 7);
+}
+
+/**
+ * 获取今天是星期几
+ * @returns 
+ */
+function getCurrentWeekday() {
+  const myDate = new Date()
+  let days : number|string = myDate.getDay()
+  const number = days
+  switch (days) {
+    case 1:
+      days = '星期一'
+      break
+    case 2:
+      days = '星期二'
+      break
+    case 3:
+      days = '星期三'
+      break
+    case 4:
+      days = '星期四'
+      break
+    case 5:
+      days = '星期五'
+      break
+    case 6:
+      days = '星期六'
+      break
+    case 0:
+      days = '星期日'
+      break
+  }
+  return {
+    number: number,
+    weekday: days
+  }
+}
+
+/**
+ * 获取今天的 年月日
+ * @returns 
+ */
+function getCurrentYearMonthDay() {
+  const date = new Date()
+  const year = date.getFullYear()
+  const month = date.getMonth() + 1
+  const day = date.getDate()
+  return {
+    year: year,
+    month: month,
+    day: day
+  }
+}
+
+/**
+ * 检查是不是在指定时间范围内
+ * 
+ * 只能比较同一天之内的时间,不跨天比较
+ * 24小时制,最大时间23:59
+ * @param start 
+ * @param end 
+ * @returns 
+ */
+function inTimePeriod(start: string, end: string) {
+  const now = new Date()
+  const nowH = now.getHours() * 1
+  const nowM = now.getMinutes() * 1
+  const _now = nowH * 60 + nowM
+  const startArr = start.split(":")
+  const startH = parseInt(startArr[0]) * 1
+  const startM = parseInt(startArr[1]) * 1
+  const _start = startH * 60 + startM
+  const endArr = end.split(":")
+  const endH = parseInt(endArr[0]) * 1
+  const endM = parseInt(endArr[1]) * 1
+  const _end = endH * 60 + endM
+  if (_now > _end) {
+    // 已经过时
+    return -1
+  }
+  if (_now >= _start && _now <= _end) {
+    // 正在进行
+    return 0
+  }
+  // 尚未开始
+  return 1
+}
+
+/**
+ * 获取星期几的中文表达字符串
+ * @param week (0-6)
+ */
+function getWeekdayStr(week: number) {
+
+  switch (week) {
+    case 1:
+      return '星期一'
+    case 2:
+      return '星期二'
+    case 3:
+      return '星期三'
+    case 4:
+      return '星期四'
+    case 5:
+      return '星期五'
+    case 6:
+      return '星期六'
+    case 0:
+      return '星期日'
+  }
+  return '?' + week
+}
+
+/**
+ * 计算两个日期之间的天数差距
+ * @param s1
+ * @param s2
+ */
+function getDayDiff(s1: Date, s2: Date) {
+  var days = s2.getTime() - s1.getTime();
+  var time = Math.floor(days / (1000 * 60 * 60 * 24));
+  return time;
+}
+
+/**
+ * 计算天数与今天的差距,将返回: x天前、前天 、昨天、今天、明天、后天、x天后
+ * @param y 年
+ * @param m 月,与 Date.getMonth 一样需要减一
+ * @param d 日
+ */
+function getDayGap(y: number, m: number, d: number) {
+  let now = new Date();
+  let diffDay = getDayDiff(new Date(), new Date(y, m, d)) + 1;
+  if (diffDay == 0) return "今天"
+  else if (diffDay == 1) return "明天"
+  else if (diffDay == 2) return "后天"
+  else if (diffDay == -1) return "昨天"
+  else if (diffDay == -2) return "前天"
+  else if (diffDay > 0) {
+    if (diffDay > 365) return `${Math.floor(diffDay / 365)}年${diffDay % 365}天后`;
+    else return `${diffDay}天后`;
+  }
+  else if (diffDay < 0) {
+    if (diffDay < -365) return `${Math.floor(-diffDay / 365)}年${-diffDay % 365}天前`;
+    else return `${-diffDay}天前`;
+  }
+}
+
+const DateUtils = {
+  FormatStrings: {
+    YearChanese: "YYYY年MM月dd日",
+  },
+  /**
+   * 日期加上指定天数
+   * @param date 日期
+   * @param days 添加的天数
+   */
+  dateAddDays(date: Date, days = 1) {
+    return new Date(date.getTime() + days * 86400000);
+  },
+  /**
+   * 日期加上指定小时
+   * @param date 日期
+   * @param hours 添加的小时数
+   */
+  dateAddHours(date: Date, hours = 1) {
+    return new Date(date.getTime() + hours * 1000 * 60 * 60);
+  },
+  /**
+   * 格式化日期
+   * @param date 日期
+   * @param formatStr 格式
+   * @returns 
+   */
+  formatDate(date: Date, formatStr = "YYYY-MM-dd HH:mm:ss"): string {
+    if (!date) 
+      return "";
+    if (!(date instanceof Date) )
+      return "!Date";
+    let str = formatStr ? formatStr : "YYYY-MM-dd HH:mm:ss";
+    //let Week = ['日','一','二','三','四','五','六'];
+    str = str.replace(/yyyy|YYYY/, date.getFullYear().toString());
+    str = str.replace(/MM/, StringUtils.pad(date.getMonth() + 1, 2));
+    str = str.replace(/M/, (date.getMonth() + 1).toString());
+    str = str.replace(/dd|DD/, StringUtils.pad(date.getDate(), 2));
+    str = str.replace(/d/, date.getDate().toString());
+    str = str.replace(/HH/, StringUtils.pad(date.getHours(), 2));
+    str = str.replace(/hh/, StringUtils.pad(date.getHours() > 12 ? date.getHours() - 12 : date.getHours(), 2));
+    str = str.replace(/mm/, StringUtils.pad(date.getMinutes(), 2));
+    str = str.replace(/ii/, StringUtils.pad(date.getMinutes(), 2));
+    str = str.replace(/ss/, StringUtils.pad(date.getSeconds(), 2));
+    return str;
+  },
+  /**
+   * 转换字符串日期为 Date
+   * @param dateString 日期字符串
+   */
+  parseDate(dateString: string | Date | number, format?: string) {
+    if (typeof dateString === 'object' && dateString instanceof Date)
+      return dateString;
+    if (typeof dateString === 'number')
+      return new Date(dateString);
+    return new Date(dateString.replace(/-/g, '/'));
+  },
+  getChineseYear,
+  getMonthDays,
+  getDayWeekday,
+  getWeekDatesForDate,
+  getWeekDates,
+  getWeekDatesForYMD,
+  getDatesAfterWeeks,
+  getCurrentWeekNumber,
+  isToday,
+  getMonthWeek,
+  getYearWeek,
+  getCurrentWeekday,
+  getCurrentYearMonthDay,
+  inTimePeriod,
+  getWeekdayStr,
+  getDayDiff,
+  getDayGap,
+}
+
+export default DateUtils;

+ 214 - 0
src/common/utils/StringUtils.ts

@@ -0,0 +1,214 @@
+/**
+ * 说明:字符串工具类
+ */
+
+/**
+ * 得到字符串含有某个字符的个数  
+ * @param str 字符串
+ * @param char 某个字符
+ * @returns 个数  
+ */
+function getCharCount(str: string, char: string) : number {
+  const regex = new RegExp(char, 'g'); // 使用g表示整个字符串都要匹配
+  const result = str.match(regex);          //match方法可在字符串内检索指定的值,或找到一个或多个正则表达式的匹配。
+  const count=!result ? 0 : result.length;
+  return count;
+}
+/**
+* 判断字符串是否是 Base64 编码
+* @param {String} str 
+*/
+function isBase64(str: string) : boolean {
+  return /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/.test(str);
+}
+/**
+ * 检测字符串是否是一串数字
+ * @param {String} val 
+ */
+function isNumber(val: string) : boolean {
+  const regPos = /^\d+(\.\d+)?$/; //非负浮点数
+  const regNeg = /^(-(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*)))$/; //负浮点数
+  if (regPos.test(val) || regNeg.test(val)) {
+    return true;
+  } else {
+    return false;
+  }
+}
+/**
+ * 检查字符串是否是中国的11位手机号
+ * @param str 字符串
+ */
+function isChinaPoneNumber(str: string) : boolean {
+  if (!/^[1][3,4,5,7,8][0-9]{9}$/.test(str)) {
+      return false;
+  } else {
+      return true;
+  }
+}
+/**
+ * 检查字符串是否是邮箱
+ * @param str 字符串
+ */
+function isEmail(str: string) : boolean {
+  const re = /^\w+((-\w+)|(\.\w+))*@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/;
+  if (re.test(str) !== true) {
+    return false;
+  }else{
+    return true;
+  }
+}
+/**
+ * 将字符串转为16进制字符串
+ * @param str 字符串
+ */
+function strToHexCharCode(str: string, with0x = true): string {
+  if(str === "")
+    return "";
+  const hexCharCode = [];
+  if(with0x) hexCharCode.push("0x"); 
+  for(let i = 0; i < str.length; i++) {
+    hexCharCode.push((str.charCodeAt(i)).toString(16));
+  }
+  return hexCharCode.join("");
+}
+/**
+ * 数字补0
+ * @param num 数字
+ * @param n 如果数字不足n位,则自动补0
+ */
+function pad(num: number, n: number) : string {
+  let strNum = num.toString();
+  let len = strNum.length;
+  while (len < n) {
+    strNum = "0" + strNum;
+    len++;
+  }
+  return strNum;
+}
+/**
+ * 按千位逗号分割
+ * @param s 需要格式化的数值.
+ * @param type 判断格式化后是否需要小数位.
+ */
+function formatNumberWithComma(s: string, addComma: boolean) : string {
+  if (/[^0-9]/.test(s))
+      return "0";
+  if (s === null || s === "")
+      return "0";
+  s = s.toString().replace(/^(\d*)$/, "$1.");
+  s = (s + "00").replace(/(\d*\.\d\d)\d*/, "$1");
+  s = s.replace(".", ",");
+
+  const re = /(\d)(\d{3},)/;
+  while (re.test(s))
+      s = s.replace(re, "$1,$2");
+  s = s.replace(/,(\d\d)$/, ".$1");
+  if (!addComma) { // 不带小数位(默认是有小数位)
+      const a = s.split(".");
+      if (a[1] === "00") {
+          s = a[0];
+      }
+  }
+  return s;
+}
+
+/**
+ * 格式化显示大数字
+ * @param Number 数字
+ */
+function formatHugeNumber(Number: number) : string {
+  if (Number >= 1000000000000)
+    return Number.toExponential(2).replace(/e\+/g, 'x10^');
+  if (Number >= 1000000000)
+    return (Number / 1000000000).toFixed(2) + 'B';
+  if (Number >= 1000000)
+    return (Number / 1000000).toFixed(2) + 'M';
+  return Number.toFixed(2);
+}
+
+const StringUtils = {
+  formatDate(date: Date, formatStr = "YYYY-MM-dd HH:mm:ss") : string {
+    let str = formatStr ? formatStr : "YYYY-MM-dd HH:mm:ss";
+    //let Week = ['日','一','二','三','四','五','六'];
+    str = str.replace(/yyyy|YYYY/, date.getFullYear().toString());
+    str = str.replace(/MM/, pad(date.getMonth() + 1, 2));
+    str = str.replace(/M/, (date.getMonth() + 1).toString());
+    str = str.replace(/dd|DD/, pad(date.getDate(), 2));
+    str = str.replace(/d/, date.getDate().toString());
+    str = str.replace(/HH/, pad(date.getHours(), 2));
+    str = str.replace(
+      /hh/,
+      pad(date.getHours() > 12 ? date.getHours() - 12 : date.getHours(), 2)
+    );
+    str = str.replace(/mm/, pad(date.getMinutes(), 2));
+    str = str.replace(/ii/, pad(date.getMinutes(), 2));
+    str = str.replace(/ss/, pad(date.getSeconds(), 2));
+    return str;
+  },
+  /**
+   * 字符串判空
+   * @param str 字符串
+   */
+  isNullOrEmpty(str: string | undefined | null | false) : boolean {
+    return !str || typeof str === 'undefined' || str === ''
+  },
+  isBase64,
+  isNumber,
+  isChinaPoneNumber,
+  isEmail,
+  strToHexCharCode,
+  pad,
+  formatHugeNumber,
+  formatNumberWithComma,
+  getFileName(path: string) : string {
+    let pos = path.lastIndexOf('/');
+    if(pos < 0) pos = path.lastIndexOf('\\');
+    return path.substring(pos + 1);  
+  },
+  getFileExt(path: string) : string {
+    return path.substring(path.lastIndexOf('.') + 1);
+  },
+  getCharCount,
+  getFileSizeStringAuto(filesize: number) : string {
+    let sizeStr = '';
+    if(filesize >= 1073741824){
+      filesize = Math.round(filesize/1073741824*100)/100;
+      sizeStr = filesize + "GB";
+    }else if(filesize >= 1048576) {
+      filesize = Math.round(filesize/1048576*100)/100;
+      sizeStr = filesize + "MB";
+    }else{
+      filesize = Math.round(filesize/1024*100)/100;
+      sizeStr = filesize + "KB";
+    }
+    return sizeStr;
+  },
+  /**
+   * 移除URL的地址部分,只保留路径
+   * @param str 原URL
+   * @returns 
+   */
+  removeUrlOrigin(str: string) : string {
+    if(str.startsWith('http://') || str.startsWith('https://') || str.startsWith('fts://') || str.startsWith('ftps://')) {
+      str = str.substr(str.indexOf('://') + 3);
+      str = str.substr(str.indexOf('/') + 1);
+    } 
+    return str;
+  },
+  /**
+   * 将手机号转换成 xxx******xx
+   * @param str 手机号
+   */
+  convertPhoneToSecret6(str: string): string{
+    return str.replace(/^(\d{3})(\d*)(\d{2}$)/, "$1******$3");
+  },
+  /**
+   * 将手机号转换成 尾号xxxx用户
+   * @param str 手机号
+   */
+  convertPhoneToUserName(str: string): string{
+    return '尾号' + str.substring(str.length - 4) + '用户';
+  },
+}
+
+export default StringUtils;

+ 27 - 2
src/components/NavBar.vue

@@ -1,6 +1,7 @@
 <template>
   <nav 
     :class="[
+      headerBlur ? 'need-blur' : '',
       scrollValue > 200 ? 'nav-scrolled' : 'nav-not-scrolled',
     ]"
   >
@@ -29,9 +30,14 @@
 </template>
 
 <script setup lang="ts">
-import { onBeforeUnmount, onMounted, ref } from 'vue';
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
+import { useRoute } from 'vue-router';
 
+const route = useRoute();
 const scrollValue = ref(0);
+const headerBlur = computed(() => {
+  return route.name != 'home';
+});
 
 function onScroll() {
   scrollValue.value = window.scrollY;
@@ -71,6 +77,10 @@ nav {
   &.nav-scrolled {
     background-color: $primary-color;
   }
+  &.need-blur.nav-not-scrolled {
+    background-color: transparent;
+    backdrop-filter: blur(10px);
+  }
 
   .group {
     display: flex;
@@ -132,7 +142,22 @@ nav {
   }
 }
 
-@media (min-width: 1024px) {
+@media (max-width: 1260px) {
+  nav {
+    
+    .group {
+      gap: 0.5rem; 
+
+      a {
+        width: 80px;
+      }
+    }
+    .headerlogos > div {
+      display: none;
+    }
+  }
+}
+@media (max-width: 1024px) {
  
 }
 </style>

+ 57 - 0
src/components/container/SimpleScrollView.vue

@@ -0,0 +1,57 @@
+<template>
+  <div 
+    :class="[
+      'nana-scroll-view',
+      scrollX ? 'x' : '',
+      scrollY ? 'y' : ''
+    ]"
+  >
+    <slot />
+  </div>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 组件说明:可滚动的容器。
+ */
+const props = defineProps({	
+  scrollX: {
+    type: Boolean,
+    default: false
+  },
+  scrollY: {
+    type: Boolean,
+    default: false 
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.nana-scroll-view {
+  overflow: hidden;
+  
+  &::-webkit-scrollbar {
+    width: 5px;
+    height: 5px;
+  }
+  &::-webkit-scrollbar-thumb {
+    background: #d6d6d6;
+    opacity: .7;
+    border-radius: 3px;
+
+    &:hover {
+      background: #707070;
+    }
+  }
+  &::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  &.x {
+    overflow-x: scroll; 
+  }
+  &.y {
+    overflow-y: scroll; 
+  }
+}
+</style>

+ 65 - 0
src/components/controls/Check.vue

@@ -0,0 +1,65 @@
+<template>
+  <div class="nana-checkbox" @click="() => $emit('update:modelValue', modelValue ? false : true)">
+    <div class="checker">
+      <CheckIcon v-if="modelValue" />
+    </div>
+    <span>
+      <slot />
+    </span>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import CheckIcon from './CheckIcon.vue';
+
+defineProps({
+  modelValue: {
+    type: [Number,Object, Boolean],
+    default: null
+  },
+})
+
+defineEmits([
+  'update:modelValue'
+])
+</script>
+
+<style lang="scss">
+.nana-checkbox {
+  vertical-align: middle;
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+  position: relative;
+
+  .checker {
+    position: relative;
+    border: 1px solid #c5c5c5;
+    width: 18px;
+    height: 18px;
+    border-radius: 6px;
+    overflow: hidden;
+    margin-right: 6px;
+
+    &:hover {
+      background-color:#ececec;
+    }
+    &:active {
+      background-color:#eaeaea;
+    }
+
+    &:active, &:hover {
+      border-color: #0092e7;
+    }
+
+    svg {
+      position: absolute;
+      left: 1px;
+      top: 1px;
+      width: 16px;
+      height: 16px;
+    }
+  }
+
+}
+</style>

+ 5 - 0
src/components/controls/CheckIcon.vue

@@ -0,0 +1,5 @@
+<template>
+  <svg viewBox="0 0 1024 1024" width="30" height="30">
+    <path d="M378.88 844.8 25.6 491.52 97.28 417.28 378.88 698.88 926.72 153.6 998.4 225.28Z" fill="currentColor" />
+  </svg>
+</template>

+ 139 - 0
src/components/controls/Dropdown.vue

@@ -0,0 +1,139 @@
+<template>
+  <!-- 下拉选项列表 -->
+  <div class="nana-dropdown-wrapper">
+    <div class="nana-dropdown" @click="isDropdownOpen=!isDropdownOpen">
+      <text>{{ selectedLabel }}</text>
+      <DropDownIcon :class="['arrow',isDropdownOpen?'open':'']" />
+    </div>
+    <div v-if="isDropdownOpen" class="nana-dropdown-options">
+      <SimpleScrollView :scroll-y="true">
+        <div
+          v-for="(option, index) in options"
+          :key="index"
+          :class="[
+            'option',
+            selectedValue === option[valueKey] ? 'selected' : '',
+          ]"
+          @click="selectOption(option)"
+        >
+          <text>{{ option[labelKey] }}</text>
+        </div>
+      </SimpleScrollView>
+    </div>
+  </div>
+  
+</template>
+
+<script setup lang="ts">
+import { computed, ref, type PropType } from 'vue';
+import DropDownIcon from './DropdownIcon.vue';
+import SimpleScrollView from '../container/SimpleScrollView.vue';
+
+const props = defineProps({
+  options: {
+    type: Array as PropType<any[]>,
+    default: () => [],
+  },
+  labelKey: {
+    type: String,
+    default: 'title',
+  },
+  valueKey: {
+    type: String,
+    default: 'value',
+  },
+  placeholder: {
+    type: String,
+    default: '请选择',
+  },
+  selectedValue: {
+    type: null
+  },
+})
+
+const emit = defineEmits([ 'update:selectedValue' ])
+
+const selectedLabel = computed(() => {
+  const selectedOption = props.options.find(option => option[props.valueKey] === props.selectedValue);
+  return selectedOption ? selectedOption[props.labelKey] : props.placeholder;
+});
+const isDropdownOpen = ref(false);
+
+function selectOption(option: string) {
+  isDropdownOpen.value = false;
+  emit('update:selectedValue', option);
+}
+
+</script>
+
+<style lang="scss">
+@use "@/assets/scss/colors.scss" as *;;
+
+.nana-dropdown-wrapper {
+  position: relative;
+
+  &.dark {
+    .nana-dropdown {
+      background-color: $box-dark-trans-color;
+      border: 1px solid $border-dark-color; 
+    }
+  }
+}
+.nana-dropdown {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 15px;
+  background-color: $box-inset-color;
+  border: 1px solid $primary-color;
+  user-select: none;
+  cursor: pointer;
+
+  .arrow {
+    width: 15px;
+    height: 15px;
+    transition: transform 0.3s;  
+    margin-left: 10px;
+
+    &.open {
+      transform: rotate(180deg);
+    }
+  }
+  text {
+    font-size: 17.5px;
+    color: var(--nana-text-1);
+  }
+}
+.nana-dropdown-options {
+  position: absolute;
+  top: 100%;
+  left: 0;
+  width: 300px;
+  background-color: transparent;
+  border: 1px solid $primary-color;
+  z-index: 20;
+
+  scroll-view {
+    max-height: 50%;
+  }
+
+  .option {
+    padding: 8px 15px;
+    background-color: $box-hover-color;
+    user-select: none;
+    cursor: pointer;
+
+    &:hover, .selected {
+      background-color: $box-hover-color;
+    }
+    text {
+      font-size: rpx(24);
+      color: $text-color;
+    }
+  }
+
+
+}
+</style>

+ 5 - 0
src/components/controls/DropdownIcon.vue

@@ -0,0 +1,5 @@
+<template>
+  <svg class="icon" viewBox="0 0 1819 1024" width="50" height="50">
+    <path d="M1788.061538 37.134066h-5.626373a112.527473 112.527473 0 0 0-154.162638 0L909.221978 750.558242 191.296703 31.507692a112.527473 112.527473 0 0 0-154.162637 0 112.527473 112.527473 0 0 0 0 154.162638L832.703297 992.492308a112.527473 112.527473 0 0 0 154.162637 0l801.195604-801.195605a112.527473 112.527473 0 0 0 0-154.162637z" fill="currentColor" ></path>
+  </svg>
+</template>

+ 123 - 0
src/components/controls/Pagination.vue

@@ -0,0 +1,123 @@
+<template>
+  <!-- 分页组件 -->
+  <div class="pagination">
+    <!-- 上一页按钮 -->
+    <div
+      :class="[
+        'page-button',
+        currentPage > 1 ? 'enable' : ''
+      ]"
+      @click="goToPage(currentPage - 1)"
+    >
+      &lt;
+    </div>
+
+    <!-- 页码按钮 -->
+    <div
+      v-for="page in visiblePages"
+      :key="page"
+      class="page-button enable"
+      :class="{ active: page === currentPage }"
+      @click="goToPage(page)"
+    >
+      {{ page }}
+    </div>
+
+    <!-- 下一页按钮 -->
+    <div
+      :class="[
+        'page-button',
+        currentPage < totalPages ? 'enable' : ''
+      ]"
+      @click="goToPage(currentPage + 1)"
+    >
+      &gt;
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from "vue";
+
+const props = defineProps({
+  /**
+   * 当前页码
+   */
+  currentPage: {
+    type: Number,
+    required: true,
+  },
+  /**
+   * 总页数
+   */
+  totalPages: {
+    type: Number,
+    required: true,
+  },
+});
+
+const emit = defineEmits(["update:currentPage"]);
+
+// 计算显示的页码范围
+const visiblePages = computed(() => {
+  const pages = [];
+  const maxPagesToShow = 10; // 最多显示 10 个页码
+  const halfMax = Math.floor(maxPagesToShow / 2);
+
+  // 计算起始页码
+  let startPage = Math.max(1, props.currentPage - halfMax);
+  let endPage = startPage + maxPagesToShow - 1;
+
+  // 如果超出总页数,调整起始页码
+  if (endPage > props.totalPages) {
+    endPage = props.totalPages;
+    startPage = Math.max(1, endPage - maxPagesToShow + 1);
+  }
+
+  for (let i = startPage; i <= endPage; i++) {
+    pages.push(i);
+  }
+
+  return pages;
+});
+
+// 跳转到指定页码
+const goToPage = (page: number) => {
+  if (page >= 1 && page <= props.totalPages) {
+    emit("update:currentPage", page);
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+@use "@/assets/scss/colors" as *;
+
+.pagination {
+  display: flex;
+  justify-content: center;
+  gap: 10px;
+  margin-top: 20px;
+}
+.page-button {
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: $box-inset-color;
+  color: $text-color;
+  cursor: pointer;
+  opacity: 0.4;
+
+  &:hover {
+    background-color: $box-hover-color;
+  }
+  &.enable {
+    opacity: 1;
+  }
+}
+.page-button.active {
+  background-color: $primary-color;
+  color: $text-color-light;
+}
+</style>

+ 110 - 0
src/components/controls/SimpleInput.vue

@@ -0,0 +1,110 @@
+<template>
+  <div 
+    :class="[
+      'nana-simple-input',
+      focusState ? 'focus' : '',
+    ]"
+  >
+    <div class="prefix">
+      <slot name="prefix"/>
+    </div>
+    <input 
+      :placeholder="placeholder"
+      v-bind="$attrs"
+      class="nana-input-text"
+      :value="modelValue"
+      @input="(e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value)"
+      @focus="handleFocus"
+      @blur="handleBlur"
+    />
+    <div class="suffix">
+      <slot name="suffix"/>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+defineProps({
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  placeholder: {
+    type: String,
+    default: '',
+  },
+})
+
+const focusState = ref(false)
+const emit = defineEmits([ 'update:modelValue', 'focus', 'blur' ])
+
+function handleFocus() {
+  focusState.value = true;
+  emit('focus');
+}
+function handleBlur() {
+  focusState.value = false;
+  emit('blur');
+}
+
+</script>
+
+<style lang="scss">
+@use "@/assets/scss/colors.scss" as *;
+
+.nana-simple-input {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 15px;
+  background-color: $box-color;
+  border: 1px solid $border-default-color;
+
+  &.focus {
+    border-color: $border-active-color; 
+  }
+
+  input {
+    font-size: 1rem;
+    color: $text-color;
+    border: none;
+    outline: none;
+    background-color: transparent;
+  }
+
+  .prefix {
+    margin-right: 6px;
+
+    img {
+      width: 24px;
+      height: 24px;
+      vertical-align: middle;
+    }
+  }
+  .suffix {
+    margin-left: 10px;
+    margin-right: 10px;
+  }
+
+  
+  &.dark {
+    background-color: $box-dark-trans-color;
+    border: 1px solid $border-dark-color;
+
+    .nana-input-text {
+      color: $text-color-light;
+
+      &::placeholder {
+        color: $text-second-color-light;
+      }
+    }
+  }
+}
+.nana-input-text {
+  color: $text-color;
+  font-size: 26px;
+}
+</style>

+ 103 - 0
src/composeable/PageAction.ts

@@ -0,0 +1,103 @@
+import { EventBus } from "@/common/EventBus";
+import { onBeforeUnmount, onMounted, type App } from "vue";
+import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
+
+/**
+ * 说明:页面导航相关函数封装。
+ */
+
+type OnPageBackCb = (data: Record<string, unknown>) => void
+
+const EventBusName = 'pageActionListenOnPageBack';
+
+export function useOnPageBack() {
+
+  const route = useRoute();
+  const cbs : OnPageBackCb[]  = [] 
+
+  function onPageBack(cb: OnPageBackCb) {
+    cbs.push(cb)
+  }
+
+  onMounted(() => {
+    EventBus.on(EventBusName, (e) => {
+      if (e.name === route.name) {
+        cbs.forEach((cb) => {
+          cb(e.data);
+        })
+      }
+    });
+  });
+  onBeforeUnmount(() => {
+    EventBus.off(EventBusName);
+  });
+
+  return {
+    onPageBack,
+  }
+}
+
+export function usePageAction() {
+  const router = useRouter();
+
+  /**
+   * 页面跳转: 后退至上一个页面。
+   */
+  function back() {
+    router.back();
+  }
+  /**
+   * 页面跳转: 后退并返回数据至上一个页面的 onPageBack 方法。
+   * @param data 要返回的数据
+   */
+  function backReturnData(data: Record<string, unknown>) {
+    callPrevOnPageBack('' + router.options.history.state.back, data);
+    router.back();
+  }
+  /**
+   * 页面跳转: 跳转到指定页面
+   * @param url 页面路径
+   * @param data 要传递的数据
+   */
+  function navTo(url: string, data: Record<string, unknown> = {}) {
+    const data2 : LocationQueryRaw = {}
+
+    for (const key in data) {
+      if (Object.prototype.hasOwnProperty.call(data, key))
+        data2[key] = data[key] as string;
+    }
+
+    router.push({
+      path: url,
+      query: data2,
+    });
+  }
+  /**
+   * 页面数据传递: 调用上一个页面的 onPageBack 方法
+   * @param name 方法名
+   * @param data 要传递的数据
+   */
+  function callPrevOnPageBack(name: string, data: Record<string, unknown>) {
+    EventBus.emit(EventBusName, {
+      name,
+      data,
+    })
+  }
+  /**
+   * 页面跳转: 调用上一个页面的 onPageBack 方法并返回至上一个页面
+   * @param name 方法名
+   * @param data 要传递的数据
+   */
+  function backAndCallOnPageBack(name: string, data: Record<string, unknown>) {
+    router.back();
+    callPrevOnPageBack(name, data);
+  }
+ 
+  return {
+    back,
+    backReturnData,
+    backAndCallOnPageBack, 
+    navTo,
+    callPrevOnPageBack,
+  }
+}

+ 8 - 0
src/composeable/PagerDefine.ts

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

+ 120 - 0
src/composeable/SimplePagerData.ts

@@ -0,0 +1,120 @@
+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,
+  }
+}

+ 5 - 0
src/router/index.ts

@@ -19,6 +19,11 @@ const router = createRouter({
       component: () => import('../views/AboutView.vue'),
     },
     {
+      path: '/news',
+      name: 'news',
+      component: () => import('../views/NewsView.vue'),
+    },
+    {
       path: '/404',
       name: 'NotFound',
       component: NotFoundView

Разница между файлами не показана из-за своего большого размера
+ 222 - 0
src/views/NewsView.vue