Browse Source

🎉 初始提交

快乐的梦鱼 3 weeks ago
commit
bd200efb08
100 changed files with 20050 additions and 0 deletions
  1. 30 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 33 0
      README.md
  4. 14 0
      Untitled-1.txt
  5. 1 0
      env.d.ts
  6. 13 0
      index.html
  7. 14430 0
      package-lock.json
  8. 56 0
      package.json
  9. BIN
      public/favicon.ico
  10. 54 0
      src/App.vue
  11. 414 0
      src/api/CommonContent.ts
  12. 12 0
      src/api/NotConfigue.ts
  13. 224 0
      src/api/RequestModules.ts
  14. 15 0
      src/api/Utils.ts
  15. 68 0
      src/api/auth/UserApi.ts
  16. 175 0
      src/api/inheritor/InheritorContent.ts
  17. BIN
      src/assets/fonts/Impact.ttf
  18. BIN
      src/assets/fonts/Impact.woff
  19. BIN
      src/assets/fonts/Impact.woff2
  20. BIN
      src/assets/fonts/STSongti-SC-Black.ttf
  21. BIN
      src/assets/fonts/STSongti-SC-Black.woff
  22. BIN
      src/assets/fonts/STSongti-SC-Black.woff2
  23. BIN
      src/assets/fonts/SourceHanSerifCN-Bold.otf
  24. BIN
      src/assets/fonts/SourceHanSerifCN-Bold.ttf
  25. BIN
      src/assets/fonts/SourceHanSerifCN-Bold.woff
  26. BIN
      src/assets/fonts/SourceHanSerifCN-Bold.woff2
  27. BIN
      src/assets/fonts/nzgrRuyinZouZhangKai.ttf
  28. BIN
      src/assets/fonts/nzgrRuyinZouZhangKai.woff
  29. BIN
      src/assets/fonts/nzgrRuyinZouZhangKai.woff2
  30. 1 0
      src/assets/images/404.svg
  31. BIN
      src/assets/images/BackArrow.png
  32. BIN
      src/assets/images/Bg1.png
  33. BIN
      src/assets/images/Bg2.png
  34. BIN
      src/assets/images/BgLong.jpg
  35. BIN
      src/assets/images/CloseMini.png
  36. BIN
      src/assets/images/DropDownArrow.png
  37. BIN
      src/assets/images/IconArrowRight.png
  38. 8 0
      src/assets/images/IconInfo.svg
  39. BIN
      src/assets/images/IconUser.png
  40. BIN
      src/assets/images/ImageFailed.png
  41. BIN
      src/assets/images/LargeTitle1.png
  42. BIN
      src/assets/images/LargeTitle2.png
  43. BIN
      src/assets/images/LargeTitle3.png
  44. BIN
      src/assets/images/LogoIcon.png
  45. BIN
      src/assets/images/TitleMiniHeader.png
  46. 1 0
      src/assets/images/Welecome.svg
  47. BIN
      src/assets/images/favicon.ico
  48. BIN
      src/assets/images/favicon.png
  49. BIN
      src/assets/images/footer/FooterPrinting.png
  50. BIN
      src/assets/images/footer/GonganLogo.png
  51. 27 0
      src/assets/scss/colors.scss
  52. 32 0
      src/assets/scss/components.scss
  53. 8 0
      src/assets/scss/fix.scss
  54. 36 0
      src/assets/scss/fonts.scss
  55. 449 0
      src/assets/scss/main.scss
  56. 425 0
      src/assets/scss/news.scss
  57. 63 0
      src/assets/scss/scroll.scss
  58. 2 0
      src/common/ConstStrings.ts
  59. 98 0
      src/common/ConvertRgeistry.ts
  60. 8 0
      src/common/EventBus.ts
  61. 18 0
      src/common/LoginPageRedirect.ts
  62. 9 0
      src/common/config/ApiCofig.ts
  63. 17 0
      src/common/config/AppCofig.ts
  64. 127 0
      src/components/Footer.vue
  65. 300 0
      src/components/NavBar.vue
  66. 44 0
      src/components/VNodeRenderer.vue
  67. 65 0
      src/components/controls/Check.vue
  68. 5 0
      src/components/controls/CheckIcon.vue
  69. 148 0
      src/components/controls/Dropdown.vue
  70. 5 0
      src/components/controls/DropdownIcon.vue
  71. 130 0
      src/components/controls/Pagination.vue
  72. 111 0
      src/components/controls/SimpleInput.vue
  73. 90 0
      src/components/display/SimplePopup.vue
  74. 24 0
      src/components/display/SimpleRemoveRichHtml.vue
  75. 147 0
      src/components/display/SimpleRichHtml.vue
  76. 57 0
      src/components/display/SimpleScrollView.vue
  77. 29 0
      src/components/dynamicf/ActionRender.ts
  78. 35 0
      src/components/dynamicf/ActionRender.vue
  79. 50 0
      src/components/dynamicf/CascaderFormItem.ts
  80. 165 0
      src/components/dynamicf/CascaderFormItem.vue
  81. 56 0
      src/components/dynamicf/CheckBoxToInt.vue
  82. 8 0
      src/components/dynamicf/CheckBoxValue.ts
  83. 51 0
      src/components/dynamicf/CheckBoxValue.vue
  84. 30 0
      src/components/dynamicf/Display/ShowDateOrNull.vue
  85. 86 0
      src/components/dynamicf/Display/ShowImageList.vue
  86. 76 0
      src/components/dynamicf/Display/ShowImageOrNull.vue
  87. 69 0
      src/components/dynamicf/Display/ShowInList.vue
  88. 35 0
      src/components/dynamicf/Display/ShowMomentOrNull.vue
  89. 48 0
      src/components/dynamicf/Display/ShowTagList.vue
  90. 73 0
      src/components/dynamicf/Display/ShowValueOrNull.vue
  91. 37 0
      src/components/dynamicf/Display/StateRenderer.vue
  92. 88 0
      src/components/dynamicf/Dropdown/IdAsValueDropdown.ts
  93. 253 0
      src/components/dynamicf/Dropdown/IdAsValueDropdown.vue
  94. 336 0
      src/components/dynamicf/Dropdown/IdAsValueTreeDropdown.vue
  95. 127 0
      src/components/dynamicf/IdAsValueTree.ts
  96. 179 0
      src/components/dynamicf/IdAsValueTree.vue
  97. 69 0
      src/components/dynamicf/NumberRange.vue
  98. 116 0
      src/components/dynamicf/PasswordStrengthMeter.vue
  99. 37 0
      src/components/dynamicf/PasswordWithStrengthInput.vue
  100. 0 0
      src/components/dynamicf/RadioValue.ts

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+# 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
+
+/cypress/videos/
+/cypress/screenshots/
+
+# 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"]
+}

+ 33 - 0
README.md

@@ -0,0 +1,33 @@
+# minnan-collect-web
+
+This template should help get you started developing with Vue 3 in Vite.
+
+## Recommended IDE Setup
+
+[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
+
+## Type Support for `.vue` Imports in TS
+
+TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
+
+## Customize configuration
+
+See [Vite Configuration Reference](https://vite.dev/config/).
+
+## Project Setup
+
+```sh
+npm install
+```
+
+### Compile and Hot-Reload for Development
+
+```sh
+npm run dev
+```
+
+### Type-Check, Compile and Minify for Production
+
+```sh
+npm run build
+```

+ 14 - 0
Untitled-1.txt

@@ -0,0 +1,14 @@
+你将专门为我生成我的项目中的动态表单配置
+本项目采用的是Vue3 web技术栈,其中主要的功能点为表单提交,因此本项目采用了动态表单库 vue-dynamic-form,
+关于动态表单库的使用文档可参考https://docs.imengyu.top/vue-dynamic-form-docs/guide/about.html
+
+
+
+你的主要任务是,为我生成重复的枯燥的表单配置:
+我会输入后端需要提交的字段列表,这包含:参数名、必选、类型、说明,
+为我选择最适合展现的表单组件,依据 vue-dynamic-form 动态表单库的配置与功能,
+为我攥写一个完整可用的表单配置,并写入页面的变量中。
+
+可用组件:在 \src\components\dynamicf\index.ts 中的 registerAllFormComponents 函数中有注册了本项目中可用的动态表单组件,可以在这里查阅。
+register的一个参数小写名字为组件的唯一标识,在配置中使用。
+本项目大部分使用 ant-design-vue 的组件,少部分组件为二次封装组件,放在项目的 \src\components\dynamicf 文件夹下。

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

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


+ 56 - 0
package.json

@@ -0,0 +1,56 @@
+{
+  "name": "minnan-collect-web",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "engines": {
+    "node": "^20.19.0 || >=22.12.0"
+  },
+  "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.14",
+    "@imengyu/js-request-transform": "^0.3.5",
+    "@imengyu/vue-dynamic-form": "^0.1.1",
+    "@imengyu/vue-scroll-rect": "^0.1.3",
+    "@vuemap/vue-amap": "^2.1.12",
+    "ant-design-vue": "^4.2.6",
+    "axios": "^1.9.0",
+    "bootstrap": "^5.3.0",
+    "lodash-es": "^4.17.21",
+    "md5": "^2.3.0",
+    "mitt": "^3.0.1",
+    "nprogress": "^0.2.0",
+    "nuxt": "^3.17.6",
+    "pinia": "^3.0.3",
+    "tslib": "^2.8.1",
+    "vue": "^3.5.18",
+    "vue-router": "^4.5.1",
+    "vue3-carousel": "^0.15.0"
+  },
+  "devDependencies": {
+    "@inquirer/prompts": "^7.5.3",
+    "@tsconfig/node22": "^22.0.2",
+    "@types/node": "^22.16.5",
+    "@types/nprogress": "^0.2.3",
+    "@vitejs/plugin-vue": "^6.0.1",
+    "@vitejs/plugin-vue-jsx": "^5.0.1",
+    "@vue/tsconfig": "^0.7.0",
+    "ali-oss": "^6.23.0",
+    "archiver": "^7.0.1",
+    "cli-progress": "^3.12.0",
+    "cli-table3": "^0.6.5",
+    "commander": "^14.0.0",
+    "npm-run-all2": "^8.0.4",
+    "sass": "^1.87.0",
+    "typescript": "~5.8.0",
+    "vite": "^7.0.6",
+    "vite-plugin-vue-devtools": "^8.0.0",
+    "vue-tsc": "^3.0.4"
+  }
+}

BIN
public/favicon.ico


+ 54 - 0
src/App.vue

@@ -0,0 +1,54 @@
+<template>
+  <a-config-provider
+    :locale="zhCN"
+    :theme="{
+      token: {
+        colorPrimary: '#bd4b36',
+      },
+    }"
+  >
+    <NavBar />
+    <main>
+      <RouterView />
+    </main>
+    <Footer />
+  </a-config-provider>
+</template>
+
+<script setup lang="ts">
+import { onMounted, watch } from 'vue';
+import { RouterView, useRoute } from 'vue-router'
+import { useAuthStore } from './stores/auth';
+import NavBar from './components/NavBar.vue';
+import Footer from './components/Footer.vue';
+import zhCN from 'ant-design-vue/es/locale/zh_CN';
+import { useRedirectLoginPage } from './common/LoginPageRedirect';
+
+const authStore = useAuthStore();
+const { checkAndRedirectLoginPage } = useRedirectLoginPage();
+
+onMounted(async () => {
+  await authStore.loadLoginState();
+  checkAndRedirectLoginPage();
+});
+
+const route = useRoute();
+
+watch(route, () => {
+  window.scrollTo({
+    top: 0,
+    behavior: 'instant'
+  });
+  checkAndRedirectLoginPage();
+});
+</script>
+
+<style>
+@import "bootstrap/dist/css/bootstrap.css";
+@import "bootstrap/dist/css/bootstrap-grid.css";
+@import "bootstrap/dist/css/bootstrap-utilities.css";
+@import "./assets/scss/main.scss";
+@import "vue3-carousel/carousel.css";
+@import "@vuemap/vue-amap/dist/style.css";
+@import "@imengyu/vue-scroll-rect/lib/vue-scroll-rect.css";
+</style>

+ 414 - 0
src/api/CommonContent.ts

@@ -0,0 +1,414 @@
+import { DataModel, transformArrayDataModel, type NewDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from './RequestModules';
+import type { QueryParams } from "@imengyu/imengyu-utils/dist/request";
+import { transformSomeToArray } from '@/api/Utils';
+
+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;
+}
+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;
+
+}
+
+export class GetColumContentList extends DataModel<GetColumContentList> {
+  constructor() {
+    super(GetColumContentList, "主体栏目列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      name: { clientSide: 'string', serverSide: 'string', clientSideRequired: true },
+      content_list: { 
+        clientSide: 'array',
+        clientSideRequired: true,
+        clientSideChildDataModel: GetContentListItem,
+      },
+    }
+  }
+
+  name = '';
+  overview = '';
+}
+export class GetContentListItem extends DataModel<GetContentListItem> {
+  constructor() {
+    super(GetContentListItem, "内容列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      mainBodyColumnId: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      title: { clientSide: 'string', serverSide: 'string', clientSideRequired: true },
+      isGuest: { clientSide: 'boolean', serverSide: 'number' },
+      isLogin: { clientSide: 'boolean', serverSide: 'number' },
+      isComment: { clientSide: 'boolean', serverSide: 'number' },
+      isLike: { clientSide: 'boolean', serverSide: 'number' },
+      isCollect: { clientSide: 'boolean', serverSide: 'number' },
+      latitude: { clientSide: 'number', serverSide: 'number' },
+      longitude: { clientSide: 'number', serverSide: 'number' },
+      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') || key.endsWith('At'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+  }
+  id = 0;
+  mainBodyColumnId = 0;
+  latitude = 0;
+  longitude = 0;
+  mapX = '';
+  mapY = '';
+  from = '';
+  modelId = 0;
+  title = '!title';
+  typeText = '';
+  region = 0;
+  image = '';
+  thumbnail = '';
+  desc = '!desc';
+  content = '!content';
+  type = 0;
+  keywords ?: string[];
+  flag ?: string[];
+  tags ?: string[];
+  views = 0;
+  comments = 0;
+  likes = 0;
+  collects = 0;
+  dislikes = 0;
+  district = '';
+  publishAt = new Date();
+}
+export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
+  constructor() {
+    super(GetContentDetailItem, "内容详情");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      title: { clientSide: 'string', serverSide: 'string' },
+      content: { clientSide: 'string', serverSide: 'string' },
+      isGuest: { clientSide: 'boolean', serverSide: 'number' },
+      isLogin: { clientSide: 'boolean', serverSide: 'number' },
+      isComment: { clientSide: 'boolean', serverSide: 'number' },
+      isLike: { clientSide: 'boolean', serverSide: 'number' },
+      isCollect: { clientSide: 'boolean', serverSide: 'number' },
+      publishAt: { clientSide: 'date', serverSide: 'string' },
+      flag: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      tags: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      type: { clientSide: 'number', serverSide: 'number' },
+      ichSitesList: { clientSide: 'array', clientSideChildDataModel: GetContentDetailItem },
+      inheritorsList: { clientSide: 'array', clientSideChildDataModel: GetContentDetailItem },
+      otherLevel: { clientSide: 'array', clientSideChildDataModel: GetContentDetailItem },
+    }
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Time') || key.endsWith('At'))
+        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 === 'https://mncdn.wenlvti.net')
+        this.image = '';
+      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.image != '') {
+        this.images = [ this.image ]
+      }
+      if (!this.images)
+         this.images = []
+    }
+  }
+
+  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 = '';
+  value = '';
+  intro = '';
+  publishAt = new Date();
+  associationMeList = [] as {
+    id: number,
+    title: string,
+    image: string,
+    thumbnail: string,
+  }[];
+  otherLevel : 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(modelId: number, debugName: string, mainBodyColumnId?: number|number[]) {
+    super();
+    this.modelId = modelId;
+    this.mainBodyColumnId = mainBodyColumnId;
+    this.debugName = debugName;
+  }
+
+  public mainBodyColumnId?: number|number[];
+  public modelId: number;
+  protected debugName: string;
+
+  /**
+   * 获取分类列表
+   * @param type 根级类型:1=区域、2=级别、3=文物类型、4=非遗类型、42=事件类型
+   * @param withself 是否返回包含自己:true=是,false=否 ,默认false
+   * @returns 
+   */
+  async getCategoryList(
+    type?: number,
+    withself?: boolean,
+  ) {
+    return (this.get('/content/category/getCategoryList', '获取分类列表', {
+      type,
+      is_tree: false,
+      withself,
+    }))
+      .then(res => transformArrayDataModel<CategoryListItem>(CategoryListItem, res.data2, `获取分类列表`, true))
+      .catch(e => { throw e });
+  }
+  /**
+   * 用于获取某一个分类需要用的子级
+   * @param pid 父级
+   * @returns 
+   */
+  async getCategoryChildList(pid?: number) {
+    return (this.get('/content/category/getCategoryOnlyChildList', '获取分类子级列表', {
+      pid,
+    }))
+      .then(res => transformArrayDataModel<CategoryListItem>(
+        CategoryListItem, 
+        transformSomeToArray(res.data2), 
+        `获取分类列表`, 
+        true
+      ))
+      .catch(e => { throw e });
+  }
+
+  private toStringArray(arr: number|number[]|undefined) {
+    if (typeof arr === 'undefined') 
+      return '';
+    return typeof arr === 'object' ? arr.join(',') : arr.toString();
+  }
+
+  /**
+   * 主体栏目列表
+   * @param params 参数 
+   * @param querys 额外参数
+   * @returns 
+   */
+  getColumList<T extends DataModel = GetColumContentList>(params: GetColumListParams, modelClassCreator: NewDataModel = GetColumContentList, querys?: QueryParams) {
+    return this.get('/content/content/getMainBodyColumnContentList', `${this.debugName} 主体栏目列表`, {
+      model_id: this.modelId,
+      ...params.toServerSide(),
+      ...querys,
+    })
+      .then(res => ({
+        list: transformArrayDataModel<T>(modelClassCreator, res.data2.list, `${this.debugName} 主体栏目列表`, true),
+        total: res.data2.total as number,
+      }))
+      .catch(e => { throw e });
+  }
+  /**
+   * 模型内容列表
+   * @param params 参数
+   * @param page 页码
+   * @param pageSize 页大小
+   * @param querys 额外参数
+   * @returns 
+   */
+  getContentList<T extends DataModel = GetContentListItem>(params: GetContentListParams, page: number, pageSize: number = 10, modelClassCreator: NewDataModel = GetContentListItem, querys?: QueryParams) {
+    return this.get('/content/content/getContentList', `${this.debugName} 模型内容列表`, {
+      ...params.toServerSide(),
+      ...querys,
+      model_id: params.modelId || this.modelId,
+      main_body_column_id: this.toStringArray(params.mainBodyColumnId || this.mainBodyColumnId),
+      page,
+      pageSize,
+    })
+      .then(res => ({ 
+        list: transformArrayDataModel<T>(modelClassCreator, res.data2.list, `${this.debugName} 模型内容列表`, true),
+        total: res.data2.total as number,
+      }))
+      .catch(e => { throw e });
+  }
+  /**
+   * 内容详情
+   * @param id id 
+   * @param querys 额外参数
+   * @returns 
+   */
+  getContentDetail<T extends DataModel = GetContentDetailItem>(id: number, modelId?: number, modelClassCreator: NewDataModel = GetContentDetailItem, querys?: QueryParams) {
+    return this.get('/content/content/getContentDetail', `${this.debugName} (${id}) 内容详情`, {
+      model_id: modelId ?? this.modelId,
+      id,
+      ...querys,
+    }, modelClassCreator)
+      .then(res => res.data as T)
+      .catch(e => { throw e });
+  }
+}
+
+export default new CommonContentApi(0, '默认通用内容');

+ 12 - 0
src/api/NotConfigue.ts

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

+ 224 - 0
src/api/RequestModules.ts

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

+ 15 - 0
src/api/Utils.ts

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

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

@@ -0,0 +1,68 @@
+import { DataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+
+
+export class LoginResult extends DataModel<LoginResult> {
+  constructor() {
+    super(LoginResult, "登录结果");
+    this._convertTable = {
+      token: { clientSide: 'string', clientSideRequired: true },
+    };
+    this._afterSolveServer = () => {
+      this.userInfo.id = this.id;
+      this.userInfo.mobile = this.mobile;
+      this.userInfo.nickname = this.nickname;
+      this.userInfo.avatar = this.avatar;
+      this.userInfo.username = this.username;
+    }
+  }
+  id = 0;
+  username = '';
+  nickname = '';
+  mobile = '';
+  avatar = '';
+  bio = '';
+  score = null as number|null;
+  token = '';
+  userId = null as number|null;
+  createtime = null as number|null;
+  expiretime = null as number|null;
+  expiresIn = null as number|null;
+  inheritorId = null as number|null;
+  userInfo = new UserInfo();
+}
+export class UserInfo extends DataModel<UserInfo> {
+  constructor() {
+    super(UserInfo, "用户信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+  }
+
+  id = 0;
+  userId = 0;
+  mobile = '';
+  nickname = '';
+  avatar = '';
+  username = '';
+}
+
+export class UserApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+  async login(data: {
+    mobile: string,
+    password: string,
+  }) {
+    return (await this.post('/ich/inheritor/login', data, '登录', undefined, LoginResult)).data as LoginResult;
+  }
+
+  async refresh() {
+    return (await this.post('/ich/inheritor/refresh', {}, '刷新token', undefined, LoginResult)).data as LoginResult;
+  }
+}
+
+export default new UserApi();

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

@@ -0,0 +1,175 @@
+import { DataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+
+export class IchInfo extends DataModel<IchInfo> {
+  constructor() {
+    super(IchInfo, "非遗项目信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+  }
+
+  id = 0 as number;
+  mainBodyColumnId = 0 as number;
+  title = '' as string;
+  region = 0 as number;
+  image = '' as string;
+  imageDesc = '' as string|null;
+  images = [] as string[];
+  audio = '' as string|null;
+  video = '' as string;
+  flag = '' as string;
+  keywords = '' as string|null;
+  tags = '' as string;
+  associationId = '' as string|null;
+  pid = 0 as number;
+  ztImage = '' as string|null;
+  intro = '' as string;
+  description = '' as string;
+  heritage = 0 as number;
+  level = 0 as number;
+  ichType = 0 as number;
+  batch = 0 as number;
+  longitude = '' as string;
+  latitude = '' as string;
+  mapX = '' as string|null;
+  mapY = '' as string|null;
+  unit = '' as string;
+  address = '' as string|null;
+  declarationRegion = '' as string;
+  popularRegion = '' as string;
+  approveTime = 0 as number;
+  typicalImages = [] as string[];
+  thumbnail = '' as string;
+  flagText = '' as string;
+  typeText = '' as string;
+  openStatusText = '' as string;
+  statusText = '' as string;
+  regionText = '' as string;
+  levelText = '' as string;
+  crTypeText = '' as string;
+  ichTypeText = '' as string;
+  claimStatusText = '' as string;
+  isMultipleClaimsText = '' as string;
+  batchText = '' as string;
+  ichSiteTypeText = '' as string;
+}
+export class InheritorInfo extends DataModel<InheritorInfo> {
+  constructor() {
+    super(InheritorInfo, "传承人信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+  }
+  id = 0 as number;
+  mainBodyColumnId = 0 as number;
+  title = '' as string;
+  region = 0 as number;
+  image = '' as string;
+  imageDesc = '' as string|null;
+  images = [] as string[];
+  audio = '' as string|null;
+  video = '' as string|null;
+  flag = '' as string;
+  keywords = '' as string|null;
+  tags = '' as string;
+  associationId = 0 as number;
+  pid = 0 as number;
+  alsoName = '' as string|null;
+  nation = '' as string;
+  dateBirth = '' as string;
+  deathBirth = '' as string|null;
+  unit = '' as string;
+  content = '' as string|null;
+  intro = '' as string;
+  prize = '' as string;
+  level = 0 as number;
+  gender = 0 as number;
+  batch = 0 as number;
+  typicalImages = [] as string[];
+  progress = 0 as number;
+  contentId = 0 as number;
+  thumbnail = '' as string;
+  flagText = '' as string;
+  typeText = '' as string;
+  openStatusText = '' as string;
+  statusText = '' as string;
+  regionText = '' as string;
+  levelText = '' as string;
+  crTypeText = '' as string;
+  ichTypeText = '' as string;
+  claimStatusText = '' as string;
+  isMultipleClaimsText = '' as string;
+  batchText = '' as string;
+  ichSiteTypeText = '' as string;
+  progressText = '' as string;
+}
+export class SeminarInfo extends DataModel<IchInfo> {
+  constructor() {
+    super(IchInfo, "传习所信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+  }
+  id = 0 as number;
+  mainBodyColumnId = 0 as number;
+  title = '' as string;
+  region = 0 as number;
+  image = '' as string|null;
+  imageDesc = '' as string|null;
+  images = [] as string[];
+  audio = '' as string|null;
+  video = '' as string|null;
+  flag = '' as string;
+  keywords = '' as string|null;
+  tags = '' as string;
+  associationId = 0 as number;
+  pid = 0 as number;
+  content = '' as string|null;
+  mapX = '' as string|null;
+  mapY = '' as string|null;
+  address = '' as string;
+  featuresType = 0 as number;
+  contact = '' as string;
+  ichSiteType = '' as string;
+  flagText = '' as string;
+  typeText = '' as string;
+  openStatusText = '' as string;
+  statusText = '' as string;
+  regionText = '' as string;
+  levelText = '' as string;
+  crTypeText = '' as string;
+  ichTypeText = '' as string;
+  claimStatusText = '' as string;
+  isMultipleClaimsText = '' as string;
+  batchText = '' as string;
+  ichSiteTypeText = '' as string;
+}
+
+export class InheritorContentApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+  async getBaseInfo<T extends DataModel>(modelId: number, newDataModel: new () => T) {
+    return (await this.post('/ich/inheritor/baseInfo', {
+      model_id: modelId,
+    }, '基础表信息', undefined, newDataModel)).data as T;
+  }
+
+  async getIchInfo() {
+    return await this.getBaseInfo(2, IchInfo);
+  }
+  async getInheritorInfo() {
+    return await this.getBaseInfo(7, InheritorInfo);
+  }
+  async getSeminarInfo() {
+    return await this.getBaseInfo(17, SeminarInfo);
+  }
+
+}
+
+export default new InheritorContentApi();

BIN
src/assets/fonts/Impact.ttf


BIN
src/assets/fonts/Impact.woff


BIN
src/assets/fonts/Impact.woff2


BIN
src/assets/fonts/STSongti-SC-Black.ttf


BIN
src/assets/fonts/STSongti-SC-Black.woff


BIN
src/assets/fonts/STSongti-SC-Black.woff2


BIN
src/assets/fonts/SourceHanSerifCN-Bold.otf


BIN
src/assets/fonts/SourceHanSerifCN-Bold.ttf


BIN
src/assets/fonts/SourceHanSerifCN-Bold.woff


BIN
src/assets/fonts/SourceHanSerifCN-Bold.woff2


BIN
src/assets/fonts/nzgrRuyinZouZhangKai.ttf


BIN
src/assets/fonts/nzgrRuyinZouZhangKai.woff


BIN
src/assets/fonts/nzgrRuyinZouZhangKai.woff2


File diff suppressed because it is too large
+ 1 - 0
src/assets/images/404.svg


BIN
src/assets/images/BackArrow.png


BIN
src/assets/images/Bg1.png


BIN
src/assets/images/Bg2.png


BIN
src/assets/images/BgLong.jpg


BIN
src/assets/images/CloseMini.png


BIN
src/assets/images/DropDownArrow.png


BIN
src/assets/images/IconArrowRight.png


File diff suppressed because it is too large
+ 8 - 0
src/assets/images/IconInfo.svg


BIN
src/assets/images/IconUser.png


BIN
src/assets/images/ImageFailed.png


BIN
src/assets/images/LargeTitle1.png


BIN
src/assets/images/LargeTitle2.png


BIN
src/assets/images/LargeTitle3.png


BIN
src/assets/images/LogoIcon.png


BIN
src/assets/images/TitleMiniHeader.png


File diff suppressed because it is too large
+ 1 - 0
src/assets/images/Welecome.svg


BIN
src/assets/images/favicon.ico


BIN
src/assets/images/favicon.png


BIN
src/assets/images/footer/FooterPrinting.png


BIN
src/assets/images/footer/GonganLogo.png


+ 27 - 0
src/assets/scss/colors.scss

@@ -0,0 +1,27 @@
+$primary-color: #bd4b36;
+$primary-dark-color: #aa6052;
+
+$text-color: #333;
+$text-color-light: #fff;
+$text-second-color: #6d6d6d;
+$text-second-color-light: #ddd;
+
+
+$text-content-color: #654A38;
+$text-content-second-color: rgba(101, 74, 56, 0.6);
+
+$selection-max-width: 1250px;
+
+$border-split-color:rgb(236, 236, 236);
+$border-grey-color: #b3b3b3;
+$border-default-color: $primary-dark-color;
+$border-active-color: $primary-color;
+$border-dark-color: #fff;
+
+$box-dark-trans-color: rgba(#000, 0.55);
+$box-dark-trans-color2: rgba(#000, 0.22);
+$box-dark-trans-color3: rgba(#000, 0.65);
+$box-color: #fff;
+$box-inset-color: rgba(#FFFDF9, 0.33);
+$box-hover-color: rgba(#FFFDF9, 0.88);
+$box-primary-color: rgba($primary-color, 0.38);

+ 32 - 0
src/assets/scss/components.scss

@@ -0,0 +1,32 @@
+@use "./colors.scss" as *;
+
+.simple-link {
+  font-size: 20px;
+  line-height: 30px;
+  color: $primary-color;
+  text-decoration: none;
+}
+.simple-carousel2-left-right {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  width: 100px;
+  margin-top: 40px;
+
+  div {
+    width: 30px;
+    height: 30px;
+    font-size: 25px;
+    text-align: center;
+    line-height: 30px;
+    cursor: pointer;
+    -webkit-user-select: none;
+    user-select: none;
+    color: $primary-color;
+  }
+}
+
+.left-right-grid {
+  
+}

+ 8 - 0
src/assets/scss/fix.scss

@@ -0,0 +1,8 @@
+.carousel-light {
+  --vc-nav-color: #fff;
+  --vc-clr-primary: #fff;
+  --vc-clr-secondary: #cfcfcfc4;
+  --vc-pgn-background-color: #cfcfcfc4;
+  --vc-clr-white: #333333;
+  --vc-pgn-active-color: var(--vc-clr-primary)
+}

+ 36 - 0
src/assets/scss/fonts.scss

@@ -0,0 +1,36 @@
+@font-face {
+  font-family: nzgrRuyinZouZhangKai;
+  src: 
+    url('@/assets/fonts/nzgrRuyinZouZhangKai.woff') format('woff')
+    url('@/assets/fonts/nzgrRuyinZouZhangKai.ttf') format('truetype')
+    url('@/assets/fonts/nzgrRuyinZouZhangKai.woff2') format('woff2');
+  font-weight: normal;
+}
+@font-face {
+  font-family: STSongtiSCBlack;
+  src: 
+    url('@/assets/fonts/STSongti-SC-Black.woff') format('woff') 
+    url('@/assets/fonts/STSongti-SC-Black.ttf') format('truetype')
+    url('@/assets/fonts/STSongti-SC-Black.woff2') format('woff2');
+  font-weight: normal;
+}
+@font-face {
+  font-family: Impact;
+  src: 
+    url('@/assets/fonts/Impact.woff') format('woff') 
+    url('@/assets/fonts/Impact.ttf') format('truetype')
+    url('@/assets/fonts/Impact.woff2') format('woff2');
+  font-weight: normal;
+}
+@font-face {
+  font-family: SourceHanSerifCNBold;
+  src: 
+    url('@/assets/fonts/SourceHanSerifCN-Bold.woff') format('woff') 
+    //url('@/assets/fonts/SourceHanSerifCN-Bold.ttf') format('truetype')
+    ;//url('@/assets/fonts/SourceHanSerifCN-Bold.woff2') format('woff2');
+  font-weight: normal;
+}
+
+.font-SourceHanSerifCNBold {
+  font-family: SourceHanSerifCNBold;
+}

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

@@ -0,0 +1,449 @@
+@use "./fonts.scss";
+@use "./fix.scss";
+@use "./components.scss";
+@use "./news.scss";
+@use "./colors.scss" as *;
+@use "sass:list";
+@use "sass:math";
+
+body,
+html {
+  font-size: 16px;
+  font-weight: normal;  
+  margin: 0;
+  padding: 0;
+  color: $text-color;
+}
+main {
+  position: relative;
+}
+
+//Header
+
+$large-banner-height: 600px;
+$small-banner-height: 445px;
+
+.main-header-box {
+  position: relative;
+  width: 100%;
+  min-height: $large-banner-height;
+  background-color: $primary-color;
+
+  &.small {
+    min-height: $small-banner-height;
+  }
+
+  img {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    z-index: 0;
+  }
+}
+.main-center-text {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  text-align: center;
+}
+.main-header-title {
+  font-family: SourceHanSerifCNBold;
+  color: $text-color-light;
+
+  h1 {
+    font-size: 3rem;
+    margin: 0;
+    margin-bottom: 16px;
+  }
+  h2 {
+    font-size: 2.5rem;
+    margin: 0;
+    margin-bottom: 16px;
+  }
+  p {
+    font-size: 1.5rem;
+    margin: 0;
+    margin-bottom: 24px;
+  }
+}
+.main-header-tab {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+
+  .list {
+    margin: 0 auto;
+    max-width: $selection-max-width;
+    background-color: $box-dark-trans-color3;
+    backdrop-filter: blur(5px);
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: flex-start;
+
+    > div {
+      min-width: 300px;
+      height: 56px;
+      color: $text-color-light;
+      text-align: center;
+      line-height: 56px;
+      cursor: pointer;
+
+      &.active {
+        background-color: $primary-color;
+        height: 60px;
+      }
+    }
+  }
+}
+
+//Utitles
+
+.main-background {
+  background-size: 100% auto;
+  background-repeat: repeat;
+  background-position: center top;
+
+  &-type0 {
+    background-image: url('@/assets/images/BgLong.jpg');
+  }
+  &-type1 {
+    background-image: url('@/assets/images/Bg1.png');
+  }
+  &-type2 {
+    background-image: url('@/assets/images/Bg2.png');
+  }
+  &-type3 {
+    background-image: url('@/assets/images/index/IntrodRight.jpg');
+  }
+}
+.main-clickable {
+  cursor: pointer;
+  -webkit-user-select: none;
+  user-select: none;
+  transition: all 0.2s ease; 
+
+  &:hover {
+    transform: scale(1.05);
+  }
+  &:active {
+    transform: scale(0.95); 
+  }
+}
+
+//Boxs
+
+.main-box {
+  overflow: hidden;
+  background-color: $box-color;
+  border-radius: 5px;
+}
+
+//Section
+
+.main-section {
+  position: relative;
+  padding: 120px 100px;
+
+  &.absolute {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    z-index: 10;
+  }
+  &.fit-small-header {
+    height: $small-banner-height;
+  }
+  &.light {
+    color: $text-color-light;
+  }
+  &.small-h {
+    padding-top: 40px;
+    padding-bottom: 40px;
+  }
+
+  h2 {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    margin: 0;
+    font-size: 2rem;
+    font-family: SourceHanSerifCNBold;
+
+    &::after, &::before {
+      content: '';
+      display: inline-block;
+      width: 20px;
+      height: 20px;
+      background-size: 20px;
+      background-image: url('@/assets/images/TitleMiniHeader.png');
+    }
+    &::after {
+      margin-left: 10px;
+    } 
+    &::before {
+      margin-right: 10px;
+    }
+  }
+  
+  .content {
+    max-width: $selection-max-width;
+    margin: 0 auto;
+
+    .title {
+
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      justify-content: center;
+
+      margin-bottom: 40px;
+
+      &.left-right {
+        justify-content: space-between;
+      }
+
+      .small-more {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+        font-size: 0.9rem;
+        color: $text-second-color;
+        cursor: pointer;
+        -webkit-user-select: none;
+        user-select: none;
+
+        img {
+          width: 80px;
+          margin-left: 20px;
+        }
+      }
+    }
+    .tab-button {
+      background-color: $primary-color;
+      color: $text-color-light;
+      padding: 10px 15px;
+      margin-right: 8px;
+      cursor: pointer;
+      user-select: none;
+      outline: none;
+      flex-shrink: 0;
+    }
+  }
+}
+
+.main-stats {
+  display: flex;
+  flex-direction: column;
+  font-family: SourceHanSerifCNBold;
+
+  h4 {
+    margin: 50px 0 10px 0;
+    font-size: 1rem;
+    color: $text-second-color;
+  }
+
+  .descs {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-around;
+
+    div {
+      text-align: center;
+      cursor: pointer;
+    }
+
+    h5 {
+      margin: 0;
+      font-size: 4.5rem;
+      font-weight: bold;
+    }
+    p {
+      margin: 0;
+      font-size: 0.9rem;
+    }
+  }
+}
+
+//Card box
+
+.main-card-box {
+  position: relative;
+  min-height: 330px;
+  color: #fff;
+  margin-right: 24px;
+  overflow: hidden;
+  //transform: translateX(-50%);
+
+  .content {
+    position: absolute;
+    inset: 24px;
+    z-index: 10;
+    display: flex;
+    flex-direction: column;
+
+    h4 {
+      font-family: SourceHanSerifCNBold;
+      font-size: 1.5rem;
+      margin: 0;
+      margin-bottom: 32px;
+    }
+    .descs {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: wrap;
+
+      .box {
+        flex: 1 1 50%;
+        margin-bottom: 32px;
+        cursor: pointer;
+        color: #fff;
+        text-decoration: none;
+
+        h5 {
+          font-size: 1rem;
+          font-weight: normal;
+          margin: 0;
+        }
+        p {
+          font-family: Impact;
+          font-weight: normal;
+          font-size: 2.8rem;
+          margin: 0;
+        }
+      }
+    }
+  }
+
+  $background-types: (
+    type1: (url('@/assets/images/index/BoxPrinting2.png'), url('@/assets/images/index/Box3.jpg')),
+    type2: (url('@/assets/images/index/BoxPrinting1.png'), url('@/assets/images/index/Box1.png')),
+    type3: (url('@/assets/images/index/BoxPrinting4.png'), url('@/assets/images/index/Box2.jpg'))
+  );
+
+  @each $typeName, $type in $background-types {
+    &.#{$typeName} {
+      &::after {
+        content: '';
+        position: absolute;
+        inset: 0;
+        background-image: list.nth($type, 2);
+        z-index: 0;
+      }
+      &::before {
+        content: '';
+        position: absolute;
+        bottom: -10px;
+        right: -10px;
+        width: 180px;
+        height: 180px;
+        background-size: 180px;
+        background-image: list.nth($type, 1);
+        z-index: 1;
+      }
+    }
+  }
+
+  &.type3 .descs div {
+    flex-basis: 33%;
+    margin-bottom: 22px;
+  }
+}
+
+@media (max-width: 1280px) {
+  .main-section {
+    padding: 100px 80px;
+    &.small-h {
+      padding-top: 40px;
+      padding-bottom: 40px;
+    }
+  }
+}
+@media (max-width: 1024px) {
+  .main-section {
+    padding: 80px 60px;
+    &.small-h {
+      padding-top: 30px;
+      padding-bottom: 30px;
+    }
+  }
+  .main-header-tab {
+    .list {
+      > div {
+        min-width: initial;
+        flex: 1;
+      }
+    }
+  }
+}
+@media (max-width: 768px) {
+  .main-section {
+    padding: 80px 40px;
+    &.small-h {
+      padding-top: 20px;
+      padding-bottom: 20px;
+    }
+  }
+  .main-stats {
+    h4 {
+      margin: 20px 0 10px 0;
+      font-size: 1rem;
+    }
+    .descs {
+      h5 {
+        font-size: 3rem;
+      }
+      p {
+        font-size: 0.9rem;
+      }
+    }
+  }
+}
+@media (max-width: 425px) {
+  .main-section {
+    padding: 80px 20px;
+    &.small-h {
+      padding-top: 20px;
+      padding-bottom: 20px;
+    }
+
+    .content .title.left-right {
+      flex-direction: column;
+    }
+  }
+  .main-card-box {
+    width: auto;
+    transform: translateX(20px);
+  }
+}
+@media (max-width: 500px) {
+  .main-header-box {
+    &.small {
+      min-height: $large-banner-height;
+    }
+  }
+  .main-section.fit-small-header {
+    height: $large-banner-height;
+  }
+  .main-stats {
+    h4 {
+      margin: 10px 0 5px 0;
+      font-size: 0.7rem;
+    }
+    .descs {
+      h5 {
+        font-size: 2rem;
+      }
+      p {
+        font-size: 0.8rem;
+      }
+    }
+  }
+}

+ 425 - 0
src/assets/scss/news.scss

@@ -0,0 +1,425 @@
+
+@use "sass:list";
+@use '@/assets/scss/colors.scss' as *;
+
+//List page
+
+.news-list {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  row-gap: 20px;
+
+  &.grid {
+    row-gap: 0;
+
+    .list {
+      flex-direction: row;
+      flex-wrap: wrap;
+      justify-content: space-between;
+      align-items: stretch;
+      column-gap: 0;
+    }
+    .item {
+      img {
+        width: 200px;
+        height: 130px;
+        margin-right: 25px;
+      }
+    }
+  }
+  .list {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    gap: 24px;
+  }
+
+  .table-list {
+    margin-top: 10px;
+
+    table {
+      border-collapse: collapse;
+      width: 100%;
+      border: 1px solid $border-grey-color;
+
+  
+      td, th {
+        border: 1px solid $border-grey-color;
+        background-color: #fff; /* 表头背景颜色 */
+        padding: 8px; /* 单元格内边距,可根据需要调整 */
+        text-align: center; /* 单元格内容居中对齐 */
+      }
+      th {
+        background-color: #f2f2f2; /* 表头背景颜色 */
+        font-weight: bold; /* 表头文字加粗 */
+      }
+    }
+  }
+
+  .item {
+    display: flex;
+    flex-direction: row;
+    padding: 25px;
+    border-radius: 6px;
+    background-color: $box-color;
+    border: 1px solid $border-split-color;
+    width: 100%;
+    text-decoration: none;
+
+    &.row-type2 {
+      flex-wrap: wrap;
+
+      .TitleDescBlock h3 {
+        margin-top: 10px;
+      }
+
+      img {
+        width: 100%;
+        height: 300px;
+        margin-right: 0;
+      }
+    }
+    &.row-type3 {
+      img {
+        width: 270px;
+        height: 180px;
+      }
+    }
+      &.row-type4 {
+        img {
+          object-fit: contain;
+          width: 270px;
+          height: 150px;
+        }
+      }
+    &.empty {
+      background-color: transparent;
+      border: none;
+    }
+
+    &:hover:not(.empty) { 
+      background-color: $box-hover-color;
+    }
+    &:active:not(.empty) {
+      transform: scale(0.95);
+    }
+
+    .tags {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: wrap;
+      column-gap: 10px;
+      row-gap: 10px;
+      margin-top: 15px;
+      margin-bottom: 10px;
+      font-size: 0.85rem;
+
+
+      > div {
+        border-radius: 15px;
+        padding: 0 15px;
+        background-color: $primary-dark-color;
+        color: $text-color-light;
+      }
+    }
+    .extra {
+      display: flex;
+      flex-direction: column;
+      flex-wrap: wrap;
+      margin-top: 15px;
+      font-size: 0.8rem;
+
+      .desc {
+        display: block;
+        min-width: 70px;
+        color: $text-second-color;
+      }
+    }
+
+
+    img {
+      flex-shrink: 0;
+      width: 320px;
+      height: 180px;
+      margin-right: 25px;
+      border-radius: 5px;
+      object-fit: cover;
+      background-color: $border-split-color;
+    }
+
+   
+  }
+}
+
+@media (max-width: 768px) {
+  .news-list {
+
+    .item {
+      display: flex;
+      flex-direction: row;
+      padding: 25px;
+      border-radius: 6px;
+      background-color: $box-color;
+
+      &.row-type2 {
+        img {
+          width: 100%;
+          height: 250px;
+          margin-right: 0;
+        }
+      }
+      &.row-type3 {
+        img {
+          width: 170px;
+          height: 90px;
+        }
+      }
+      &.row-type4 {
+        img {
+          width: 180px;
+          height: 110px;
+        }
+      }
+
+      img {
+        width: 200px;
+        height: 140px;
+        margin-right: 25px;
+      }
+    }
+
+    &.grid {
+      .item {
+        img {
+          width: 120px;
+          height: 90px;
+          margin-right: 15px;
+        }
+      }
+    }
+  }
+}
+@media (max-width: 540px) {
+  .news-list {
+    .item {
+      flex-direction: column;
+
+      img {
+        width: 100%;
+        height: 180px;
+        
+        margin-right: 0;
+        margin-bottom: 16px;
+      }
+    }
+  }
+}
+
+//Detail page
+
+.news-detail {
+  color: $text-content-color;
+
+  h1 {
+    font-size: 1.8rem;
+    font-family: SourceHanSerifCNBold;
+    text-align: center;
+  }
+  .small-info {
+    text-align: center;
+    font-size: 0.75rem;
+    flex-wrap: nowrap;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+
+    &.img {
+      width: 20px;
+      height: 20px;
+    }
+  }
+  .back-button2 {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    column-gap: 10px;
+    background-color: $box-inset-color;
+    padding: 4px 5px;
+    border-radius: 5px;
+    cursor: pointer;
+
+    img { 
+      width: 20px;
+      height: 20px;
+    }
+
+    &:hover {
+      background-color: $box-hover-color;
+    }
+  }
+  .back-button {
+    width: 92px;
+    height: 92px;
+    border-radius: 50%;
+    text-align: center;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    display: flex;
+    background-color: $box-inset-color;
+    cursor: pointer;
+
+    &:hover {
+      background-color: $box-hover-color;
+    }
+
+    img { 
+      width: 25px;
+      height: 25px;
+      margin-bottom: 5px;
+    }
+    span {
+      font-size: 0.75rem;
+    }
+  }
+
+  .news-video {
+    position: relative;
+    width: 100%;
+    height: 50vh;
+    border-radius: 8px;
+  }
+  .news-content {
+    position: relative;
+    min-height: 50vh;
+
+    img {
+      max-width: 100%;
+      text-align: center;
+      border-radius: 5px;
+    }
+
+    p > img {
+      display: block;
+      margin: 0 auto;
+    }
+
+    h1 {
+      margin-top: 15px;
+    }
+    h2 {
+      margin-top: 12px; 
+    }
+    h3 {
+      margin-top: 10px; 
+    }
+    h4 {
+      margin-top: 5px; 
+    }
+    h5 {
+      margin-top: 3px;
+    }
+  }
+
+  .info-list {
+    margin-top: 10px;
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    row-gap: 10px;
+    background-color: $box-color;
+    border-radius: 8px;
+    padding: 15px 20px;
+
+    .entry {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: nowrap;
+      width: 50%;
+
+      &.hidden {
+        display: none;
+      }
+
+      .label {
+        width: 120px;
+        color: $text-content-second-color;
+      }
+      .value {
+        color: $text-color;
+      }
+    }
+
+    img {
+      width: 200px;
+      max-width: 100%;
+    }
+  }
+
+  .carousel {
+    position: relative;
+    border-radius: 8px;
+    overflow: hidden;
+    margin: 20px 0;
+
+    &.float {
+      img {
+        width: 100%;
+        height: 30vh;
+        max-height: 250px;
+        object-fit: contain;
+        background-color: $border-split-color;
+      }
+    }
+    &.large {
+      width: 100%;
+      height: 60vh;
+
+      img {
+        width: 50%;
+        height: 60vh;
+        object-fit: contain;
+        background-color: $border-split-color;
+      }
+    }
+
+  }
+}
+@media (max-width: 1200px) {
+  .news-detail .carousel.float {
+    width: 300px;
+  }
+}
+@media (max-width: 1000px) {
+  .news-detail .carousel.float {
+    width: 40%;
+  }
+  .news-detail .carousel.large img {
+    width: 70%;
+  }
+}
+@media (max-width: 768px) {
+  .news-detail .carousel.float {
+    width: 60%;
+  }
+  .news-detail .carousel.large img {
+    width: 100%;
+  }
+}
+
+@media (min-width: 808px) {
+  .news-detail .carousel.float {
+    width: 300px;
+    float: right;
+    margin: 0;
+    margin-left: 20px;
+  }
+}
+
+@media (max-width: 768px) {
+  
+}
+@media (max-width: 540px) {
+  
+}

+ 63 - 0
src/assets/scss/scroll.scss

@@ -0,0 +1,63 @@
+/* Common Scroll bar */
+
+/* PC Scrollbar */
+
+@mixin pc-hide-scrollbar(){
+  &::-webkit-scrollbar {
+      width: 5px;
+      height: 5px;
+  }
+  &::-webkit-scrollbar-thumb {
+      background: transparent;
+
+      &:hover {
+        background: transparent;
+      }
+  }
+  &::-webkit-scrollbar-track {
+      background: transparent;
+  }
+}
+
+@mixin pc-fix-scrollbar(){
+    &::-webkit-scrollbar {
+        width: 5px;
+        height: 5px;
+    }
+    &::-webkit-scrollbar-thumb {
+        background: #707070;
+        border-radius: 3px;
+
+        &:hover {
+            background: #e0e0e0;
+        }
+    }
+    &::-webkit-scrollbar-track {
+        background: transparent;
+    }
+}
+
+@mixin pc-fix-scrollbar-white(){
+    &::-webkit-scrollbar {
+        width: 5px;
+        height: 5px;
+    }
+    &::-webkit-scrollbar-thumb {
+        background: #d6d6d6;
+        opacity: .7;
+        border-radius: 3px;
+
+        &:hover {
+            background: #707070;
+        }
+    }
+    &::-webkit-scrollbar-track {
+        background: transparent;
+    }
+}
+
+.vertical-scroll {
+  overflow-y: scroll;
+
+  @include pc-fix-scrollbar-white();
+}

+ 2 - 0
src/common/ConstStrings.ts

@@ -0,0 +1,2 @@
+export const NO_CONTENT_STRING = '无内容,请添加内容!';
+export const TITLE = '闽南文化资源调查采集';

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

+ 18 - 0
src/common/LoginPageRedirect.ts

@@ -0,0 +1,18 @@
+import AppConfig from "./config/AppCofig";
+import { useAuthStore } from "@/stores/auth";
+import { useRoute, useRouter } from "vue-router";
+
+export function useRedirectLoginPage() {
+  const { noLoginPages, loginPage } = AppConfig;
+  const route = useRoute();
+  const router = useRouter();
+  const authStore = useAuthStore();
+
+  return {
+    checkAndRedirectLoginPage() {
+      if (!authStore.isLogged && !noLoginPages.includes(route.path)) {
+        router.replace(loginPage);
+      }
+    },
+  }
+}

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

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

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

@@ -0,0 +1,17 @@
+
+/**
+ * 说明:应用静态配置
+ */
+export default {
+  version: '0.0.1',
+  loginPage: '/login',
+  noLoginPages: [
+    '/login',
+    '/',
+  ],
+}
+
+/**
+ * 是否是开发环境
+ */
+export const isDev = import.meta.env.DEV;

+ 127 - 0
src/components/Footer.vue

@@ -0,0 +1,127 @@
+<template>
+  <footer class="main-footer">
+    <div>
+      <div class="row">
+        <div class="col-sm-12 col-md-6">
+          <div class="logo">
+            <img src="@/assets/images/LogoIcon.png" />
+            <h2>{{ TITLE }}</h2>
+          </div>
+        </div>
+        <div class="col-sm-12 col-md-6">
+          <div class="d-block links text-md-end">
+            <span>友情链接:</span>
+            <a href="https://minnan.wenlvti.net/">闽南文化生态保护区 (厦门市)</a>
+            <a href="#">厦门市文化馆</a>
+            <a href="#">厦门市图书馆</a>
+            <a href="#">厦门市博物馆</a>
+          </div>
+        </div>
+      </div>
+      <div class="row mt-3 mt-md-0">
+        <div class="links">
+          <a href="#">
+            <img src="@/assets/images/footer/GonganLogo.png" />
+            闽公网安备 44040202000131号
+          </a>
+          <a href="http://beian.miit.gov.cn/">闽ICP备09020130号</a>
+        </div>
+      </div>
+    </div>
+  </footer>
+</template>
+
+<script setup lang="ts">
+import { TITLE } from '@/common/ConstStrings';
+
+
+
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+.main-footer {
+  background-color: $primary-color;
+  color: #fff;
+
+  > div {
+    padding: 60px 55px;
+    background-image: url("@/assets/images/footer/FooterPrinting.png");
+    background-position: bottom 0 right 11px;
+    background-repeat: no-repeat;
+    background-size: 533px;
+  }
+
+  .logo {
+    display: flex;
+    align-items: center;
+    margin-bottom: 10px;
+    font-family: nzgrRuyinZouZhangKai;
+    margin-bottom: 48px;
+
+    h2 {
+      margin: 0;
+    }
+    img {
+      width: 30px;
+      height: 30px;
+      margin-right: 10px;
+    }
+  }
+  .links {
+    display: flex;
+    align-items: center;
+  }
+
+  span {
+    font-size: 0.9rem;
+    margin-right: 40px;
+  }
+
+  a {
+    text-decoration: none;
+    color: #F9EDD3;
+    font-size: 0.9rem;
+    margin-right: 40px;
+
+    &:hover {
+      color: #fff;
+    }
+
+    img {
+      margin-right: 10px;
+    }
+  }
+}
+
+
+@media (max-width: 768px) {
+  .main-footer {
+    > div {
+      padding: 40px 30px;
+      background-size: 333px;
+    }
+    a {
+      margin-right: 10px;
+    }
+  }
+}
+@media (max-width: 425px) {
+ .main-footer {
+    > div {
+      padding: 20px 20px;
+      background-size: 233px;
+    }
+    span {
+      display: block;
+      text-align: left;
+      margin-bottom: 5px;
+    }
+    a {
+      margin-right: 5px;
+    }
+  } 
+
+}
+</style>

+ 300 - 0
src/components/NavBar.vue

@@ -0,0 +1,300 @@
+<template>
+  <!-- 移动端菜单 -->
+  <div class="mobile-menu">
+    <IconMenu 
+      :openState="mobileMenuShow"
+      @click="mobileMenuShow = !mobileMenuShow"
+    />
+    <Teleport to="body">
+      <div 
+        v-show="mobileMenuShow"
+        :class="[
+          'mobile-menu-popover',
+          mobileMenuShow ? 'visible' : '',
+        ]"
+        @click="mobileMenuShow=false"
+      >
+        <div>
+          <RouterLink to="/">首页</RouterLink>
+          <RouterLink to="/inheritor">我的</RouterLink>
+        </div>
+      </div>
+    </Teleport>
+  </div>
+
+  <!-- 导航栏 -->
+  <nav 
+    :class="[
+      'main',
+      headerBlur ? 'need-blur' : '',
+      scrollValue > 200 ? 'nav-scrolled' : 'nav-not-scrolled',
+    ]"
+  >
+    <div></div>
+    <div class="group">
+      <!-- <div class="link-placeholder" /> -->
+      <RouterLink to="/">首页</RouterLink>
+    </div>
+    <div class="group center">
+      <div class="headerlogos">
+        <img class="main-clickable" src="@/assets/images/LogoIcon.png" @click="goIndex" />
+        <div>
+          <p class="large">{{ TITLE }}</p>
+          <p>闽南文化生态保护区<span>(厦门市)</span></p>
+        </div>
+      </div>
+    </div>
+    <div class="group">
+      <RouterLink to="/inheritor">我的</RouterLink>
+    </div>
+    <div></div>
+  </nav>
+</template>
+
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import IconMenu from './icons/IconMenu.vue';
+import { TITLE } from '@/common/ConstStrings';
+
+const router = useRouter();
+const route = useRoute();
+const scrollValue = ref(0);
+const mobileMenuShow = ref(false);
+const headerBlur = computed(() => {
+  return route.name != 'home';
+});
+
+function goIndex() {
+  router.push({ path: '/' });
+}
+function onScroll() {
+  scrollValue.value = window.scrollY;
+}
+
+onMounted(() => {
+  window.addEventListener('scroll', onScroll);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener('scroll', onScroll);
+});
+
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+$nav-height: 70px;
+
+.nav-placeholder {
+  height: $nav-height; 
+  background-color: $primary-color;
+}
+nav.main {
+  position: fixed;
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  z-index: 100;
+  width: 100%;
+  height: $nav-height;
+  background-color: rgba(#000, 0.1);
+  border-bottom: 1px solid rgba(#fff, 0.2);
+  color: #fff;
+  transition: all ease-in-out 0.3s;
+
+  &.nav-scrolled {
+    background-color: $primary-color;
+  }
+  &.need-blur.nav-not-scrolled {
+    background-color: transparent;
+    backdrop-filter: blur(10px);
+  }
+  a {
+    color: rgba(#fff, 0.8);
+    text-align: center;
+    text-decoration: none;
+
+    &:focus {
+      outline: none;
+    }
+    &.router-link-active, &.router-link-exact-active, &:hover {
+      color: #fff;
+    }
+    &.router-link-exact-active {
+      font-weight: bold;
+    }
+  }
+
+  .group {
+    display: flex;
+    gap: 1rem; 
+
+    a, .link-placeholder {
+      width: 100px;
+      height: $nav-height;
+      line-height: $nav-height;
+    }
+
+  }
+  .headerlogos {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+
+    img {
+      width: 45px;
+      height: 45px; 
+      margin-right: 10px;
+    }
+    > div {
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      font-family: nzgrRuyinZouZhangKai;
+      margin-bottom: 6px;
+
+      p {
+        margin: 0;
+        font-size: 1rem;
+        height: 20px;
+        letter-spacing: 0.15rem;
+
+        span {
+          font-size: 1rem;
+          margin-left: 10px;
+        }
+        &.large {
+          height: 33px;
+          font-size: 1.6rem;
+          letter-spacing: -0.1rem;
+        }
+      }
+    }
+  }
+}
+
+.mobile-menu {
+  display: none;
+  position: fixed;
+  left: 25px;
+  top: 20px;
+  z-index: 120;
+  width: 30px;
+  height: 30px;
+  color: $text-color-light;
+}
+.mobile-menu-popover {
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background-color: $box-color;
+  border-bottom: $border-grey-color;
+  color: $text-color;
+  z-index: 101;
+  background-color: rgba(#000, 0.1);
+
+  > div {
+    display: flex;
+    flex-direction: column;
+    padding-top: $nav-height;
+    height: 100%;
+    width: 190px;
+    text-align: center;
+    background-color: #FBF8F3;
+  }
+
+  font-family: SourceHanSerifCNBold;
+  animation: mobile-menu-popover-fade 0.3s ease-in-out;
+
+  a {
+    line-height: 40px;
+    height: 40px;
+    width: 100%;
+    color: $text-color;
+  }
+
+  &.visible {
+    display: flex; 
+  }
+}
+
+@media (max-width: 1460px) {
+  nav.main {
+    
+    .group {
+      gap: 0.5rem; 
+
+      a {
+        width: 80px;
+      }
+    }
+    .headerlogos > div {
+      display: none;
+    }
+  }
+}
+@media (max-width: 1024px) {
+ 
+}
+@media (max-width: 768px) {
+  nav.main {
+    justify-content: space-between;
+    padding: 0 30px;
+
+    .group:not(.center) {
+      display: none;
+    }
+    .headerlogos > div {
+      display: initial;
+    }
+  }
+  .mobile-menu {
+    display: block;
+  } 
+}
+@media (max-width: 550px) {
+  nav.main {
+    .headerlogos {
+      img {
+        width: 35px;
+        height: 35px; 
+        margin-right: 7px;
+      }
+      > div {
+        display: initial;
+        margin-bottom: 0;
+
+        p {
+          font-size: 0.7rem;
+          height: 20px;
+
+          span {
+            font-size: 0.7rem;
+            margin-left: 10px;
+          }
+          &.large {
+            height: 23px;
+            font-size: 1.4rem;
+            letter-spacing: -0.1rem;
+          }
+        }
+      }
+    }
+  }
+}
+@media (max-width: 388px) {
+  nav.main {
+    .headerlogos > div {
+      display: none;
+    }
+  }
+}
+
+@keyframes mobile-menu-popover-fade {
+  0% { opacity: 0; transform: translateX(-100px); }
+  100% { opacity: 1; transform: translateX(0); }
+}
+</style>

+ 44 - 0
src/components/VNodeRenderer.vue

@@ -0,0 +1,44 @@
+<script lang="ts">
+import { defineComponent, h, type PropType, type VNode } from 'vue'
+
+export default defineComponent({
+  name: 'VNodeRenderer',
+  props: {
+    vnode: {
+      type: Object as PropType<VNode>,
+      default: null
+    },
+    render: {
+      type: Function as PropType<(data: Record<string, unknown>|null) => VNode>,
+      default: null
+    },
+    renderChild: {
+      type: Boolean,
+      default: null
+    },
+    data: {
+      type: Object as PropType<Record<string, unknown>>,
+      default: null
+    },
+  },
+  render() {
+    if(this.vnode) {
+      if (typeof this.vnode === 'string')
+        return h('div', this.vnode);
+      const props = this.vnode.props;
+      if(props)
+        for(let key in this.data)
+          props[key] = this.data[key];
+      return this.renderChild ? 
+        h('div', { style: { height: '100%'} }, [
+          this.vnode
+        ]) : 
+        this.vnode;
+    }
+    if(this.render) {
+      return this.render(this.data);
+    }
+    return h('div');
+  }
+})
+</script>

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

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

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

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

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

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

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

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

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

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

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

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

+ 90 - 0
src/components/display/SimplePopup.vue

@@ -0,0 +1,90 @@
+<template>
+  <teleport to="body">
+    <!-- 遮罩层 -->
+    <div
+      v-if="isClose2 || isVisible" 
+      :class="[
+        'popup-overlay',
+        isVisible ? 'open' : '',
+        isClose2 ? 'close' : '',
+      ]" 
+      @click="close"
+    >
+      <!-- 弹出框内容 -->
+      <div class="popup-content" @click.stop>
+        <slot />
+      </div>
+    </div>
+  </teleport>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const emit = defineEmits([ 'change' ]);
+
+const isVisible = ref(false);
+const isClose2 = ref(false);
+
+const open = () => {
+  isClose2.value = true;
+  setTimeout(() => {
+    isClose2.value = false;
+    isVisible.value = true;
+    emit('change', true);
+  }, 100);
+};
+const close = () => {
+  isClose2.value = true;
+  isVisible.value = false;
+  setTimeout(() => {
+    isClose2.value = false;
+    emit('change', false);
+  }, 300);
+};
+
+defineExpose({
+  open,
+  close
+});
+</script>
+
+<style lang="scss">
+@use "@/assets/scss/colors";
+
+.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: 300;
+  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>

+ 24 - 0
src/components/display/SimpleRemoveRichHtml.vue

@@ -0,0 +1,24 @@
+<template>
+  <span>{{ removeHtmlTags(content) }}</span>
+</template>
+
+<script setup lang="ts">
+const props = defineProps({	
+  content: {
+    type: String,
+    default: '',
+  },
+})
+
+function removeHtmlTags(str: string) {
+  str = str.replace(/<[^>]*>/g, '');
+  str = str.replace(/&nbsp;/gi, ' '); // 替换为普通空格
+  str = str.replace(/&amp;/gi, '&');  // 替换为 &
+  str = str.replace(/&lt;/gi, '<');  // 替换为 <
+  str = str.replace(/&gt;/gi, '>');  // 替换为 >
+  str = str.replace(/&quot;/gi, '"'); // 替换为 "
+  str = str.replace(/&#39;/gi, "'");  // 替换为 '
+  return str;
+}
+
+</script>

+ 147 - 0
src/components/display/SimpleRichHtml.vue

@@ -0,0 +1,147 @@
+<template>
+  <div v-show="show" 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 && content != 'null'"
+          :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">
+      <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,
+  },
+  noStyle: {
+    type: Boolean,
+    default: false,
+  },
+  show: {
+    type: Boolean,
+    default: true,
+  },
+})
+
+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, () => {
+  if (import.meta.server)
+    return;
+  setTimeout(() => {
+    generateCatalog();
+  }, 200);
+}, { immediate: true })
+
+onBeforeUnmount(() => {
+  if (import.meta.server)
+    return;
+  if (lastStyleTag) {
+    lastStyleTag.parentElement?.removeChild(lastStyleTag);
+    lastStyleTag = null;
+  }
+})
+onMounted(() => {
+  if (import.meta.server)
+    return;
+  genTagCss()
+});
+</script>
+
+<style lang="scss">
+.nana-rich-html-container {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+
+  .rich-html {
+    flex: 1 1 100%;
+  }
+  .rich-html-catalog {
+    position: fixed;
+    top: 140px;
+    right: 0;
+    bottom: 0px;
+    width: 15rem;
+  }
+}
+
+
+@media (max-width: 1648px) {
+  .rich-html-catalog {
+    display: none;
+  }
+}
+</style>

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

+ 29 - 0
src/components/dynamicf/ActionRender.ts

@@ -0,0 +1,29 @@
+export interface ActionRenderProps {
+  /**
+   * 操作条目
+   */
+  actions: Array<ActionRenderItem>;
+}
+
+export interface ActionRenderItem {
+  /**
+   * 按钮文字
+   */
+  text: string,
+  /**
+   * 按钮键值
+   */
+  key?: string,
+  /**
+   * 这个按钮是否换行,默认否
+   */
+  wrap?: boolean,
+  /**
+   * 按钮类型
+   */
+  type?: 'primary'|'danger'|'success'|'warning'|'secondary',
+  /**
+   * 按钮点击回调
+   */
+  onClick?: (key: string|undefined, record: Record<string, unknown>) => void;
+}

+ 35 - 0
src/components/dynamicf/ActionRender.vue

@@ -0,0 +1,35 @@
+<template>
+  <span>
+    <a-button
+      v-for="(act, k) in actions" 
+      :key="k" 
+      :class="`mr-3 text-${act.type}` + (act.wrap ? ' display-block' : '')"
+      @click="actionClick(act)"
+    >{{act.text}}</a-button>
+  </span>
+</template>
+
+<script lang="ts">
+import type { DataModel } from '@imengyu/js-request-transform';
+import type { ActionRenderItem } from './ActionRender';
+import { defineComponent, type PropType } from "vue";
+
+export default defineComponent({
+  props: {
+    rawModel: {
+      type: Object as PropType<Record<string, unknown>>,
+    },
+    actions: {
+      type: Object as PropType<Array<ActionRenderItem>>,
+    },
+  },
+  methods: {
+    actionClick(action: ActionRenderItem) {
+      if (typeof action.onClick === 'function')
+        action.onClick(action.key, this.rawModel as DataModel);
+      else
+        console.warn('action ' + action.key + ' onClick is not a function!');
+    },
+  },
+});
+</script>

+ 50 - 0
src/components/dynamicf/CascaderFormItem.ts

@@ -0,0 +1,50 @@
+import type { CascaderProps } from "ant-design-vue";
+
+
+export type CascaderFormItemOptionType = CascaderProps['options'];
+
+export type LoadDataFun = (parentValue: string|number|null, level: number, parentObject: unknown) => Promise<CascaderFormItemOptionType>;
+export type OnChooseFun = (values: (string|number|null)[], objects: unknown[]) => void;
+
+/**
+ * CascaderFormItem 的公共接口
+ */
+export interface CascaderFormItemInterface {
+  /**
+   * 加载树形数据至当前选中层级
+   */
+  doLoadDataToCurrentValue: () => void;
+}
+/**
+ * CascaderFormItem 的公共接口
+ */
+export interface CascaderFormItemProps {
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart:  boolean,
+  /**
+   * 初始化时是否递归加载数据到当前选中的数据
+   */
+  loadCascaderToCurrentValueAtStart:  boolean,
+  /**
+   * 加载数据
+   */
+  loadData: LoadDataFun;
+  /**
+   * placeholder
+   */
+  placeholder?: string;
+  /**
+   * 选择后回调查找出的对象键,默认是id
+   */
+  onSelectFindIdKey?: string;
+  /**
+   * 选择后回调
+   */
+  onSelect?: OnChooseFun;
+  /**
+   * a-cascader 其他自定义参数
+   */
+  customProps?: CascaderProps;
+}

+ 165 - 0
src/components/dynamicf/CascaderFormItem.vue

@@ -0,0 +1,165 @@
+<template>
+  <a-cascader
+    :value="value"
+    :options="options"
+    :load-data="doLoadData"
+    :placeholder="placeholder"
+    @update:value="onUpdateValue"
+    change-on-select
+    v-bind="(customProps as any)"
+  />
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, type PropType, ref, toRefs, watch } from "vue";
+import type { CascaderProps } from "ant-design-vue";
+import type { LoadDataFun, OnChooseFun } from "./CascaderFormItem";
+
+export default defineComponent({
+  props: {
+    loadData: {
+      type: Function as PropType<LoadDataFun>,
+      default: null,
+    },
+    placeholder: {
+      type: String,
+      default: '请选择地址',
+    },
+    loadAtStart:  {
+      type: Boolean,
+      default: true,
+    },
+    loadCascaderToCurrentValueAtStart: {
+      type: Boolean,
+      default: true,
+    },
+    value: {},
+    /**
+     * 选择后回调
+     */
+    onSelect: {
+      type: Function as PropType<OnChooseFun>,
+      default: null,
+    },
+    onSelectFindIdKey: {
+      type: String,
+      default: 'id',
+    },
+    /**
+     * a-cascader 其他自定义参数
+     */
+    customProps: {
+      //type: Object as PropType<CascaderProps>,
+      default: null,
+    },
+  },
+  emits: [
+    'update:value'
+  ],
+  setup(props, context) {
+
+    const { loadAtStart, loadCascaderToCurrentValueAtStart, value, loadData, onSelect } = toRefs(props);
+    const options = ref<CascaderProps['options']>([]);
+
+    const doLoadData = ((selectedOptions) => {
+      const parent = selectedOptions && selectedOptions.length > 0 ? selectedOptions[selectedOptions.length - 1] : null;
+      loadData.value(parent?.value as number, selectedOptions ? selectedOptions.length : 0, parent)
+        .then((d) => {
+          if (!d)
+            throw new Error("loadData return invalid data!");
+          if (parent) {
+            //添加数据至指定层级下方
+            if (!parent.children)
+              parent.children = d;
+            else
+              parent.children = parent.children.concat(d);
+          } else {
+            //添加数据
+            options.value = d;
+            //这个时候加载一下默认选择项目
+            if (loadCascaderToCurrentValueAtStart.value)
+              doLoadDataToCurrentValue();
+          }
+        }).catch((e) => {
+          console.error(e);
+        });
+    }) as CascaderProps['loadData'];
+
+    function onUpdateValue(v: number[]) {
+      context.emit('update:value', v);
+
+      //选中后回调
+      if (typeof onSelect.value === 'function') {
+        const objArr = [] as unknown[];
+        const valueArrNow = v.concat();
+        //通过ID查找指定的对象
+        let optionsCurrent = options.value as CascaderProps['options'];
+        for (let i = 0; i < v.length; i++) {
+          if (!optionsCurrent)
+            break;
+          const item = optionsCurrent.find(k => k.value === valueArrNow[i]);
+          if (item) {
+            objArr.push(item);
+            optionsCurrent = item.children;//下一级
+          } else {
+            break;
+          }
+        }
+        //回调
+        onSelect.value(v, objArr);
+      }
+    }
+
+    //加载树形数据至当前选中层级
+    function doLoadDataToCurrentValue() {
+      const valueArrNow = (value.value as number[]).concat();
+
+      function findChildren(index: number, optionsCurrent: CascaderProps['options']) {
+        if (!optionsCurrent)
+          return;
+        //当前级数据,查找是否存在,
+        const option = optionsCurrent.find(k => k.value === valueArrNow[index]);
+        if (option && (!option.children || option.children.length === 0)) {
+          //存在,尝试加载下一级数据
+          loadData.value(valueArrNow[index], index, option)
+            .then((d) => {
+              if (!d)
+                throw new Error("loadData return invalid data!");
+              if (parent) {
+                if (!option.children)
+                  option.children = d;
+                else
+                  option.children = option.children.concat(d);
+                
+                //如果还没有达到输入选择的层级,则进行下一次加载
+                if (index < valueArrNow.length - 1) {
+                  findChildren(index + 1, option.children);
+                }
+              }
+            }).catch((e) => {
+              console.error(e);
+            });
+        }
+      }
+
+      findChildren(0, options.value);
+    }
+
+    onMounted(() => {
+      if (loadAtStart.value && typeof doLoadData === 'function')
+        doLoadData([]);
+    });
+
+    watch(value, () => {
+      doLoadDataToCurrentValue();
+    });
+
+    return {
+      doLoadData,
+      doLoadDataToCurrentValue,
+      onUpdateValue,
+      options,
+    };
+  },
+});
+</script>

+ 56 - 0
src/components/dynamicf/CheckBoxToInt.vue

@@ -0,0 +1,56 @@
+<template>
+  <a-checkbox 
+    :checked="checked"
+    @update:checked="(v: boolean) => $emit('update:value', v)"
+    :disabled="disabled"
+  >
+    <slot>{{text}}</slot>
+  </a-checkbox>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "CheckBoxToInt",
+  data() {
+    return {
+      checked: false
+    }
+  },
+  emits: [ 'update:value' ],
+  props: {
+    checkedValue: {
+      type: Number,
+      default: 1
+    },
+    uncheckedValue: {
+      type: Number,
+      default: 0
+    },
+    disabled: {
+      type: Boolean,
+      default: false
+    },
+    text: {
+      type: String,
+      default: '启用',
+    },
+    value: {
+      default: null,
+    }
+  },
+  mounted: function() {
+    this.loadChecked();
+  },
+  watch: {
+    value() { this.loadChecked(); },
+    checked() { this.$emit('update:value', this.checked ? this.checkedValue : this.uncheckedValue) }
+  },
+  methods: {
+    loadChecked() {
+      this.checked = this.value == this.checkedValue;
+    }
+  }
+});
+</script>

+ 8 - 0
src/components/dynamicf/CheckBoxValue.ts

@@ -0,0 +1,8 @@
+import type { CheckboxProps } from "ant-design-vue";
+
+export interface CheckBoxValueProps {
+  checkboxProps?: CheckboxProps,
+  text: string,
+  checkedValue?: unknown,
+  uncheckedValue?: unknown,
+}

+ 51 - 0
src/components/dynamicf/CheckBoxValue.vue

@@ -0,0 +1,51 @@
+<template>
+  <a-checkbox 
+    v-model:checked="checked" 
+    v-bind="checkboxProps"
+  >{{text}}</a-checkbox>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+import type { CheckboxProps } from "ant-design-vue";
+
+export default defineComponent({
+  props: {
+    checkboxProps: {
+      type: Object as PropType<CheckboxProps>,
+      default: null,
+    },
+    text: {
+      type: String,
+      default: '',
+    },
+    checkedValue: {
+      default: true,
+    },
+    uncheckedValue: {
+      default: false,
+    },
+    value: {
+    },
+  },
+  emits: [ 'update:value' ],
+  watch: {
+    checked(v) {
+      this.$emit('update:value', v ? this.checkedValue : this.uncheckedValue);
+    },
+    value(v) {
+      const checked = v === this.checkedValue;
+      if (this.checked != checked)
+        this.checked = checked;
+    },
+  },
+  data() {
+    return {
+      checked: false,
+    }
+  },
+  mounted() {
+    this.checked = this.value === this.checkedValue;
+  },
+});
+</script>

+ 30 - 0
src/components/dynamicf/Display/ShowDateOrNull.vue

@@ -0,0 +1,30 @@
+<template>
+  <span :class="'vc-show-date '+size">
+    <span v-if="value !== undefined && value !== null">
+      {{ typeof value.format === 'function' ? value.format() : '不是日期类型' }}
+    </span>
+    <span v-else class="text-secondary"><i>{{ nullText }}</i></span>
+  </span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+  name: "ShowDateOrNull",
+  props: {
+    nullText: {
+      default: '暂无',
+      type: String
+    },
+    size: {
+      default: '',
+      type: String
+    },
+    value: {
+      type: Object as import('vue').PropType<Date>,
+      default: null,
+    }
+  },
+});
+</script>

+ 86 - 0
src/components/dynamicf/Display/ShowImageList.vue

@@ -0,0 +1,86 @@
+<template>
+  <span v-if="!images||images.length==0">无图片</span>
+  <div v-else-if="images" class="image-list">
+    <a-image 
+      v-for="(image, k) in (showAll ? images : images.filter((_: unknown, i: number) => i < maxCount))"
+      :key="k"
+      :width="imgSize"
+      :height="imgSize"
+      :src="image"
+      :fallback="failImage"
+    />
+    <div v-if="images.length > maxCount" class="overflow-count" :style="{ 
+      width: `${imgSize}px`, 
+      height: `${imgSize}px`,
+      lineHeight: `${imgSize}px`,
+    }" @click="showAll=!showAll">
+      {{showAll ? '折叠' : `+${images.length - maxCount}` }}
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+
+export default defineComponent({
+  name: "ShowImageList",
+  props: {
+    images: {
+      type: Object as PropType<Array<string>>,
+      default: null,
+    },
+    size: {
+      type: [Number,String],
+      default: 30,
+    },
+    maxCount: {
+      type: Number,
+      default: 5,
+    },
+    failImage: {
+      default: () => require('@/assets/images/failed.svg'),
+      type: String
+    },
+  },
+  computed: {
+    imgSize() : number {
+      if (typeof this.size === 'string')
+        switch(this.size) {
+          case 'default': return 45;
+          case 'middle': return 30;
+          case 'small': return 20;
+        }
+      return this.size as number;
+    },  
+  },
+  data() {
+    return {
+      showAll: false,
+    };
+  },
+});
+</script>
+
+<style lang="scss">
+.image-list {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  flex-wrap: wrap;
+
+  .overflow-count {
+    color: #fff;
+    background-color: rgba(0,0,0,0.5);
+    text-align: center;
+    cursor: pointer;
+  }
+  .ant-image {
+    background-color: #ececec;
+    overflow: hidden;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+}
+</style>

+ 76 - 0
src/components/dynamicf/Display/ShowImageOrNull.vue

@@ -0,0 +1,76 @@
+<template>
+  <div :style="{
+    display: 'inline-block',
+    overflow: 'hidden',
+    width: `${imgSize}px`,
+    height: `${imgSize}px`,
+  }">
+    <a-image
+      :src="imgUrl"
+      :fallback="failImage"
+      :width="imgSize"
+      :height="imgSize"
+    />
+  </div>
+</template>
+
+<script lang="ts">
+import { StringUtils } from '@imengyu/imengyu-utils';
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+  name: "ShowImageOrNull",
+  props: {
+    nullImage: {
+      type: String,
+      default: () => require('@/assets/images/none.png'),
+    },
+    failImage: {
+      type: String,
+      default: () => require('@/assets/images/failed.svg'),
+    },
+    size: {
+      type: [Number,String],
+      default: 30,
+    },
+    src: {
+      type: String,
+      default: null,
+    }
+  },
+  data() {
+    return {
+      imgUrl: '',
+    }
+  },
+  computed: {
+    imgSize() : number {
+      if (typeof this.size === 'string')
+        switch(this.size) {
+          case 'default': return 55;
+          case 'middle': return 45;
+          case 'small': return 25;
+        }
+      return this.size as number;
+    },  
+  },
+  mounted() {
+    setTimeout(() => { this.loadImage(); },100);
+  },
+  watch: {
+    src() { this.loadImage(); }
+  },
+  methods: {
+    loadImage() {
+      if(StringUtils.isNullOrEmpty(this.src))
+        this.imgUrl = this.nullImage as string;
+      else
+        this.imgUrl = this.src as string;
+    },
+    onError() {
+      if(this.imgUrl != this.failImage)
+        this.imgUrl = this.failImage as string;
+    }
+  }
+});
+</script>

+ 69 - 0
src/components/dynamicf/Display/ShowInList.vue

@@ -0,0 +1,69 @@
+<template>
+  <div>{{ result }}</div>
+</template>
+
+<script lang="ts">
+import type { KeyValue } from "@imengyu/js-request-transform";
+import { defineComponent, type PropType } from "vue";
+
+export default defineComponent({
+  name: "ShowInList",
+  data() {
+    return {
+      result: ''
+    }
+  },
+  props: {
+    noMatchText: {
+      type: String,
+      default: '暂无',
+    },
+    useProp: {
+      type: Boolean,
+      default: true,
+    },
+    usePropName: {
+      type: String,
+      default: 'id',
+    },
+    usePropValue: {
+      type: String,
+      default: 'name',
+    },
+    list: {
+      type: Object as PropType<Array<KeyValue>>,
+      default: null,
+    },
+    value: {
+      default: null,
+    }
+  },
+  mounted: function() {
+    this.loadText();
+  },
+  watch: {
+    list() { this.loadText(); },
+    value() { this.loadText(); }
+  },
+  methods: {
+    loadText() {
+      const list = this.list as Array<KeyValue>;
+      if(list && this.value && list.length > 0){
+        for(let i = 0, c = list.length; i < c; i++){
+          if(this.useProp)
+            if(list[i][this.usePropName as string] == this.value) {
+              this.result = list[i][this.usePropValue as string] as string;
+              return;
+            }
+          else
+            if(list[i] == this.value) {
+              this.result = list[i] as unknown as string;
+              return;
+            }
+        }
+        this.result = this.noMatchText as string;
+      }else this.result = this.noMatchText as string;
+    }
+  }
+});
+</script>

+ 35 - 0
src/components/dynamicf/Display/ShowMomentOrNull.vue

@@ -0,0 +1,35 @@
+<template>
+  <span :class="'vc-show-date '+size">
+    <span v-if="value && value!=null">
+      {{ typeof value.format === 'function' ? value.format(dateFormat) : '不是日期类型' }}
+    </span>
+    <span v-else class="text-secondary"><i>{{ nullText }}</i></span>
+  </span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import dayjs from 'dayjs';
+
+export default defineComponent({
+  name: "ShowDateOrNull",
+  props: {
+    nullText: {
+      default: '暂无',
+      type: String
+    },
+    dateFormat: {
+      default: 'YYYY-MM-DD HH:mm:ss',
+      type: String
+    },
+    size: {
+      default: '',
+      type: String
+    },
+    value: {
+      type: Object as import('vue').PropType<dayjs.Dayjs>,
+      default: null,
+    }
+  },
+});
+</script>

+ 48 - 0
src/components/dynamicf/Display/ShowTagList.vue

@@ -0,0 +1,48 @@
+<template>
+  <span v-if="!tags||tags.length==0">暂无</span>
+  <span v-else-if="small">
+    {{ tags[0] }}等{{tags.length}}个
+  </span>
+  <div v-else-if="tagsC" class="d-flex flex-row flex-wrap">
+    <a-tag v-for="(n, k) in tagsC" :key="k">{{n}}</a-tag>
+    <small class="text-primary" v-if="!expand && tags.length > maxCount" @click="expand=true">等{{tags.length}}个</small>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from "vue";
+
+export default defineComponent({
+  name: "ShowImageList",
+  data() {
+    return {
+      expand: false,
+    };
+  },
+  computed: {
+    tagsC() {
+      if (!this.expand && this.maxCount < this.tags.length)
+        return this.tags.slice(0, this.maxCount)
+      else
+        return this.tags;
+    },
+    small() {
+      return this.size === 'small';
+    },
+  },
+  props: {
+    size: {
+      type: String,
+      default: '',
+    },
+    maxCount: {
+      type: Number,
+      default: 10,
+    },
+    tags: {
+      type: Object as PropType<Array<string>>,
+      default: null,
+    },
+  },
+});
+</script>

+ 73 - 0
src/components/dynamicf/Display/ShowValueOrNull.vue

@@ -0,0 +1,73 @@
+<template>
+  <span
+    :class="'vc-show-value'+(block? ' d-block' : '')+(clickable?' link':'')"
+    @click="onClick"
+  >
+    {{ prefix }}
+    <template v-if="(value && value!='') || value === 0">
+      <span v-if="typeof value === 'number'">{{ numericalPrecision > 0 ? (value as number).toFixed(numericalPrecision) : value }}</span>
+      <span v-else-if="typeof value === 'boolean'">{{ value ? '是' : '否' }}</span>
+      <span v-else-if="typeof value === 'object'">{{ JSON.stringify(value) }}</span>
+      <span v-else>{{ value }}</span>
+    </template>
+    <span v-else class="text-secondary"><i>{{ nullText }}</i></span>
+  </span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "ShowValueOrNull",
+  data() {
+    return {
+      result: ''
+    }
+  },
+  emits: [ 'click' ],
+  props: {
+    block: {
+      default: false,
+      type: Boolean
+    },
+    clickable: {
+      default: false,
+      type: Boolean
+    },
+    prefix: {
+      default: '',
+      type: String
+    },
+    numericalPrecision: {
+      default: 0,
+      type: Number
+    },
+    nullText: {
+      default: '暂无',
+      type: String
+    },
+    record: {
+      default: null,
+    },
+    value: {
+      type: null,
+      default: null,
+    },
+  },
+  methods: {
+    onClick() {
+      if (this.clickable)
+        this.$emit('click', this.record);
+    },
+  },
+});
+</script>
+
+<style lang="scss">
+.vc-show-value {
+  &.link {
+    color: #008cff;
+    cursor: pointer;
+  }
+}
+</style>

+ 37 - 0
src/components/dynamicf/Display/StateRenderer.vue

@@ -0,0 +1,37 @@
+<template>
+  <span>
+    <a-badge 
+      v-if="currentState"
+      :status="currentState.badgeState" 
+      :color="currentState.badgeColor" 
+      :text="currentState.text"
+    />
+    <span v-else>未知状态:{{value}}</span>
+  </span>
+</template>
+
+<script lang="ts">
+import type { IDynamicFormItemSelectOption } from "@imengyu/vue-dynamic-form";
+import { defineComponent, type PropType } from "vue";
+
+export interface IStateOption extends IDynamicFormItemSelectOption {
+  badgeState?: 'success' | 'processing' | 'error' | 'default' | 'warning';
+  badgeColor?: string;
+}
+
+export default defineComponent({
+  props: {
+    value: {
+    },
+    stateValues: {
+      type: Object as PropType<Array<IStateOption>>,
+    },
+  },
+  computed: {
+    currentState() {
+      return (this.stateValues as IStateOption[])
+        .find(k => k.value === this.value || k.text === this.value);
+    },
+  },
+});
+</script>

+ 88 - 0
src/components/dynamicf/Dropdown/IdAsValueDropdown.ts

@@ -0,0 +1,88 @@
+import type { DataModel } from "@imengyu/js-request-transform";
+import type { SelectProps } from "ant-design-vue";
+import type { VNode } from "vue";
+
+/**
+ * 通用下拉框返回结构定义
+ */
+export interface DropdownValues<T> {
+  label: string,
+  value: number,
+  raw: T;
+}
+
+export type LoadDataFun<T extends DataModel> = (val: string | null) => Promise<DropdownValues<T>[]>;
+
+/**
+ * IdAsValueDropdown 的公共接口
+ */
+export interface IdAsValueDropdownInterface {
+  /**
+   * 获取某个ID的Lablel
+   * @param value 要获取的ID
+   */
+  getLableByValue(value: number): string;
+  /**
+   * 重新加载数据
+   * @param clearValue 是否需要清除选中数据,默认否
+   */
+  reload(clearValue?: boolean): void;
+}
+/**
+ * IdAsValueDropdown 的公共接口
+ */
+export interface IdAsValueDropdownProps<T extends DataModel> {
+  /**
+   * 允许清除
+   */
+  allowClear?: boolean,
+  /**
+   * 显示空?
+   */
+  showNull?: boolean,
+  /**
+   * 禁用
+   */
+  disabled?: boolean,
+  /**
+   * 多选?
+   */
+  multiple?: boolean,
+  /**
+   * 允许搜索
+   */
+  showSearch?: boolean,
+  placeholder?: string,
+  /**
+   * 未找到数据时的文案
+   */
+  notFoundContent?: string,
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart?: boolean,
+  /**
+   * 不使用后端筛选数据而是前端直接筛选
+   */
+  filterDirectly?: boolean,
+  /**
+   * 初始化时的搜索数据
+   */
+  intitialSearchValue?: Record<string, unknown>,
+  /**
+   * 加载数据回调
+   */
+  loadData: LoadDataFun<T>,
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps?: SelectProps,
+  /**
+   * 是否自定义渲染option插槽
+   */
+  renderOption?: RenderOption;
+}
+export type RenderOption = (data: {
+  value: unknown,
+  label: string,
+}) => VNode;

+ 253 - 0
src/components/dynamicf/Dropdown/IdAsValueDropdown.vue

@@ -0,0 +1,253 @@
+<template>
+  <a-select
+    :value="valueV"
+    :mode="multiple ? 'multiple' : 'combobox'"
+    :allowClear="allowClear"
+    :showSearch="showSearch"
+    :disabled="disabled"
+    :placeholder="placeholder"
+    :default-active-first-option="false"
+    :notFoundContent="notFoundContent"
+    :options="data"
+    :filterOption="showSearch && filterDirectly ? filterOption : false"
+    @update:value="handleChange"
+    @search="handleSearch"
+    v-bind="customProps"
+    style="min-width: 150px"
+  >
+    <template v-if="renderOption" #option="data">
+      <VNodeRenderer :render="renderOption" :data="data" />
+    </template>
+    <a-select-option v-if="showNull" :value="null">(空)</a-select-option>
+  </a-select>
+</template>
+
+<script lang="ts">
+import VNodeRenderer from "@/components/VNodeRenderer.vue";
+import { type SelectProps } from "ant-design-vue";
+import { defineComponent, markRaw, type PropType, type VNode } from "vue";
+import { debounce } from 'lodash-es';
+import type { DropdownValues, LoadDataFun } from "./IdAsValueDropdown";
+import type { DataModel } from "@imengyu/js-request-transform";
+import { StringUtils } from "@imengyu/imengyu-utils";
+
+/**
+ * IdAsValueDropdown 的公共接口
+ */
+export interface IdAsValueDropdownInterface {
+  /**
+   * 获取某个ID的Lablel
+   * @param value 要获取的ID
+   */
+  getLableByValue(value: number): string;
+  /**
+   * 重新加载数据
+   * @param clearValue 是否需要清除选中数据,默认否
+   */
+  reload(clearValue?: boolean): void;
+}
+/**
+ * IdAsValueDropdown 的公共接口
+ */
+export interface IdAsValueDropdownProps<T extends DataModel> {
+  /**
+   * 允许清除
+   */
+  allowClear?: boolean,
+  /**
+   * 显示空?
+   */
+  showNull?: boolean,
+  /**
+   * 禁用
+   */
+  disabled?: boolean,
+  /**
+   * 多选?
+   */
+  multiple?: boolean,
+  /**
+   * 允许搜索
+   */
+  showSearch?: boolean,
+  placeholder?: string,
+  /**
+   * 未找到数据时的文案
+   */
+  notFoundContent?: string,
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart?: boolean,
+  /**
+   * 不使用后端筛选数据而是前端直接筛选
+   */
+  filterDirectly?: boolean,
+  /**
+   * 初始化时的搜索数据
+   */
+  intitialSearchValue?: Record<string, unknown>,
+  /**
+   * 加载数据回调
+   */
+  loadData: LoadDataFun<T>,
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps?: SelectProps,
+  /**
+   * 是否自定义渲染option插槽
+   */
+  renderOption?: RenderOption<T>;
+}
+type RenderOption<T> = (data: {
+  value: unknown,
+  label: string,
+  raw: T
+}) => VNode;
+
+/**
+ * 使用数据的ID作为value的下拉框包装
+ */
+export default defineComponent({
+  name: "IdAsValueDropdown",
+  data() {
+    return {
+      valueV: null,
+      data: [] as DropdownValues<DataModel>[],
+      lastLoadValue: null,
+      handleSearch: markRaw(debounce((val: string) => {
+        if (!this.filterDirectly)
+          this.doLoadData(val);
+      }, 500)),
+    };
+  },
+  emits: [
+    "update:value",
+    "change",
+    "loaded",
+  ],
+  props: {
+    showNull: {
+      default: false,
+      type: Boolean
+    },
+    renderOption: {
+      default: null,
+      type: Function as PropType<RenderOption<DataModel>>
+    },
+    allowClear: {
+      default: false,
+      type: Boolean
+    },
+    multiple: {
+      default: false,
+      type: Boolean
+    },
+    disabled: {
+      default: false,
+      type: Boolean
+    },
+    showSearch: {
+      default: true,
+      type: Boolean
+    },
+    placeholder: {
+      default: "输入可进行搜索",
+      type: String
+    },
+    notFoundContent: {
+      default: "未找到数据,请换个搜索词再试",
+      type: String
+    },
+    loadAtStart: {
+      default: true,
+      type: Boolean
+    },
+    filterDirectly: {
+      default: true,
+      type: Boolean
+    },
+    value: {
+      default: null,
+    },
+    intitialSearchValue: {
+      default: null,
+      type: String
+    },
+    loadData: {
+      type: Function as PropType<LoadDataFun<DataModel>>,
+      default: null,
+    },
+    /**
+     * a-select 其他自定义参数
+     */
+    customProps: {
+      type: Object as PropType<SelectProps>,
+      default: null,
+    },
+  },
+  methods: {
+    handleChange(value: unknown) {
+      this.$emit("change", value);
+      this.$emit("update:value", value);
+    },
+    doLoadData(val: string | null) {
+      if (typeof this.loadData === "function") {
+        const oldValue = this.valueV;
+        this.valueV = null;
+        (this.loadData as LoadDataFun<DataModel>)(val).then((d) => {
+          this.data = d;
+          setTimeout(() => {
+            this.valueV = oldValue;
+            this.$emit("loaded");
+          }, 30);
+        });
+      }
+    },
+    filterOption(input: string, option: {
+      label: string;
+    }) {
+      return !this.filterDirectly || option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
+    },
+    getLableByValue(value: number) {
+      for (let i = 0; i < this.data.length; i++) {
+        if (this.data[i].value == value) {
+          return this.data[i].label;
+        }
+      }
+      return "";
+    },
+    reload(clearValue = false) {
+      if (clearValue) {
+        this.valueV = null;
+        this.handleChange(null);
+      }
+      this.data = [];
+      this.doLoadData(this.intitialSearchValue as string);
+    },
+  },
+  watch: {
+    loadData() {
+      this.doLoadData(this.intitialSearchValue);
+    },
+    intitialSearchValue(v) {
+      if (!this.filterDirectly && !StringUtils.isNullOrEmpty(v)) {
+        this.doLoadData(v);
+      }
+    },
+    value(v) {
+      this.valueV = v;
+    },
+  },
+  mounted() {
+    this.valueV = this.value;
+    setTimeout(() => {
+      if (this.loadAtStart) {
+        this.doLoadData(this.intitialSearchValue as string);
+      }
+    }, 300);
+  },
+  components: { VNodeRenderer }
+});
+</script>

+ 336 - 0
src/components/dynamicf/Dropdown/IdAsValueTreeDropdown.vue

@@ -0,0 +1,336 @@
+<template>
+  <div v-if="showDisplayValue" class="display-value" @click="handleDisplayValueClick">
+    <span>{{displayValue}}</span>
+  </div>
+  <a-tree-select
+    v-else
+    ref="selectRef"
+    style="min-width: 150px"
+    :defaultOpen="true"
+    :value="valueV"
+    :dropdown-style="dropdownStyle"
+    :notFoundContent="notFoundContent"
+    :tree-data="treeData"
+    :load-data="handleLoadData"
+    :treeDataSimpleMode="true"
+    :placeholder="placeholder"
+    :allow-clear="allowClear"
+    :multiple="multiple"
+    v-bind="customProps"
+    @blur="handleSelectBlur"
+    @update:value="handleChange"
+  />
+</template>
+
+<script lang="ts">
+import type { SelectProps } from "ant-design-vue";
+import { defineComponent, type PropType } from "vue";
+import type { TreeDataItem } from "../IdAsValueTree";
+
+export type LoadDataFun = (pid: string|number, level: number) => Promise<TreeDataItem[]>;
+export type CheckClickableFun = (item: TreeDataItem) => Promise<boolean>;
+
+export type GetDiaplayValue = (ref: IdAsValueTreeDropdownInterface) => string;
+export type GetRef = (ref: IdAsValueTreeDropdownInterface) => void;
+
+/**
+ * IdAsValueTreeDropdown 的公共接口
+ */
+export interface IdAsValueTreeDropdownInterface {
+  /**
+   * 获取某个ID的树(正排列)
+   * @param value 要获取的ID
+   */
+  getTree(value: number) : Array<TreeDataItem>;
+  /**
+   * 获取某个ID的Lablel
+   * @param value 要获取的ID
+   */
+  getLableByValue(value: number) : string;
+  /**
+   * 重新加载数据
+   */
+  reload(): void;
+}
+/**
+ * IdAsValueTreeDropdown 的公共接口
+ */
+export interface IdAsValueTreeDropdownProps {
+  /**
+   * 允许清除
+   */
+  allowClear?: boolean,
+  /**
+   * 多选?
+   */
+  multiple?: boolean,
+  dropdownStyle?: Record<string, unknown>,
+  disabled?: boolean,
+  placeholder?: string,
+  /**
+   * 未找到数据时的文案
+   */
+  notFoundContent?: string,
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart?: boolean,
+  /**
+   * 加载数据
+   */
+  loadData?: LoadDataFun,
+  /**
+   * 自定义检查条目是否可点击回调
+   */
+  checkClickable?: CheckClickableFun,
+  /**
+   * 获取显示数据回调
+   */
+  getDisplayValue?: GetDiaplayValue,
+  /**
+   * 是否在非激活时显示临时字符串(防止树形数据没有加载,而无法显示当前值)
+   */
+  showDisplayValueBeforeEdit?: boolean,
+  /**
+   * 子数据最大层级
+   */
+  maxLevel?: number,
+  /**
+   * 是否只有最后一级可以点击
+   */
+  onlyLastLevelClickable?: boolean,
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps?: SelectProps,
+}
+
+/**
+ * 使用数据的ID作为value的下拉框包装
+ */
+export default defineComponent({
+  name: "IdAsValueTreeDropdown",
+  emits: [
+    'update:value',
+    'change',
+    'blur',
+  ],
+  props: {
+    allowClear: {
+      default: true,
+      type: Boolean
+    },
+    multiple: {
+      default: false,
+      type: Boolean
+    },
+    dropdownStyle: {
+      type: Object,
+      default: () => { return { maxHeight: '400px', overflow: 'auto' } }
+    },
+    disabled: {
+      default: false,
+      type: Boolean
+    },
+    placeholder: {
+      default: '请选择,输入可进行搜索',
+      type: String
+    },
+    notFoundContent: {
+      default: '未找到数据,请换个搜索词再试',
+      type: String
+    },
+    loadAtStart: {
+      default: true,
+      type: Boolean
+    },
+    value: {
+      default: null,
+    },
+    loadData: {
+      type: Function as PropType<LoadDataFun>,
+      default: null,
+    },
+    checkClickable: {
+      type: Function as PropType<CheckClickableFun>,
+      default: null,
+    },
+    getDisplayValue: {
+      type: Function as PropType<GetDiaplayValue>,
+      default: null,
+    },
+    defaultDisplayValue: {
+      type: String,
+      default: '',
+    },
+    showDisplayValueBeforeEdit: {
+      default: false,
+      type: Boolean
+    },
+    maxLevel: {
+      default: 0,
+      type: Number,
+    },
+    onlyLastLevelClickable: {
+      default: false,
+      type: Boolean
+    },
+    /**
+     * a-select 其他自定义参数
+     */
+    customProps: {
+      type: Object as PropType<SelectProps>,
+      default: null,
+    },
+  },
+  computed: {
+    displayValue() : string {
+      if (this.valueV != null && this.valueV != 0 && this.defaultDisplayValue != '')
+        return this.defaultDisplayValue;
+      if (this.getDisplayValue)
+        return (this.getDisplayValue as GetDiaplayValue)(this as IdAsValueTreeDropdownInterface); 
+      return '';
+    },
+  },
+  methods: {
+    handleChange(value: unknown) {
+      this.$nextTick(() => {
+        if(value != this.value) {
+          this.$emit('update:value', value); 
+          this.$emit('change', value); 
+        }
+      })
+    },
+    handleLoadData(treeNode: { dataRef: TreeDataItem }) {
+      return new Promise((resolve: (value?: unknown) => void) => {
+        const { id, level } = treeNode.dataRef;
+        this.doLoadData(id, level as number).then(() => resolve()).catch(() => resolve());
+      });
+    },
+    handleDisplayValueClick() {
+      this.showDisplayValue = false;
+      setTimeout(() => {
+        (this.$refs.selectRef as {
+          focus: () => void
+        }).focus();
+      }, 200);
+    },
+    handleSelectBlur() {
+      if(this.showDisplayValueBeforeEdit) {
+        if(this.valueV != null && this.valueV != 0 && this.defaultDisplayValue != '')
+          this.showDisplayValue = true;
+        else if (this.getLableByValue(this.valueV as number) === '') //只有没有在列表中搜索到数据时,才显示临时数据
+          this.showDisplayValue = true;
+      }
+    },
+    doLoadData(pid: string|number|null, level: number) {
+      const loadData = this.loadData;
+      if(typeof loadData === 'function') {
+        return (loadData as LoadDataFun)(pid as string, level).then((d) => {
+          for(let i = this.treeData.length - 1; i >= 0; i--)
+            if(this.treeData[i].pId == pid)
+              this.treeData.splice(i, 1);
+          d.forEach(h => {
+            h.level = level + 1;
+            if(this.maxLevel > 0 && h.level >= this.maxLevel)
+              h.isLeaf = true;
+            if(typeof this.checkClickable === 'function')
+              this.checkClickable(h).then((v: boolean) => h.selectable = v);
+            else if(this.maxLevel > 0) { 
+              if(h.level >= this.maxLevel)
+                h.selectable = false;
+              if(this.onlyLastLevelClickable) 
+                h.selectable = (h.level == this.maxLevel);
+            }
+            this.treeData.push(h)
+          });
+        });
+      } else 
+        return Promise.resolve();
+    },
+
+    /**
+     * 获取某个ID的树(正排列)
+     */
+    getTree(value: number) {
+      const result = new Array<TreeDataItem>();
+      let child : TreeDataItem|null = this.treeData.find((v) => v.id == value) as TreeDataItem;
+      while(child) {
+        result.unshift(child);
+        if(child.pId == 0) child = null;
+        else child = this.treeData.find((v) => v.id == (child as TreeDataItem).pId) as TreeDataItem;
+      }
+      return result;
+    },
+    /**
+     * 获取某个ID的Lablel
+     */
+    getLableByValue(value: number) {
+      const data = this.treeData;
+      for (let i = 0; i < data.length; i++) {
+        if(data[i].value == value) {
+          return data[i].title;
+        }
+      }
+      return '';
+    },
+    /**
+     * 重新加载数据
+     */
+    reload() {
+      this.treeData = [];
+      this.doLoadData(0, 0) 
+    },
+  },
+  watch: {
+    value(v) {
+      this.valueV = v;
+    },
+    showDisplayValueBeforeEdit(v, old) {
+      if(!old && v) {
+        this.showDisplayValue = true;
+      }
+    },
+  },
+  data() {
+    return {
+      showDisplayValue: false,
+      valueV: null as null|number|string,
+      treeData: [] as TreeDataItem[],
+    }
+  },
+  mounted() { 
+    this.valueV = this.value;
+    if(this.showDisplayValueBeforeEdit)
+      this.showDisplayValue = true;
+    setTimeout(() => { 
+      if(this.loadAtStart) {
+        this.treeData = [];
+        this.doLoadData(0, 0) ;
+      }
+    } , 300);
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.display-value {
+  min-width: 150px;
+  padding: 4px 11px;
+  color: rgba(0, 0, 0, 0.85);
+  font-size: 14px;
+  line-height: 1.5715;
+  background-color: #fff;
+  background-image: none;
+  border: 1px solid #d9d9d9;
+  border-radius: 2px;
+}
+//暗黑主题
+body[data-theme="dark"] {
+  .display-value {
+    color: #dedede;
+    background-color: #1f1f1f;
+    border: 1px solid #434343;
+  }
+}
+</style>

+ 127 - 0
src/components/dynamicf/IdAsValueTree.ts

@@ -0,0 +1,127 @@
+import type { SelectProps } from "ant-design-vue";
+
+export type LoadDataFun = (pid: string|number, level: number) => Promise<TreeNode[]>;
+export type CheckClickableFun = (item: TreeNode) => Promise<boolean>;
+
+export type GetDiaplayValue = (ref: IdAsValueTreeInterface) => string;
+export type GetRef = (ref: IdAsValueTreeInterface) => void;
+export interface TreeDataItem {
+  id: string | number;
+  pId?: number;
+  value: string | number;
+  title?: string;
+  isLeaf?: boolean;
+  selectable?: boolean;
+  checkable?: boolean;
+  disableCheckbox?: boolean;
+  disabled?: boolean;
+  level?: number;
+}
+
+export interface TreeNode {
+  id?: number;
+  pid?: number;
+  level?: number;
+
+  /**
+   * 当树为 checkable 时,设置独立节点是否展示 Checkbox
+   */
+  checkable?: boolean;
+  /**
+   * 节点的 class
+   */
+  class?: string;
+  /**
+   * 	禁掉 checkbox
+   */
+  disableCheckbox?: boolean;
+  /**
+   * 禁掉响应
+   */
+  disabled?: boolean;
+  /**
+   * 自定义图标。可接收组件,props 为当前节点 props
+   */
+  icon?: unknown;
+  /**
+   * 设置为叶子节点(设置了loadData时有效)
+   */
+  isLeaf?: boolean;
+  /**
+   * 被树的 (default)ExpandedKeys / (default)CheckedKeys / (default)SelectedKeys 属性所用。注意:整个树范围内的所有节点的 key 值不能重复!
+   */
+  key: string | number;
+  /**
+   * 设置节点是否可被选中
+   */
+  selectable?: boolean;
+  /**
+   * 节点的 style	
+   */
+  // eslint-disable-next-line @typescript-eslint/ban-types
+  style?: string|object;
+  /**
+   * 标题
+   */
+  title: string;
+
+  children?: TreeNode[],
+}
+
+/**
+ * IdAsValueTree 的公共接口
+ */
+export interface IdAsValueTreeInterface {
+  /**
+   * 获取某个ID的树(正排列)
+   * @param value 要获取的ID
+   */
+  getTree(value: number) : Array<TreeNode>;
+  /**
+   * 获取某个ID的Lablel
+   * @param value 要获取的ID
+   */
+  getLableByValue(value: number) : string;
+  /**
+   * 重新加载数据
+   */
+  reload(): void;
+}
+/**
+ * IdAsValueTree 的公共接口
+ */
+export interface IdAsValueTreeProps {
+  /**
+   * 允许清除
+   */
+  allowClear?: boolean,
+  /**
+   * 多选?
+   */
+  multiple?: boolean,
+  disabled?: boolean,
+  /**
+   * 初始化时加载数据
+   */
+  loadAtStart?: boolean,
+  /**
+   * 加载数据
+   */
+  loadData?: LoadDataFun,
+  /**
+   * 自定义检查条目是否可点击回调
+   */
+  checkClickable?: CheckClickableFun,
+  /**
+   * 子数据最大层级
+   */
+  maxLevel?: number,
+  /**
+   * 是否只有最后一级可以点击
+   */
+  onlyLastLevelClickable?: boolean,
+  /**
+   * a-select 其他自定义参数
+   */
+  customProps?: SelectProps,
+}

+ 179 - 0
src/components/dynamicf/IdAsValueTree.vue

@@ -0,0 +1,179 @@
+<template>
+  <div class="IdAsValueTree">
+    <a-tree
+      ref="selectRef"
+      style="min-width: 150px"
+      :defaultOpen="true"
+      v-model:expandedKeys="expandedKeys"
+      v-model:checkedKeys="checkedKeys"
+      checkable
+      :tree-data="treeData"
+      :load-data="handleLoadData"
+      :allow-clear="allowClear"
+      v-bind="customProps"
+    />
+  </div>
+</template>
+
+<script lang="ts">
+import type { TreeProps } from "ant-design-vue";
+import { defineComponent, type PropType } from "vue";
+import type { CheckClickableFun, LoadDataFun, TreeNode } from "./IdAsValueTree";
+
+/**
+ * 使用数据的ID作为value的tree包装
+ */
+export default defineComponent({
+  name: "IdAsValueTree",
+  emits: [
+    'update:value',
+    'change',
+    'blur',
+  ],
+  props: {
+    allowClear: {
+      default: true,
+      type: Boolean
+    },
+    disabled: {
+      default: false,
+      type: Boolean
+    },
+    loadAtStart: {
+      default: true,
+      type: Boolean
+    },
+    value: {
+      default: null,
+    },
+    loadData: {
+      type: Function as PropType<LoadDataFun>,
+      default: null,
+    },
+    checkClickable: {
+      type: Function as PropType<CheckClickableFun>,
+      default: null,
+    },
+    maxLevel: {
+      default: 0,
+      type: Number,
+    },
+    onlyLastLevelClickable: {
+      default: false,
+      type: Boolean
+    },
+    /**
+     * a-select 其他自定义参数
+     */
+    customProps: {
+      type: Object as PropType<TreeProps>,
+      default: null,
+    },
+  },
+  methods: {
+    handleChange() {
+      this.$nextTick(() => {
+        this.$emit('update:value', this.checkedKeys); 
+        this.$emit('change', this.checkedKeys); 
+      })
+    },
+    handleLoadData(treeNode: { dataRef: TreeNode }|null) {
+      return new Promise((resolve: (value?: unknown) => void) => {
+        this.doLoadData(treeNode?.dataRef || null).then(() => resolve()).catch(() => resolve());
+      });
+    },
+    doLoadData(dataRef: TreeNode|null) {
+      const { id, level } = dataRef || { id: 0, level: 0 };
+      const pid = id as number;
+      const loadData = this.loadData;
+      if(typeof loadData === 'function') {
+        return (loadData as LoadDataFun)(pid, level as number).then((d) => {
+          if (dataRef && !dataRef.children)
+            dataRef.children = [];
+          d.forEach(h => {
+            h.level = level as number + 1;
+            if(this.maxLevel > 0 && h.level >= this.maxLevel)
+              h.isLeaf = true;
+            if(typeof this.checkClickable === 'function')
+              this.checkClickable(h).then((v: boolean) => h.selectable = v);
+            else if(this.maxLevel > 0) { 
+              if(h.level >= this.maxLevel)
+                h.selectable = false;
+              if(this.onlyLastLevelClickable) 
+                h.selectable = (h.level == this.maxLevel);
+            }
+            if (dataRef)
+              dataRef.children?.push(h);
+            else
+              this.treeData.push(h);
+          });
+        });
+      } else 
+        return Promise.resolve();
+    },
+
+    /**
+     * 获取某个ID的树(正排列)
+     */
+    getTree(value: number) {
+      const result = new Array<TreeNode>();
+      let child : undefined|TreeNode = (this.treeData as TreeNode[]).find((v) => v.id == value);
+      while(child) {
+        result.unshift(child);
+        if(child.pid == 0) child = undefined;
+        else child = (this.treeData as TreeNode[]).find((v) => v.id == (child as TreeNode).pid);
+      }
+      return result;
+    },
+    /**
+     * 获取某个ID的Lablel
+     */
+    getLableByValue(value: number) {
+      const data = this.treeData;
+      for (let i = 0; i < data.length; i++) {
+        if(data[i].id == value) {
+          return data[i].title;
+        }
+      }
+      return '';
+    },
+    /**
+     * 重新加载数据
+     */
+    reload() {
+      this.treeData = [];
+    },
+  },
+  watch: {
+    value(v) {
+      this.checkedKeys = (v as string[]);
+    },
+    checkedKeys() {
+      this.handleChange();
+    },
+  },
+  data() {
+    return {
+      expandedKeys: [] as string[],
+      checkedKeys: [] as string[],
+      treeData: [] as TreeNode[],
+    }
+  },
+  mounted() { 
+    this.checkedKeys = (this.value as unknown as string[]) || [];
+    setTimeout(() => { 
+      if(this.loadAtStart) {
+        this.treeData = [];
+        this.handleLoadData(null);
+      }
+    } , 300);
+  }
+});
+</script>
+
+<style>
+.IdAsValueTree {
+  border: 1px solid #efefef;
+  padding: 10px;
+}
+</style>

+ 69 - 0
src/components/dynamicf/NumberRange.vue

@@ -0,0 +1,69 @@
+<template>
+  <div class="vc-number-range d-flex flex-row align-items-center">
+    <a-input-number placeholder="最小值" :disabled="disabled" :value="realValue[0]" @update:value="(v: number) => onUpdateValue(v, 0)" v-bind="customProps" />
+    <span class="p-2">-</span>
+    <a-input-number placeholder="最大值" :disabled="disabled" :value="realValue[1]" @update:value="(v: number) => onUpdateValue(v, 1)" v-bind="customProps" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 下拉框表单控件,用于解决 a-select 不能选择对象的问题
+ */
+import { Form, type InputNumberProps } from 'ant-design-vue';
+import { type PropType, ref, watch, onMounted } from 'vue';
+
+const props = defineProps({
+  /**
+   * 是否禁用
+   */
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  /**
+   * 选择值
+   */
+  value: {
+  },
+  /**
+   * a-number-input 其他自定义参数
+   */
+  customProps: {
+    type: Object as PropType<InputNumberProps>,
+    default: null,
+  },
+});
+
+const emits = defineEmits([
+  'update:value',
+]);
+
+const realValue = ref<number[]>([]);
+const { onFieldChange } = Form.useInjectFormItemContext();
+
+watch(() => props.value, (v) => {
+  if ((v as number[])?.length == 2)
+    realValue.value = v as number[];
+  else {
+    if (realValue.value.length === 1 && realValue.value[0] === undefined) {
+      realValue.value = [];
+      emits('update:value', []);
+    } else {
+      realValue.value = [];
+    }
+  }
+});
+onMounted(() => {
+  realValue.value = (props.value as number[])?.length == 2 ? props.value as number[] : [];
+});
+
+function onUpdateValue(v : number, index: number) {
+  realValue.value[index] = v;
+  if (realValue.value.length < 2)
+    realValue.value.push(0);
+  emits('update:value', realValue.value);
+  onFieldChange();
+}
+
+</script>

+ 116 - 0
src/components/dynamicf/PasswordStrengthMeter.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="password-meter">
+    <div class="bar">
+      <div :class="'level'+level"></div>
+    </div>
+    <span :class="'level'+level">密码强度 {{levelString}}</span>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { checkPassWordSecrityLevel } from '@imengyu/imengyu-utils'
+
+/**
+ * 密码强度显示组件
+ */
+export default defineComponent({
+  props: {
+    password: {
+      type: String,
+      default: '',
+    }
+  },
+  data() {
+    return {
+      level: 0,
+      levelString: '',
+    }
+  },
+  watch: {
+    password(v: string) {
+      this.level = Math.floor((checkPassWordSecrityLevel(v) / 100) * 5);
+      switch(this.level) {
+        case 0: this.levelString = '非常弱'; break;
+        case 1: this.levelString =  '弱'; break;
+        case 2: this.levelString =  '中等'; break;
+        case 3: this.levelString =  '强'; break;
+        case 4: this.levelString =  '非常强'; break;
+      }
+    },
+  }
+})
+</script>
+
+<style lang="scss">
+.password-meter {
+  position: relative;
+  height: 20px;
+  margin: 10px 0;
+
+  .bar {
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    right: 130px;
+    background-color: #e7e7e7;
+    border: 1px solid #a1a1a1;
+
+    div {
+      position: absolute;
+      left: 0;
+      top: 0;
+      bottom: 0;
+
+      &.level0 {
+        width: 0;
+        background-color: #000;
+      }
+      &.level1 {
+        width: 25%;
+        background-color: #ca410a;
+      }
+      &.level2 {
+        width: 50%;
+        background-color: #d8c40c;
+      }
+      &.level3 {
+        width: 75%;
+        background-color: #9ab814;
+      }
+      &.level4 {
+        width: 100%;
+        background-color: #2fbe0b;
+      } 
+    }
+  }
+  > span {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    width: 100px;
+    right: 0;
+    font-size: 12px;
+
+    &.level0 {
+      color: #646464;
+    }
+    &.level1 {
+      color: #a8380c;
+    }
+    &.level2 {
+      color: #af9f0e;
+    }
+    &.level3 {
+      color: #91ac18;
+    }
+    &.level4 {
+      color: #26920b;
+    }
+  }
+
+
+
+}
+</style>

+ 37 - 0
src/components/dynamicf/PasswordWithStrengthInput.vue

@@ -0,0 +1,37 @@
+<template>
+  <div>
+    <a-input 
+      :value="value"
+      @update:value="(v: string) => $emit('update:value', v)"
+      :disabled="disabled"
+      type="password"
+      v-bind="(item?.additionalProps as {})"
+    />
+    <PasswordStrengthMeter :password="(value as string)" />
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+import PasswordStrengthMeter from "./PasswordStrengthMeter.vue";
+import type { IDynamicFormItem } from "@imengyu/vue-dynamic-form";
+
+export default defineComponent({
+  props: {
+    item: {
+      type: Object as PropType<IDynamicFormItem>,
+    },
+    disabled: {
+      type: Boolean
+    },
+    value: {},
+    additionalProps: {
+      type: Object as PropType<Record<string, unknown>>,
+    },
+  },
+  emits: [
+    "update:value"
+  ],
+  components: { PasswordStrengthMeter }
+});
+</script>

+ 0 - 0
src/components/dynamicf/RadioValue.ts


Some files were not shown because too many files changed in this diff