浏览代码

✨初始框架提交

快乐的梦鱼 3 月之前
当前提交
f53ac7a687
共有 100 个文件被更改,包括 24746 次插入0 次删除
  1. 31 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 33 0
      README.md
  4. 3 0
      env.d.ts
  5. 13 0
      index.html
  6. 14905 0
      package-lock.json
  7. 66 0
      package.json
  8. 二进制
      public/favicon.ico
  9. 59 0
      src/App.vue
  10. 473 0
      src/api/CommonContent.ts
  11. 11 0
      src/api/NotConfigue.ts
  12. 246 0
      src/api/RequestModules.ts
  13. 15 0
      src/api/Utils.ts
  14. 101 0
      src/api/auth/UserApi.ts
  15. 709 0
      src/api/inheritor/InheritorContent.ts
  16. 223 0
      src/api/inhert/VillageApi.ts
  17. 220 0
      src/api/inhert/VillageInfoApi.ts
  18. 二进制
      src/assets/fonts/Impact.ttf
  19. 二进制
      src/assets/fonts/Impact.woff
  20. 二进制
      src/assets/fonts/Impact.woff2
  21. 二进制
      src/assets/fonts/STSongti-SC-Black.ttf
  22. 二进制
      src/assets/fonts/STSongti-SC-Black.woff
  23. 二进制
      src/assets/fonts/STSongti-SC-Black.woff2
  24. 二进制
      src/assets/fonts/SourceHanSerifCN-Bold.otf
  25. 二进制
      src/assets/fonts/SourceHanSerifCN-Bold.ttf
  26. 二进制
      src/assets/fonts/SourceHanSerifCN-Bold.woff
  27. 二进制
      src/assets/fonts/SourceHanSerifCN-Bold.woff2
  28. 二进制
      src/assets/fonts/nzgrRuyinZouZhangKai.ttf
  29. 二进制
      src/assets/fonts/nzgrRuyinZouZhangKai.woff
  30. 二进制
      src/assets/fonts/nzgrRuyinZouZhangKai.woff2
  31. 1 0
      src/assets/images/404.svg
  32. 二进制
      src/assets/images/BackArrow.png
  33. 二进制
      src/assets/images/Bg1.png
  34. 二进制
      src/assets/images/Bg2.png
  35. 二进制
      src/assets/images/BgLong.jpg
  36. 二进制
      src/assets/images/CloseMini.png
  37. 二进制
      src/assets/images/DropDownArrow.png
  38. 二进制
      src/assets/images/IconArrowRight.png
  39. 8 0
      src/assets/images/IconInfo.svg
  40. 二进制
      src/assets/images/IconUser.png
  41. 二进制
      src/assets/images/ImageFailed.png
  42. 二进制
      src/assets/images/LargeTitle1.png
  43. 二进制
      src/assets/images/LargeTitle2.png
  44. 二进制
      src/assets/images/LargeTitle3.png
  45. 二进制
      src/assets/images/LogoIcon.png
  46. 二进制
      src/assets/images/LogoIconDark.png
  47. 二进制
      src/assets/images/TitleMiniHeader.png
  48. 二进制
      src/assets/images/favicon.ico
  49. 二进制
      src/assets/images/favicon.png
  50. 二进制
      src/assets/images/footer/FooterPrinting.png
  51. 二进制
      src/assets/images/footer/GonganLogo.png
  52. 30 0
      src/assets/scss/colors.scss
  53. 32 0
      src/assets/scss/components.scss
  54. 81 0
      src/assets/scss/fix.scss
  55. 388 0
      src/assets/scss/fonts.scss
  56. 451 0
      src/assets/scss/main.scss
  57. 456 0
      src/assets/scss/news.scss
  58. 63 0
      src/assets/scss/scroll.scss
  59. 5 0
      src/assets/scss/vueexp.module.scss
  60. 2 0
      src/common/ConstStrings.ts
  61. 113 0
      src/common/ConvertRgeistry.ts
  62. 8 0
      src/common/EventBus.ts
  63. 18 0
      src/common/LoginPageRedirect.ts
  64. 9 0
      src/common/config/ApiCofig.ts
  65. 17 0
      src/common/config/AppCofig.ts
  66. 47 0
      src/common/upload/AliOssUploadCo.ts
  67. 23 0
      src/common/upload/ImageUploadCo.ts
  68. 127 0
      src/components/Footer.vue
  69. 38 0
      src/components/FooterSmall.vue
  70. 329 0
      src/components/NavBar.vue
  71. 432 0
      src/components/content/CommonListBlock.vue
  72. 1 0
      src/components/content/MapConfig.ts
  73. 62 0
      src/components/content/SimplePointedMap.vue
  74. 68 0
      src/components/content/TagBar.vue
  75. 65 0
      src/components/controls/Check.vue
  76. 5 0
      src/components/controls/CheckIcon.vue
  77. 148 0
      src/components/controls/Dropdown.vue
  78. 5 0
      src/components/controls/DropdownIcon.vue
  79. 130 0
      src/components/controls/Pagination.vue
  80. 117 0
      src/components/controls/SimpleInput.vue
  81. 67 0
      src/components/dynamicf/SelectCity.vue
  82. 34 0
      src/components/dynamicf/index.ts
  83. 44 0
      src/components/icons/IconMenu.vue
  84. 5 0
      src/components/icons/IconSearch.vue
  85. 63 0
      src/components/parts/EmptyToRecord.vue
  86. 150 0
      src/components/parts/TitleDescBlock.vue
  87. 45 0
      src/main.ts
  88. 46 0
      src/pages/404.vue
  89. 105 0
      src/pages/admin.vue
  90. 214 0
      src/pages/admin/FormVolunteer.vue
  91. 141 0
      src/pages/change-password.vue
  92. 26 0
      src/pages/composeable/TaskEntryForm.ts
  93. 174 0
      src/pages/details.vue
  94. 171 0
      src/pages/forms/common.vue
  95. 2476 0
      src/pages/forms/forms.ts
  96. 154 0
      src/pages/forms/list.vue
  97. 245 0
      src/pages/index.vue
  98. 92 0
      src/pages/inheritor.vue
  99. 104 0
      src/pages/login.vue
  100. 0 0
      src/pages/task/building.vue

+ 31 - 0
.gitignore

@@ -0,0 +1,31 @@
+# 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/
+/public/tinymce/
+
+# 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
+```

+ 3 - 0
env.d.ts

@@ -0,0 +1,3 @@
+/// <reference types="vite/client" />
+
+declare module 'vue-esign';

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

文件差异内容过多而无法显示
+ 14905 - 0
package-lock.json


+ 66 - 0
package.json

@@ -0,0 +1,66 @@
+{
+  "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",
+    "updater": "node src/scripts/UpdateScript/index.mjs"
+  },
+  "dependencies": {
+    "@imengyu/imengyu-utils": "^0.0.14",
+    "@imengyu/imengyu-web-shared": "^0.0.1",
+    "@imengyu/js-request-transform": "^0.3.5",
+    "@imengyu/vue-dynamic-form": "^0.1.3",
+    "@imengyu/vue-scroll-rect": "^0.1.3",
+    "@tinymce/tinymce-vue": "^6.3.0",
+    "@vuemap/vue-amap": "^2.1.12",
+    "@vueup/vue-quill": "^1.2.0",
+    "ant-design-vue": "^4.2.6",
+    "async-validator": "^4.2.5",
+    "axios": "^1.11.0",
+    "bootstrap": "^5.3.0",
+    "dayjs": "^1.11.18",
+    "lodash-es": "^4.17.21",
+    "md5": "^2.3.0",
+    "mitt": "^3.0.1",
+    "nprogress": "^0.2.0",
+    "pinia": "^3.0.3",
+    "quill-image-uploader": "^1.3.0",
+    "tinymce": "^8.1.2",
+    "tslib": "^2.8.1",
+    "vue": "^3.5.18",
+    "vue-clipboard3": "^2.0.0",
+    "vue-esign": "^1.1.4",
+    "vue-router": "^4.5.1",
+    "vue3-carousel": "^0.15.0"
+  },
+  "devDependencies": {
+    "@inquirer/prompts": "^7.8.4",
+    "@tsconfig/node22": "^22.0.2",
+    "@types/ali-oss": "^6.16.11",
+    "@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"
+  }
+}

二进制
public/favicon.ico


+ 59 - 0
src/App.vue

@@ -0,0 +1,59 @@
+<template>
+  <a-config-provider
+    :locale="zhCN"
+    :theme="{
+      token: {
+        colorPrimary: Colors.primaryColor,
+      },
+    }"
+    :componentSize="'large'"
+  >
+    <NavBar />
+    <main>
+      <RouterView />
+      <!-- <RouterView v-slot="{ Component }">
+        <KeepAlive>
+          <component :is="Component" v-if="route.meta.keepAlive" />
+        </KeepAlive>
+        <component :is="Component" v-if="!route.meta.keepAlive" />
+      </RouterView> -->
+    </main>
+    <FooterSmall />
+  </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 zhCN from 'ant-design-vue/es/locale/zh_CN';
+import { useRedirectLoginPage } from './common/LoginPageRedirect';
+import FooterSmall from './components/FooterSmall.vue';
+import Colors from './assets/scss/vueexp.module.scss';
+
+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";
+</style>

+ 473 - 0
src/api/CommonContent.ts

@@ -0,0 +1,473 @@
+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,
+  ) {
+    if (type === 1)
+      withself = true;
+    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 });
+  }
+
+  
+  /**
+   * 上传文件到服务器
+   */
+  async uploadSmallFile(
+    file: File, 
+    fileType?: "image" | "video" | "audio" | undefined, 
+    name = 'file', 
+    data?: Record<string, any>
+  ) {
+    return new Promise<{
+      fullurl: string,
+      url: string
+    }>(async (resolve, reject) => {
+      try {
+        let url = this.config.baseUrl + '/common/upload';
+        const formData = new FormData();
+        formData.append(name, file);
+
+        // 添加额外数据
+        if (data) {
+          Object.entries(data).forEach(([key, value]) => {
+            formData.append(key, value);
+          });
+        }
+
+        let requestOptions: RequestInit = {
+          method: 'POST',
+          body: formData,
+          headers: {}
+        };
+
+        // 应用请求拦截器
+        if (this.config.requestInceptor) {
+          const { newReq, newUrl } = this.config.requestInceptor(url, requestOptions as any);
+          url = newUrl;
+          requestOptions = newReq as RequestInit;
+        }
+
+        // 移除Content-Type,让浏览器自动处理
+        if (requestOptions.headers && (requestOptions.headers as Record<string, string>)['Content-Type'])
+          delete (requestOptions.headers as Record<string, string>)['Content-Type'];
+
+        const response = await fetch(url, requestOptions);
+        const responseData = await response.json();
+
+        if (!response.ok)
+          throw new Error(`HTTP error! status: ${response.status}`);
+        if (responseData.code !== 1)
+          throw new Error(responseData.msg ?? `code: ${responseData.code}`);
+        resolve(responseData.data);
+      } catch (error) {
+        reject(error);
+      }
+    });
+  }
+}
+
+export default new CommonContentApi(0, '默认通用内容');

+ 11 - 0
src/api/NotConfigue.ts

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

+ 246 - 0
src/api/RequestModules.ts

@@ -0,0 +1,246 @@
+
+/**
+ * 这里写的是业务相关的:
+ * * 请求数据处理函数。
+ * * 自定义请求模块。
+ * * 自定义错误报告处理函数。
+ */
+
+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 "@imengyu/imengyu-web-shared";
+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 (!req.header)
+    req.header = {};
+  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,
+    });
+  }
+}
+function responseErrorHandler<T extends DataModel>(err: Error, instance: RequestCoreInstance<T>, apiName: string | undefined) : RequestApiError {
+  if (err instanceof TypeError) {
+    let errorMessage = '';
+    if (err.message.indexOf('Failed to fetch') >= 0)
+      errorMessage = '连接网络失败,请检查您的网络连接';
+    else if (err.message.toLowerCase().indexOf('timeout') >= 0)
+      errorMessage = '请求超时,请稍后重试';
+    else if (err.message.indexOf('CORS') >= 0)
+      errorMessage = '跨域请求失败';
+    else if (err.message.indexOf('abort') >= 0)
+      errorMessage = '请求已取消';
+    else if (err.message.indexOf('Invalid URL') >= 0)
+      errorMessage = '无效URL';
+    else 
+      errorMessage = err.message;
+    return new RequestApiError('networkError', errorMessage, '', 0, null, null, null, apiName, '');
+  }
+  return new RequestApiError('networkError', err.message, '', 0, null, null, null, apiName, '');
+}
+
+/**
+ * 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.responseErrorHandler = responseErrorHandler;
+    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;
+}

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

@@ -0,0 +1,101 @@
+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 },
+      userInfo: { clientSide: 'object', clientSideChildDataModel: UserInfo }
+    };
+    this._nameMapperServer = {
+      'userinfo': 'userInfo',
+    }
+    this._beforeSolveServer = (data, self) => {
+      if (data.userinfo)
+      data.token = (data.userinfo as any).token;
+      return data;
+    }
+    this._afterSolveServer = () => {
+      if (!this.userInfo.id)
+        this.userInfo.id = this.id;
+      if (!this.userInfo.mobile)
+        this.userInfo.mobile = this.mobile;
+      if (!this.userInfo.nickname)
+        this.userInfo.nickname = this.nickname;
+      if (!this.userInfo.avatar)
+        this.userInfo.avatar = this.avatar;
+      if (!this.userInfo.username)
+        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;
+  loginType = 0;
+  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 = '';
+  regionId = 0;
+  isReviewer = false;
+}
+
+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 loginAdmin(data: {
+    account: string,
+    password: string,
+  }) {
+    return (await this.post('/user/adminLogin', {
+      account: data?.account,
+      password: data?.password,
+    }, '登录', undefined, LoginResult)).data as LoginResult;
+  }
+  async updatePassword(data: {
+    newpassword: string,
+    oldpassword: string,
+  }) {
+    return (await this.post('/content/main_body_user/changepwd', data, '更新密码'))
+  }
+
+  async refresh() {
+    return (await this.post('/ich/inheritor/refresh', {}, '刷新token', undefined, LoginResult)).data as LoginResult;
+  }
+
+  
+}
+
+export default new UserApi();

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

@@ -0,0 +1,709 @@
+import { DataModel, transformArrayDataModel, transformDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+import dayjs from 'dayjs';
+import { transformSomeToArray } from '../Utils';
+import { GetContentListItem } from '../CommonContent';
+
+export class CommonInfo<T extends DataModel> extends DataModel<T> {
+
+  constructor(classCreator?: (new () => T) | undefined, name: string = '基础信息') {
+    super(classCreator, name);
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      flag: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      type: { clientSide: 'number', serverSide: 'number' },
+      keywords: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      images: { clientSide: 'array', serverSide: 'array' },
+      expandInfo: { serverSide: 'undefined' },
+      region: { clientSide: 'number', serverSide: 'number' },
+      progress: { clientSide: 'number', serverSide: 'number' },
+      longitude: { clientSide: 'number', serverSide: 'number' },
+      latitude: { clientSide: 'number', serverSide: 'number' },
+    };
+    this._beforeSolveClient = (data) => {
+      if (!data.contentId && data.id)
+        data.contentId = data.id;
+    }
+  }
+
+  contentId = null as number|null;
+  collectId = null as number|null;
+  
+  title = '' as string;
+  region = null as number|null;
+  image = null as string|null;
+  imageDesc = '' as string|null;
+  images = [] as string[];
+  audio = '' as string|null;
+  video = '' as string|null;
+  flag = [] as string[];
+  keywords = [] as string[];
+  tags = '' as string;
+  associationId = 0 as number;
+  pid = 0 as number;
+  content = '' as string|null;
+}
+
+export class IchInfo extends CommonInfo<IchInfo> {
+  constructor() {
+    super(IchInfo, "非遗项目信息");
+    this._convertTable = {
+      ...this._convertTable,
+      lonlat: { serverSide: 'undefined' },
+      batch: { clientSide: 'number', serverSide: 'string' },
+      typicalImages: [
+        { 
+          clientSide: 'object', 
+          clientSideChildDataModel: {
+            convertTable: {},
+          }, 
+          serverSide: 'string' 
+        },
+        {
+          clientSide: 'addDefaultValue',
+          clientSideParam: {
+            defaultValue: [],
+          }
+        },
+      ],
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+    };
+    this._afterSolveServer = (self) => {
+      self.lonlat = [ self.longitude, self.latitude ];
+      if (!self.intro && self.description)
+        self.intro = self.description;
+    };
+    this._afterSolveClient = (data) => {
+      data.longitude = this.lonlat[0];
+      data.latitude = this.lonlat[1];
+    };
+
+    const _superBeforeSolveClient = this._beforeSolveClient;
+    this._beforeSolveClient = (data) => {
+      _superBeforeSolveClient?.(data);
+      if (!this.expandInfo)
+        this.expandInfo = new IchExpandInfo();
+      this.expandInfo.batch = this.batch;
+      this.expandInfo.region = this.region;
+      this.expandInfo.image = this.image;
+      this.expandInfo.level = this.level!;
+      this.expandInfo.ichType = this.ichType!;
+      this.expandInfo.contentId = this.contentId!;
+      this.expandInfo.collectId = this.collectId!;
+
+    };
+  }
+
+  lonlat = [] as (number|string)[];
+  expandInfo : IchExpandInfo|null = new IchExpandInfo();
+
+  id = 0 as number;
+  modelId = 2;
+  mainBodyColumnId = 0 as number;
+  ztImage = '' as string|null;
+  intro = '' as string;
+  description = '' as string;
+  heritage = null as number|null;
+  level = null as number|null;
+  ichType = null as number|null;
+  batch = '' as string;
+  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 = '' as string;
+  typicalImages = [] as {
+    form: string,
+    mobile: string,
+    desc: string,
+    url: 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 IchExpandInfo extends DataModel<IchExpandInfo> {
+  constructor() {
+    super(IchExpandInfo, "非遗项目信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      protectLevel: { clientSide: 'number', serverSide: 'string' },
+      id: { clientSide: 'number', serverSide: 'undefined' },
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+      if (key.endsWith('At')) {
+        return {
+          clientSide: 'date',
+          serverSide: 'undefined',
+        };
+      }
+    };
+    this._afterSolveServer = (self) => {
+    };
+    this._afterSolveClient = (data) => {
+    };
+  }
+
+  id = 0 as number;
+  modelId = 2;
+  userId = 0 as number;
+  reviewId = 0 as number;
+  originId = '' as string|null;
+  contentId = 0 as number;
+  name = '' as string;
+  level = 0 as number;
+  ichType = 0 as number;
+  protectLevel = null as number|null;
+  image = '' as string|null;
+  images = [] as string[];
+  otherNames = '' as string|null;
+  history = false;
+  existence = false;
+  folkCulture = '' as string|null;
+  culturalRelic = '' as string|null;
+  description = '' as string|null;
+  desc = '' as string;
+  mapX = '' as string|null;
+  mapY = '' as string|null;
+  declarationRegion = '' as string|null;
+  popularRegion = '' as string|null;
+  createdAt = '' as string;
+  updatedAt = '' as string;
+  deletedAt = '' as string|null;
+  progress = 0 as number;
+  comment = '' as string;
+  levelText = '' as string;
+  ichTypeText = '' as string;
+  protectLevelText = '' as string;
+  progressText = '' as string;
+}
+export class InheritorInfo extends CommonInfo<InheritorInfo> {
+  constructor() {
+    super(InheritorInfo, "传承人信息");
+    this._convertTable = {
+      ...this._convertTable,
+      gender: { clientSide: 'number', serverSide: 'string' },
+      level: { clientSide: 'number', serverSide: 'string' },
+      batch: { clientSide: 'number', serverSide: 'string' },
+      typicalImages: [
+        { 
+          clientSide: 'object', 
+          clientSideChildDataModel: {
+            convertTable: {},
+          }, 
+          serverSide: 'string' 
+        },
+        {
+          clientSide: 'addDefaultValue',
+          clientSideParam: {
+            defaultValue: [],
+          },
+          serverSide: 'original',
+        },
+      ],
+      works: {
+        clientSide: 'array',
+        clientSideChildDataModel: InheritorWorkInfo,
+        serverSide: 'array' 
+      },
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+    };
+  }
+
+  expandInfo : InheritorExpandInfo|null = new InheritorExpandInfo();
+
+  id = 0 as number;
+  modelId = 7;
+  mainBodyColumnId = 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 = null as number|null;
+  gender = 0 as number;
+  batch = '' as string|null;
+  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;
+  works = [] as InheritorWorkInfo[];
+}
+export class InheritorExpandInfo extends DataModel<InheritorExpandInfo> {
+  constructor() {
+    super(InheritorExpandInfo, "非遗项目信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      gender: { clientSide: 'number', serverSide: 'string' },
+      level: { clientSide: 'number', serverSide: 'string' },
+      batch: { clientSide: 'number', serverSide: 'string' },
+      photosJson: [
+        { 
+          clientSide: 'object', 
+          clientSideChildDataModel: {
+            convertTable: {},
+          }, 
+          serverSide: 'string' 
+        },
+        {
+          clientSide: 'addDefaultValue',
+          clientSideParam: {
+            defaultValue: [],
+          }
+        },
+      ],
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+    };
+    this._afterSolveServer = (self) => {
+    };
+    this._afterSolveClient = (data) => {
+    };
+  }
+  modelId = 7;
+}
+export class InheritorWorkInfo extends DataModel<InheritorWorkInfo> {
+  constructor() {
+    super(InheritorWorkInfo, "传承人作品");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      collectionTime: { clientSide: 'dayjs', serverSide: 'string' },
+      type: { clientSide: 'number', serverSide: 'number' },
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+      if (key.endsWith('At')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+    };
+    this._afterSolveServer = (self) => {
+    };
+    this._afterSolveClient = (data) => {
+    };
+  }
+
+  id = 0;
+  modelId = 16;
+  category = '';
+  feature = '';
+  otherName = '';
+  creator = '';
+  language = '';
+  overview = '';
+  ethnicGroup = '';
+  creationEra = '';
+  mainPerformer = '';
+  otherPerformers = '';
+  fullString = '';
+  tune = '';
+  development = '';
+  spread = '';
+  influence = '';
+  collector = '';
+  collectionTime = dayjs();
+  collectionLocation = '';
+}
+export class SeminarInfo extends CommonInfo<SeminarInfo> {
+  constructor() {
+    super(SeminarInfo, "传习所信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      ...this._convertTable,
+      lonlat: { serverSide: 'undefined' },
+      visit: { clientSide: 'number' },
+      ichSiteType: { clientSide: 'number', serverSide: 'number' },
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+    };
+    this._afterSolveServer = (self) => {
+      self.lonlat = [ self.longitude, self.latitude ];
+    };
+    this._afterSolveClient = (data) => {
+      data.longitude = this.lonlat[0];
+      data.latitude = this.lonlat[1];
+    };
+  }
+
+  lonlat = [] as (number|string)[];
+  expandInfo : SeminarExpandInfo|null = new SeminarExpandInfo();
+
+  id = 0 as number;
+  modelId = 17;
+  mainBodyColumnId = 0 as number;
+  content = '' as string|null;
+  mapX = '' as string|null;
+  mapY = '' as string|null;
+  longitude = '' as string|null;
+  latitude = '' as string|null;
+  address = '' as string;
+
+  featuresType = null as number|null;
+  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 SeminarExpandInfo extends DataModel<SeminarExpandInfo> {
+  constructor() {
+    super(SeminarExpandInfo, "非遗项目信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      lonlat: { serverSide: 'undefined' },
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+    };
+    this._afterSolveServer = (self) => {
+      self.lonlat = [ self.longitude, self.latitude ];
+    };
+    this._afterSolveClient = (data) => {
+      data.longitude = this.lonlat[0];
+      data.latitude = this.lonlat[1];
+    };
+  }
+  modelId = 17;
+  lonlat = [] as (number|string)[];
+}
+export class PlanInfo extends DataModel<PlanInfo> {
+  constructor() {
+    super(PlanInfo, "五年计划");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      progress: { clientSide: 'number', serverSide: 'undefined' },
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return {
+          clientSide: 'string',
+          serverSide: 'undefined',
+        };
+      }
+    };
+    this._afterSolveServer = (self) => {
+    };
+    this._afterSolveClient = (data) => {
+    };
+  }
+
+  id = 0 as number;
+  ichId = 0 as number;
+  name = '' as string;
+  investment = 0 as number;
+  desc = '' as string;
+  target = '' as string;
+  unit = 0 as number;
+  department = 0 as number;
+  userId = 0 as number;
+  progress = 0 as number;
+  createdAt = '' as string;
+  updatedAt = '' as string;
+  ichName = '' as string;
+  progressText = '' as string;
+}
+export class InheritorAccountInfo extends DataModel<InheritorAccountInfo> {
+  constructor() {
+    super(InheritorAccountInfo, "传承人账号信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      username: { clientSide: 'string', clientSideRequired: true },
+      password: { clientSide: 'string', clientSideRequired: true },
+    };
+  }
+
+  id = 0 as number;
+  username = '' as string;
+  password = '' as string;
+  nickname = '' as string;
+}
+export class InheritorSubmitInfo extends DataModel<InheritorSubmitInfo> {
+  constructor() {
+    super(InheritorSubmitInfo, "传承人采集数据信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      progress: { clientSide: 'number', serverSide: 'undefined' },
+    };
+  }
+
+  id = 0 as number;
+  title = '' as string;
+  userId = 0 as number;
+  nickname = '' as string;
+  logintime = '' as string;
+  updatedAt = '' as string;
+  collectTotal = 0 as number;
+  progress = 0 as number;
+}
+
+export class InheritorContentApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+
+  async getBaseInfo<T extends DataModel>(id: number|undefined, newDataModel: new () => T, contentId?: number) {
+    return (await this.post('/ich/inheritor/baseInfo', {
+      model_id: new newDataModel().modelId,
+      id,
+      content_id: contentId,
+    }, '基础表信息', undefined, newDataModel)).data as T;
+  }
+  /**
+   * 项目五年计划
+   * @param ichId 项目ID:传承人只返回绑定项目的计划
+   * @param progress 审核进度:-1=不通过,0=待审核,1=已通过
+   * @returns 
+   */
+  async getPlanList(ichId: number, progress?: number) {
+    return transformArrayDataModel<PlanInfo>(
+      PlanInfo,
+      (await this.post('/ich/inheritor/plans', {
+        ich_id: ichId,
+        progress,
+      }, '获取计划列表')).data2.data,
+      "data2"
+    );
+  }
+  async saveBaseInfo<T extends DataModel>(dataModel: T) {
+    return (await this.post('/ich/inheritor/saveBase', dataModel.toServerSide(), '基础内容表采集(非遗,传承人,传习所)'));
+  }
+  async getExpandInfo<T extends DataModel>(id: number|undefined, newDataModel: new () => T) : Promise<T | null> {
+    return this.post('/ich/inheritor/expandInfo', {
+      model_id: new newDataModel().modelId,
+      id,
+    }, '扩展表信息', undefined).then((res) => {
+      if (!res.data2) 
+        return null;
+      return transformDataModel(newDataModel, res.data2) as T;
+    })
+  }
+  async saveExpandInfo<T extends DataModel>(dataModel: T) {
+    return (await this.post('/ich/inheritor/saveExpand', dataModel.toServerSide(), '扩展内容表采集(非遗,传承人,传习所)'));
+  }
+  async saveWorkInfo(dataModel: InheritorWorkInfo) {
+    return (await this.post('/ich/inheritor/saveWork', {
+      ...dataModel.toServerSide(),
+    }, '保存传承人作品信息'));
+  }
+  async savePlanInfo(dataModel: PlanInfo) {
+    return (await this.post('/ich/inheritor/savePlans', dataModel.toServerSide(), '保存项目五年计划'));
+  }
+
+  async getCollectListInfo<T extends DataModel>(dataModel: new () => T, id: number) {
+    return this.post('/ich/inheritor/collectInfo', {
+      model_id: new dataModel().modelId,
+      id,
+    }, '获取采集记录详情', undefined).then((res) => {
+      return transformDataModel(dataModel, res.data2);
+    })
+  }
+  /**
+   * 获取采集列表
+   * @param data 
+   * @returns 
+   */
+  async getCollectList<T extends DataModel>(dataModel: new () => T, data: {
+    /**
+     * 采集类型
+     * * content 基础
+     * * ich 扩展
+     */
+    collectType: 'content'|'ich',
+    /**
+     * 提交用户ID
+     */
+    userId?: number,
+    /**
+     * 进度:-1=审核失败,0=待审核,1=审核通过
+     */
+    progress?: number,
+    /**
+     * 审核人用户ID
+     */
+    reviewId?: number,
+    /**
+     * 原基础表记录ID
+     */
+    contentId?: number,
+    page?: number,
+    pageSize?: number,
+  }) {
+    return this.post('/ich/inheritor/collectList', {
+      collect_type: data.collectType,
+      model_id: new dataModel().modelId,
+      user_id: data.userId,
+      progress: data.progress,
+      review_id: data.reviewId,
+      content_id: data.contentId,
+      page: data.page,
+      pageSize: data.pageSize,
+    }, '获取采集列表', undefined).then((res) => {
+      return {
+        data: transformArrayDataModel<T>(dataModel, transformSomeToArray(res.data2.data), 'data2'),
+        total: res.data2.total,
+      }
+    })
+  }
+
+  async getInheritorAccountInfo(contentId: number) {
+    return this.post('/ich/inheritor/getAccount', {
+      content_id: contentId,
+    }, '获取传承人账号信息', undefined).then((res) => {
+      const arr = transformSomeToArray(res.data2);
+      if (arr.length === 0)
+        return null;
+      return transformDataModel(InheritorAccountInfo, arr[0]);
+    })
+  }
+  async getInheritorSubmtList(modelId: number) {
+    return this.post('/ich/inheritor/list', {
+      model_id: modelId
+    }, '获取传承人采集数据列表', undefined).then((res) => {
+      return transformArrayDataModel<InheritorSubmitInfo>(InheritorSubmitInfo, transformSomeToArray(res.data2), 'data2');
+    })
+  }  
+
+  async getIchSeminarInfo(data: {
+    ichId?: number,
+    page?: number,
+    pageSize?: number,
+    keywords?: string,
+  }) {
+    return this.post('/ich/inheritor/sites', {
+      ich_id: data.ichId,
+      page: data.page,
+      pageSize: data.pageSize,
+      keywords: data.keywords,
+    }, '获取传习所列表', undefined).then((res) => {
+      return transformArrayDataModel<SeminarInfo>(SeminarInfo, transformSomeToArray(res.data2), 'data2');
+    })
+  }
+  async getIchWorksInfo(data: {
+    ichId: number,
+    page?: number,
+    pageSize?: number,
+  }) {
+    return this.post('/ich/inheritor/works', {
+      ich_id: data.ichId,
+      page: data.page,
+      pageSize: data.pageSize,
+    }, '获取项目作品列表', undefined).then((res) => {
+      return transformArrayDataModel<InheritorWorkInfo>(InheritorWorkInfo, res.data2.data, 'data2');
+    })
+  }
+  async getIchWorksDetail(id: number) {
+    return this.post('/ich/inheritor/info', {
+      id,
+      model_id: 16,
+    }, '获取项目作品详情', undefined).then((res) => {
+      return transformDataModel<InheritorWorkInfo>(InheritorWorkInfo, res.data2);
+    })
+  }
+
+  async getIchInfo(id: number|undefined) {
+    return await this.getBaseInfo(id, IchInfo);
+  }
+  async getInheritorInfo(id: number|undefined) {
+    return await this.getBaseInfo(id, InheritorInfo);
+  }
+  async getSeminarInfo(id: number|undefined) {
+    return await this.getBaseInfo(undefined, SeminarInfo, id);
+  }
+  async getIchExpandInfo(id: number|undefined) {
+    return await this.getExpandInfo(id, IchExpandInfo);
+  }
+  async getInheritorExpandInfo(id: number|undefined) {
+    return await this.getExpandInfo(id, InheritorExpandInfo);
+  }
+  async getSeminarExpandInfo(id: number|undefined) {
+    return await this.getExpandInfo(id, SeminarExpandInfo);
+  }
+}
+
+export default new InheritorContentApi();

+ 223 - 0
src/api/inhert/VillageApi.ts

@@ -0,0 +1,223 @@
+import { CONVERTER_ADD_DEFAULT, DataModel, transformArrayDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+import { transformSomeToArray } from '../Utils';
+import dayjs from 'dayjs';
+
+export class VillageListItem extends DataModel<VillageListItem> {
+  constructor() {
+    super(VillageListItem, "活动列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+    this._nameMapperServer = {
+      name: 'villageName',
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('At'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+    this._afterSolveServer = () => {
+      this.address = 
+        (this.province || '') + 
+        (this.city || '') + 
+        (this.district || '') + 
+        (this.township || '');
+      if (this.images && this.images && this.images.length > 0  ) {
+        this.image = this.images[0]
+      }
+      this.thumbnail = this.image;
+      this.title = this.villageName
+    }
+
+  }
+
+  id !: number;
+  province = '' as string|null;
+  city = '' as string|null;
+  district = '' as string|null;
+  township = '' as string|null;
+  address = '';
+  villageVolunteerId = null as number|null;
+  villageId = null as number|null;
+  claimReason = '';
+  status = '';
+  statusText = '';
+  createdAt = null as Date|null;
+  updatedAt = null as Date|null;
+  deleteAt = null as Date|null;
+  image = '';
+  images = [] as string[];
+  villageName = '';
+  title = '';
+  volunteerName = '';
+}
+export class VolunteerRanklistItem extends DataModel<VolunteerRanklistItem> {
+  constructor() {
+    super(VolunteerRanklistItem, "活动列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      points: [{ clientSide: 'number', serverSide: 'number' }, { clientSide: CONVERTER_ADD_DEFAULT, clientSideParam: { defaultValue: 0 } }],
+      level: [{ clientSide: 'number', serverSide: 'number' }, { clientSide: CONVERTER_ADD_DEFAULT, clientSideParam: { defaultValue: 0 } }],
+    }
+  }
+
+  id !: number;
+  name = '';
+  mobile = '';
+  points = 0;
+  level = 0;
+  typeText = '';
+  sexText = '';
+  statusText = '';
+  image = '';
+}
+export class VolunteerInfo extends DataModel<VolunteerInfo> {
+  constructor() {
+    super(VolunteerInfo, "活动列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      points: [{ clientSide: 'number', serverSide: 'number' }, { clientSide: CONVERTER_ADD_DEFAULT, clientSideParam: { defaultValue: 0 } }],
+      level: [{ clientSide: 'number', serverSide: 'number' }, { clientSide: CONVERTER_ADD_DEFAULT, clientSideParam: { defaultValue: 0 } }],
+      collectModule: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      birthday: { clientSide: 'dayjs', serverSide: 'string' },
+    }
+  }
+
+  id !: number;
+  mainBodyId !: number;
+  type = '';
+  name = '';
+  sex = 0;
+  mobile = '';
+  regionId = null as number|null;
+  address = '';
+  image = '';
+  birthday = dayjs();
+  intro = '';
+  points = 0;
+  level = 0;
+  status = '';
+  createdAt = '';
+  updatedAt = '';
+  typeText = '';
+  sexText = '';
+  statusText = '';
+}
+
+export class VillageMenuListItem extends DataModel<VillageMenuListItem> {
+  constructor() {
+    super(VillageMenuListItem, "村落菜单列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+    this._nameMapperServer = {
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('At'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+
+  }
+  name = '';
+  logo = '';
+}
+
+export class VillageApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+
+  async getClaimedVallageList(volunteerId?: string) {
+    return (this.get('/village/village/getVillageList', '获取已认领村落', {
+      village_volunteer_id: volunteerId,
+    })) 
+      .then(res => transformArrayDataModel<VillageListItem>(VillageListItem, transformSomeToArray(res.data2), `村落`, true))
+      .catch(e => { throw e });
+  }
+  async getCanClaimVallageList() {
+    return (this.get('/village/village/getClaimList', '可认领村落列表', {
+      main_body_id: 2,
+    })) 
+      .then(res => transformArrayDataModel<VillageListItem>(VillageListItem, transformSomeToArray(res.data2), `村落`, true))
+      .catch(e => { throw e });
+  }
+  async claimVallage(data: any) {
+    return (this.post('/village/village/addVillageClaim', {
+      ...data
+    }, '认领村落')) ;
+  }
+  async getVallageList(level?: number) {
+    return (this.get('/village/village/getList', '村落列表', {
+      history_level: level,
+    })) 
+      .then(res => transformArrayDataModel<VillageListItem>(VillageListItem, transformSomeToArray(res.data2), `村落`, true))
+      .catch(e => { throw e });
+  }
+  async getVolunteerInfo() {
+    return (await this.post('/village/volunteer/getInfo', {
+    }, '获取志愿者信息', undefined, VolunteerInfo)).data as VolunteerInfo
+  }
+  async getVolunteerRanklist(category?: number) {
+    return (this.post('/village/volunteer/getRanklist', {
+      category,
+    }, '志愿者排行榜')) 
+      .then(res => transformArrayDataModel<VolunteerRanklistItem>(VolunteerRanklistItem, res.data2, ``, true))
+      .catch(e => { throw e });
+  }
+
+  async addVolunteer(data: VolunteerInfo) {
+    return (this.post('/village/volunteer/add', data.toServerSide(), '添加志愿者')) ;
+  }
+  async updateVolunteer(data: VolunteerInfo) {
+    return (this.post('/village/volunteer/update', data.toServerSide(), '更新志愿者')) ;
+  }
+  async getVillageVolunteerList(villageId?: number) {
+    return (this.post('/village/volunteer/getList', {
+      village_id: villageId,
+    }, '获取志愿者列表')) 
+      .then(res => transformArrayDataModel<VolunteerInfo>(VolunteerInfo, res.data2 || [], ``, true))
+      .catch(e => { throw e });
+  }
+  async getCollectModuleList() {
+    return (this.get('/village/volunteer/getCollectModuleList', '获取采集版块列表', {
+    })) 
+      .then(res => {
+        const result = [] as {
+          value: string,
+          label: string,
+        }[];
+        for (const key in res.data2) {
+          result.push({
+            value: key,
+            label: res.data2[key],
+          })
+        }
+        return result;
+      })
+      .catch(e => { throw e });
+  }
+  
+  async getVillageMenuList(id: number) {
+    return (this.get('/village/menu/getList', '村落菜单列表', {
+      village_id: id,
+    })) 
+      .then(res => transformArrayDataModel<VillageMenuListItem>(VillageMenuListItem, res.data2, `村落菜单`, true))
+      .catch(e => { throw e });
+  }
+
+}
+
+export default new VillageApi();

+ 220 - 0
src/api/inhert/VillageInfoApi.ts

@@ -0,0 +1,220 @@
+import { DataModel, transformArrayDataModel, type NewDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+import CommonContent from '../CommonContent';
+
+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 CommonInfoModel extends DataModel<CommonInfoModel> {
+  constructor() {
+    super(CommonInfoModel, "信息详情");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+
+    },
+    this._blackList.toServer.push(
+      'updatedAt', 'createdAt', 'deletedAt',
+    );
+    this._afterSolveServer = () => {
+      if (this.province && this.city && this.district) {
+        this.cityAddress = [this.province as string, this.city as string, this.district as string];
+      }
+    };
+    this._afterSolveClient = (data) => {
+      if (this.cityAddress) {
+        data.province = this.cityAddress[0];
+        data.city = this.cityAddress[1];
+        data.district = this.cityAddress[2];
+      }
+    };
+  }
+  id !: number;
+  cityAddress?: string[];
+
+}
+
+export class VillageEnvInfo extends DataModel<VillageEnvInfo> {
+  constructor() {
+    super(VillageEnvInfo, "地理信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      landforms: [
+        { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+        { clientSide: 'arrayInt', serverSide: 'original' },
+      ],
+      villageType: { clientSide: 'number', serverSide: 'number' },
+    },
+    this._blackList.toServer.push(
+      'updatedAt', 'createdAt', 'deletedAt',
+    );
+    this._afterSolveServer = () => {
+      if (this.longitude && this.latitude) {
+        this.lonlat = [this.longitude as number, this.latitude as number];
+      }
+    };
+    this._afterSolveClient = (data) => {
+      if (this.lonlat) {
+        data.longitude = this.lonlat[0];
+        data.latitude = this.lonlat[1];
+      }
+    };
+  }
+  id !: number;
+  lonlat?: number[];
+  landforms = [] as string[];
+}
+export class VillageListItem extends DataModel<VillageListItem> {
+  constructor() {
+    super(VillageListItem, "村庄信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    },
+    this._blackList.toServer.push(
+      'updatedAt', 'createdAt', 'deletedAt',
+    );
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('At'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+    this._afterSolveServer = () => {
+      if (!this.title) {
+        if (this.name) this.title = this.name as string;
+        if (typeof this.content === 'object' && (this.content as any)?.title) this.title = (this.content as any).title as string;
+        if (this.content) this.title = this.content as string;
+        if (this.structure) this.title = this.structure as string;
+        if (this.wisdom) this.title = this.wisdom as string;
+      }
+      if (!this.image) {
+        if (this.distribution) this.image = this.distribution as string;
+      }
+    };
+  }
+  id !: number;
+  createdAt = new Date();
+  updatedAt = new Date();
+  title = '';
+  image = '';
+}
+export class VillageBulidingInfo extends DataModel<VillageBulidingInfo> {
+  constructor() {
+    super(VillageBulidingInfo, "历史建筑信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+    this._blackList.toServer.push(
+      'updatedAt', 'createdAt', 'deletedAt',
+    );
+    const commaArrayKeys = [
+      'purpose','floorType','wallType','roofForm','bearingType',
+    ]
+    this._convertKeyType = (key, direction) => {
+      if (commaArrayKeys.includes(key))
+        return [
+          { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+          { clientSide: 'arrayInt', serverSide: 'original' },
+        ];
+      return undefined;
+    };
+  }
+  id !: number;
+}
+
+export class VillageInfoApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+
+  /**
+   * 获取分类列表
+   * @param type 根级类型:1=区域、2=级别、3=文物类型、4=非遗类型、42=事件类型
+   * @param withself 是否返回包含自己:true=是,false=否 ,默认false
+   * @returns 
+   */
+  getCategoryList(
+    type?: number,
+    withself?: boolean,
+  ) {
+    return CommonContent.getCategoryList(type, withself);
+  }
+  /**
+   * 用于获取某一个分类需要用的子级
+   * @param pid 父级
+   * @returns 
+   */
+  getCategoryChildList(pid?: number) {
+    return CommonContent.getCategoryChildList(pid);
+  }
+
+  async getInfo<T extends DataModel>(
+    sub: string,
+    subId: number,
+    villageId: number,
+    villageVolunteerId: number,
+    id?: number,
+    modelClassCreator: (new () => T) = CommonInfoModel as any
+  ) {
+    return (await this.post(`/village/${sub}/getInfo`, {
+      type: subId,
+      village_id: villageId,
+      village_volunteer_id: villageVolunteerId,
+      id,
+    }, '获取信息详情', undefined, modelClassCreator)).data as T
+  }
+  async getList<T extends DataModel = VillageListItem>(
+    sub: string,
+    subId: number|undefined,
+    subKey: string|undefined,
+    villageId: number,
+    villageVolunteerId: number,
+    modelClassCreator: (new () => T) = VillageListItem as any 
+  ) {
+    return (this.post(`/village/${sub}/getList`, {
+      [subKey ? subKey : 'type']: subId,
+      village_id: villageId,
+      village_volunteer_id: villageVolunteerId,
+    }, '获取信息详情'))
+      .then(res => transformArrayDataModel<T>(modelClassCreator, res.data2 || [], `获取分类列表`, true))
+      .catch(e => { throw e });
+  }
+  async updateInfo<T extends DataModel>(
+    sub: string,
+    villageId: number,
+    villageVolunteerId: number,
+    data: T,
+  ) {
+    return (await this.post(`/village/${sub}/save`, {
+      sub,
+      village_id: villageId,
+      village_volunteer_id: villageVolunteerId,
+      ...data.toServerSide(),
+    }, '更新信息详情'));
+  }
+}
+
+export default new VillageInfoApi();

二进制
src/assets/fonts/Impact.ttf


二进制
src/assets/fonts/Impact.woff


二进制
src/assets/fonts/Impact.woff2


二进制
src/assets/fonts/STSongti-SC-Black.ttf


二进制
src/assets/fonts/STSongti-SC-Black.woff


二进制
src/assets/fonts/STSongti-SC-Black.woff2


二进制
src/assets/fonts/SourceHanSerifCN-Bold.otf


二进制
src/assets/fonts/SourceHanSerifCN-Bold.ttf


二进制
src/assets/fonts/SourceHanSerifCN-Bold.woff


二进制
src/assets/fonts/SourceHanSerifCN-Bold.woff2


二进制
src/assets/fonts/nzgrRuyinZouZhangKai.ttf


二进制
src/assets/fonts/nzgrRuyinZouZhangKai.woff


二进制
src/assets/fonts/nzgrRuyinZouZhangKai.woff2


文件差异内容过多而无法显示
+ 1 - 0
src/assets/images/404.svg


二进制
src/assets/images/BackArrow.png


二进制
src/assets/images/Bg1.png


二进制
src/assets/images/Bg2.png


二进制
src/assets/images/BgLong.jpg


二进制
src/assets/images/CloseMini.png


二进制
src/assets/images/DropDownArrow.png


二进制
src/assets/images/IconArrowRight.png


文件差异内容过多而无法显示
+ 8 - 0
src/assets/images/IconInfo.svg


二进制
src/assets/images/IconUser.png


二进制
src/assets/images/ImageFailed.png


二进制
src/assets/images/LargeTitle1.png


二进制
src/assets/images/LargeTitle2.png


二进制
src/assets/images/LargeTitle3.png


二进制
src/assets/images/LogoIcon.png


二进制
src/assets/images/LogoIconDark.png


二进制
src/assets/images/TitleMiniHeader.png


二进制
src/assets/images/favicon.ico


二进制
src/assets/images/favicon.png


二进制
src/assets/images/footer/FooterPrinting.png


二进制
src/assets/images/footer/GonganLogo.png


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

@@ -0,0 +1,30 @@
+$primary-color: #f09115;
+$primary-dark-color: #db8719;
+
+$text-color: #333;
+$text-color-light: #fff;
+$text-second-color: #6d6d6d;
+$text-second-color-light: #ddd;
+
+$background-color: rgb(249, 246, 237);
+
+$text-content-color: #654A38;
+$text-content-second-color: rgba(101, 74, 56, 0.6);
+
+$selection-max-width: 980px;
+$selection-max-width-large: 1280px; 
+
+$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-shadow-color: rgba(#000, 0.04);
+$box-color: #fff;
+$box-inset-color: rgba(#FFFDF9, 0.33);
+$box-hover-color: rgba(#FFFDF9, 0.88);
+$box-primary-color: rgba($primary-color, 0.18);

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

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

@@ -0,0 +1,81 @@
+@use "./colors.scss" as *;
+
+.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)
+}
+.ant-form-item {
+  margin-bottom: 48px;
+
+ .ant-form-item-label > label {
+  font-weight: 600;
+  font-size: 16px;
+  line-height: 24px;
+ }
+}
+
+.ant-list.light-round {
+  padding: 0.4rem;
+
+  .ant-list-item {
+    background-color: #fff;     // 每条背景白色
+    border-radius: 8px;         // 圆角
+    margin-bottom: 0.8rem;      // 条目间距
+    padding: 1rem 1.2rem;       // 内边距,可按需调整
+    transition: box-shadow 0.2s;
+    border: 1px solid #eeeeee;
+
+    &:last-child {
+      margin-bottom: 0;         // 最后一条去掉底边距
+    }
+
+    &:hover {
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
+    }
+  }
+}
+.ant-descriptions.light {
+  .ant-descriptions-view {
+    background-color: #fff;
+  }
+}
+.ant-upload-list-item-name {
+  white-space: wrap!important;
+}
+
+.dynamic-form-group {
+  background-color: transparent;
+  margin-bottom: 10px;
+  padding: 0 0 0 10px;
+
+  h5 {
+    display: inline-block;
+    border-radius: 10px;
+    background-color: $border-active-color;
+    color: $text-color-light;
+    padding: 3px 6px;
+    font-size: 13px;
+  }
+}
+
+@media screen and (max-width: 768px) {
+  .ant-form-item {
+    margin-bottom: 24px !important;
+  }
+}
+@media screen and (max-width: 425px) {
+
+}
+
+//utils-fix
+
+.color-primary {
+  color: $primary-color!important;;
+}
+.bg-primary {
+  background-color: $box-primary-color!important;;
+}

文件差异内容过多而无法显示
+ 388 - 0
src/assets/scss/fonts.scss


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

@@ -0,0 +1,451 @@
+@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;
+    -webkit-backdrop-filter: blur(5px);
+    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');
+  }
+}
+.main-clickable {
+  cursor: pointer;
+  -webkit-user-select: none;
+  user-select: none;
+
+  &:active {
+    transform: scale(0.996); 
+  }
+}
+
+//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;
+    }
+  }
+
+  &.large {
+    > .content {
+      max-width: $selection-max-width-large;
+    }
+  }
+  
+  > .content {
+    max-width: $selection-max-width;
+    margin: 0 auto;
+
+    > .title {
+
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      justify-content: center;
+
+      margin-bottom: 40px;
+
+      &.small {
+        margin-bottom: 20px;
+      }
+      &.left-right {
+        justify-content: space-between;
+      }
+
+      .button-placeholder {
+        flex-shrink: 0;
+        width: 100px;
+      }
+      .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;
+      -webkit-user-select: none;
+      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;
+    }
+  }
+}
+
+.form-container {
+  max-width: 600px;
+  margin: 0 auto;
+}
+.form-box {
+  background-color: $box-color;
+  border-radius: 20px;
+  padding: 90px;
+  box-shadow: 0 0 10px $box-shadow-color;
+}
+
+//Card box
+
+.task-list {
+  display: flex;
+  flex-direction: column;
+
+  .item {
+    margin-top: 20px;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    padding: 20px;
+    background-color: $box-color;
+    border-radius: 10px;
+    cursor: pointer;
+
+    &:hover {
+      background-color: $box-hover-color;
+    }
+
+    .info {
+      flex: 1;
+      font-size: 20px;
+      margin: 0 20px;
+
+      .desc {
+        font-size: 15px;
+        color: $text-content-color;
+      }
+    }
+
+    .iconfont {
+      width: 50px;
+      height: 50px;
+      border-radius: 50%;
+      border: 1px solid $primary-color;
+      text-align: center;
+      color: $primary-color;
+      font-size: 30px;
+      line-height: 50px;
+      display: inline-block;
+    }
+  }
+}
+
+@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 20px;
+    &.small-h {
+      padding-top: 20px;
+      padding-bottom: 20px;
+    }
+    .content .title.left-right {
+      flex-direction: column;
+    }
+  }
+  .main-stats {
+    h4 {
+      margin: 20px 0 10px 0;
+      font-size: 1rem;
+    }
+    .descs {
+      h5 {
+        font-size: 3rem;
+      }
+      p {
+        font-size: 0.9rem;
+      }
+    }
+  }
+  .form-box {
+    padding: 40px;
+  }
+  .task-list .item {
+    padding: 15px;
+  }
+}
+@media (max-width: 425px) {
+  .main-section {
+    padding: 80px 10px;
+    &.small-h {
+      padding-top: 20px;
+      padding-bottom: 20px;
+    }
+
+    .content .title { 
+      h2 {
+        font-size: 1.5rem;
+      }
+      &.left-right {
+        gap: 20px;
+        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;
+      }
+    }
+  }
+  .form-box {
+    padding: 0;
+    background-color: transparent;
+    box-shadow: none;
+  }
+  .task-list .item {
+    padding: 10px;
+  }
+}

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

@@ -0,0 +1,456 @@
+
+@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;
+
+    .item-right {
+      flex: 1;
+      display: flex;
+      flex-direction: row;
+      justify-content: flex-end;
+      align-items: center;
+      flex-wrap: wrap;
+    }
+
+    &.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;
+      }
+    }
+    &.row-type5 {
+      img {
+        object-fit: cover;
+        border-radius: 50%;
+        width: 80px;
+        height: 80px;
+      }
+    }
+    &.row-type6 {
+      .TitleDescBlock h3 {
+        font-size: 15px;
+      }
+      img {
+        display: none;
+      }
+    }
+    &.empty {
+      background-color: transparent;
+      border: none;
+    }
+
+    &:hover:not(.empty) { 
+      background-color: $box-hover-color;
+    }
+    &:active:not(.empty) {
+      transform: scale(0.995);
+    }
+
+    .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;
+        }
+      }
+      &.row-type5 {
+        img {
+          width: 60px;
+          height: 60px;
+        }
+      }
+
+      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();
+}

+ 5 - 0
src/assets/scss/vueexp.module.scss

@@ -0,0 +1,5 @@
+@use './colors';
+
+:export {
+  primaryColor: colors.$primary-color;
+}

+ 2 - 0
src/common/ConstStrings.ts

@@ -0,0 +1,2 @@
+export const NO_CONTENT_STRING = '无内容,请添加内容!';
+export const TITLE = '乡源·乡村文化资源挖掘平台';

+ 113 - 0
src/common/ConvertRgeistry.ts

@@ -0,0 +1,113 @@
+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 ? source.split(',') : [],
+        }
+      return {
+        success: false,
+        convertFailMessage: `[${key}] 不是字符串类型`,
+      };
+    }
+  })
+  DataConverter.registerConverter({
+    key: 'ArrayInt',
+    targetType: 'arrayInt',
+    converter: (source, key, type) => {
+      if (source instanceof Array) 
+        return {
+          success: true,
+          result: source ? source.map((item) => parseInt(item)) : [],
+        }
+      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;

+ 47 - 0
src/common/upload/AliOssUploadCo.ts

@@ -0,0 +1,47 @@
+import type { AntUploadRequestOption, UploadCoInterface } from "@/components/dynamicf/UploadImageFormItem";
+import { RandomUtils, StringUtils } from "@imengyu/imengyu-utils";
+import OSS from 'ali-oss';
+
+const client = new OSS({
+  region: 'oss-cn-shenzhen',
+  accessKeyId: 'LTAI5t5e7wAQ1FvUA4LCsNs5',
+  accessKeySecret: 'lhF0SimpatPMHNjmjtIKsWsYwTmJhx',
+  bucket: 'minnanwenhua', 
+});
+
+export function useAliOssUploadCo(subPath: string) : UploadCoInterface {
+  return {
+    uploadRequest: async (requestOption: AntUploadRequestOption) => {
+      const uploadPath = `${subPath}/${requestOption.file.name.split('.')[0]}-${RandomUtils.genNonDuplicateID(26)}.${StringUtils.path.getFileExt(requestOption.file.name)}`;      
+
+      //小于8mb则直接上传,否则分片上传
+      if (requestOption.file.size < 8 * 1024 * 1024) {
+        client.put(uploadPath, requestOption.file).then((res) => {  
+          requestOption.onSuccess?.({
+            url: res.url,
+            key: uploadPath,
+          }, null);
+        }).catch((err) => {
+          requestOption.onError?.(err, {});
+        })
+      } else {
+        client.multipartUpload(uploadPath, requestOption.file, {
+          progress(percentage) {
+            requestOption.onProgress({ percent: percentage * 100 })
+          },
+        }).then((res) => {
+          requestOption.onSuccess?.({
+            url: client.generateObjectUrl(uploadPath),
+            key: uploadPath,
+          }, null);
+        }).catch((err) => {
+          requestOption.onError?.(err, {});
+        });
+      }
+    },
+    getUrlByUploadResponse: (response: unknown) => {
+      return (response as any).url as string;
+    },
+  }
+}
+

+ 23 - 0
src/common/upload/ImageUploadCo.ts

@@ -0,0 +1,23 @@
+import CommonContent from "@/api/CommonContent";
+import type { AntUploadRequestOption, UploadCoInterface } from "@/components/dynamicf/UploadImageFormItem";
+
+export function useImageSimpleUploadCo(additionData?: Record<string, any>) : UploadCoInterface {
+
+  return {
+    uploadRequest: (requestOption: AntUploadRequestOption) => {
+      CommonContent.uploadSmallFile(requestOption.file, 'image', 'file', additionData)
+        .then((res) => {
+          requestOption.onSuccess?.({
+            url: res.fullurl,
+            key: res.fullurl,
+          }, null);
+        }).catch((err) => {
+          requestOption.onError?.(err, {});
+        })
+    },
+    getUrlByUploadResponse: (response: unknown) => {
+      return (response as any).url as string;
+    },
+  }
+}
+

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

+ 38 - 0
src/components/FooterSmall.vue

@@ -0,0 +1,38 @@
+<template>
+  <footer class="small-footer">
+    <!-- <a href="https://minnan.wenlvti.net/">闽南文化生态保护区 (厦门市)</a> -->
+    <!-- <a href="#">
+      <img src="@/assets/images/footer/GonganLogo.png" />
+      闽公网安备 44040202000131号
+    </a> -->
+    <a href="http://beian.miit.gov.cn/">闽ICP备2023000538号-1</a>
+  </footer>
+</template>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+.small-footer {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+  padding: 10px 0;
+  background-color: $background-color;
+
+  a {
+    text-decoration: none;
+    color: $primary-color;
+    font-size: 0.9rem;
+    margin-right: 40px;
+
+    &:hover {
+      color: $primary-dark-color;
+    }
+
+    img {
+      margin-right: 10px;
+    }
+  }
+}
+</style>

+ 329 - 0
src/components/NavBar.vue

@@ -0,0 +1,329 @@
+<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>
+    <div class="group center">
+      <div class="headerlogos">
+        <img class="main-clickable" src="@/assets/images/LogoIcon.png" @click="goIndex" />
+        <div>
+          <p class="large">{{ TITLE }}</p>
+        </div>
+      </div>
+    </div>
+    <div class="group">
+    </div>
+    
+    <a-dropdown v-if="authStore.isLogged" :trigger="['click']">
+      <a-image :src="IconUser" class="right-button" :preview="false" />
+      <template #overlay>
+        <a-menu>
+          <a-menu-item key="2" @click="router.push('/change-password')">修改密码</a-menu-item>
+          <a-menu-item key="3" @click="logout">退出登录</a-menu-item>
+        </a-menu>
+      </template>
+    </a-dropdown>
+    <div v-else></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 IconUser from '@/assets/images/IconUser.png';
+import { TITLE } from '@/common/ConstStrings';
+import { useAuthStore } from '@/stores/auth';
+import { Modal } from 'ant-design-vue';
+
+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;
+}
+
+const authStore = useAuthStore();
+
+function logout() {
+  Modal.confirm({
+    title: '确认退出登录吗?',
+    okText: '确认',
+    okType: 'danger',
+    onOk: async () => {
+      await authStore.logout();
+      router.push('/');
+    }
+  })
+}
+
+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.35rem;
+
+        span {
+          font-size: 1rem;
+          margin-left: 10px;
+        }
+        &.large {
+          height: 33px;
+          font-size: 1.6rem;
+          letter-spacing: -0.1rem;
+        }
+      }
+    }
+  }
+
+  .right-button {
+    width: 30px;
+    height: 30px;
+    cursor: pointer;
+  }
+}
+
+.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.3rem;
+            letter-spacing: -0.15rem;
+          }
+        }
+      }
+    }
+  }
+}
+@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>

+ 432 - 0
src/components/content/CommonListBlock.vue

@@ -0,0 +1,432 @@
+<template>
+  <!-- 通用列表页详情 -->
+  <div v-show="show" >
+    <div class="content mb-2">
+      <!-- 搜素栏 -->
+      <div class="row mt-3 align-items-center">
+        <!-- 左栏 -->
+        <div class="col-sm-12 col-md-6 col-lg-6">
+          <!-- 分类 -->
+          <TagBar 
+            :tags="tagsData || []"
+            :margin="[30, 70]" 
+            v-model:selectedTag="selectedTag"
+          />
+          <!-- 标题 -->
+          <div v-if="showNav" class="nav-back-title">
+            <img src="@/assets/images/BackArrow.png" alt="返回" @click="router.back()" />
+            <h2>{{ title }}</h2>
+          </div>
+          <!-- 标题 -->
+          <div v-if="showTotal" class="nav-back-title">
+            共有 {{ newsLoader.total }} 个{{ title }}
+          </div>
+          <slot name="headLeft"></slot>
+        </div>
+        <!-- 右栏 -->
+        <div class="col-sm-12 col-md-6 col-lg-6 d-flex flex-row justify-content-end align-items-start flex-wrap" style="gap:5px">
+          <Dropdown
+            v-for="(drop, k) in dropDownNames" :key="k" 
+            :selectedValue="dropDownValues[k]"
+            :options="drop.options" 
+            labelKey="name"
+            valueKey="id"
+            style="max-width: 150px"
+            @update:selectedValue="(v) => handleChangeDropDownValue(k, v)"
+          />
+          <div class="d-flex flex-row">
+            <SimpleInput v-if="showSearch" v-model="searchText" placeholder="请输入关键词" @search="handleSearch">
+              <template #suffix>
+                <IconSearch
+                  class="search-icon"
+                  src="@/assets/images/news/IconSearch.png"
+                  alt="搜索" 
+                  @click="newsLoader.loadData(undefined, true)"
+                />
+              </template>
+            </SimpleInput>
+            <slot name="searchRight"></slot>
+          </div>
+          <button class="tab-button" v-if="showTableSwitch" @click="tableListShow=!tableListShow">
+            ▼ 清单
+          </button>
+          <slot name="headRight"></slot>
+        </div>
+      </div>
+    </div>
+    <div 
+      :class="[
+        'content', 
+        'news-list',
+        rowCount === 1 ? '' : 'grid',
+      ]"
+    >
+      <!-- 新闻列表 -->
+      <SimplePageContentLoader :loader="newsLoader">
+        <div v-if="tableListShow" class="table-list">
+          <table>
+            <thead>
+              <tr>
+                <th>序号</th>
+                <th>{{ tableSwitchOptions.title ?? '标题'}}</th>
+                <th v-for="(t, k) in newsLoader.list.value[0]?.addItems || []" :key="k">{{ t.name }}</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(item, k) in newsLoader.list.value" :key="item.id">
+                <td>{{ (newsLoader.page.value - 1) * 100 + k + 1 }}</td>
+                <td>{{ item.title }}</td>
+                <td v-for="(t, k) in item.addItems || []" :key="k">{{ t.text }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+        <div v-else class="list">
+          <div 
+            v-for="(item, k) in newsLoader.list.value"
+            :key="item.id"
+            :class="'item user-select-none main-clickable row-type'+rowType"
+            :style="{ width: rowWidth }"
+            @click="handleShowDetail(item)"
+          >
+            <a class="d-none" :href="router.resolve({ path: props.detailsPage, query: { id: item.id }}).href" />
+            <img
+              :src="item.image || defaultImage" alt="新闻图片" 
+            />
+            <TitleDescBlock
+              :title="item[props.titleKey]"
+              :desc="item[props.descKey]"
+            >
+              <template #addon>
+                <div v-if="item.bottomTags" class="tags">
+                  <div
+                    v-for="(tag, k) in item.bottomTags"
+                    :key="k"
+                    :class="tag ? '' : 'd-none'"
+                  >{{ tag }}</div>
+                </div>
+                <div v-if="item.addItems" class="extra">
+                  <div 
+                    v-for="(addItem, k) in item.addItems" 
+                    :key="k" 
+                    class="d-flex flex-row align-items-center"
+                    :class="[
+                      addItem.text ? '' : 'd-none',
+                    ]"
+                  >
+                    <span class="desc">{{ addItem.name }}:</span>
+                    <span>{{ addItem.text }}</span>
+                  </div>
+                </div>
+              </template>
+            </TitleDescBlock>
+            <div class="item-right">
+              <slot name="itemRight" :index="k" :item="item" />
+            </div>
+          </div>
+          <div 
+            v-for="count of placeholderItemCount"
+            :key="count"
+            class="item empty"
+            :style="{ width: rowWidth }"
+          />
+        </div>
+      </SimplePageContentLoader>
+    </div>
+    <!-- 分页 -->
+    <Pagination
+      v-model:currentPage="newsLoader.page.value"
+      :totalPages="newsLoader.totalPages.value"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, watch, type PropType } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useSimplePagerDataLoader, SimplePageContentLoader } from '@imengyu/imengyu-web-shared';
+import TagBar from '../content/TagBar.vue';
+import Dropdown from '../controls/Dropdown.vue';
+import SimpleInput from '../controls/SimpleInput.vue';
+import Pagination from '../controls/Pagination.vue';
+import TitleDescBlock from '../parts/TitleDescBlock.vue';
+import IconSearch from '../icons/IconSearch.vue';
+
+export interface DropdownCommonItem {
+  id: number; 
+  name: string;
+}
+export interface DropDownNames {
+  options: (string|DropdownCommonItem)[],
+  label?: string,
+  defaultSelectedValue: number|string,
+}
+
+const tableListShow = ref(false);
+
+const props = defineProps({	
+  title: {
+    type: String,
+    default: '',
+  },
+  titleKey: {
+    type: String,
+    default: 'title',
+  },
+  descKey: {
+    type: String,
+    default: 'desc',
+  },
+  show: {
+    type: Boolean,
+    default: true,
+  },
+  showTableSwitch: {
+    type: Boolean,
+    default: false,
+  },
+  tableSwitchOptions: {
+    type: Object,
+    default: () => ({}), 
+  },
+  showNav: {
+    type: Boolean,
+    default: false,
+  },
+  showTotal: {
+    type: Boolean,
+    default: false, 
+  },
+  prevPage: {
+    type: Object as PropType<{
+      title: string,
+      url?: string,
+    }>,
+    default: null,
+  },
+  dropDownNames: {
+    type: Object as PropType<DropDownNames[]>,
+    default: null,
+  },
+  showSearch: {
+    type: Boolean,
+    default: true,
+  },
+  tagsData: {
+    type: Object as PropType<{
+      id: number,
+      name: string,
+    }[]>,
+    default: null,
+  },
+  pageSize: {
+    type: Number,
+    default: 8,
+  },
+  rowCount: {
+    type: Number,
+    default: 2,
+  },
+  rowType: {
+    type: Number,
+    default: 1,
+  },
+  defaultSelectTag: {
+    type: Number,
+    default: 1,
+  },
+  load: {
+    type: Function as PropType<(
+      page: number, 
+      pageSize: number,
+      selectedTag: number,
+      searchText: string,
+      dropDownValues: number[],
+    ) => Promise<{
+      page: number,
+      total: number,
+      data: any[],
+    }>>,
+    required: true,
+  },
+  showDetail: {
+    type: Function as PropType<(item: any) => void>,
+    default: null,
+  },
+  subName: {
+    type: String,
+    default: '',
+  },
+  /**
+   * 点击详情跳转页面路径
+   */
+  detailsPage: {
+    type: String,
+    default: '/news/detail'
+  },
+  /**
+   * 详情跳转页面参数
+   */
+  detailsParams: {
+    type: Object as PropType<Record<string, any>>,
+    default: () => ({})
+  },
+  defaultImage: {
+    type: String,
+    default: 'https://mncdn.wenlvti.net/app_static/minnan/EmptyImage.png'
+  },
+  startSearchText: {
+    type: String,
+    default: '',
+  }
+})
+
+const router = useRouter();
+
+const realRowCount = computed(() => {
+   if (window.innerWidth < 768) 
+    return 1;
+  return props.rowCount;
+});
+const rowWidth = computed(() => {
+  switch (realRowCount.value) {
+    case 2:
+      return `calc(50% - 25px)`;
+    case 3:
+      return `calc(33% - 25px)`;
+    case 4:
+      return `calc(25% - 25px)`;
+  }
+});
+const placeholderItemCount = computed(() => {
+  switch (realRowCount.value) {
+    case 2:
+    case 3:
+    case 4:
+      return newsLoader.list.value.length % realRowCount.value;
+  }
+  return 0;
+});
+const searchText = ref(props.startSearchText);
+const dropDownValues = ref<any>([]);
+
+function handleSearch() {
+  newsLoader.loadData(undefined, true);
+}
+function handleChangeDropDownValue(index: number, value: number) {
+  dropDownValues.value[index] = value;
+  newsLoader.loadData(undefined, true);
+}
+function handleShowDetail(item: any) {
+  if (props.showDetail)
+    return props.showDetail(item);
+  if (props.detailsPage === 'none')
+    return;
+  router.push({ 
+    path: props.detailsPage,
+    query: {
+      id: item.id,
+    }
+  });
+}
+
+
+//子分类
+const selectedTag = ref(props.defaultSelectTag);
+const pageSize = ref(props.pageSize);
+const route = useRoute();
+
+const newsLoader = useSimplePagerDataLoader(pageSize, (page, size) => {
+  return props.load(
+    page, size, 
+    selectedTag.value, 
+    searchText.value,
+    dropDownValues.value,
+  )
+});
+
+watch(() => props.defaultSelectTag, (v) => {
+  selectedTag.value = v;
+})
+watch(() => props.dropDownNames, () => {
+  loadDropValues();
+})
+watch(selectedTag, () => {
+  newsLoader.loadData(undefined, true);
+})
+watch(tableListShow, (v) => {
+  pageSize.value = v ? 100 : props.pageSize;
+  newsLoader.loadData(undefined, true);
+})
+
+function loadDropValues() {
+  dropDownValues.value = [];
+  const oldDropDownValues = dropDownValues.value;
+  if (props.dropDownNames)
+    for (const element of props.dropDownNames)
+      dropDownValues.value.push(element.defaultSelectedValue);
+  if(isEqual(oldDropDownValues, dropDownValues.value))
+    return;
+  newsLoader.loadData();
+}
+function isEqual(arr1 : Array<unknown>, arr2 : Array<unknown>) : boolean {
+  if(!arr1 && !arr2)
+    return true;
+  if(!arr1 || !arr2)
+    return false;
+  if(arr1.length !== arr2.length)
+    return false;
+  for (let i = arr1.length - 1; i >= 0; i--) {
+    if(arr1[i] !== arr2[i]) {
+      return false;
+    }
+  }
+  return true;
+}
+
+onMounted(() => {
+  setTimeout(() => {
+    loadDropValues();
+    newsLoader.loadData(undefined, true);
+  }, 600);
+})
+watch(route, () => {
+  loadDropValues();
+})
+
+defineExpose({
+  reload() {
+    newsLoader.loadData(undefined, true);
+  }
+})
+</script>
+
+<style lang="scss">
+@use "@/assets/scss/colors";
+
+.nav-back-title {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-start;
+
+  h2 {
+    font-size: 20px;
+    font-family: SourceHanSerifCNBold;
+    margin: 0;
+  }
+  img { 
+    width: 25px;
+    height: 25px;
+    cursor: pointer;
+    margin-right: 10px;
+  }
+}
+.search-icon {
+  width: 25px;
+  height: 25px;
+  cursor: pointer;
+  color: colors.$primary-color;
+}
+</style>
+

+ 1 - 0
src/components/content/MapConfig.ts

@@ -0,0 +1 @@
+export const defaultCenter = [118.1476536, 24.503791];

+ 62 - 0
src/components/content/SimplePointedMap.vue

@@ -0,0 +1,62 @@
+<template>
+  <div :style="{ width, height }">
+    <el-amap
+      style="width: 100%"
+      :center="center"
+      :zoom="zoom"
+      @init="handleInit"
+      v-bind="$attrs"
+    >
+      <el-amap-marker :position="center" />
+    </el-amap>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref, watch } from 'vue';
+import { defaultCenter } from './MapConfig';
+
+const props = defineProps({
+  latitude: {
+    type: [Number, String],
+    default: 0
+  },
+  longitude: {
+    type: [Number, String],
+    default: 0
+  },
+  width: {
+    type: [Number, String],
+    default: '100%'
+  },
+  height: {
+    type: [Number, String],
+    default: '300px'
+  }
+});
+
+const zoom = ref(12);
+const center = ref(defaultCenter);
+let map: any = null;
+
+function handleInit(mapRef: any) {
+  map = mapRef;
+}
+function loadMaker() {
+  if (!map || !props.longitude || !props.latitude) 
+    return;
+  center.value = [Number(props.longitude), Number(props.latitude)];
+}
+
+watch(() => props.latitude, () => {
+  loadMaker();
+});
+watch(() => props.longitude, () => {
+  loadMaker();
+});
+onMounted(() => {
+  setTimeout(() => {
+    loadMaker();
+  }, 200);
+});
+</script>

+ 68 - 0
src/components/content/TagBar.vue

@@ -0,0 +1,68 @@
+<template>
+  <!-- 单选标签选择按钮条,可显示一行标签,然后高亮选中项 -->
+  <div class="d-flex flex-row flex-wrap">
+    <div
+      :class="[
+        'tag-button',
+        { 'active': tag.id === selectedTag },
+      ]"
+      v-for="tag in tags"
+      :key="tag.id"
+      @click="emit('update:selectedTag', tag.id ?? tag)"
+    >
+      {{ tag.name?? tag }}
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+
+defineProps({	
+  /**
+   * 标签列表
+   */
+  tags: {
+    type: Object as PropType<Array<{
+      id: number|string,
+      name: string,
+    }>>,
+    required: true,
+  },
+  /**
+   * 选中的标签,可双向绑定
+   */
+  selectedTag: {
+    type: [Number,String],
+    default: null,
+  }
+})
+
+const emit = defineEmits([	
+  "update:selectedTag"	
+])
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+.tag-button {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background-color: $box-inset-color;
+  color: $text-color;
+  padding: 10px 15px;
+  margin-right: 8px;
+  cursor: pointer;
+  user-select: none;
+
+  &:hover {
+    background-color: $box-hover-color;
+  }
+  &:active, &.active {
+    color: $text-color-light;
+    background-color: $primary-color;
+  }
+}
+</style>

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

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

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

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

+ 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">
+      <ScrollRect scroll="vertical">
+        <div
+          v-for="(option, index) in options"
+          :key="index"
+          :class="[
+            'option',
+            selectedValue === option[valueKey] ? 'selected' : '',
+          ]"
+          @click="selectOption(option)"
+        >
+          <span>{{ option[labelKey] }}</span>
+        </div>
+      </ScrollRect>
+    </div>
+  </div>
+  
+</template>
+
+<script setup lang="ts">
+import { computed, ref, type PropType } from 'vue';
+import DropDownIcon from './DropdownIcon.vue';
+import { ScrollRect } from '@imengyu/vue-scroll-rect';
+
+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>

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

@@ -0,0 +1,117 @@
+<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: 6px 10px;
+  border-radius: 10px;
+  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;
+  }
+
+  .icon {
+    width: 24px;
+    height: 24px;
+  }
+
+  
+  &.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>

+ 67 - 0
src/components/dynamicf/SelectCity.vue

@@ -0,0 +1,67 @@
+<template>
+  <a-cascader
+    v-model:value="value"
+    :options="data"
+    :field-names="{ label: 'text', value: useCode ? 'value' : 'text' }"
+    :style="{ width: '100%' }"
+    placeholder="请选择省市"
+    @change="handleChange"
+  />
+</template>
+
+<script setup lang="ts">
+import NotConfigue from '@/api/NotConfigue';
+import { onMounted, ref, computed, watch } from 'vue';
+
+const data = ref<Array<any>>([]);
+const localValue = ref<Array<string | number>>([]);
+
+const props = defineProps({
+  modelValue: {
+    type: Array as () => Array<string | number>,
+    default: () => []
+  },
+  useCode: {
+    type: Boolean,
+    default: false,
+  },
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+// 计算属性用于双向绑定
+const value = computed({
+  get() {
+    return props.modelValue || localValue.value;
+  },
+  set(newValue) {
+    localValue.value = newValue;
+    emit('update:modelValue', newValue);
+  }
+})
+
+// 监听外部modelValue变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (newValue && JSON.stringify(newValue) !== JSON.stringify(localValue.value)) {
+      localValue.value = [...newValue];
+    }
+  },
+  { deep: true }
+)
+
+onMounted(() => {
+  NotConfigue.get('https://mn.wenlvti.net/app_static/xiangan/city-data.json', '', undefined).then((res) => {
+    data.value = res.data as any[];
+    // 如果有初始值,设置到本地状态
+    if (props.modelValue && props.modelValue.length > 0) {
+      localValue.value = [...props.modelValue];
+    }
+  })
+});
+
+function handleChange(values: Array<string | number>) {
+  emit('update:modelValue', values);
+}
+</script>

+ 34 - 0
src/components/dynamicf/index.ts

@@ -0,0 +1,34 @@
+import { configDefaultDynamicFormOptions, DynamicFormItemRegistry } from "@imengyu/vue-dynamic-form";
+import { Form, FormItem, type FormProps } from "ant-design-vue";
+import { markRaw } from "vue";
+import SelectCity from "./SelectCity.vue";
+
+export function configDynamicForm() {
+  
+  configDefaultDynamicFormOptions({
+    formAdditionaProps: {
+      layout: 'vertical',
+    } as FormProps,
+    internalWidgets: {
+      Form: {
+        component: markRaw(Form),
+        propsMap: {
+          rules: 'rules',
+          wrapperCol: 'wrapperCol',
+          labelCol: 'labelCol',
+        },
+      },
+      FormItem: {
+        component: markRaw(FormItem),
+        propsMap: {
+          name: 'name',
+          wrapperCol: 'wrapperCol',
+          labelCol: 'labelCol',
+        },
+      },
+    },
+  });
+
+  DynamicFormItemRegistry
+    .register('select-city', markRaw(SelectCity), {}, 'modelValue')
+}

+ 44 - 0
src/components/icons/IconMenu.vue

@@ -0,0 +1,44 @@
+<template>
+  <svg 
+    :class="[
+      'icon-menu',
+      openState ? 'open' : '',
+    ]"
+    viewBox="0 0 1024 1024"
+    width="30"
+    height="30"
+  >
+    <path class="line1" d="M133.310936 296.552327l757.206115 0c19.781623 0 35.950949-16.169326 35.950949-35.950949 0-19.781623-15.997312-35.950949-35.950949-35.950949L133.310936 224.650428c-19.781623 0-35.950949 16.169326-35.950949 35.950949C97.359987 280.383 113.529313 296.552327 133.310936 296.552327z" fill="currentColor"></path>
+    <path class="line2" d="M890.51705 476.135058 133.310936 476.135058c-19.781623 0-35.950949 16.169326-35.950949 35.950949 0 19.781623 16.169326 35.950949 35.950949 35.950949l757.206115 0c19.781623 0 35.950949-16.169326 35.950949-35.950949C926.467999 492.304384 910.298673 476.135058 890.51705 476.135058z" fill="currentColor"></path>
+    <path class="line3" d="M890.51705 727.447673 133.310936 727.447673c-19.781623 0-35.950949 15.997312-35.950949 35.950949s16.169326 35.950949 35.950949 35.950949l757.206115 0c19.781623 0 35.950949-15.997312 35.950949-35.950949S910.298673 727.447673 890.51705 727.447673z" fill="currentColor"></path>
+  </svg>
+</template>
+
+<script setup lang="ts">
+defineProps({	
+  openState: Boolean	
+})
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+.icon-menu {
+  path {
+    transition: all 0.3s;
+  }
+  &.open {
+    color: $primary-color;
+    .line1 {
+      transform: rotate(45deg) translate(25%, -25%);
+    }
+    .line2 {
+      opacity: 0;
+    }
+    .line3 {
+      transform: rotate(-45deg) translate(-50%, 0%);
+    }
+  }
+}
+
+</style>

文件差异内容过多而无法显示
+ 5 - 0
src/components/icons/IconSearch.vue


+ 63 - 0
src/components/parts/EmptyToRecord.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+import type { PropType } from 'vue';
+import { type ISimpleDataLoader, SimplePageContentLoader } from '@imengyu/imengyu-web-shared';
+
+const emit = defineEmits([ 'edit' ]);
+defineProps({
+  loader: {
+    type: Object as PropType<ISimpleDataLoader<any, any>>,
+    default: undefined
+  },
+  title: {
+    type: String,
+    default: '非遗项目'
+  },
+  buttonText: {
+    type: String,
+    default: '去补充'
+  },
+  emptyText: {
+    type: String,
+    default: ''
+  },
+  showEdited: {
+    type: Boolean,
+    default: true
+  },
+  showAdd: {
+    type: Boolean,
+    default: true
+  }
+})
+</script>
+
+<template>
+  <SimplePageContentLoader :loader="loader">
+    <a-result
+      v-if="!loader?.content.value"
+      status="404"
+      :title="`${title}信息`"
+      :subTitle="emptyText || `暂无${title}信息,快去补充`"
+    > 
+      <template #extra>
+        <a-button v-if="showAdd" type="primary" @click="emit('edit')">{{ buttonText }}</a-button>
+      </template>
+    </a-result>
+    <div v-else>
+      <a-alert
+        v-if="showEdited"
+        :message="`点击这里可以修改 ${title} 信息`"
+        type="info"
+        show-icon
+        @click="emit('edit')"
+      >
+        <template #action>
+          <a-space>
+            <a-button size="small" type="primary" @click="emit('edit')">去修改</a-button>
+          </a-space>
+        </template>
+      </a-alert>
+      <slot></slot>
+    </div>
+  </SimplePageContentLoader>
+</template>

+ 150 - 0
src/components/parts/TitleDescBlock.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="TitleDescBlock">
+    <h3>{{ title }}</h3>
+    <span v-if="date" class="time">{{ date }}</span>
+    <SimpleRichHtml hydrate-never :class="'desc ' + (expand?'expand':'no-expand')" :contents="[desc]" />
+    <slot name="addon" />
+
+    <div class="footer">
+      <div v-if="showExpand && desc.length > 200" :class="'expand'+(expand?' on':'')" @click="expand=!expand">
+        {{expand?'折叠':'展开'}}
+        <img src="@/assets/images/IconArrowRight.png" />
+      </div>
+      <div v-if="more" class="more" @click="moreLink ? undefined : emit('moreClick')">
+        <RouterLink :to="moreLink">
+          更多
+          <img src="@/assets/images/IconArrowRight.png" alt="更多" />
+        </RouterLink>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { SimpleRichHtml } from '@imengyu/imengyu-web-shared';
+
+const props = defineProps({	
+  title : {
+    type: String,
+    default: '',
+  },
+  desc: {
+    type: String,
+    default: '',
+  },
+  descLines: {
+    type: Number,
+    default: 3,
+  },
+  more: {
+    type: Boolean,
+    default: false,
+  },
+  moreLink: {
+    type: String,
+    default: '',
+  },
+  showExpand: {
+    type: Boolean,
+    default: false,
+  },
+  date: {
+    type: String,
+    default: '',
+  },
+})
+
+const expand = ref(false)
+
+const emit = defineEmits([	
+  "moreClick"	
+]);
+
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+.TitleDescBlock {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+
+  h3 {
+    color: $text-content-color;
+    font-size: 1.2rem;
+    margin-top: 0;
+    margin-bottom: 8px;
+  }
+
+  .desc,
+  .time {
+    color: $text-content-second-color;
+    font-size: 0.85rem;
+  }
+
+  .time {
+    margin-bottom: 16px;
+  }
+  > .desc {
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    margin: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+
+    &.expand {
+      -webkit-line-clamp: 100;
+      line-clamp: 100;
+    }
+    &.no-expand {
+      -webkit-line-clamp: 5;
+      line-clamp: 5;
+      max-height: 300px; 
+    }
+  }
+
+  .footer {
+    display: flex;
+    flex-direction: row;
+    align-items: center; 
+    justify-content: space-between;
+
+    > div {
+      display: flex;
+      flex-direction: row;
+      align-items: center; 
+      color: $text-content-second-color;
+      font-size: 0.85rem;
+      margin-top: 25px;
+      cursor: pointer;
+    
+      &:hover {
+        color: $primary-color;
+      }
+    }
+  }
+
+  .expand {
+    img {
+      width: 16px;
+      height: 16px;
+      transform: rotate(90deg);
+    }
+    &.on {
+      img {
+        transform: rotate(270deg); 
+      }
+    }
+  }
+
+  .more {
+    img {
+      width: 16px;
+      height: 16px;
+      margin-left: 8px;
+    }
+  }
+}
+</style>

+ 45 - 0
src/main.ts

@@ -0,0 +1,45 @@
+import 'vue3-carousel/carousel.css'
+import '@imengyu/imengyu-web-shared/lib/imengyu-web-shared.css'
+import '@imengyu/vue-dynamic-form/dist/vue-dynamic-form.css'
+import '@vueup/vue-quill/dist/vue-quill.snow.css';
+import 'tinymce/tinymce';
+import 'tinymce/themes/silver/theme';
+import 'tinymce/icons/default';
+
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+
+import App from './App.vue'
+import router from './router'
+import NProgress from 'nprogress';
+import ImengyuCommon from '@imengyu/imengyu-web-shared';
+import { registryConvert } from '@/common/ConvertRgeistry'
+import { initAMapApiLoader } from '@vuemap/vue-amap';
+import { QuillEditor } from '@vueup/vue-quill'
+import { configDynamicForm } from './components/dynamicf';
+
+initAMapApiLoader({
+  key: '212b86dc49a5116a34e945d31da7ad14',
+  securityJsCode: '46cae8205a707cfaf5801abcc4301566',
+  plugins: ['AMap.MarkerCluster'],
+});
+
+const app = createApp(App)
+
+app.use(createPinia())
+app.use(router)
+app.use(ImengyuCommon, {})
+app.component('QuillEditor', QuillEditor);
+app.mount('#app').$nextTick(() => {
+  configDynamicForm();
+});
+
+router.beforeEach((to, from, next) => {
+  NProgress.start();
+  next();
+});
+router.afterEach(() => {
+  NProgress.done();
+});
+
+registryConvert();

+ 46 - 0
src/pages/404.vue

@@ -0,0 +1,46 @@
+<template>
+  <div>
+    <div class="nav-placeholder"></div>
+    <div class="empty-page">
+      <img src="@/assets/images/404.svg" />
+      <h1>抱歉,您访问的页面不存在</h1>
+      <RouterLink to="/">点击这里返回上一页</RouterLink>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+.empty-page {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  font-family: SourceHanSerifCNBold;
+  min-height: 66vh;
+
+  img {
+    width: 150px;
+    height: 150px;
+    margin-bottom: 20px;
+  }
+  h1 {
+    font-size: 2rem;
+  }
+  a {
+    text-decoration: none;
+    font-size: 1.2rem;
+    cursor: pointer;
+    color: #333;
+
+    &:hover {
+      color: $primary-color;
+    }
+  }
+}
+</style>

+ 105 - 0
src/pages/admin.vue

@@ -0,0 +1,105 @@
+<template>
+  <!-- 管理员管理首页 -->
+  <div class="about main-background main-background-type0">
+    <div class="nav-placeholder">
+    </div>
+    <!-- 表单 -->
+    <section class="main-section large">
+      <div class="content">
+        <div class="title">
+          <h2>管理员管理</h2>
+        </div>
+       
+        <a-tabs v-model:activeKey="activeKey" centered>
+          <a-tab-pane key="1" tab="志愿者列表">
+            <CommonListBlock 
+              :showTotal="true"
+              :rowCount="1"
+              :rowType="5"
+              :load="(page: number, pageSize: number, _, searchText: string, dropDownValues: number[]) => loadVolenteerData(page, pageSize, dropDownValues, searchText)"
+              :showDetail="(item) => router.push({ name: 'FormSeminar', query: { id: item.id } })"
+            >
+              <template #searchRight>
+                <a-button type="primary" class="ml-2" @click="router.push({ 
+                  name: 'FormVolunteer', query: { villageId }
+                })" >+ 新增</a-button>
+              </template>
+              <template #itemRight="{ item }">
+                <!-- <a-button type="link" @click.stop="router.push({ name: 'FormSeminar', query: { id: item.id } })">编辑</a-button> -->
+              </template>
+            </CommonListBlock>
+          </a-tab-pane>
+        </a-tabs>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useAuthStore } from '@/stores/auth';
+import { message, Modal } from 'ant-design-vue';
+import type { GetContentListItem } from '@/api/CommonContent';
+import useClipboard from 'vue-clipboard3';
+import CommonListBlock from '@/components/content/CommonListBlock.vue';
+import InheritorContent from '@/api/inheritor/InheritorContent';
+import VillageApi from '@/api/inhert/VillageApi';
+
+const { toClipboard } = useClipboard();
+const router = useRouter();
+const route = useRoute();
+const authStore = useAuthStore();
+const villageId = ref(Number(route.query.id) || 1);
+const activeKey = ref(route.query.tab as string || '1');
+watch(activeKey, (newValue) => {
+  router.replace({ query: { tab: newValue } });
+})
+
+async function loadVolenteerData(page: number, pageSize: number, dropDownValues: number[], searchText: string) {
+  if (page === 1) {
+    const res = await VillageApi.getVillageVolunteerList(villageId.value);
+    return {
+      page,
+      total: res.length,
+      data: res.map((item) => ({
+        ...item,
+        title: `${item.name} ${item.sex === 0 ? '男' : '女'} 手机号:${item.mobile} 地址:${item.address || ''}`,
+        desc: `可采编:${item.collectModuleText || '暂无'}`,
+      })),
+    }
+  }
+  return {
+    page,
+    total: 0,
+    data: [],
+  }
+}
+async function handleCopyAccount(item: GetContentListItem) {
+  let result;
+  try {
+    result = await InheritorContent.getInheritorAccountInfo(item.id);
+    if (!result)
+      throw '该传承人没有账号';
+  } catch (e) {
+    Modal.error({
+      title: '获取账号失败',
+      content: '' + e,
+    });
+    return;
+  }
+
+  const resultString = `传承人${item.title}的账号:\n用户名:${result.username}\n密码:${result.password}\n登录网址:https://zycj.wenlvti.net/#login`;
+
+  try {
+    await toClipboard(resultString);
+    message.success('复制到剪贴板成功');
+  } catch (e) {
+    Modal.error({
+      title: '复制失败',
+      content: '复制到剪贴板失败,可能是浏览器不支持或未授权,可手动复制:' + resultString,
+    });
+  }
+
+}
+</script>

+ 214 - 0
src/pages/admin/FormVolunteer.vue

@@ -0,0 +1,214 @@
+<template>
+  <!-- 编辑志愿者表单 -->
+  <div class="about main-background main-background-type0">
+    <div class="nav-placeholder"></div>
+    <section class="main-section small-h">
+      <div class="content">
+        <div class="title left-right small">
+          <a-button :icon="h(ArrowLeftOutlined)" @click="handleBack">返回主页</a-button>
+          <h2>志愿者信息填写</h2>
+          <div class="button-placeholder"></div>
+        </div>
+        <a-spin :spinning="loading">
+          <div class="form-box">
+            <DynamicForm
+              ref="formRef"
+              :model="(formModel as any)" 
+              :options="formOptions"
+            />
+            <div class="d-flex flex-column mt-3">
+              <div class="d-flex flex-row w-100 align-items-center justify-content-between">
+                <span>
+                  <ExclamationCircleOutlined class="me-2" />
+                  提示:上传文件时请勿离开页面防止上传失败,离开之前请保存您的修改以防丢失。
+                </span>
+              </div>
+              <div class="d-flex flex-row w-100 align-items-center justify-content-end mt-3">
+                <a-button 
+                  type="primary"
+                  block 
+                  :loading="loading"
+                  @click="handleSubmit()"
+                >
+                  提交
+                </a-button>
+              </div>
+            </div>
+          </div>
+        </a-spin>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, type Ref, h, computed } from 'vue';
+import CommonContent from '@/api/CommonContent';
+import { DynamicForm, type IDynamicFormOptions, type IDynamicFormRef } from '@imengyu/vue-dynamic-form';
+import { useAliOssUploadCo } from '@/common/upload/AliOssUploadCo';
+import { useBeforeUploadImageChecker, type UploadImageFormItemProps, type AddressItem, useLoadQuerys } from '@imengyu/imengyu-web-shared';
+import { useRoute, useRouter } from 'vue-router';
+import type { RuleItem } from 'async-validator';
+import VillageApi, { VolunteerInfo } from '@/api/inhert/VillageApi';
+import { ArrowLeftOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue';
+import { message, Modal, type FormInstance } from 'ant-design-vue';
+
+const { querys } = useLoadQuerys({
+  id: 0,
+  villageId: 0,
+}, (querys) => {
+  if (!querys.id) {
+    formModel.value.villageId = querys.villageId;
+  }
+})
+const isNew = computed(() => !querys.value.id);
+const router = useRouter();
+const loading = ref(false);
+const formRef = ref<IDynamicFormRef>();
+const formModel = ref(new VolunteerInfo()) as Ref<VolunteerInfo>;
+const formOptions = ref<IDynamicFormOptions>({
+  formLabelCol: { span: 6 },
+  formWrapperCol: { span: 24 },
+  formAdditionaProps: {
+    layout: 'vertical',
+    scrollToFirstError: true,
+  },
+  formNestNameGenerateType: 'array',
+  formItems: [
+    { 
+      label: '用户名', name: 'username', type: 'text',
+      additionalProps: { placeholder: '请输入用户名' },
+      rules: [{ required: true, message: '请输入用户名' }],
+      disabled: { callback: () => !isNew.value },
+    },
+    {
+      label: '密码',
+      name: 'password',
+      type: 'password',
+      additionalProps: { placeholder: '请输入密码' },
+      rules: [{ required: true, message: '请输入密码' }],
+    },
+    {
+      label: '确认密码',
+      name: 'passwordRepeat',
+      type: 'password',
+      additionalProps: { placeholder: '请再输入一次密码' },
+      rules: [
+        { required: true, message: '请再输入一次密码' },
+        {
+          async validator(rule, value) {
+            if (value !== formModel.value.password)
+              throw '两次输入密码不一致,请检查';
+          },
+        }
+      ] as RuleItem[],
+    },
+    {
+      label: '真实名称', name: 'name', type: 'text',
+      additionalProps: { placeholder: '请输入真实名称' },
+      rules: [{ required: true, message: '请输入真实名称' }],
+    },
+    {
+      label: '手机号', name: 'mobile', type: 'text',
+      additionalProps: { placeholder: '请输入手机号' },
+      rules: [{ required: true, message: '请输入手机号' }],
+    },
+    { 
+      label: '区域', name: 'regionId', type: 'select-id',
+      additionalProps: {
+        placeholder: '请选择区域',
+        loadData: async () => (await CommonContent.getCategoryList(1)).map(p => ({ label: p.title, value: p.id, raw: p }))
+      },
+      rules: [{ required: true, message: '请选择区域' }],
+      disabled: { callback: () => !isNew.value },
+    },
+    { 
+      label: '所属村庄', name: 'villageId', type: 'select-id',
+      additionalProps: {
+        placeholder: '请选择所属村庄',
+        loadData: async () => {
+          return VillageApi.getClaimedVallageList().then(res => res.map(p => ({ label: p.title, value: p.id, raw: p })))
+        }
+      },
+      rules: [{ required: true, message: '请选择所属村庄' }],
+      disabled: { callback: () => !isNew.value },
+    },
+    {
+      label: '性别', name: 'sex', type: 'radio-value',
+      additionalProps: {
+        placeholder: '请选择性别',
+        options: [
+          { text: '男', value: 1 },
+          { text: '女', value: 2 }
+        ]
+      },
+    },
+    { 
+      label: '头像', name: 'image', type: 'single-image',
+      additionalProps: {
+        placeholder: '请上传图片',
+        name: 'file',
+        accept: 'image/*',
+        beforeUpload: useBeforeUploadImageChecker(),
+        uploadCo: useAliOssUploadCo('xiangyuan/volunteer/images')
+      } as UploadImageFormItemProps,
+    },
+    { label: '地址', name: 'address', type: 'text', additionalProps: { placeholder: '请输入地址' } },
+    { label: '介绍', name: 'intro', type: 'text-area', additionalProps: { placeholder: '请输入介绍' } },
+    { label: '生日', name: 'birthday', type: 'date', additionalProps: { placeholder: '请选择生日' } },
+    { 
+      label: '采集版块', name: 'collectModule', type: 'check-box-list', 
+      additionalProps: { 
+        placeholder: '请选择采集版块',
+        loadData: async () => 
+          (await VillageApi.getCollectModuleList())
+            .map((p) => ({
+              ...p,
+              raw: p
+            }))
+          ,
+      },
+    },
+    { label: '村落认领说明', name: 'claimReason', type: 'text', additionalProps: { placeholder: '请输入村落认领说明' } },
+  ],
+});
+const route = useRoute();
+
+async function handleSubmit() {
+  loading.value = true;
+  const ref = formRef.value?.getFormRef() as FormInstance;
+  try {
+    await ref.validate();
+  } catch(e) {
+    console.error(e);
+    message.error('请填写完整信息');
+    loading.value = false;
+    if ((e as any).errorFields)
+      ref.scrollToField((e as any).errorFields[0].name, { block: 'center' })
+    return;
+  }
+  try {
+    if (querys.value.id) {
+      await VillageApi.updateVolunteer(formModel.value);
+    } else {
+      await VillageApi.addVolunteer(formModel.value);
+    };
+    Modal.success({
+      title: '操作成功',
+      content: '新增志愿者成功',
+      onOk: () => handleBack(),
+    });
+  } catch (error) {
+    Modal.error({
+      title: '操作失败',
+      content: '' + error,
+    });
+  } finally {
+    loading.value = false;
+  }
+}
+function handleBack() {
+  router.back();
+}
+
+</script>

+ 141 - 0
src/pages/change-password.vue

@@ -0,0 +1,141 @@
+<template>
+  <!-- 登录页 -->
+  <div class="login main-background main-background-type0">
+    <div class="nav-placeholder"></div>
+    <!-- 表单 -->
+    <section class="main-section ">
+      <div class="content small-h">
+        <div class="title">
+          <h2>修改密码</h2>
+        </div>
+        <div class="form-container">
+          <template v-if="isSuccess">
+            <a-result
+              status="success"
+              title="修改成功"
+              sub-title="您可以使用新密码登录"
+            >
+              <template #extra>
+                <a-button class="mt-3" block @click="router.back()">返回</a-button>
+              </template>
+            </a-result>
+          </template>
+          <template v-else>
+            <DynamicForm 
+              ref="form"
+              :model="formModel" 
+              :options="formOptions"
+            />
+            <a-button type="primary" block :loading="isSubmiting" @click="handleSubmit">确认修改</a-button>
+            <a-button class="mt-3" block @click="router.back()">返回</a-button>
+          </template>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import UserApi from '@/api/auth/UserApi';
+import { DynamicForm, type IDynamicFormOptions, type IDynamicFormRef } from '@imengyu/vue-dynamic-form';
+import { message, Modal, type FormInstance } from 'ant-design-vue';
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+import type { RuleItem } from 'async-validator';
+
+const form = ref<IDynamicFormRef>();
+const formModel = ref({
+  oldPassword: '',
+  password: '',
+  passwordRepeat: '',
+});
+const formOptions = ref<IDynamicFormOptions>({
+  formLabelCol: { span: 6 },
+  formWrapperCol: { span: 24 },
+  formAdditionaProps: {
+    layout: 'vertical'
+  },
+  formItems: [
+    {
+      label: '旧密码',
+      name: 'oldPassword',
+      type: 'password',
+      additionalProps: {
+        placeholder: '请输入旧密码' 
+      }
+    },
+    {
+      label: '新密码',
+      name: 'password',
+      type: 'password',
+      additionalProps: {
+        placeholder: '请输入密码' 
+      }
+    },
+    {
+      label: '确认新密码',
+      name: 'passwordRepeat',
+      type: 'password',
+      additionalProps: {
+        placeholder: '请输入新密码' 
+      }
+    },
+  ],
+  formRules: {
+    oldPassword: [
+      { required: true, message: '请输入旧密码' },
+      { min: 6, message: '密码长度必须大于等于6位' },
+    ],
+    password: [
+      { required: true, message: '请输入密码' },
+      { min: 6, message: '密码长度必须大于等于6位' },
+    ],
+    passwordRepeat: [
+      { required: true, message: '请再输入一次密码' },
+      {
+        async validator(rule, value) {
+          if (value !== formModel.value.password)
+            throw '两次输入密码不一致,请检查';
+        },
+      }
+    ] as RuleItem[],
+  },
+});
+
+const isSubmiting = ref(false);
+const isSuccess = ref(false);
+
+const router = useRouter();
+
+async function handleSubmit() {
+  isSubmiting.value = true;
+  try {
+    await (form.value?.getFormRef() as FormInstance).validate();
+  } catch {
+    isSubmiting.value = false;
+    return;
+  }
+
+  try {
+    await UserApi.updatePassword({
+      oldpassword: formModel.value.oldPassword,
+      newpassword: formModel.value.password,
+    });
+    message.success('修改密码成功');
+    isSuccess.value = true;
+  } catch (error) {
+    Modal.error({
+      title: '修改密码失败',
+      content: '' + error,
+    });
+  } finally {
+    isSubmiting.value = false;
+  }
+}
+</script>
+
+<style lang="scss">
+.login {
+  min-height: calc(100vh - 50px);
+}
+</style>

+ 26 - 0
src/pages/composeable/TaskEntryForm.ts

@@ -0,0 +1,26 @@
+import router from "@/router";
+import { useLoadQuerys } from "@imengyu/imengyu-web-shared";
+
+export function useTaskEntryForm() {
+  const { querys } = useLoadQuerys({ 
+    villageId: 0,  
+    villageVolunteerId: 0,
+  });
+  
+  function goForm(subType: string, subId: number, subKey = 'type', type = 'list') {
+    router.push({
+      path: '../forms/' + type, 
+      query: {
+        villageId: querys.value.villageId,  
+        villageVolunteerId: querys.value.villageVolunteerId,  
+        subType,
+        subId,
+        subKey,
+      }
+    })
+  }
+
+  return {
+    goForm,
+  }
+}

+ 174 - 0
src/pages/details.vue

@@ -0,0 +1,174 @@
+<template>
+  <div class="details main-background main-background-type0">
+    <div class="nav-placeholder">
+    </div>
+    <!-- 表单 -->
+    <section class="main-section">
+      <div class="content">
+        <div class="title left-right">
+          <a-button :icon="h(ArrowLeftOutlined)" class="mb-3" @click="router.back()">返回</a-button>
+          <h2>乡源·乡村文化资源挖掘平台</h2>
+          <span style="width:50px;"></span>
+        </div>
+        <img class="head-img w-100 radius-base" src="https://mn.wenlvti.net/app_static/xiangan/banner_dig_1.jpg"/>
+ 
+        <div class="mt-2 p-2 bg-primary radius-s rounded-3 d-flex flex-row align-center">
+          <div class="flex-one d-flex flex-row justify-center align-baseline">
+            <div class="label">已认领:</div>
+            <div class="size-l ml-2 color-primary">
+              {{ querys.name }}
+              <span class="size-base">Lv.{{ querys.level }}</span>
+            </div>
+          </div>
+          <div class="flex-one d-flex flex-row justify-center align-baseline">
+            <div class="label">文化积分:</div>
+            <div class="size-l ml-2 color-primary">{{ querys.points }}</div>
+          </div>
+        </div>
+
+        <div class="task-list">
+          <div v-if="canCollect('village')" class="item">
+            <i class="iconfont icon-task-summary"></i>
+            <div class="info">
+              <div class="title">村落概况</div>
+              <div class="desc">探索村落的历史渊源与发生轨迹</div>
+            </div>
+            <a-button type="primary" @click="navTo('task/summary', nextPageData)">去完成</a-button>
+          </div>
+          <div v-if="canCollect('cultural')" class="item">
+            <i class="iconfont icon-task-history"></i>
+            <div class="info">
+              <div class="title">历史文化</div>
+              <div class="desc">传承百年文化遗产和精神财富</div>
+            </div>
+            <a-button type="primary" @click="navTo('task/history', nextPageData)">
+              去完成
+            </a-button>
+          </div>
+          <div v-if="canCollect('ich')" class="item">
+            <i class="iconfont icon-task-custom-1"></i>
+            <div class="info">
+              <div class="title">非物质文化遗产项目</div>
+              <div class="desc">维护文化多样性</div>
+            </div>
+            <a-button type="primary" @click="goForm('ich', 0)">
+              填写
+            </a-button>
+          </div>
+          <div v-if="canCollect('environment')" class="item">
+            <i class="iconfont icon-task-environment"></i>
+            <div class="info">
+              <div class="title">环境格局</div>
+              <div class="desc">感受自然人文环境之美</div>
+            </div>
+            <a-button type="primary" @click="navTo('task/environment', nextPageData)">
+              去完成
+            </a-button>
+          </div>
+          <div v-if="canCollect('building')" class="item">
+            <i class="iconfont icon-task-building"></i>
+            <div class="info">
+              <div class="title">传统建筑</div>
+              <div class="desc">领略古建筑的独特魅力</div>
+            </div>
+            <a-button type="primary" @click="navTo('task/building', nextPageData)">
+              去完成
+            </a-button>
+          </div>
+          <div v-if="canCollect('folk_culture')" class="item">
+            <i class="iconfont icon-task-custom"></i>
+            <div class="info">
+              <div class="title">民俗文化</div>
+              <div class="desc">体验民间传统习俗与节庆</div>
+            </div>
+            <a-button type="primary" @click="navTo('task/custom', nextPageData)">
+              去完成
+            </a-button>
+          </div>
+          <div v-if="canCollect('food_product')" class="item">
+            <i class="iconfont icon-task-food"></i>
+            <div class="info">
+              <div class="title">美食物产</div>
+              <div class="desc">正宗、传统地方特色美食</div>
+            </div>
+            <a-button type="primary" @click="navTo('task/food', nextPageData)">
+              去完成
+            </a-button>
+          </div>
+          <div v-if="canCollect('food_product')" class="item">
+            <i class="iconfont icon-task-mine"></i>
+            <div class="info">
+              <div class="title">物产资源</div>
+              <div class="desc">特定地域的植物、矿物或手工艺</div>
+            </div>
+            <a-button type="primary" @click="navTo('task/mine', nextPageData)">
+              去完成
+            </a-button>
+          </div>
+          <div v-if="canCollect('route')" class="item">
+            <i class="iconfont icon-task-trip"></i>
+            <div class="info">
+              <div class="title">旅游路线</div>
+              <div class="desc">体验独特的文化魅力</div>
+            </div>
+            <a-button type="primary" @click="navTo('task/trip', nextPageData)">
+              去完成
+            </a-button>
+          </div>
+          <div class="item">
+            <i class="iconfont icon-task-other"></i>
+            <div class="info">
+              <div class="title">其他</div>
+              <div class="desc">更多文化传承相关信息</div>
+            </div>
+            <a-button type="primary" disabled @click="navTo('task/other', nextPageData)">
+              待开放
+            </a-button>
+          </div>
+        </div>
+
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useLoadQuerys } from '@imengyu/imengyu-web-shared';
+import { computed, h } from 'vue';
+import { useRouter } from 'vue-router';
+import { ArrowLeftOutlined } from '@ant-design/icons-vue';
+import { useCollectStore } from '@/stores/collect';
+import { useTaskEntryForm } from './composeable/TaskEntryForm';
+
+const router = useRouter();
+const { querys } = useLoadQuerys({ 
+  id: 0,  
+  name: '',
+  points: 0,
+  level: 0,
+  villageVolunteerId: 0,
+});
+const nextPageData = computed(() => ({
+  villageId: querys.value.id,  
+  villageVolunteerId: querys.value.villageVolunteerId,
+}));
+const { canCollect } = useCollectStore();
+const { goForm } = useTaskEntryForm();
+
+function navTo(path: string, data: any) {
+  router.push({
+    path,
+    query: data,
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+@use '../assets/scss/colors.scss' as *;
+
+.head-img {
+  height: 160px;
+  object-fit: cover;
+}
+
+</style>

+ 171 - 0
src/pages/forms/common.vue

@@ -0,0 +1,171 @@
+<template>
+  <div class="tasks main-background main-background-type0">
+    <div class="nav-placeholder">
+    </div>
+    <section class="main-section">
+      <div class="content">
+        <a-button :icon="h(ArrowLeftOutlined)" class="mb-3" @click="handleBack">返回上一页</a-button>
+
+        <div class="form-box">
+          <a-spin :spinning="loading">
+            <DynamicForm
+              ref="formRef"
+              :model="(formModel as any)" 
+              :options="formOptions"
+            />
+            <div class="d-flex flex-column mt-3">
+              <span>
+                <ExclamationCircleOutlined class="me-2" />
+                提示:上传文件时请勿离开页面防止上传失败,离开之前请保存您的修改以防丢失。
+              </span>
+              <div class="d-flex flex-row w-100 align-items-center justify-content-end mt-3">  
+                <a-button 
+                  type="primary"
+                  block 
+                  :loading="loading"
+                  @click="submit()"
+                >
+                  提交
+                </a-button>
+              </div>
+            </div>
+          </a-spin>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, h, reactive } from 'vue';
+import { getVillageInfoForm } from './forms';
+import { RequestApiError } from '@imengyu/imengyu-utils/dist/request';
+import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+import { logError, useLoadQuerys, usePageAction } from '@imengyu/imengyu-web-shared';
+import { message, Modal } from 'ant-design-vue';
+import { DynamicForm, type IDynamicFormOptions, type IDynamicFormRef } from '@imengyu/vue-dynamic-form';
+import VillageInfoApi from '@/api/inhert/VillageInfoApi';
+import type { FormInstance } from 'ant-design-vue/es/form/Form';
+import { waitTimeOut } from '@imengyu/imengyu-utils';
+import { ArrowLeftOutlined } from '@ant-design/icons-vue';
+
+const loading = ref(false);
+const formRef = ref<IDynamicFormRef>();
+const formModel = ref<any>();
+const formOptions = ref<IDynamicFormOptions>();
+const {
+  backAndCallOnPageBack,
+  back,
+} = usePageAction();
+
+async function submit() {
+  if (!formRef.value)
+    return;
+  try {
+    const ref = (formRef.value?.getFormRef() as FormInstance);
+    try {
+      await ref.validate();
+    } catch (e) {
+      message.warn('请填写完整信息');
+      loading.value = false;
+      if ((e as any).errorFields)
+        ref.scrollToField((e as any).errorFields[0].name, { block: 'center' })
+      return;
+    }
+
+    loading.value = true;
+    formModel.value.type = querys.value.subId;
+    console.log('Submit form ', formModel.value);
+    
+    const result = await VillageInfoApi.updateInfo(
+      querys.value.subType,
+      querys.value.villageId,
+      querys.value.villageVolunteerId,
+      formModel.value,
+    );
+    Modal.success({
+      title: '提交成功',
+      content: result.message,
+      onOk() {
+        back();
+      },
+    });
+  } catch (e) {
+    showError(e);
+  } finally {
+    loading.value = false;
+  }
+}
+function backPrev(needRefresh: boolean) {
+  backAndCallOnPageBack('list', {
+    needRefresh,
+  });
+}
+
+function handleBack() {
+  Modal.confirm({
+    title: '如果有修改请先提交,未保存的修改将丢失,您确认返回上一页吗?',
+    okText: '确认',
+    okType: 'danger',
+    onOk() {
+      back();
+    },
+  });
+}
+
+const { querys } = useLoadQuerys({ 
+  villageId: 0,  
+  villageVolunteerId: 0,
+  subType: '',
+  subId: 0,
+  id: 0,
+}, async (querys) => {
+  loading.value = true;
+  if (!formRef.value)
+    return;
+  let formData = undefined;
+  try {
+    const [model, forms] = getVillageInfoForm(querys.subType, querys.subId);
+    formModel.value = new model();
+    formOptions.value = forms;
+    if (querys.id !== -1) {
+      formData = await VillageInfoApi.getInfo(
+        querys.subType, 
+        querys.subId,
+        querys.villageId, 
+        querys.villageVolunteerId,
+        querys.id,
+        model,
+      );
+      console.log('Load form ', formData);
+    }
+    await waitTimeOut(500);
+  } catch (e) {
+    if (!(e instanceof RequestApiError && e.errorMessage.startsWith('请完成')))
+      showError(e, undefined, () => backPrev(false));
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+
+  if (formData) {
+    formModel.value = reactive(formData);
+  }
+});
+
+function showError(e: any, title?: string, callback?: () => void) {
+  Modal.error({
+    title: (title ?? '错误'),
+    content: e.stack || e,
+    onOk() {
+      callback?.();
+    },
+  })
+  logError({
+    message: (title ?? '') + '' + e,
+    detail: e.stack || e,
+    type: 'error',
+  });
+}
+
+</script>

文件差异内容过多而无法显示
+ 2476 - 0
src/pages/forms/forms.ts


+ 154 - 0
src/pages/forms/list.vue

@@ -0,0 +1,154 @@
+<template>
+   <div class="list main-background main-background-type0">
+    <div class="nav-placeholder">
+    </div>
+    <section class="main-section">
+      <div class="content">
+        <div class="d-flex flex-row justify-between align-items-center w-100">
+          <a-button :icon="h(ArrowLeftOutlined)" class="mb-2" @click="handleBack">返回主页</a-button>
+          <div class="d-flex flex-row align-items-center">
+            <SimpleInput v-model="searchText" placeholder="请输入关键词" @search="search">
+              <template #suffix>
+                <IconSearch
+                  class="search-icon"
+                  alt="搜索" 
+                  @click="search"
+                />
+              </template>
+            </SimpleInput>
+            <a-button type="primary" class="ml-2" @click="newData">+ 新增</a-button>
+          </div>
+        </div>
+        <div class="task-list">
+          <div 
+            class="item"
+            v-for="item in listLoader.list.value"
+            :key="item.id" 
+            @click="goDetail(item.id)"
+          >
+            <a-image class="radius-s" :src="item.image" width="80px" height="80px" />
+            <div class="info">
+              <div class="size-ss">{{ item.title}}</div>
+              <div class="desc">{{ item.date}}</div>
+            </div>
+          </div>
+        </div>
+        <SimplePageListContentLoader :loader="listLoader" :noEmpty="true">
+          <template #empty>
+            <a-empty description="暂无数据,点击按钮新增数据">
+              <a-button type="primary" @click="newData">+ 新增数据</a-button>
+            </a-empty>
+          </template>
+        </SimplePageListContentLoader>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, h } from 'vue';
+import { DataDateUtils } from '@imengyu/js-request-transform';
+import { useLoadQuerys, useSimplePagerDataLoader, SimplePageListContentLoader, useOnPageBack } from '@imengyu/imengyu-web-shared';
+import { useRouter } from 'vue-router';
+import { ArrowLeftOutlined } from '@ant-design/icons-vue';
+
+import IconSearch from '@/components/icons/IconSearch.vue';
+import SimpleInput from '@/components/controls/SimpleInput.vue';
+import VillageInfoApi from '@/api/inhert/VillageInfoApi';
+
+const router = useRouter();
+const searchText = ref('');
+const listLoader = useSimplePagerDataLoader<{
+  id: number,
+  image: string,
+  title: string,
+  date: string
+}, {
+  villageId: number,  
+  villageVolunteerId: number,
+  subType: string,
+  subId: number,
+  subKey: string,
+}>(8, async (page, pageSize, params) => {
+  if (!params || !params.subType || !params.villageId || !params.villageVolunteerId)
+    throw new Error("未传入参数,当前页面需要参数");
+  let res = (page == 1 ? await VillageInfoApi.getList(
+    params.subType,
+    params.subId,
+    params.subKey,
+    params.villageId,
+    params.villageVolunteerId,
+  ) : [])
+  if (searchText.value)
+    res = res.filter((p) => p.title.includes(searchText.value));
+  const list = res.map((item) => {
+    return {
+      id: item.id,
+      image: item.image,
+      title: item.title,
+      date: DataDateUtils.formatDate(item.updatedAt, 'YYYY-MM-dd'),
+    }
+  })
+  return {
+    data: list,
+    total: list.length,
+  };
+});
+
+function newData() {
+  router.push({ 
+    path: 'common',
+    query: { 
+      id: -1,
+      villageId: querys.value.villageId,  
+      villageVolunteerId: querys.value.villageVolunteerId,  
+      subType: querys.value.subType,  
+      subId: querys.value.subId,  
+    }
+  });
+}
+function goDetail(id: number) {
+  router.push({ 
+    path: 'common',
+    query: { 
+      id,
+      villageId: querys.value.villageId,
+      villageVolunteerId: querys.value.villageVolunteerId,
+      subType: querys.value.subType,
+      subKey: querys.value.subKey,
+      subId: querys.value.subId,
+    }
+  });
+}
+function search() {
+  listLoader.loadData(undefined, true);
+}
+
+const { querys } = useLoadQuerys({ 
+  villageId: 0,  
+  villageVolunteerId: 0,
+  subType: '',
+  subKey: '',
+  subId: 0,
+}, async (querys) => {
+  listLoader.loadData(querys, true)
+});
+
+const { onPageBack } = useOnPageBack();
+
+onPageBack((param: any) => {
+  if (param && param.needRefresh)
+    listLoader.loadData(undefined, true);
+});
+
+function handleBack() {
+  router.back();
+}
+
+</script>
+
+<style lang="scss">
+.article_list {
+  padding: 20rpx;
+}
+</style>

+ 245 - 0
src/pages/index.vue

@@ -0,0 +1,245 @@
+<template>
+  <div class="main-background main-background-type0 index">
+    <div class="nav-placeholder"></div>
+    <div class="hero-section">
+      <!-- 大标题区域 -->
+      <div class="hero-header">
+        <img src="@/assets/images/LogoIconDark.png" class="hero-logo" />
+        <h1 class="main-title">{{TITLE}}</h1>
+        <p class="welcome-text">欢迎您 {{authStore.userInfo?.nickname}}!</p>
+      </div>
+      <div class="action-buttons">
+        <RouterLink v-if="authStore.isLogged" to="/inheritor" class="button-link">
+          <a-button size="large" type="primary" class="action-button">进入采集系统</a-button>
+        </RouterLink>
+        <RouterLink v-else to="/login" class="button-link">
+          <a-button size="large" type="primary" class="action-button">去登录</a-button>
+        </RouterLink>
+      </div>
+    </div>
+
+    <!--介绍内容区域-->
+    <section class="main-section large small-h">
+      <div class="content">
+        <div class="title">
+          <h2>平台优势</h2>
+        </div>
+        <div class="features-grid">
+          <div class="feature-card">
+            <div class="feature-icon">📸</div>
+            <h3>数字化采集</h3>
+            <p>高效便捷地采集闽南文化资源,支持图片、音频、视频等多种格式</p>
+          </div>
+          <div class="feature-card">
+            <div class="feature-icon">📋</div>
+            <h3>智能管理</h3>
+            <p>系统化管理非遗项目信息,实现快速检索和数据统计分析</p>
+          </div>
+          <div class="feature-card">
+            <div class="feature-icon">🔍</div>
+            <h3>精准校对</h3>
+            <p>专业的信息校对流程,确保数据准确性和文化传承质量</p>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!--常见问题-->
+    <section class="main-section large small-h">
+      <div class="content">
+        <div class="title">
+          <h2>常见问题</h2>
+        </div>
+
+        <a-collapse class="faq-collapse" v-model:activeKey="activeKey" accordion>
+          <a-collapse-panel key="1" header="如何使用">
+            <p>用户可通过注册账号登录系统,提交非遗项目,传承人,传习所信息。</p>
+          </a-collapse-panel>
+          <a-collapse-panel key="2" header="是免费使用的吗?">
+            <p>是的,传承人可登录系统进行资源信息提交和修改。</p>
+          </a-collapse-panel>
+          <a-collapse-panel key="3" header="如果我需要帮助,可以获得支持吗?">
+            <p>欢迎致电:18649931391 获取电话支持服务。</p>
+          </a-collapse-panel>
+        </a-collapse>
+        
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { TITLE } from '@/common/ConstStrings';
+import { useAuthStore } from '@/stores/auth';
+import { ref } from 'vue';
+import { RouterLink } from 'vue-router';
+
+const authStore = useAuthStore();
+const activeKey = ref('1');
+</script>
+
+<style lang="scss">
+@use '@/assets/scss/colors.scss' as *;
+
+.index {
+  min-height: calc(100vh - 50px);
+}
+
+.hero-section {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  padding: 40px 20px;
+  min-height: 80vh;
+}
+.hero-header {
+  margin-bottom: 40px;
+}
+.hero-logo {
+  width: 60px;
+  height: 60px;
+  margin-top: 64px;
+  margin-bottom: 24px;
+  transition: transform 0.3s ease;
+
+  &:hover {
+    transform: scale(1.05);
+  }
+}
+
+.main-title {
+  font-size: 2.8rem;
+  font-weight: 700;
+  color: #333;
+  margin-bottom: 16px;
+  letter-spacing: 1px;
+  font-family: 'SourceHanSerifCNBold', sans-serif;
+}
+
+.welcome-text {
+  font-size: 1.4rem;
+  color: #666;
+  margin-bottom: 32px;
+}
+.action-buttons {
+  display: flex;
+  gap: 20px;
+  flex-wrap: wrap;
+  justify-content: center;
+  margin-bottom: 60px;
+}
+.button-link {
+  text-decoration: none;
+}
+.action-button {
+  padding: 12px 24px;
+  font-size: 1.1rem;
+  border-radius: 8px;
+  transition: all 0.3s ease;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  min-width: 200px;
+
+  &:hover {
+    transform: translateY(-3px);
+    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
+  }
+}
+
+.features-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+  gap: 30px;
+}
+.feature-card {
+  background: white;
+  border-radius: 12px;
+  padding: 30px;
+  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
+  transition: all 0.3s ease;
+
+  h3 {
+    text-align: center;
+  }
+
+  &:hover {
+    transform: translateY(-5px);
+    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
+  }
+
+  .feature-icon {
+    font-size: 2.5rem;
+    margin-bottom: 20px;
+    height: 60px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  h3 {
+    font-size: 1.3rem;
+    color: #333;
+    margin-bottom: 16px;
+    font-weight: 600;
+  }
+
+  p {
+    color: #666;
+    line-height: 1.6;
+  }
+}
+
+.faq-collapse {
+  border-radius: 0!important;
+  border: none!important;
+  background-color: transparent;
+
+  .ant-collapse-item {
+    background-color: #fff!important;
+    border-radius: 0.75rem!important;
+    border: 1px solid #e5e7eb!important;
+    margin-top: 1.5rem;
+    overflow: hidden;
+  }
+  .ant-collapse-item-active {
+    border-radius: 0.75rem!important;
+  }
+  .ant-collapse-header {
+    display: flex;
+    flex-direction: row;
+    align-items: center !important;
+    font-size: 1.125rem!important;
+    line-height: 1.75rem!important;
+    font-weight: 600;
+    padding: 1.5rem !important;
+
+  }
+  .ant-collapse-content {
+    border: none!important;
+    padding-left: 1.5rem;
+    padding-right: 1.5rem;
+    font-size: 1rem!important;
+  }
+  .ant-collapse-content-box {
+    padding: 0!important;
+  }
+}
+
+@media (max-width: 768px) {
+  .main-title {
+    font-size: 2.2rem;
+  }
+  .welcome-text {
+    font-size: 1.2rem;
+  }
+  .action-buttons {
+    flex-direction: column;
+    align-items: center;
+    gap: 16px;
+  }
+  .features-grid {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 92 - 0
src/pages/inheritor.vue

@@ -0,0 +1,92 @@
+<template>
+  <!-- 传承人提交首页 -->
+  <div class="about main-background main-background-type0">
+    <div class="nav-placeholder">
+    </div>
+    <!-- 表单 -->
+    <section class="main-section large">
+      <div class="content">
+        <div class="title left-right">
+          <span style="width:160px;"></span>
+          <h2>乡源·乡村文化资源挖掘平台</h2>
+          <small class="text-secondary">技术支持:18649931391</small>
+        </div>
+
+        <SimplePageContentLoader
+          :loader="villageListLoader"
+          :showEmpty="villageListLoader.content.value?.length == 0"
+          :emptyView="{
+            text: '你还没有认领的村庄',
+            button: true,
+            buttonText: '请联系管理员认领',
+            buttonClick: () => {},
+          }"
+        >
+          <div class="task-list">
+            <div 
+              v-for="item in villageListLoader.content.value"
+              :key="item.id"
+              class="item"
+            >
+              <a-image 
+                :src="item.image" 
+                width="84px"
+                height="84px"
+                class="radius-s"
+              />
+              <div class="d-flex flex-row flex-one align-center pl-3 pr-3">
+                <div class="size-ss w-100">{{ item.villageName }}</div>
+                <div class="d-flex flex-row align-center">
+                  <a-button type="primary" @click="goSubmitDigPage(item)">采编</a-button>
+                  <a-button class="ml-2" @click="goManagePage(item)">管理</a-button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </SimplePageContentLoader>
+
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router';
+import { useSimpleDataLoader, SimplePageContentLoader } from '@imengyu/imengyu-web-shared';
+import { useCollectStore } from '@/stores/collect';
+import VillageApi, { VillageListItem } from '@/api/inhert/VillageApi';
+
+const router = useRouter();
+
+const collectStore = useCollectStore();
+const villageListLoader = useSimpleDataLoader(async () => await VillageApi.getClaimedVallageList(), true);
+const volunteerInfoLoader = useSimpleDataLoader(async () =>{
+  const res = await VillageApi.getVolunteerInfo();
+  const collectableModules = (volunteerInfoLoader.content.value?.collectModule as string)?.split(',') || [];
+  collectStore.setCollectableModules(collectableModules);
+  return res;
+}, true);
+
+function goSubmitDigPage(item: VillageListItem) {
+  router.push({
+    path: '/details',
+    query: {
+      id: item.villageId,
+      name: item.villageName,
+      villageVolunteerId: item.villageVolunteerId,
+      points: volunteerInfoLoader.content.value?.points,
+      level: volunteerInfoLoader.content.value?.level,
+    }
+  });
+}
+function goManagePage(item: VillageListItem) {
+  router.push({
+    path: '/admin',
+    query: {
+      id: item.villageId,
+      name: item.villageName,
+    }
+  });
+}
+</script>
+

+ 104 - 0
src/pages/login.vue

@@ -0,0 +1,104 @@
+<template>
+  <!-- 登录页 -->
+  <div class="login main-background main-background-type0">
+    <div class="nav-placeholder"></div>
+    <!-- 表单 -->
+    <section class="main-section ">
+      <div class="content">
+        <div class="title">
+          <h2>登录</h2>
+        </div>
+        <div class="form-container">
+          <DynamicForm 
+            ref="form"
+            :model="formModel" 
+            :options="formOptions"
+          />
+          <a-button type="primary" block @click="handleSubmit">登录</a-button>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useAuthStore } from '@/stores/auth';
+import { waitTimeOut } from '@imengyu/imengyu-utils';
+import { DynamicForm, type IDynamicFormOptions, type IDynamicFormRef } from '@imengyu/vue-dynamic-form';
+import { message, Modal, type FormInstance } from 'ant-design-vue';
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+
+const form = ref<IDynamicFormRef>();
+const formModel = ref({
+  mobile: '',
+  password: '',
+  account: '',
+  type: 0,
+});
+const formOptions = ref<IDynamicFormOptions>({
+  formLabelCol: { span: 6 },
+  formWrapperCol: { span: 24 },
+  formAdditionaProps: {
+    layout: 'vertical'
+  },
+  formItems: [
+    {
+      label: '账号',
+      name: 'account',
+      type: 'text',
+      additionalProps: {
+        placeholder: '请输入账号'
+      },
+    },
+    {
+      label: '密码',
+      name: 'password',
+      type: 'password',
+      additionalProps: {
+        placeholder: '请输入密码' 
+      }
+    },
+  ],
+  formRules: {
+    account: [
+      { required: true, message: '请输入密码' }
+    ],
+    password: [
+      { required: true, message: '请输入密码' }
+    ],
+  },
+});
+
+const router = useRouter();
+const authStore = useAuthStore();
+
+async function handleSubmit() {
+  try {
+    await (form.value?.getFormRef() as FormInstance).validate();
+  } catch {
+    return;
+  }
+  try {
+    await authStore.login(
+      formModel.value.account,
+      formModel.value.password,
+      1,
+    );
+    message.success('您已成功登录');
+    await waitTimeOut(200);
+    router.push('/inheritor');
+  } catch (error) {
+    Modal.error({
+      title: '登录失败',
+      content: '' + error,
+    });
+  }
+}
+</script>
+
+<style lang="scss">
+.login {
+  min-height: calc(100vh - 50px);
+}
+</style>

+ 0 - 0
src/pages/task/building.vue


部分文件因为文件数量过多而无法显示