Browse Source

✨初始功能提交

快乐的梦鱼 10 hours ago
commit
a7284af898
89 changed files with 13643 additions and 0 deletions
  1. 39 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 30 0
      README.md
  4. 27 0
      components.d.ts
  5. 1 0
      env.d.ts
  6. 13 0
      index.html
  7. BIN
      logo.png
  8. 210 0
      pack/manifest.json
  9. 4471 0
      package-lock.json
  10. 41 0
      package.json
  11. BIN
      public/favicon.ico
  12. 21 0
      src/App.vue
  13. 460 0
      src/api/CommonContent.ts
  14. 12 0
      src/api/NotConfigue.ts
  15. 232 0
      src/api/RequestModules.ts
  16. 67 0
      src/api/auth/UserApi.ts
  17. 10 0
      src/api/inherit/ProjectsContent.ts
  18. 122 0
      src/api/user/UserApi.ts
  19. BIN
      src/assets/images/Button1.png
  20. BIN
      src/assets/images/Button2.png
  21. BIN
      src/assets/images/Button3.png
  22. BIN
      src/assets/images/Button4.png
  23. BIN
      src/assets/images/Button5.png
  24. BIN
      src/assets/images/Button6.png
  25. BIN
      src/assets/images/IndexBackground.jpg
  26. BIN
      src/assets/images/PlayList/Background.jpg
  27. BIN
      src/assets/images/PlayList/Box.png
  28. BIN
      src/assets/images/PlayList/Box2.png
  29. BIN
      src/assets/images/PlayList/Button1.png
  30. BIN
      src/assets/images/PlayList/Button2.png
  31. 94 0
      src/assets/scss/main.scss
  32. 15 0
      src/assets/scss/mengyuu/define/border-radius.scss
  33. 25 0
      src/assets/scss/mengyuu/define/colors.scss
  34. 32 0
      src/assets/scss/mengyuu/define/margin-padding.scss
  35. 60 0
      src/assets/scss/mengyuu/define/size.scss
  36. 42 0
      src/assets/scss/mengyuu/define/wing-height.scss
  37. 3999 0
      src/assets/scss/mengyuu/global/base.css
  38. 1 0
      src/assets/scss/mengyuu/global/base.css.map
  39. 104 0
      src/assets/scss/mengyuu/global/base.scss
  40. 55 0
      src/assets/scss/mengyuu/global/border.scss
  41. 17 0
      src/assets/scss/mengyuu/global/color.scss
  42. 141 0
      src/assets/scss/mengyuu/global/flex.scss
  43. 3 0
      src/assets/scss/mengyuu/global/grid.scss
  44. 5 0
      src/assets/scss/mengyuu/global/image.scss
  45. 356 0
      src/assets/scss/mengyuu/global/margin-padding.scss
  46. 51 0
      src/assets/scss/mengyuu/global/radius.scss
  47. 25 0
      src/assets/scss/mengyuu/global/shadow.scss
  48. 80 0
      src/assets/scss/mengyuu/global/size.scss
  49. 147 0
      src/assets/scss/mengyuu/global/text.scss
  50. 71 0
      src/assets/scss/mengyuu/global/wing-space-height.scss
  51. 1 0
      src/assets/scss/mengyuu/index.scss
  52. 98 0
      src/common/ConvertRgeistry.ts
  53. 8 0
      src/common/EventBus.ts
  54. 10 0
      src/common/config/ApiCofig.ts
  55. 12 0
      src/common/config/AppCofig.ts
  56. 59 0
      src/common/request/core/RequestApiConfig.ts
  57. 172 0
      src/common/request/core/RequestApiResult.ts
  58. 398 0
      src/common/request/core/RequestCore.ts
  59. 130 0
      src/common/request/core/RequestHandler.ts
  60. 7 0
      src/common/request/core/RequestImplementer.ts
  61. 1 0
      src/common/request/core/RequestSharedData.ts
  62. 103 0
      src/common/request/implementer/Uniapp.ts
  63. 48 0
      src/common/request/implementer/WebFetch.ts
  64. 33 0
      src/common/request/index.ts
  65. 52 0
      src/common/request/utils/AllType.ts
  66. 84 0
      src/common/request/utils/Utils.ts
  67. 80 0
      src/components/SimplePageContentLoader.vue
  68. 98 0
      src/components/SimplePageListContentLoader.vue
  69. 111 0
      src/components/SimplePopup.vue
  70. 37 0
      src/components/SimpleRemoveRichHtml.vue
  71. 152 0
      src/components/SimpleRichHtml.vue
  72. 57 0
      src/components/SimpleScrollView.vue
  73. 137 0
      src/components/content/CommonCatalog.vue
  74. 21 0
      src/components/small/Box1.vue
  75. 9 0
      src/composeable/LoaderCommon.ts
  76. 57 0
      src/composeable/SimpleDataLoader.ts
  77. 142 0
      src/composeable/SimplePagerDataLoader.ts
  78. 27 0
      src/main.ts
  79. 30 0
      src/router/index.ts
  80. 77 0
      src/stores/auth.ts
  81. 12 0
      src/stores/counter.ts
  82. 15 0
      src/views/AboutView.vue
  83. 27 0
      src/views/HomeView.vue
  84. 117 0
      src/views/ListView.vue
  85. 67 0
      src/views/PlayerView.vue
  86. 12 0
      tsconfig.app.json
  87. 11 0
      tsconfig.json
  88. 19 0
      tsconfig.node.json
  89. 30 0
      vite.config.ts

+ 39 - 0
.gitignore

@@ -0,0 +1,39 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+dist/
+pack/index.html
+pack/favicon.ico
+pack/assets/
+
+/cypress/videos/
+/cypress/screenshots/
+unpackage/
+
+src/assets/scss/mengyuu/index.css
+src/assets/scss/mengyuu/index.css.map
+3dtest/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo

+ 3 - 0
.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar"]
+}

+ 30 - 0
README.md

@@ -0,0 +1,30 @@
+# wenlv-huli-player
+
+湖里非遗视听
+
+## 项目依赖安装
+
+```sh
+npm install
+```
+
+### 项目启动
+
+```sh
+npm run dev
+```
+
+### 项目打包
+
+运行以下命令打包项目,输出文件至dist目录。
+
+```sh
+npm run build-only
+```
+
+打包为安卓App:
+
+1. 在 HBuilderX 中打开 pack 项目。
+2. 复制dist目录下的文件到pack项目的根目录。
+3. 使用 HBuilderX 打包App。
+

+ 27 - 0
components.d.ts

@@ -0,0 +1,27 @@
+/* eslint-disable */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+// biome-ignore lint: disable
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+  export interface GlobalComponents {
+    AButton: typeof import('ant-design-vue/es')['Button']
+    AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
+    AEmpty: typeof import('ant-design-vue/es')['Empty']
+    APagination: typeof import('ant-design-vue/es')['Pagination']
+    ASpin: typeof import('ant-design-vue/es')['Spin']
+    Box1: typeof import('./src/components/small/Box1.vue')['default']
+    CommonCatalog: typeof import('./src/components/content/CommonCatalog.vue')['default']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    SimplePageContentLoader: typeof import('./src/components/SimplePageContentLoader.vue')['default']
+    SimplePageListContentLoader: typeof import('./src/components/SimplePageListContentLoader.vue')['default']
+    SimplePopup: typeof import('./src/components/SimplePopup.vue')['default']
+    SimpleRemoveRichHtml: typeof import('./src/components/SimpleRemoveRichHtml.vue')['default']
+    SimpleRichHtml: typeof import('./src/components/SimpleRichHtml.vue')['default']
+    SimpleScrollView: typeof import('./src/components/SimpleScrollView.vue')['default']
+  }
+}

+ 1 - 0
env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>湖里非遗视听</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

BIN
logo.png


+ 210 - 0
pack/manifest.json

@@ -0,0 +1,210 @@
+{
+    "@platforms" : [ "android", "iPhone", "iPad" ],
+    "id" : "H5A0733B1", /*应用的标识*/
+    "name" : "湖里非遗视听", /*应用名称,程序桌面图标名称*/
+    "version" : {
+        "name" : "1.0", /*应用版本名称*/
+        "code" : "100"
+    },
+    "description" : "", /*应用描述信息*/
+    "icons" : {
+        "72" : "icon.png"
+    },
+    "launch_path" : "https://mncdn.wenlvti.net/app_static/huli-showroom/index.html", /*应用的入口页面,默认为根目录下的index.html;支持网络地址,必须以http://或https://开头*/
+    "developer" : {
+        "name" : "", /*开发者名称*/
+        "email" : "", /*开发者邮箱地址*/
+        "url" : "" /*开发者个人主页地址*/
+    },
+    "permissions" : {
+        "Accelerometer" : {
+            "description" : "访问加速度感应器"
+        },
+        "Audio" : {
+            "description" : "访问麦克风"
+        },
+        "Cache" : {
+            "description" : "管理应用缓存"
+        },
+        "Console" : {
+            "description" : "跟踪调试输出日志"
+        },
+        "Device" : {
+            "description" : "访问设备信息"
+        },
+        "Downloader" : {
+            "description" : "文件下载管理"
+        },
+        "Events" : {
+            "description" : "应用扩展事件"
+        },
+        "File" : {
+            "description" : "访问本地文件系统"
+        },
+        "Gallery" : {
+            "description" : "访问系统相册"
+        },
+        "Invocation" : {
+            "description" : "使用Native.js能力"
+        },
+        "Orientation" : {
+            "description" : "访问方向感应器"
+        },
+        "Proximity" : {
+            "description" : "访问距离感应器"
+        },
+        "Storage" : {
+            "description" : "管理应用本地数据"
+        },
+        "Uploader" : {
+            "description" : "管理文件上传任务"
+        },
+        "Runtime" : {
+            "description" : "访问运行期环境"
+        },
+        "XMLHttpRequest" : {
+            "description" : "跨域网络访问"
+        },
+        "Zip" : {
+            "description" : "文件压缩与解压缩"
+        },
+        "Webview" : {
+            "description" : "窗口管理"
+        },
+        "NativeUI" : {
+            "description" : "原生UI控件"
+        },
+        "Navigator" : {
+            "description" : "浏览器信息"
+        },
+        "NativeObj" : {
+            "description" : "原生对象"
+        },
+        "VideoPlayer" : {}
+    },
+    "plus" : {
+        "splashscreen" : {
+            "autoclose" : true, /*是否自动关闭程序启动界面,true表示应用加载应用入口页面后自动关闭;false则需调plus.navigator.closeSplashscreen()关闭*/
+            "waiting" : true /*是否在程序启动界面显示等待雪花,true表示显示,false表示不显示。*/
+        },
+        "popGesture" : "close", /*设置应用默认侧滑返回关闭Webview窗口,"none"为无侧滑返回功能,"hide"为侧滑隐藏Webview窗口。参考http://ask.dcloud.net.cn/article/102*/
+        "runmode" : "normal", /*应用的首次启动运行模式,可取liberate或normal,liberate模式在第一次启动时将解压应用资源(Android平台File API才可正常访问_www目录)*/
+        "signature" : "Sk9JTiBVUyBtYWlsdG86aHIyMDEzQGRjbG91ZC5pbw==", /*可选,保留给应用签名,暂不使用*/
+        "distribute" : {
+            "apple" : {
+                "appid" : "", /*iOS应用标识,苹果开发网站申请的appid,如io.dcloud.HelloH5*/
+                "mobileprovision" : "", /*iOS应用打包配置文件*/
+                "password" : "", /*iOS应用打包个人证书导入密码*/
+                "p12" : "", /*iOS应用打包个人证书,打包配置文件关联的个人证书*/
+                "devices" : "universal", /*iOS应用支持的设备类型,可取值iphone/ipad/universal*/
+                "frameworks" : [] /*调用Native.js调用原生Objective-c API需要引用的FrameWork,如需调用GameCenter,则添加"GameKit.framework"*/
+            },
+            "google" : {
+                "packagename" : "", /*Android应用包名,如io.dcloud.HelloH5*/
+                "keystore" : "", /*Android应用打包使用的密钥库文件*/
+                "password" : "", /*Android应用打包使用密钥库中证书的密码*/
+                "aliasname" : "", /*Android应用打包使用密钥库中证书的别名*/
+                "permissions" : [
+                    "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
+                ]
+            },
+            /*使用Native.js调用原生安卓API需要使用到的系统权限*/
+            "orientation" : [ "portrait-primary" ], /*应用支持的方向,portrait-primary:竖屏正方向;portrait-secondary:竖屏反方向;landscape-primary:横屏正方向;landscape-secondary:横屏反方向*/
+            "icons" : {
+                "ios" : {
+                    "prerendered" : true, /*应用图标是否已经高亮处理,在iOS6及以下设备上有效*/
+                    "auto" : "", /*应用图标,分辨率:512x512,用于自动生成各种尺寸程序图标*/
+                    "iphone" : {
+                        "normal" : "", /*iPhone3/3GS程序图标,分辨率:57x57*/
+                        "retina" : "", /*iPhone4程序图标,分辨率:114x114*/
+                        "retina7" : "", /*iPhone4S/5/6程序图标,分辨率:120x120*/
+                        "retina8" : "", /*iPhone6 Plus程序图标,分辨率:180x180*/
+                        "spotlight-normal" : "", /*iPhone3/3GS Spotlight搜索程序图标,分辨率:29x29*/
+                        "spotlight-retina" : "", /*iPhone4 Spotlight搜索程序图标,分辨率:58x58*/
+                        "spotlight-retina7" : "", /*iPhone4S/5/6 Spotlight搜索程序图标,分辨率:80x80*/
+                        "settings-normal" : "", /*iPhone4设置页面程序图标,分辨率:29x29*/
+                        "settings-retina" : "", /*iPhone4S/5/6设置页面程序图标,分辨率:58x58*/
+                        "settings-retina8" : "", /*iPhone6Plus设置页面程序图标,分辨率:87x87*/
+                        "app@2x" : "unpackage/res/icons/120x120.png",
+                        "app@3x" : "unpackage/res/icons/180x180.png",
+                        "notification@2x" : "unpackage/res/icons/40x40.png",
+                        "notification@3x" : "unpackage/res/icons/60x60.png",
+                        "settings@2x" : "unpackage/res/icons/58x58.png",
+                        "settings@3x" : "unpackage/res/icons/87x87.png",
+                        "spotlight@2x" : "unpackage/res/icons/80x80.png",
+                        "spotlight@3x" : "unpackage/res/icons/120x120.png"
+                    },
+                    "ipad" : {
+                        "normal" : "", /*iPad普通屏幕程序图标,分辨率:72x72*/
+                        "retina" : "", /*iPad高分屏程序图标,分辨率:144x144*/
+                        "normal7" : "", /*iPad iOS7程序图标,分辨率:76x76*/
+                        "retina7" : "", /*iPad iOS7高分屏程序图标,分辨率:152x152*/
+                        "spotlight-normal" : "", /*iPad Spotlight搜索程序图标,分辨率:50x50*/
+                        "spotlight-retina" : "", /*iPad高分屏Spotlight搜索程序图标,分辨率:100x100*/
+                        "spotlight-normal7" : "", /*iPad iOS7 Spotlight搜索程序图标,分辨率:40x40*/
+                        "spotlight-retina7" : "", /*iPad iOS7高分屏Spotlight搜索程序图标,分辨率:80x80*/
+                        "settings-normal" : "", /*iPad设置页面程序图标,分辨率:29x29*/
+                        "settings-retina" : "", /*iPad高分屏设置页面程序图标,分辨率:58x58*/
+                        "app" : "unpackage/res/icons/76x76.png",
+                        "app@2x" : "unpackage/res/icons/152x152.png",
+                        "notification" : "unpackage/res/icons/20x20.png",
+                        "notification@2x" : "unpackage/res/icons/40x40.png",
+                        "proapp@2x" : "unpackage/res/icons/167x167.png",
+                        "settings" : "unpackage/res/icons/29x29.png",
+                        "settings@2x" : "unpackage/res/icons/58x58.png",
+                        "spotlight" : "unpackage/res/icons/40x40.png",
+                        "spotlight@2x" : "unpackage/res/icons/80x80.png"
+                    },
+                    "appstore" : "unpackage/res/icons/1024x1024.png"
+                },
+                "android" : {
+                    "mdpi" : "", /*普通屏程序图标,分辨率:48x48*/
+                    "ldpi" : "", /*大屏程序图标,分辨率:48x48*/
+                    "hdpi" : "unpackage/res/icons/72x72.png", /*高分屏程序图标,分辨率:72x72*/
+                    "xhdpi" : "unpackage/res/icons/96x96.png", /*720P高分屏程序图标,分辨率:96x96*/
+                    "xxhdpi" : "unpackage/res/icons/144x144.png", /*1080P 高分屏程序图标,分辨率:144x144*/
+                    "xxxhdpi" : "unpackage/res/icons/192x192.png"
+                }
+            },
+            "splashscreen" : {
+                "ios" : {
+                    "iphone" : {
+                        "default" : "", /*iPhone3启动图片选,分辨率:320x480*/
+                        "retina35" : "", /*3.5英寸设备(iPhone4)启动图片,分辨率:640x960*/
+                        "retina40" : "", /*4.0 英寸设备(iPhone5/iPhone5s)启动图片,分辨率:640x1136*/
+                        "retina47" : "", /*4.7 英寸设备(iPhone6)启动图片,分辨率:750x1334*/
+                        "retina55" : "", /*5.5 英寸设备(iPhone6 Plus)启动图片,分辨率:1242x2208*/
+                        "retina55l" : "" /*5.5 英寸设备(iPhone6 Plus)横屏启动图片,分辨率:2208x1242*/
+                    },
+                    "ipad" : {
+                        "portrait" : "", /*iPad竖屏启动图片,分辨率:768x1004*/
+                        "portrait-retina" : "", /*iPad高分屏竖屏图片,分辨率:1536x2008*/
+                        "landscape" : "", /*iPad横屏启动图片,分辨率:1024x748*/
+                        "landscape-retina" : "", /*iPad高分屏横屏启动图片,分辨率:2048x1496*/
+                        "portrait7" : "", /*iPad iOS7竖屏启动图片,分辨率:768x1024*/
+                        "portrait-retina7" : "", /*iPad iOS7高分屏竖屏图片,分辨率:1536x2048*/
+                        "landscape7" : "", /*iPad iOS7横屏启动图片,分辨率:1024x768*/
+                        "landscape-retina7" : "" /*iPad iOS7高分屏横屏启动图片,分辨率:2048x1536*/
+                    }
+                },
+                "android" : {
+                    "mdpi" : "", /*普通屏启动图片,分辨率:240x282*/
+                    "ldpi" : "", /*大屏启动图片,分辨率:320x442*/
+                    "hdpi" : "", /*高分屏启动图片,分辨率:480x762*/
+                    "xhdpi" : "", /*720P高分屏启动图片,分辨率:720x1242*/
+                    "xxhdpi" : "" /*1080P高分屏启动图片,分辨率:1080x1882*/
+                }
+            }
+        }
+    },
+    "screenOrientation" : [ "landscape-primary" ],
+    "fullscreen" : true
+}

File diff suppressed because it is too large
+ 4471 - 0
package-lock.json


+ 41 - 0
package.json

@@ -0,0 +1,41 @@
+{
+  "name": "wenlv-huli-player",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "run-p type-check \"build-only {@}\" --",
+    "preview": "vite preview",
+    "build-only": "vite build",
+    "type-check": "vue-tsc --build"
+  },
+  "dependencies": {
+    "@imengyu/imengyu-utils": "^0.0.12",
+    "@imengyu/js-request-transform": "^0.3.5",
+    "@imengyu/vue-scroll-rect": "^0.1.7",
+    "ant-design-vue": "^4.2.6",
+    "md5": "^2.3.0",
+    "pinia": "^3.0.3",
+    "three": "^0.178.0",
+    "vue": "^3.5.17",
+    "vue-router": "^4.5.1",
+    "vue3-carousel": "^0.16.0",
+    "vue3-marquee": "^4.2.2"
+  },
+  "devDependencies": {
+    "@tsconfig/node22": "^22.0.2",
+    "@types/node": "^22.15.32",
+    "@types/three": "^0.178.1",
+    "@vitejs/plugin-vue": "^6.0.0",
+    "@vitejs/plugin-vue-jsx": "^5.0.0",
+    "@vue/tsconfig": "^0.7.0",
+    "npm-run-all2": "^8.0.4",
+    "sass-embedded": "^1.89.2",
+    "typescript": "~5.8.0",
+    "unplugin-vue-components": "^28.8.0",
+    "vite": "^7.0.0",
+    "vite-plugin-vue-devtools": "^7.7.7",
+    "vue-tsc": "^2.2.10"
+  }
+}

BIN
public/favicon.ico


+ 21 - 0
src/App.vue

@@ -0,0 +1,21 @@
+<script setup lang="ts">
+import zhCN from 'ant-design-vue/es/locale/zh_CN';
+import { RouterView } from 'vue-router'
+</script>
+
+<template>
+  
+  <a-config-provider
+    :locale="zhCN"
+    :theme="{
+      token: {
+        colorPrimary: '#bc5f29',
+      },
+    }"
+  >
+    <RouterView />
+  </a-config-provider>
+</template>
+
+<style scoped>
+</style>

+ 460 - 0
src/api/CommonContent.ts

@@ -0,0 +1,460 @@
+import { DataModel, transformArrayDataModel, type NewDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from './RequestModules';
+import type { QueryParams } from '../common/request/utils/AllType';
+import ApiCofig from '@/common/config/ApiCofig';
+import { transformSomeToArray } from '@/common/request/utils/Utils';
+import RequestApiConfig from '@/common/request/core/RequestApiConfig';
+import type { RequestOptions } from '@/common/request/core/RequestCore';
+
+export class GetColumListParams extends DataModel<GetColumListParams> {
+  
+  public constructor() {
+    super(GetColumListParams);
+    this.setNameMapperCase('Camel', 'Snake');
+  }
+
+  setModelId(val: number) {
+    this.modelId = val;
+    return this;
+  }
+  setMainBodyColumnId(val: number) {
+    this.mainBodyColumnId = val;
+    return this;
+  }
+  setFlag(val: 'hot'|'recommend'|'top') {
+    this.flag = val;
+    return this; 
+  }
+  setSize(val: number) {
+    this.size = val;
+    return this; 
+  }
+
+  modelId?: number;
+  /**
+   * 	主体栏目id
+   */
+  mainBodyColumnId: number = 0;
+  /**
+   * 标志:hot=热门,recommend=推荐,top=置顶
+   */
+  flag ?: 'hot'|'recommend'|'top';
+  /**
+   * 内容数量,默认4
+   */
+  size = 4;
+  /**
+   * 地区ID 默认湖里区
+   */
+  region = ApiCofig.regionId;
+}
+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)),
+      },
+    }
+  }
+
+
+  setMainBodyColumnId(val: number|number[]) {
+    this.mainBodyColumnId = val;
+    return this;
+  }
+  setFlag(val: 'hot'|'recommend'|'top') {
+    this.flag = val;
+    return this; 
+  }
+  setIds(val: number[]) {
+    this.ids = val;
+    return this; 
+  }
+  setType(val: 1|2|3|4) {
+    this.type = val;
+    return this;
+  }
+  setSize(val: number) {
+    this.size = val;
+    return this;
+  }
+  setKeywords(val: string) {
+    this.keywords = val;
+    return this; 
+  }
+  setModelId(val: number) {
+    this.modelId = val;
+    return this; 
+  }
+
+  static TYPE_ARTICLE = 1;
+  static TYPE_AUDIO = 2;
+  static TYPE_VIDEO = 3;
+  static TYPE_IMAGE = 4;
+
+  modelId ?: number;
+  /**
+   * 主体栏目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;
+  /**
+   * 地区ID 默认湖里区
+   */
+  region = ApiCofig.regionId;
+
+}
+
+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' },
+      title: { 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' },
+      latitude: { clientSide: 'number', serverSide: 'number' },
+      longitude: { clientSide: 'number', serverSide: 'number' },
+      publishAt: { 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';
+  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 = '';
+  publishAt = new Date();
+}
+export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
+  constructor() {
+    super(GetContentDetailItem, "内容详情");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number' },
+      title: { 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' },
+      publishAt: { clientSide: 'date', serverSide: 'string' },
+      volunteerIds: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      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',
+        };
+      else if (key.endsWith('List')) {
+        return [
+          { clientSide: 'map', serverSide: 'original'},
+          { clientSide: 'array', clientSideChildDataModel: GetContentDetailItem, serverSide: 'original' },
+        ]
+      }
+      return undefined;
+    };
+    this._afterSolveServer = () => {
+      if (!this.image && this.images && this.images && this.images.length > 0  ) {
+        this.image = this.images[0]
+      }
+      if ((!this.images || this.images.length == 0) && this.image) {
+        this.images = [ this.image ]
+      }
+    }
+  }
+
+  id = 0;
+  from = '';
+  modelId = 0;
+  type = 0;
+  title = '';
+  region = 0;
+  image = '';
+  images = [] as string[];
+  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 = '';
+  publishAt = new Date();
+  volunteerIds: string[] = [];
+  associationMeList = [] as GetContentDetailItem[];
+}
+
+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(
+    mainBodyId = ApiCofig.mainBodyId, 
+    modelId = 0, debugName = 'CommonContent', 
+    mainBodyColumnId?: number|number[]) {
+    super();
+    this.modelId = modelId;
+    this.mainBodyId = mainBodyId;
+    this.mainBodyColumnId = mainBodyColumnId;
+    this.debugName = debugName;
+  }
+
+  public mainBodyId: number;
+  public mainBodyColumnId?: number|number[];
+  public modelId: number;
+  protected debugName: string;
+
+  private toStringArray(arr: number|number[]|undefined) {
+    if (typeof arr === 'undefined') 
+      return undefined;
+    return typeof arr === 'object' ? arr.join(',') : arr.toString();
+  }
+
+  /**
+   * 获取分类列表
+   * @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, Array.from(res.data2), `获取分类列表`, true))
+      .catch(e => { throw e });
+  }
+  /**
+   * 用于获取某一个分类需要用的子级
+   * @param pid 父级 湖里区11
+   * @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} 主体栏目列表`, {
+      main_body_id: this.mainBodyId,
+      model_id: this.modelId,
+      ...params.toServerSide(),
+      ...querys,
+    })
+      .then(res => { 
+		    return {
+			    list: transformArrayDataModel<T>(modelClassCreator, Array.from(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(),
+      model_id: params.modelId || this.modelId,
+      main_body_id: params.mainBodyId || this.mainBodyId,
+      main_body_column_id: this.toStringArray(params.mainBodyColumnId || this.mainBodyColumnId),
+      page,
+      pageSize,
+      ...querys,
+    })
+      .then(res => {
+        let resList : any = null;
+        let resTotal : any = null;
+
+        if (res.data2?.list && Array.isArray(res.data2.list)) {
+          resList = res.data2.list;
+          resTotal = res.data2.total ?? resList.length;
+        }
+        else if (res.data2 && Array.isArray(res.data2)) {
+          resList = res.data2;
+          resTotal = resList.length;
+        } else
+          resList = res.data;
+
+        if (resList === null)
+          return { list: [], total: 0 };
+        
+        return { 
+          list: transformArrayDataModel<T>(modelClassCreator, Array.from(resList), `${this.debugName} 模型内容列表`, true),
+          total: resTotal as number,
+        }
+      })
+      .catch(e => { throw e });
+  }
+  /**
+   * 内容详情
+   * @param id id 
+   * @param querys 额外参数
+   * @returns 
+   */
+  getContentDetail<T extends DataModel = GetContentDetailItem>(id: number, modelClassCreator: NewDataModel = GetContentDetailItem, modelId?: number, querys?: QueryParams) {
+    return this.get('/content/content/getContentDetail', `${this.debugName} (${id}) 内容详情`, {
+      main_body_id: this.mainBodyId,
+      model_id: modelId ?? this.modelId,
+      id,
+      ...querys,
+    }, modelClassCreator)
+      .then(res => res.data as T)
+      .catch(e => { throw e });
+  }
+
+  /**
+   * 搜索内容(不分模型)
+   * @param id id 
+   * @param querys 额外参数
+   * @returns 
+   */
+  search<T extends DataModel = GetContentDetailItem>(text: string, page: number, pageSize: number = 10, modelClassCreator: NewDataModel = GetContentListItem, querys?: QueryParams) {
+    return this.post('/content/content/searchContent', {
+      page,
+      pageSize,
+      keywords: text,
+      region: ApiCofig.regionId,
+      ...querys,
+    }, `搜索内容(不分模型)`, querys)
+      .then(res => { 
+		    return {
+			    list: transformArrayDataModel<T>(modelClassCreator, res.data2.data, `搜索`, true),
+			    total: res.data2.total as number,
+		    }
+	    })
+      .catch(e => { throw e });
+  }
+}
+
+export default new CommonContentApi(undefined, 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();

+ 232 - 0
src/api/RequestModules.ts

@@ -0,0 +1,232 @@
+
+/**
+ * 这里写的是业务相关的:
+ * * 请求数据处理函数。
+ * * 自定义请求模块。
+ * * 自定义错误报告处理函数。
+ */
+
+import { appendGetUrlParams, appendPostParams, checkIfStringAllEnglish, isNullOrEmpty } from "@/common/request/utils/Utils";
+import AppCofig, { isDev } from "../common/config/AppCofig";
+import RequestApiConfig from "../common/request/core/RequestApiConfig";
+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 type { DataModel, NewDataModel } from "@imengyu/js-request-transform";
+import ApiCofig from "@/common/config/ApiCofig";
+import implementer from "@/common/request/implementer/WebFetch";
+import { Modal } from "ant-design-vue";
+import { RequestSharedData } from "@/common/request/core/RequestSharedData";
+
+/**
+ * 不报告错误的 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 token = RequestSharedData.get('token') ?? '';
+  const userId = RequestSharedData.get('userId') ?? '';
+  if (isNullOrEmpty((req.header as any).token as string)) {
+    req.header['token'] = token
+    req.header['__token__'] = token;
+  }
+  const main_body_user_id = userId ?? '';
+  const append_main_body_user_id = !(url.includes('content/content'));
+
+  if (req.method == 'GET') {
+    //追加GET参数
+    url = appendGetUrlParams(url, 'main_body_id', ApiCofig.mainBodyId);
+    if (append_main_body_user_id)
+      url = appendGetUrlParams(url, 'main_body_user_id', main_body_user_id);
+  } else {
+    req.data = appendPostParams(req.data,'main_body_id', ApiCofig.mainBodyId);
+    if (append_main_body_user_id)
+      req.data = appendPostParams(req.data,'main_body_user_id', main_body_user_id);
+  } 
+  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 (isDev) {
+    if (response instanceof RequestApiError) {
+      Modal.error({
+        title: `请求错误 ${response.apiName} : ${response.errorMessage}`,
+        content: 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,
+      });
+    } else {
+      Modal.error({
+        title: '错误报告 代码错误',
+        content: response?.stack || ('' + response),
+      });
+    }
+  } else {    
+    let errMsg = '';
+    if (response instanceof RequestApiError)
+      errMsg = response.errorMessage + '。';
+      
+    errMsg += '服务出现了异常,请稍后重试或联系客服。';
+    errMsg += '版本:' + AppCofig.version;
+
+    Modal.error({
+      title: '抱歉',
+      content: errMsg
+    });
+}
+}
+
+export class HuliRequestModule<T extends DataModel> extends RequestCoreInstance<T> {
+  constructor() {
+    super(implementer);
+    this.config.baseUrl = 'https://huli-app.wenlvti.net';
+    this.config.errCodes = []; //
+    this.config.requestInceptor = requestInceptor;
+    this.config.responseDataHandler = responseDataHandler;
+    this.config.responseErrReoprtInceptor = responseErrReoprtInceptor;
+    this.config.reportError = reportError;
+  }
+}
+export class AppServerRequestModule<T extends DataModel> extends RequestCoreInstance<T> {
+  constructor() {
+    super(implementer);
+    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;
+  }
+}

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

@@ -0,0 +1,67 @@
+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/inherit/ProjectsContent.ts

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

+ 122 - 0
src/api/user/UserApi.ts

@@ -0,0 +1,122 @@
+import { DataModel, transformArrayDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+import { GetContentListItem } from '../CommonContent';
+
+export class UserCollectItem extends DataModel<UserCollectItem> {
+  constructor() {
+    super(UserCollectItem, "收藏条目");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      createdAt: { clientSide: 'date' },
+    }
+    this._nameMapperServer = {
+    };
+  }
+  mainBodyUserId = null as number|null;
+  contentId = null as number|null;
+  groupId = null as number|null;
+  createdAt = '';
+  title = '';
+  image = '';
+  flag = '';
+  type = '';
+  mainBodyColumnId = null as number|null;
+  modelId = null as number|null;
+  modelName = '';
+  columnName = '';
+  flagText = '';
+  typeText = '';
+  thumbnail = '';
+}
+export class UserCollectGroupItem extends DataModel<UserCollectGroupItem> {
+  constructor() {
+    super(UserCollectGroupItem, "分组");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {}
+    this._nameMapperServer = {
+    };
+  }
+  id = 0;
+  name = '';
+}
+
+
+export class UserApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+
+  async addCollectGroup(name: string) {
+    return (this.post('/content/main_body_user/saveGroup', {
+      name,
+      content_id: 0,
+    }, '增加收藏分组'));
+  }
+  async updateCollectGroup(id: number, name: string) {
+    return (this.post('/content/main_body_user/recently', {
+      id,
+      name: 0,
+      content_id: 0,
+    }, '更新收藏分组'));
+  }
+
+  async getUserCollect(page: number, pageSize: number, groupId?: number, modelId?: number, searchValue?: string) {
+    return (this.get('/content/main_body_user/getUserCollect', '获取收藏内容列表', {
+      group_id: groupId,
+      model_id: modelId,
+      page,
+      pageSize,
+      keywords: searchValue,
+    }))
+      .then(res => ({
+        list: transformArrayDataModel<UserCollectItem>(UserCollectItem, Array.from(res.data2.data), `获取收藏内容列表`, true),
+        total: res.data2.total as number
+      }))
+      .catch(e => { throw e });
+  }
+  async getCollectGroups(page: number, pageSize: number, searchValue: string) {
+    return (this.get('/content/main_body_user/getGroup', '获取收藏夹', {
+      page,
+      pageSize,
+      keywords: searchValue,
+    }))
+      .then(res => transformArrayDataModel<UserCollectGroupItem>(UserCollectGroupItem, Array.from(res.data2), `获取收藏夹`, true))
+      .catch(e => { throw e });
+  }
+  async getUserRecently(page: number, pageSize: number, modelId?: number, searchValue?: string) {
+    return (this.get('/content/main_body_user/getUserRecently', '获取最近浏览列表', {
+      page,
+      pageSize,
+      model_id: modelId,
+      keywords: searchValue,
+    }))
+      .then(res => ({
+        list: transformArrayDataModel<UserCollectItem>(UserCollectItem, Array.from(res.data2.data), `获取最近浏览列表`, true),
+        total: res.data2.total as number
+      }))
+      .catch(e => { throw e });
+  }
+
+  async addDeviceToRecently(id: number, deviceId: number) {
+    return (this.get('/content/main_body_user/recently', '最近浏览(监控) ', {
+      device_id: deviceId,
+      content_id: id,
+    }));
+  }
+  async collectDevice(id: number, deviceId: number, groupId: number) {
+    return (this.get('/content/main_body_user/collect', '收藏监控', {
+      device_id: deviceId,
+      group_id: groupId,
+      content_id: id,
+    }));
+  }
+  async unCollectDevice(id: number, deviceId: number) {
+    return (this.get('/content/main_body_user/uncollect', '取消收藏监控', {
+      device_id: deviceId,
+      content_ids: id,
+    }));
+  }
+}
+
+export default new UserApi();

BIN
src/assets/images/Button1.png


BIN
src/assets/images/Button2.png


BIN
src/assets/images/Button3.png


BIN
src/assets/images/Button4.png


BIN
src/assets/images/Button5.png


BIN
src/assets/images/Button6.png


BIN
src/assets/images/IndexBackground.jpg


BIN
src/assets/images/PlayList/Background.jpg


BIN
src/assets/images/PlayList/Box.png


BIN
src/assets/images/PlayList/Box2.png


BIN
src/assets/images/PlayList/Button1.png


BIN
src/assets/images/PlayList/Button2.png


+ 94 - 0
src/assets/scss/main.scss

@@ -0,0 +1,94 @@
+html, body {
+  margin: 0;
+  padding: 0;
+}
+
+:root {
+  --color-primary: #bc5f29;
+  --color-primary2: #ff6a13;
+  --color-secondary: #9d613d;
+  --color-light-bg: #f8efe1;
+  --color-border: #000;
+  --color-text: #333;
+  --color-text-second: #563530;
+  --color-divider: #ddd0c4;
+}
+
+main {
+  width: 100%;
+  height: 100vh;
+
+  button {
+    font-family: nzgrRuyinZouZhangKai-Regular;
+    padding: 12px 24px 12px 24px;
+    font-size: 24px;
+    margin: auto;
+    cursor: pointer;
+    background-color: var(--color-light-bg);
+    box-sizing: border-box;
+    border: 1px solid var(--color-border);
+    outline: 8px solid var(--color-light-bg);
+    color: var(--color-secondary);
+
+    &:focus {
+      outline: 8px solid var(--color-primary2);
+    }
+  }
+  .carousel__next, .carousel__prev, .carousel__pagination-button {
+    outline: none;
+  }
+}
+
+.main-box1 {
+  position: absolute;
+  top: 10vh;
+  left: 10vw;
+  right: 20vw;
+  bottom: 35vh;
+}
+.main-box2 {
+  position: absolute;
+  top: 10vh;
+  left: 5vw;
+  right: 10vw;
+  bottom: 30vh;
+}
+.main-bg {
+  background-size: 100% 100%;
+  background-position: center;
+
+  &1 {
+    background-image: url('@/assets/images/IndexBackground.jpg');
+  }
+  &2 {
+    background-image: url('@/assets/images/PlayList/Background.jpg');
+  }
+}
+.main-image-button {
+  flex-shrink: 1;
+  width: 50px;
+  height: 50px;
+  cursor: pointer;
+
+  &.fill {
+    width: 100%;
+    height: 100%;
+  }
+  &.fill2 {
+    flex: 1 1 100%;
+  }
+  &.disabled {
+    cursor: not-allowed;
+  }
+  &:active:not(.disabled) {
+    transform: scale(0.96);
+  }
+}
+
+hr {
+  border-color: var(--color-divider);
+  border-width: 1px;
+}
+h1, h2, h3, h4, h5 {
+  margin-top: 0;
+}

+ 15 - 0
src/assets/scss/mengyuu/define/border-radius.scss

@@ -0,0 +1,15 @@
+$border-radius: (
+  xxxs: 1px,
+  xxs: 2px,
+  xs: 4px,
+  sss: 6px,
+  ss: 10px,
+  s: 12px,
+  base: 15px,
+  l: 20px,
+  ll: 40px,
+  lll: 60px,
+  xl: 80px,
+  xxl: 100px,
+  xxxl: 150px,
+);

+ 25 - 0
src/assets/scss/mengyuu/define/colors.scss

@@ -0,0 +1,25 @@
+$colors: (
+  primary: #bc5f29,
+  text: #333,
+  text-second: #563530, 
+  success: #218b3c,
+  warning: #FFD666,
+  error: #ec4545,
+  danger: #9c2121,
+  base: #efefef,
+  custom: #4A5061,
+  link: #0273F1,
+  light: #f8efe1,
+  dark: #000000,
+  muted: #858585,
+  second: #666666,
+  third: #999999,
+  forth: #CCCCCC,
+  place: #dde1f7,
+  pure: #fff,
+  mask-white: rgba(255, 255, 255, 0.6),
+  mask-black: rgba(0, 0, 0, 0.6),
+  disabled: #CCC8C8,
+  none: transparent,
+  transparent: transparent,
+);

+ 32 - 0
src/assets/scss/mengyuu/define/margin-padding.scss

@@ -0,0 +1,32 @@
+
+$paddings: (
+  none: 0,
+  xxs: 1px,
+  xs: 2px,
+  sss: 5px,
+  ss: 10px,
+  s: 15px,
+  base: 20px,
+  l: 40px,
+  ll: 80px,
+  lll: 120px,
+  xl: 160px,
+  xxl: 200px,
+  xxxl: 300px
+);
+
+$margins: (
+  none: 0,
+  xxs: 1px,
+  xs: 2px,
+  sss: 5px,
+  ss: 10px,
+  s: 15px,
+  base: 20px,
+  l: 40px,
+  ll: 80px,
+  lll: 120px,
+  xl: 160px,
+  xxl: 200px,
+  xxxl: 300px
+);

+ 60 - 0
src/assets/scss/mengyuu/define/size.scss

@@ -0,0 +1,60 @@
+$font-sizes: (
+  xxxs: 12px,
+  xxs: 14px,
+  xs: 16px,
+  sss: 18px,
+  ss: 20px,
+  s: 24px,
+  base: 28px,
+  l: 30px,
+  ll: 33px,
+  lll: 36px,
+  xl: 45px,
+  xxl: 60px,
+  xxxl: 100px,
+);
+
+$image-sizes: (
+  xxs: 14px,
+  xs: 16px,
+  sss: 18px,
+  ss: 24px,
+  s: 28px,
+  base: 30px,
+  l: 35px,
+  ll: 40px,
+  lll: 50px,
+  xl: 75px,
+  xxl: 100px,
+  xxxl: 150px
+);
+
+
+$exact-sizes: (
+  5: 5px,
+  10: 10px,
+  15: 15px,
+  20: 20px,
+  25: 25px,
+  30: 30px,
+  35: 35px,
+  40: 40px,
+  45: 45px,
+  50: 50px,
+  60: 60px,
+  80: 80px,
+  100: 100px,
+  150: 150px,
+  200: 200px,
+  250: 250px,
+  300: 300px,
+  350: 350px,
+  400: 400px,
+  450: 450px,
+  500: 500px,
+  550: 550px,
+  600: 600px,
+  650: 650px,
+  700: 700px,
+  750: 750px,
+);

+ 42 - 0
src/assets/scss/mengyuu/define/wing-height.scss

@@ -0,0 +1,42 @@
+$wings: (
+  none: 0,
+  sss: 2px,
+  ss: 4px,
+  s: 8px,
+  base: 10px,
+  l: 15px,
+  ll: 20px,
+  lll: 50px,
+  xl: 100px,
+  xxl: 200px,
+  xxxl: 300px
+);
+
+$heights: (
+  none: 0,
+  xxs: 1px,
+  xs: 2px,
+  sss: 4px,
+  ss: 8px,
+  s: 10px,
+  base: 15px,
+  l: 20px,
+  ll: 40px,
+  lll: 80px,
+  xl: 100px,
+  xxl: 200px,
+  xxxl: 300px
+);
+
+$space: (
+  none: 0,
+  ss: 2px,
+  s: 5px,
+  base: 10px,
+  l: 20px,
+  ll: 40px,
+  lll: 60px,
+  xl: 100px,
+  xxl: 200px,
+  xxxl: 300px
+);

File diff suppressed because it is too large
+ 3999 - 0
src/assets/scss/mengyuu/global/base.css


File diff suppressed because it is too large
+ 1 - 0
src/assets/scss/mengyuu/global/base.css.map


+ 104 - 0
src/assets/scss/mengyuu/global/base.scss

@@ -0,0 +1,104 @@
+//基础公共样式
+
+@use './border.scss' as *;
+@use './color.scss' as *;
+@use './flex.scss' as *;
+@use './radius.scss' as *;
+@use './size.scss' as *;
+@use './text.scss' as *;
+@use './shadow.scss' as *;
+@use './wing-space-height.scss' as *;
+@use './margin-padding.scss' as *;
+@use './grid.scss' as *;
+@use './image.scss' as *;
+
+.position {
+	&-relative {
+		position: relative;
+	}
+	&-absolute {
+		position: absolute;
+	}
+	&-fixed {
+		position: fixed;
+	}
+	&-sticky {
+		position: sticky;
+	}
+}
+.d {
+	&-flex {
+		display: flex !important;
+	}
+	/* #ifndef APP-VUE */
+	&-none {
+		display: none !important;
+	}
+	&-inline {
+		display: inline !important;
+	}
+	&-inline-block {
+		display: inline-block !important;
+	}
+	&-block {
+		display: block !important;
+	}
+	&-table {
+		display: table !important;
+	}
+	&-table-row {
+		display: table-row !important;
+	}
+	&-table-cell {
+		display: table-cell !important;
+	}
+	&-inline-flex {
+		display: inline-flex !important;
+	}
+	/* #endif */
+}
+
+.background {
+	/* #ifndef APP-NVUE */
+	z-index: 0;
+	/* #endif */
+	
+	&-deep {
+		/* #ifndef APP-NVUE */
+		z-index: -1;
+		/* #endif */
+	}
+}
+
+.overflow {
+	&-hidden {
+		overflow: hidden;
+	}
+	&-scroll {
+		overflow: scroll;
+	}
+	&-auto {
+		overflow: auto;
+	}
+	&-visible {
+		overflow: visible;
+	}
+}
+
+.visible {
+	&-hidden {
+		visibility: hidden;
+	}
+	&-visible {
+		visibility: visible;
+	}
+}
+
+.cursor {
+  &-pointer {
+    cursor: pointer;
+  }
+  &-default {
+    cursor: default;
+  }
+}

+ 55 - 0
src/assets/scss/mengyuu/global/border.scss

@@ -0,0 +1,55 @@
+//边框公共样式
+@use "../define/colors.scss" as *;
+
+$border-width: 1px;
+
+.border {
+	&-all {
+		@each $key, $color in $colors {
+			&-#{''+$key} {
+				border-color: $color;
+				border-width: $border-width;
+				border-style: solid;
+			}
+		}
+	}
+	&-top {
+		@each $key, $color in $colors {
+			&-#{''+$key} {
+				border-top-color: $color;
+				border-top-width: $border-width;
+				border-top-style: solid;
+			}
+		}
+	}
+	&-bottom {
+		@each $key, $color in $colors {
+			&-#{''+$key} {
+				border-bottom-color: $color;
+				border-bottom-width: $border-width;
+				border-bottom-style: solid;
+			}
+		}
+	}
+	&-left {
+		@each $key, $color in $colors {
+			&-#{''+$key} {
+				border-left-color: $color;
+				border-left-width: $border-width;
+				border-left-style: solid;
+			}
+		}
+	}
+	&-right {
+		@each $key, $color in $colors {
+			&-#{''+$key} {
+				border-right-color: $color;
+				border-right-width: $border-width;
+				border-right-style: solid;
+			}
+		}
+	}
+	&-none {
+		border-width: 0;
+	}
+}

+ 17 - 0
src/assets/scss/mengyuu/global/color.scss

@@ -0,0 +1,17 @@
+//颜色相关样式
+@use "../define/colors.scss" as *;
+
+.bg {
+	@each $key, $color in $colors {
+		&-#{''+$key} {
+			background-color: $color;
+		}
+	}
+}
+.color {
+	@each $key, $color in $colors {
+		&-#{''+$key} {
+			color: $color;
+		}
+	}
+}

+ 141 - 0
src/assets/scss/mengyuu/global/flex.scss

@@ -0,0 +1,141 @@
+//弹性布局相关样式
+
+.flex {
+	&-row {
+		/* #ifndef APP-NVUE */
+		display: flex;
+		box-sizing: border-box;
+		/* #endif */
+		flex-direction: row!important;
+	}
+	&-col {
+		/* #ifndef APP-NVUE */
+		display: flex;
+		box-sizing: border-box;
+		/* #endif */
+		flex-direction: column!important;
+	}
+	&-column {
+		/* #ifndef APP-NVUE */
+		display: flex;
+		box-sizing: border-box;
+		/* #endif */
+		flex-direction: column!important;
+	}
+	&-one {
+		flex: 1;
+	}
+	&-two {
+		flex: 3;
+	}
+	&-three {
+		flex: 3;
+	}
+	&-four {
+		flex: 4;
+	}
+	&-five {
+		flex: 5;
+	}
+	&-six {
+		flex: 6;
+	}
+	&-center {
+		justify-content: center;
+		align-items: center;
+	}
+  &-grow-0 {
+    flex-grow: 0 !important;
+  }
+  &-grow-1 {
+    flex-grow: 1 !important;
+  }
+  &-shrink-0 {
+    flex-shrink: 0 !important;
+  }
+  &-shrink-1 {
+    flex-shrink: 1 !important;
+  }
+  &-wrap {
+    flex-wrap: wrap !important;
+
+    &-reverse {
+      flex-wrap: wrap-reverse !important;
+    }
+  }
+  &-nowrap {
+    flex-wrap: nowrap !important;
+  }
+}
+.justify {
+	&-start {
+		justify-content: flex-start;
+	}
+	&-center {
+		justify-content: center;
+	}
+	&-end {
+		justify-content: flex-end;
+	}
+	&-between {
+		justify-content: space-between;
+	}
+	&-around {
+		justify-content: space-around;
+	}
+	&-stretch {
+		justify-content: stretch;
+	}
+}
+.align {
+	&-start {
+		align-items: flex-start;
+	}
+	&-center {
+		align-items: center;
+	}
+	&-end {
+		align-items: flex-end;
+	}
+  &-baseline {
+    align-items: baseline;
+  }
+  &-stretch {
+    align-items: stretch;
+  }
+}
+.full {
+	&-width {
+		width: 100%;
+	}
+	&-height {
+		flex: 1;
+		height: 100%;
+	}
+	&-page-width {
+		width: 100vw;
+	}
+	&-page-height {
+		flex: 1;
+		height: 100vh;
+	}
+	&-abs {
+		position: absolute;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+	}
+}
+.gap-0 {
+  &-row-0 {
+    row-gap: 0;
+  }
+  &-column-0 {
+    column-gap: 0;
+  }
+  &-0 {
+    gap: 0;
+  }
+}
+

+ 3 - 0
src/assets/scss/mengyuu/global/grid.scss

@@ -0,0 +1,3 @@
+.d-grid {
+  display: grid;
+} 

+ 5 - 0
src/assets/scss/mengyuu/global/image.scss

@@ -0,0 +1,5 @@
+.object-fit {
+  &-cover {
+    object-fit: cover;
+  }
+}

+ 356 - 0
src/assets/scss/mengyuu/global/margin-padding.scss

@@ -0,0 +1,356 @@
+@use '../define/margin-padding.scss' as *;
+
+
+$common-level-sizes: (
+  0: 0,
+  1: 0.25rem,
+  2: 0.5rem,
+  25: 0.75rem,
+  3: 1rem,
+  35: 1.25rem,
+  4: 1.5rem,
+  45: 1.75rem,
+  5: 3rem,
+);
+
+.m {
+  &-auto {
+    margin: auto!important;
+  }
+	@each $key, $size in $common-level-sizes {
+    &-#{$key} {
+      margin: $size;
+    }
+  }
+
+  &l {
+    &-auto {
+      margin-left: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        margin-left: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        margin-left: -$size!important;
+      }
+    }
+  }
+  &r {
+    &-auto {
+      margin-right: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        margin-right: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        margin-right: -$size!important;
+      }
+    }
+  }
+  &t {
+    &-auto {
+      margin-top: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        margin-top: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        margin-top: -$size!important;
+      }
+    }
+  }
+  &b {
+    &-auto {
+      margin-bottom: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        margin-bottom: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        margin-bottom: -$size!important;
+      }
+    }
+  }
+  &x {
+    &-auto {
+      margin-left: auto!important;
+      margin-right: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        margin-left: $size!important;
+        margin-right: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        margin-left: -$size!important;
+        margin-right: -$size!important;
+      }
+    }
+  }
+  &y {
+    &-auto {
+      margin-top: auto!important;
+      margin-bottom: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        margin-top: $size!important;
+        margin-bottom: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        margin-top: -$size!important;
+        margin-bottom: -$size!important;
+      }
+    }
+  }
+}
+.p {
+  &-auto {
+    padding: auto!important;
+  }
+	@each $key, $size in $common-level-sizes {
+    &-#{$key} {
+      padding: $size;
+    }
+  }
+
+  &l {
+    &-auto {
+      padding-left: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        padding-left: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        padding-left: -$size!important;
+      }
+    }
+  }
+  &r {
+    &-auto {
+      padding-right: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        padding-right: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        padding-right: -$size!important;
+      }
+    }
+  }
+  &t {
+    &-auto {
+      padding-top: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        padding-top: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        padding-top: -$size!important;
+      }
+    }
+  }
+  &b {
+    &-auto {
+      padding-bottom: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        padding-bottom: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        padding-bottom: -$size!important;
+      }
+    }
+  }
+  &x {
+    &-auto {
+      padding-left: auto!important;
+      padding-right: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        padding-left: $size!important;
+        padding-right: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        padding-left: -$size!important;
+        padding-right: -$size!important;
+      }
+    }
+  }
+  &y {
+    &-auto {
+      padding-top: auto!important;
+      padding-bottom: auto!important;
+    }
+    @each $key, $size in $common-level-sizes {
+      &-#{$key} {
+        padding-top: $size!important;
+        padding-bottom: $size!important;
+      }
+    }
+    @each $key, $size in $common-level-sizes {
+      &-n#{$key} {
+        padding-top: -$size!important;
+        padding-bottom: -$size!important;
+      }
+    }
+  }
+}
+
+.h-100vh {
+  height: 100vh;
+}
+.h-50vh {
+  height: 50vh;
+}
+.h-25vh {
+  height: 25vh;
+}
+.h-75vh {
+  height: 75vh;
+}
+.h-0 {
+  height: 0;
+}
+.l-0 {
+  left: 0;
+}
+.r-0 {
+  right: 0;
+}
+.t-0 {
+  top: 0;
+}
+.b-0 {
+  bottom: 0;
+}
+
+.margin {
+	&-all {	
+		@each $key, $size in $margins {
+			&-#{$key} {
+				margin: $size;
+			}
+		}
+	}
+	&-top {	
+		@each $key, $size in $margins {
+			&-#{$key} {
+				margin-top: $size;
+			}
+		}
+	}
+	&-bottom {
+		@each $key, $size in $margins {
+			&-#{$key} {
+				margin-bottom: $size;
+			}
+		}
+	}
+	&-left {
+		@each $key, $size in $margins {
+			&-#{$key} {
+				margin-left: $size;
+			}
+		}
+	}
+	&-right {
+		@each $key, $size in $margins {
+			&-#{$key} {
+				margin-right: $size;
+			}
+		}
+	}
+	&-none {
+		margin: 0;
+
+		&-left-right {
+			margin-left: 0;
+			margin-right: 0;
+		}
+		&-top-bottom {
+			margin-top: 0;
+			margin-bottom: 0;
+		}
+	}
+}
+.padding {
+	&-all {	
+		@each $key, $size in $paddings {
+			&-#{$key} {
+				padding: $size;
+			}
+		}
+	}
+	&-top {
+		@each $key, $size in $paddings {
+			&-#{$key} {
+				padding-top: $size;
+			}
+		}
+	}
+	&-bottom {
+		@each $key, $size in $paddings {
+			&-#{$key} {
+				padding-bottom: $size;
+			}
+		}
+	}
+	&-left {
+		@each $key, $size in $paddings {
+			&-#{$key} {
+				padding-left: $size;
+			}
+		}
+	}
+	&-right {
+		@each $key, $size in $paddings {
+			&-#{$key} {
+				padding-right: $size;
+			}
+		}
+	}
+	&-none {
+		padding: 0;
+
+		&-left-right {
+			padding-left: 0;
+			padding-right: 0;
+		}
+		&-top-bottom {
+			padding-top: 0;
+			padding-bottom: 0;
+		}
+	}
+}

+ 51 - 0
src/assets/scss/mengyuu/global/radius.scss

@@ -0,0 +1,51 @@
+//圆角相关样式
+@use "../define/border-radius.scss" as *;
+
+.radius {
+  &-round {
+    border-radius: 50%;
+  }
+	&-none {
+		border-radius: 0;
+
+		&-bottom {
+			border-bottom-left-radius: 0;
+			border-bottom-right-radius: 0;
+		}
+		&-top {
+			border-top-left-radius: 0;
+			border-top-right-radius: 0;
+		}
+		&-left {
+			border-bottom-left-radius: 0;
+			border-top-left-radius: 0;
+		}
+		&-right {
+			border-bottom-right-radius: 0;
+			border-top-right-radius: 0;
+		}
+		
+	}
+	@each $key, $size in $border-radius {
+		&-#{''+$key} {
+			border-radius: $size;
+
+			&-bottom {
+				border-bottom-left-radius: $size;
+				border-bottom-right-radius: $size;
+			}
+			&-top {
+				border-top-left-radius: $size;
+				border-top-right-radius: $size;
+			}
+			&-left {
+				border-bottom-left-radius: $size;
+				border-top-left-radius: $size;
+			}
+			&-right {
+				border-bottom-right-radius: $size;
+				border-top-right-radius: $size;
+			}
+		}
+	}
+}

+ 25 - 0
src/assets/scss/mengyuu/global/shadow.scss

@@ -0,0 +1,25 @@
+
+
+.shadow-sm {
+  box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.055) !important;
+}
+
+.shadow-s {
+  box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.055) !important;
+}
+
+.shadow {
+  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.075) !important;
+}
+
+.shadow-l {
+  box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.100) !important;
+}
+
+.shadow-lg {
+  box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.150) !important;
+}
+
+.shadow-none {
+  box-shadow: none !important;
+}

+ 80 - 0
src/assets/scss/mengyuu/global/size.scss

@@ -0,0 +1,80 @@
+//宽高, 大小相关样式
+@use "sass:math";
+@use "sass:list";
+
+@use "../define/size.scss" as *;
+
+$full-width: 100vh;
+
+.height {
+	//数字形式 : height-100 表示100rpx; height-5 表示5rpx,等等
+	@each $key, $w in $exact-sizes {
+		&-#{''+$key} {
+			height: $w!important;
+		}
+	}
+}
+.width {
+	&-auto {
+		width: auto!important;
+	}
+	&-full {
+		width: $full-width!important;
+	}
+	&-half {
+		width: $full-width*0.5;
+	}
+	&-one-third {
+		width: math.div($full-width, 3);
+	}
+	&-one-fourth {
+		width: $full-width*0.25;
+	}
+	&-one-fifth {
+		width: $full-width*0.2;
+	}
+	&-one-sixth {
+		width: math.div($full-width, 6);
+	}
+	&-one-eighth {
+		width: $full-width*0.125;
+	}
+	&-one-tenth {
+		width: $full-width*0.1;
+	}
+	
+	//数字形式: width-1-2 表示 二分之一; width-4-9 表示 9分之4,等等
+	@for $i from 2 to 10 {
+		&-1-#{$i} {
+			width: math.percentage(math.div(1, $i))!important;
+		}
+		
+		@for $j from 2 to $i {
+			&-#{$j}-#{$i} {
+				width: $full-width*math.div($j, $i)!important;
+			}
+		}
+	}
+	
+	//数字形式 2: width-100 表示100rpx; width-5 表示5rpx,等等
+	@each $key, $w in $exact-sizes {
+		&-#{''+$key} {
+			width: $w!important;
+		}
+	}
+}
+.size {
+	@each $key, $size in $font-sizes {
+		&-#{''+$key} {
+			font-size: $size!important;
+		}
+	}
+}
+.image-size {
+	@each $key, $size in $image-sizes {
+		&-#{''+$key} {
+			width: $size;
+			height: $size;
+		}
+	}
+}

+ 147 - 0
src/assets/scss/mengyuu/global/text.scss

@@ -0,0 +1,147 @@
+//文字相关样式
+
+.text-shadow {
+	/* #ifndef APP-NVUE */
+	text-shadow: 1px 1px 3px rgba(0,0,0,0.3);
+	
+	&-deep {
+		text-shadow: 1px 1px 5px rgba(0,0,0,0.5);
+	}
+	/* #endif */
+}
+.text-indent {
+	/* #ifndef APP-NVUE */
+	text-indent: 2em;
+	
+	&-1 {
+		text-indent: 1em;
+	}	
+	&-2 {
+		text-indent: 2em;
+	}
+	&-3 {
+		text-indent: 3em;
+	}
+	&-3 {
+		text-indent: 3em;
+	}
+	&-none {
+		text-indent: 0;
+	}
+	/* #endif */
+}
+.text-overflow {
+	&-ellipsis {
+		text-overflow: ellipsis;
+	}
+}
+.text-italic {
+	font-style: italic;
+}
+.text-bold {
+	font-weight: 700 !important;
+}
+.text-bolder {
+	font-weight: bolder !important;
+}
+.text-light {
+	font-weight: 300 !important;
+}
+.text-lowercase {
+  text-transform: lowercase !important;
+}
+
+.text-uppercase {
+  text-transform: uppercase !important;
+}
+
+.text-capitalize {
+  text-transform: capitalize !important;
+}
+
+.text-left {
+  text-align: left !important;
+}
+
+.text-right {
+  text-align: right !important;
+}
+
+.text-center {
+  text-align: center !important;
+}
+.text-lines {
+	&-1 {
+		lines: 1;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 1;
+    line-clamp: 1;
+	}
+	&-2 {
+		overflow: hidden;
+		lines: 2;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 2;
+    line-clamp: 2;
+	}
+	&-3 {
+		overflow: hidden;
+		lines: 3;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 3;
+    line-clamp: 3;
+	}
+	&-4 {
+		overflow: hidden;
+		lines: 4;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 4;
+	}
+	&-5 {
+		overflow: hidden;
+		lines: 5;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 5;
+	}
+	&-6 {
+		overflow: hidden;
+		lines: 6;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 6;
+	}
+}
+.text {
+	&-none-decoration {
+		text-decoration: none;	
+	}
+	&-underline {
+		text-decoration: underline;	
+	}
+	&-line-through {
+		text-decoration: line-through;
+	}
+}
+.text-align {
+	&-left {
+		text-align: left;	
+	}
+	&-center {
+		text-align: center;	
+	}
+	&-right {
+		text-align: right;
+	}
+}

+ 71 - 0
src/assets/scss/mengyuu/global/wing-space-height.scss

@@ -0,0 +1,71 @@
+//高度,两翼,空格相关样式
+@use "../define/wing-height.scss" as *;
+
+.height {
+	@each $key, $size in $heights {
+		&-#{''+$key} {
+			height: $size;
+		}
+	}
+}
+.gap {
+	@each $key, $size in $heights {
+		&-#{''+$key} {
+			gap: $size;
+		}
+	}
+}
+.row-gap {
+	@each $key, $size in $heights {
+		&-#{''+$key} {
+			row-gap: $size;
+		}
+	}
+}
+.column-gap {
+	@each $key, $size in $heights {
+		&-#{''+$key} {
+			column-gap: $size;
+		}
+	}
+}
+
+.wing {
+	@each $key, $size in $wings {
+		&-#{''+$key} {	
+			margin-left: $size;
+			margin-right: $size;
+		}
+	}
+}
+.padding-wing {
+	@each $key, $size in $wings {
+		&-#{''+$key} {	
+			padding-left: $size;
+			padding-right: $size;
+		}
+	}
+}
+.space {
+	@each $key, $size in $space {
+		&-#{''+$key} {	
+			margin-top: $size;
+			margin-bottom: $size;
+		}
+	}
+}
+
+.h {
+  @for $i from 0 through 20 {
+    &-#{$i * 5} { 
+      height: $i * 5%;
+    }
+  }
+}
+.w {
+  @for $i from 0 through 20 {
+    &-#{$i * 5} { 
+      width: $i * 5%;
+    }
+  }
+}

+ 1 - 0
src/assets/scss/mengyuu/index.scss

@@ -0,0 +1 @@
+@use "./global/base.scss" as *;

+ 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>();

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

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

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

@@ -0,0 +1,12 @@
+
+/**
+ * 说明:应用静态配置
+ */
+export default {
+  version: '0.0.1',
+}
+
+/**
+ * 是否是开发环境
+ */
+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>;
+}

+ 1 - 0
src/common/request/core/RequestSharedData.ts

@@ -0,0 +1 @@
+export const RequestSharedData = new Map<string, any>();

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

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

@@ -0,0 +1,48 @@
+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);
+
+    const header = init?.header || {};
+    let body : string|FormData|undefined;
+    if (init?.data instanceof FormData)
+      body = init.data; 
+      if (header['Content-Type'] == 'multipart/form-data')
+        delete header['Content-Type']; //由浏览器自己指定boundary
+    else if (typeof init?.data === 'object') {
+      body = JSON.stringify(init.data); 
+    }
+
+
+    // 发起 fetch 请求
+    const response = fetch(url, { 
+      headers: 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;
+}

+ 80 - 0
src/components/SimplePageContentLoader.vue

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

+ 98 - 0
src/components/SimplePageListContentLoader.vue

@@ -0,0 +1,98 @@
+<template>
+  <div class="box">
+    <div
+      v-if="loader?.loadStatus.value == 'loading'"
+      class="full"
+    >
+      <a-spin tip="加载中" />
+    </div>
+    <div
+      v-else-if="loader?.loadStatus.value == 'error'"
+      class="full"
+    >
+      <a-empty :description="loader.loadError.value" >
+        <a-button  @click="handleRetry">重试</a-button>
+      </a-empty>
+    </div>
+    <slot></slot>
+    <div
+      v-if="showEmpty || loader?.loadStatus.value == 'nomore'"
+      class="full empty"
+    >
+      <a-empty :description="emptyView?.text ?? '暂无数据'">
+        <a-button
+          v-if="emptyView?.button"
+          @click="emptyView?.buttonClick ?? handleRetry"
+        >
+          {{emptyView?.buttonText ?? '刷新'}}
+        </a-button>
+      </a-empty>
+    </div>
+  </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>
+
+<style lang="scss" scoped>
+.box {
+  position: relative;
+  min-height: 200px;
+}
+.full {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+</style>

+ 111 - 0
src/components/SimplePopup.vue

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

+ 37 - 0
src/components/SimpleRemoveRichHtml.vue

@@ -0,0 +1,37 @@
+<template>
+  <div ref="solveHtmlDiv" class="nana-no-rich-html" v-html="content" />
+  {{ pureContent }}
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+
+const props = defineProps({	
+  content: {
+    type: String,
+    default: '',
+  },
+})
+
+const solveHtmlDiv = ref<HTMLElement|null>(null);
+const pureContent = ref('');
+
+watch(() => props.content, () => {
+  setTimeout(() => {
+    if (solveHtmlDiv.value) {
+      pureContent.value = solveHtmlDiv.value.textContent || '';
+    }
+  }, 200);
+}, { immediate: true })
+
+</script>
+
+<style lang="scss">
+.nana-no-rich-html {
+  position: absolute;
+  opacity: 0;
+  left: -1000px;
+  pointer-events: none;
+}
+
+</style>

+ 152 - 0
src/components/SimpleRichHtml.vue

@@ -0,0 +1,152 @@
+<template>
+  <div ref="scrollContainer" class="nana-rich-html-container">
+    <div class="rich-html">
+      <slot name="prepend" />
+      <template 
+        v-for="(content, i) in contents"
+        :key="i"
+      >
+        <div 
+          v-if="content"
+          :data-r-id="id"
+          class="content"
+          v-html="content"
+        />
+      </template>
+      <slot name="append" />
+    </div>
+    <div class="rich-html-catalog" v-if="catalog && catalogItems.length > 0 && catalogShow">
+      <CommonCatalog
+        :items="catalogItems"
+        :scrollContainer="scrollContainer"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { RandomUtils } from '@imengyu/imengyu-utils';
+import CommonCatalog, { type CatalogItem } from './content/CommonCatalog.vue';
+import { onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue';
+
+const props = defineProps({	
+  contents: {
+    type: Array as PropType<string[]>,
+    default: () => ([]),
+  },
+  tagStyle: {
+    type: Object as PropType<Record<string, string>>,
+    default: () => ({}),
+  },
+  catalog: {
+    type: Boolean,
+    default: true,
+  },
+  catalogShow: {
+    type: Boolean,
+    default: true,
+  },
+  noStyle: {
+    type: Boolean,
+    default: false,
+  },
+})
+
+const id = RandomUtils.genNonDuplicateIDHEX(12);
+const catalogItems = ref<CatalogItem[]>([]);
+const scrollContainer = ref<HTMLElement|null>(null);
+let lastStyleTag : HTMLElement|null = null;
+
+function genTagCss() {
+  if (Object.keys(props.tagStyle).length > 0) {
+    const style = document.createElement('style');
+    let css = '';
+    for (const key in props.tagStyle) {
+      css += `.rich-html div[data-r-id="${id}"] ${key} { ${props.tagStyle[key]} } `
+    }
+    style.innerHTML = css;
+    document.body.appendChild(style);
+    lastStyleTag = style;
+  }
+}
+function generateCatalog() {
+  catalogItems.value = [];
+
+  if (!props.catalog) 
+    return;
+
+  let anchrId = 0;
+  for (let i = 1; i <= 6; i++) {
+    const heades = document.querySelectorAll(`.rich-html div[data-r-id="${id}"] h${i}`);
+    for (const header of heades) {
+      anchrId++;
+      if (header instanceof HTMLHeadingElement) {
+        if (header.id == '')
+          header.id = 'header' + anchrId + 'a' + RandomUtils.genNonDuplicateIDHEX(12);
+        catalogItems.value.push({
+          title: header.textContent || '',
+          scrollPos: header.offsetTop,
+          level: i,
+          anchor: header.id,
+        });
+      }
+    }
+  }
+  catalogItems.value.sort((a, b) => {
+    return a.scrollPos - b.scrollPos;
+  })
+}
+
+watch(() => props.contents, () => {
+  setTimeout(() => {
+    generateCatalog();
+  }, 200);
+}, { immediate: true })
+
+onBeforeUnmount(() => {
+  if (lastStyleTag) {
+    lastStyleTag.parentElement?.removeChild(lastStyleTag);
+    lastStyleTag = null;
+  }
+})
+onMounted(() => {
+  genTagCss()
+});
+</script>
+
+<style lang="scss">
+
+.nana-rich-html-container {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  overflow-y: scroll;
+
+  &::-webkit-scrollbar {
+    width: 5px;
+    height: 5px;
+  }
+  &::-webkit-scrollbar-thumb {
+    background: #d6d6d6;
+    opacity: .7;
+    border-radius: 3px;
+
+    &:hover {
+      background: #707070;
+    }
+  }
+  &::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  .rich-html {
+    flex: 1 1 100%;
+  }
+  .rich-html-catalog {
+    position: sticky;
+    top: 0px;
+    width: 18rem;
+  }
+}
+
+</style>

+ 57 - 0
src/components/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>

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

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

+ 21 - 0
src/components/small/Box1.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="box">
+    <slot />
+  </div>
+</template>
+
+<script setup lang="ts">
+</script>
+
+<style lang="scss" scoped>
+.box {
+  position: relative;
+  background-image: url('@/assets/images/PlayList/Box2.png');
+  background-size: 100% 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 20px;
+}
+</style>

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

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

+ 142 - 0
src/composeable/SimplePagerDataLoader.ts

@@ -0,0 +1,142 @@
+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>;
+  pageSize: 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|Ref<number>, 
+  loader: (page: number, pageSize: number, params?: P) => Promise<{
+    list: T[],
+    total: number,
+  }>,
+  options?: {
+    /**
+     * 是否追加数据,而不是覆盖数据。
+     */
+    append: boolean
+  },
+)  : ISimplePageListLoader<T, P>
+{
+  const { 
+    append = false,
+  } = options || {};
+
+
+  const page = ref(0);
+  const pageSize = computed(() => {
+    return typeof _pageSize == 'object'? _pageSize.value : _pageSize;
+  });
+  const list = ref<T[]>([]) as Ref<T[]>;
+  const total = ref(0);
+  const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
+  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.value, lastParams));
+      list.value = append ? list.value.concat(res.list) : res.list;
+      total.value = res.total;
+      loadStatus.value = res.list.length > 0 ? 'finished' : 'nomore';
+      loadError.value = '';
+      loading = false;
+    } catch(e) {
+      loadError.value = '' + e;
+      loadStatus.value = 'error';
+      loading = false;
+    }
+  }
+  /**
+   * 下一页
+   */
+  async function next() {
+    if (page.value > totalPages.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,
+    pageSize,
+    /**
+     * 总数据条数
+     */
+    total,
+    /**
+     * 总页数
+     */
+    totalPages,
+    loadError,
+    loadStatus,
+  }
+}

+ 27 - 0
src/main.ts

@@ -0,0 +1,27 @@
+import './assets/scss/main.scss'
+import './assets/scss/mengyuu/index.scss'
+import 'ant-design-vue/dist/reset.css';
+import '@imengyu/vue-scroll-rect/lib/vue-scroll-rect.css';
+import "vue3-carousel/carousel.css";
+
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+
+import Vue3Marquee from 'vue3-marquee'
+import VueScrollRect from '@imengyu/vue-scroll-rect';
+import App from './App.vue'
+import router from './router'
+import { registryConvert } from './common/ConvertRgeistry';
+
+const app = createApp(App)
+
+app.use(createPinia())
+app.use(router)
+app.use(VueScrollRect);
+app.use(Vue3Marquee);
+
+app.mount('#app').$nextTick(() => {
+  registryConvert();
+})
+
+

+ 30 - 0
src/router/index.ts

@@ -0,0 +1,30 @@
+import { createRouter, createWebHashHistory } from 'vue-router'
+import HomeView from '../views/HomeView.vue'
+
+const router = createRouter({
+  history: createWebHashHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: '/',
+      name: 'Home',
+      component: HomeView,
+    },
+    {
+      path: '/about',
+      name: 'About',
+      component: () => import('../views/AboutView.vue'),
+    },
+    {
+      path: '/list',
+      name: 'List',
+      component: () => import('../views/ListView.vue'),
+    },
+    {
+      path: '/player',
+      name: 'Player',
+      component: () => import('../views/PlayerView.vue'),
+    },
+  ],
+})
+
+export default router

+ 77 - 0
src/stores/auth.ts

@@ -0,0 +1,77 @@
+import UserApi, { LoginResult, UserInfo } from "@/api/auth/UserApi";
+import { RequestSharedData } from "@/common/request/core/RequestSharedData";
+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.setToken(authInfo.token, 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.setToken('', 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.setToken(loginResult.userInfo.token, 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.setToken('', 0);
+      this.userInfo = null;
+
+      localStorage.removeItem(STORAGE_KEY);
+    },
+    setToken(token: string, userId: number) {
+      this.token = token;
+      this.userId = userId;
+      RequestSharedData.set('token', token);
+      RequestSharedData.set('userId', userId);
+    }
+  },
+  getters: {
+    isLogged(state) {
+      return state.token != '' && state.userId != 0
+    },
+  },
+})

+ 12 - 0
src/stores/counter.ts

@@ -0,0 +1,12 @@
+import { ref, computed } from 'vue'
+import { defineStore } from 'pinia'
+
+export const useCounterStore = defineStore('counter', () => {
+  const count = ref(0)
+  const doubleCount = computed(() => count.value * 2)
+  function increment() {
+    count.value++
+  }
+
+  return { count, doubleCount, increment }
+})

+ 15 - 0
src/views/AboutView.vue

@@ -0,0 +1,15 @@
+<template>
+  <div class="about">
+    <h1>This is an about page</h1>
+  </div>
+</template>
+
+<style>
+@media (min-width: 1024px) {
+  .about {
+    min-height: 100vh;
+    display: flex;
+    align-items: center;
+  }
+}
+</style>

+ 27 - 0
src/views/HomeView.vue

@@ -0,0 +1,27 @@
+<script setup lang="ts">
+import { useRouter } from 'vue-router';
+
+const router = useRouter();
+
+
+</script>
+
+<template>
+  <main class="main-bg main-bg1 d-flex flex-col align-center justify-start">
+    <div class="d-flex flex-row main-box1 align-stretch">
+      <div class="d-flex flex-col align-stretch flex-four flex-shrink-1 gap-l">
+        <img class="main-image-button w-100 fill2" src="@/assets/images/Button1.png" @click="router.push({ name: 'List', query: { id: 187 } })" alt="">
+        <img class="main-image-button w-100 fill2" src="@/assets/images/Button2.png" @click="router.push({ name: 'List', query: { id: 188 } })" alt="">
+      </div>
+      <div class="d-flex flex-col align-center flex-six flex-shrink-1">
+        <img class="main-image-button w-100 fill2" src="@/assets/images/Button6.png" @click="router.push({ name: 'List', query: { id: 189 } })" alt="">
+      </div>
+      <div class="d-flex flex-col align-center flex-five flex-shrink-1">
+        <img class="main-image-button w-100 fill2" src="@/assets/images/Button3.png" @click="router.push({ name: 'List', query: { id: 190 } })" alt="">
+        <img class="main-image-button w-100 fill2" src="@/assets/images/Button4.png" @click="router.push({ name: 'List', query: { id: 191 } })" alt="">
+        <img class="main-image-button w-100 fill2 disabled" src="@/assets/images/Button5.png" alt="">
+      </div>
+    </div>
+  </main>
+</template>
+

+ 117 - 0
src/views/ListView.vue

@@ -0,0 +1,117 @@
+<script setup lang="ts">
+import { GetContentListParams } from '@/api/CommonContent';
+import ProjectsContent from '@/api/inherit/ProjectsContent';
+import SimplePageListContentLoader from '@/components/SimplePageListContentLoader.vue';
+import { useSimplePagerDataLoader } from '@/composeable/SimplePagerDataLoader';
+import { onMounted } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const route = useRoute();
+const router = useRouter();
+const newsData = useSimplePagerDataLoader(6, async (p, s) => 
+  await ProjectsContent.getContentList(new GetContentListParams()
+    .setMainBodyColumnId(
+      route.query.id ? parseInt(route.query.id as string) : ProjectsContent.mainBodyColumnId!
+    )
+    .setModelId(
+      route.query.modelId ? parseInt(route.query.modelId as string) : ProjectsContent.modelId!
+    )
+  , p, s)
+);
+
+onMounted(async () => {
+  await newsData.next();
+})
+
+</script>
+
+<template>
+  <main class="main-bg main-bg2 d-flex flex-col align-center justify-center">
+    <div class="main-box2 d-flex flex-row align-center">
+      <div class="d-flex flex-col align-center flex-one gap-base padding-top-ll">
+        <img class="main-image-button fill" src="@/assets/images/PlayList/Button1.png" @click="router.push('/')" alt="">
+        <img class="main-image-button fill disabled" src="@/assets/images/PlayList/Button2.png" alt="">
+      </div>
+      <div class="d-flex flex-col align-center margin-left-ll flex-six h-100">
+        <SimplePageListContentLoader :loader="newsData" class="main-list">
+          <div 
+            v-for="(value, k) in newsData.list.value"
+            class="main-list-box1"
+            @click="router.push({ name: 'Player', query: { id: value.id } })"
+          >
+            <div>
+              <img :src="value.image" />
+            </div>
+            <h6>{{ value.title }}</h6>
+          </div>
+        </SimplePageListContentLoader>
+        <a-pagination 
+          v-if="newsData.totalPages.value > 1"
+          v-model:current="newsData.page.value"
+          :total="newsData.totalPages.value"
+          :page-size="newsData.pageSize.value"
+        />
+      </div>
+    </div>
+  </main>
+</template>
+
+<style lang="scss" scoped>
+
+
+.main-list {
+  position: relative;
+  width: 100%;
+  height: calc(100% - 20px);
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+
+}
+.main-list-box1 {
+  position: relative;
+  height: 48%;
+  width: 30%;
+  margin-bottom: 2vh;
+  cursor: pointer;
+
+  &:active {
+    transform: scale(0.95);
+  }
+
+  > div {
+    height: 75%;
+    background-size: 100% 100%;
+    background-position: center;
+    background-image: url('@/assets/images/PlayList/Box.png');
+    padding: 7px 10px;
+
+    img {
+      border-radius: 10px;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+  }
+
+  h6 {
+    margin-top: 1.5vh;
+    margin-bottom: 0;
+    text-align: center;
+    color: #d75b4d;
+    font-size: 20px;
+    font-weight: bold;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 2;
+    line-clamp: 2;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+  }
+
+}
+
+</style>

+ 67 - 0
src/views/PlayerView.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import ProjectsContent from '@/api/inherit/ProjectsContent';
+import SimplePageContentLoader from '@/components/SimplePageContentLoader.vue';
+import SimpleRichHtml from '@/components/SimpleRichHtml.vue';
+import Box1 from '@/components/small/Box1.vue';
+import { useSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { ScrollRect } from '@imengyu/vue-scroll-rect';
+import { useRouter, useRoute } from 'vue-router';
+
+const route = useRoute();
+const router = useRouter();
+const data = useSimpleDataLoader(async () => 
+  await ProjectsContent.getContentDetail(parseInt(route.query.id as string))
+);
+</script>
+
+<template>
+  <main class="main-bg main-bg2 d-flex flex-col align-center justify-center">
+    <div class="main-box2 d-flex flex-row align-center">
+      <div class="d-flex flex-col align-center flex-one gap-base padding-top-ll">
+        <img class="main-image-button fill" src="@/assets/images/PlayList/Button1.png" alt="" @click="router.push('/')">
+        <img class="main-image-button fill disabled" src="@/assets/images/PlayList/Button2.png" alt="">
+      </div>
+      <div class="d-flex flex-row align-stretch flex-six margin-left-ll w-55 h-100">
+        <SimplePageContentLoader :loader="data">
+          <div class="d-flex flex-col align-stretch flex-six flex-shrink-1">
+            <video :src="data.content.value?.video" class="w-100 h-100" :controls="true" :autoplay="true" />
+          </div>
+          <div class="d-flex flex-col align-stretch flex-four margin-left-base flex-shrink-1 w-30 gap-l">
+            <Box1 class="desc h-50">
+              <ScrollRect scroll="vertical">
+                <h1>作品信息</h1>
+                <p>{{ data.content.value?.desc }}</p>
+                <SimpleRichHtml :catalog="false" :contents="[ data.content.value?.intro as string ]" :tag-style="{
+                  img: 'max-width: 100%'
+                }" />
+              </ScrollRect>
+            </Box1>
+            
+            <Box1 class="desc h-45">
+              <img :src="data.content.value?.image" class="w-100 h-100" alt="">
+
+            </Box1>
+          </div>
+        </SimplePageContentLoader>
+      </div>
+    </div>
+  </main>
+</template>
+
+<style lang="scss" scoped>
+.desc {
+  text-align: center;
+
+  h1 {
+    font-size: 26px;
+  }
+  p {
+    margin-bottom: 0;
+  }
+    
+  :deep(.rich-html) {
+    max-width: 100%;
+    background-color: rgba(#fff, 0.7);
+  }
+}
+</style>

+ 12 - 0
tsconfig.app.json

@@ -0,0 +1,12 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
+  "exclude": ["src/**/__tests__/*"],
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  }
+}

+ 11 - 0
tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.node.json"
+    },
+    {
+      "path": "./tsconfig.app.json"
+    }
+  ]
+}

+ 19 - 0
tsconfig.node.json

@@ -0,0 +1,19 @@
+{
+  "extends": "@tsconfig/node22/tsconfig.json",
+  "include": [
+    "vite.config.*",
+    "vitest.config.*",
+    "cypress.config.*",
+    "nightwatch.conf.*",
+    "playwright.config.*",
+    "eslint.config.*"
+  ],
+  "compilerOptions": {
+    "noEmit": true,
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "types": ["node"]
+  }
+}

+ 30 - 0
vite.config.ts

@@ -0,0 +1,30 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+import vueDevTools from 'vite-plugin-vue-devtools'
+import Components from 'unplugin-vue-components/vite';
+import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [
+    vue(),
+    vueJsx(),
+    vueDevTools(),
+    Components({
+      resolvers: [
+        AntDesignVueResolver({
+          importStyle: false, // css in js
+        }),
+      ],
+    }),
+  ],
+  base: './',
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    },
+  },
+})