Преглед изворни кода

迁移闽南文化相关页面

快乐的梦鱼 пре 3 недеља
родитељ
комит
51b456cded
65 измењених фајлова са 4412 додато и 169 уклоњено
  1. 1 0
      nuxt.config.ts
  2. 18 12
      package-lock.json
  3. 2 2
      package.json
  4. 1 0
      server/api/article/[id].ts
  5. 1 0
      server/api/article/byChannel.ts
  6. 1 0
      server/api/article/byChannelAndOnLevelChild.ts
  7. 1 0
      server/api/article/byChannelName.ts
  8. 1 0
      server/api/article/recommend.ts
  9. 1 0
      server/api/article/search.ts
  10. 0 0
      server/api/ecms/carousel.ts
  11. 0 0
      server/api/ecms/channel/[id].ts
  12. 0 0
      server/api/ecms/channel/byName.ts
  13. 0 0
      server/api/ecms/channel/nav.ts
  14. 1 1
      server/api/health.ts
  15. 1 1
      src/App.vue
  16. 510 0
      src/api/CommonContent.ts
  17. 12 0
      src/api/NotConfigue.ts
  18. 185 0
      src/api/RequestModules.ts
  19. 15 0
      src/api/Utils.ts
  20. 10 0
      src/api/inheritor/InheritorContent.ts
  21. 10 0
      src/api/inheritor/ProjectsContent.ts
  22. 10 0
      src/api/inheritor/SeminarContent.ts
  23. 10 0
      src/api/inheritor/UnitContent.ts
  24. 20 0
      src/api/inheritor/UnmoveableContent.ts
  25. 20 1
      src/assets/scss/main.scss
  26. 424 0
      src/assets/scss/news.scss
  27. 3 26
      src/components/NavBar.vue
  28. 49 0
      src/components/PageContainer.vue
  29. 61 0
      src/components/Sidebar.vue
  30. 142 0
      src/components/content/CommonCatalog.vue
  31. 427 0
      src/components/content/CommonListBlock.vue
  32. 191 0
      src/components/content/CommonListPage.vue
  33. 56 0
      src/components/content/ImageGrid.vue
  34. 34 0
      src/components/content/ImageSwiper.vue
  35. 112 0
      src/components/content/ImageTitleBlock.vue
  36. 45 0
      src/components/content/ImageTitleDescBlock.vue
  37. 66 0
      src/components/content/TagBar.vue
  38. 160 0
      src/components/content/TitleDescBlock.vue
  39. 65 0
      src/components/controls/Check.vue
  40. 5 0
      src/components/controls/CheckIcon.vue
  41. 139 0
      src/components/controls/Dropdown.vue
  42. 5 0
      src/components/controls/DropdownIcon.vue
  43. 100 0
      src/components/controls/SimpleInput.vue
  44. 24 0
      src/components/display/SimpleRemoveRichHtml.vue
  45. 147 0
      src/components/display/SimpleRichHtml.vue
  46. 57 0
      src/components/display/SimpleScrollView.vue
  47. 42 0
      src/components/icons/IconMenu.vue
  48. 5 0
      src/components/icons/IconSearch.vue
  49. 37 0
      src/composeable/InternalData.ts
  50. 2 0
      src/composeable/SimpleDataLoader.ts
  51. 5 1
      src/composeable/SimplePagerDataLoader.ts
  52. 70 99
      src/pages/channel/[id].vue
  53. 3 3
      src/pages/channel/laws.vue
  54. 7 7
      src/pages/index.vue
  55. 103 0
      src/pages/inheritor/artifact.vue
  56. 32 0
      src/pages/inheritor/components/IntroBlock.vue
  57. 211 0
      src/pages/inheritor/details/TabDetailView.vue
  58. 77 0
      src/pages/inheritor/details/artifact.vue
  59. 124 0
      src/pages/inheritor/details/default.vue
  60. 231 0
      src/pages/inheritor/details/intangible.vue
  61. 113 0
      src/pages/inheritor/inheritor.vue
  62. 112 0
      src/pages/inheritor/intangible.vue
  63. 85 0
      src/pages/inheritor/seminar.vue
  64. 9 15
      src/pages/page/[id].vue
  65. 1 1
      src/pages/search.vue

+ 1 - 0
nuxt.config.ts

@@ -34,6 +34,7 @@ export default defineNuxtConfig({
     transpile: [
       '@imengyu/vue-scroll-rect',
       '@imengyu/imengyu-utils',
+      '@imengyu/js-request-transform',
     ],
   },
   vite: {

+ 18 - 12
package-lock.json

@@ -9,8 +9,8 @@
       "version": "0.0.0",
       "dependencies": {
         "@ant-design-vue/nuxt": "^1.4.6",
-        "@imengyu/imengyu-utils": "^0.0.24",
-        "@imengyu/js-request-transform": "^0.3.5",
+        "@imengyu/imengyu-utils": "^0.0.25",
+        "@imengyu/js-request-transform": "^0.4.0",
         "@imengyu/vue-dynamic-form": "^0.1.1",
         "@imengyu/vue-scroll-rect": "^0.1.3",
         "@nuxt/icon": "^2.1.1",
@@ -1198,23 +1198,29 @@
       }
     },
     "node_modules/@imengyu/imengyu-utils": {
-      "version": "0.0.24",
-      "resolved": "https://registry.npmmirror.com/@imengyu/imengyu-utils/-/imengyu-utils-0.0.24.tgz",
-      "integrity": "sha512-IFlN6DUmSqrD1pUQ8jhU98FV+0woS8fxJ0fR0f8cwDgh2AW7eIrYtDKZ6ouC8r6ytWncHkwRDQrfwBTiS0NR0g==",
+      "version": "0.0.25",
+      "resolved": "https://registry.npmmirror.com/@imengyu/imengyu-utils/-/imengyu-utils-0.0.25.tgz",
+      "integrity": "sha512-xs+8dLnG4o4ssPgtoRUfUq8n64yoyj0rAGQlDsxWy+4BBdEpRowPtznlcaEHWnM5p6PukVfWTr0JVkzKhTa2Vg==",
       "license": "MIT",
       "dependencies": {
         "@imengyu/js-request-transform": "^0.3.6"
       }
     },
-    "node_modules/@imengyu/js-request-transform": {
-      "version": "0.3.6",
-      "resolved": "https://registry.npmjs.org/@imengyu/js-request-transform/-/js-request-transform-0.3.6.tgz",
-      "integrity": "sha512-MeBI2uRWNOwqK0CexCNd831inckQaAp70itpOsN+jneisYOgoV/xTDGnOjj50DJ1NxNyWeja/p8X+EWsz1pLqQ==",
+    "node_modules/@imengyu/imengyu-utils/node_modules/@imengyu/js-request-transform": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmmirror.com/@imengyu/js-request-transform/-/js-request-transform-0.3.10.tgz",
+      "integrity": "sha512-OpzBZvLOB5n9cIbMDibxD2VHgj0HISRty0q/ob7/drYXXz/yQHE40cTwTH2BaUNXOZlkTGplW9OPgXGupbnRhw==",
       "license": "MIT",
       "dependencies": {
         "dayjs": "^1.11.7"
       }
     },
+    "node_modules/@imengyu/js-request-transform": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/@imengyu/js-request-transform/-/js-request-transform-0.4.0.tgz",
+      "integrity": "sha512-gVS9y1EdS1r9Eg8/srCSUKFw2qJZ4kYLiOVIMPUan4TT2CXbFsqZIUcMA43mvIkSXQXgapmhV1WxABb/6ejYxg==",
+      "license": "MIT"
+    },
     "node_modules/@imengyu/vue-dynamic-form": {
       "version": "0.1.1",
       "resolved": "https://registry.npmmirror.com/@imengyu/vue-dynamic-form/-/vue-dynamic-form-0.1.1.tgz",
@@ -7202,9 +7208,9 @@
       }
     },
     "node_modules/dayjs": {
-      "version": "1.11.13",
-      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz",
-      "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+      "version": "1.11.7",
+      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.7.tgz",
+      "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==",
       "license": "MIT"
     },
     "node_modules/db0": {

+ 2 - 2
package.json

@@ -15,8 +15,8 @@
   },
   "dependencies": {
     "@ant-design-vue/nuxt": "^1.4.6",
-    "@imengyu/imengyu-utils": "^0.0.24",
-    "@imengyu/js-request-transform": "^0.3.5",
+    "@imengyu/imengyu-utils": "^0.0.25",
+    "@imengyu/js-request-transform": "^0.4.0",
     "@imengyu/vue-dynamic-form": "^0.1.1",
     "@imengyu/vue-scroll-rect": "^0.1.3",
     "@nuxt/icon": "^2.1.1",

+ 1 - 0
server/api/article/[id].ts

@@ -32,6 +32,7 @@ export default defineEventHandler<EventHandlerRequest, Promise<IResponse<IArticl
       return createErrorResponse('分类ID不能为空');
     const article = await DB.table('pr_cms_archives')
             .where('id', id)
+            .where('deletetime', 'null')
             .where('status', 'normal')
             .first();
     if (!article) 

+ 1 - 0
server/api/article/byChannel.ts

@@ -13,6 +13,7 @@ export default defineEventHandler<EventHandlerRequest, Promise<IResponse<CommonP
     if (!channelId)
       return createErrorResponse('分类ID不能为空');
     const articles = await DB.table('pr_cms_archives')
+            .where('deletetime', 'null')
             .where('channel_id', channelId)
             .where('status', 'normal')
             .orderBy('weigh', 'desc')

+ 1 - 0
server/api/article/byChannelAndOnLevelChild.ts

@@ -21,6 +21,7 @@ export default defineEventHandler<EventHandlerRequest, Promise<IResponse<CommonP
 
     const articles = await DB.table('pr_cms_archives')
             .whereIn('channel_id', [...childChannels.map(item => item.id), channelId])
+            .where('deletetime', 'null')
             .where('status', 'normal')
             .orderBy('weigh', 'desc')
             .orderBy('publishtime', 'desc')

+ 1 - 0
server/api/article/byChannelName.ts

@@ -29,6 +29,7 @@ export default defineEventHandler<EventHandlerRequest, Promise<IResponse<ICommon
     const articles = await DB.table('pr_cms_archives')
             .where('channel_id', channelId)
             .where('status', 'normal')
+            .where('deletetime', 'null')
             .orderBy('weigh', 'desc')
             .orderBy('publishtime', 'desc')
             .orderBy('createtime', 'desc')

+ 1 - 0
server/api/article/recommend.ts

@@ -13,6 +13,7 @@ export default defineEventHandler<EventHandlerRequest, Promise<IResponse<IArticl
     // 先按weigh降序排序,再按publishtime降序排序
     const articles = await DB.table('pr_cms_archives')
         .where('status', 'normal')
+        .where('deletetime', 'null')
         .where('flag', 'like', '%recommend%')
         .orderBy('weigh','desc')
         .orderBy('publishtime','desc')

+ 1 - 0
server/api/article/search.ts

@@ -14,6 +14,7 @@ export default defineEventHandler<EventHandlerRequest, Promise<IResponse<ICommon
     // 2. 从pr_cms_archives表中通过channel_id查询文章
     const articles = await DB.table('pr_cms_archives')
             .where('status', 'normal')
+            .where('deletetime', 'null')
             .where('title', 'like', `%${search}%`)
             .orderBy('weigh', 'desc')
             .orderBy('publishtime', 'desc')

server/api/carousel.ts → server/api/ecms/carousel.ts


server/api/channel/[id].ts → server/api/ecms/channel/[id].ts


server/api/channel/byName.ts → server/api/ecms/channel/byName.ts


server/api/channel/nav.ts → server/api/ecms/channel/nav.ts


+ 1 - 1
server/api/health.ts

@@ -1,5 +1,5 @@
 import { defineEventHandler } from 'h3';
-import { testConnection } from '../config/db';
+import { testConnection } from '~~/server/config/db';
 
 export default defineEventHandler(async (event) => {
   try {

+ 1 - 1
src/App.vue

@@ -47,7 +47,7 @@ watch(route, () => {
 </script>
 
 <style>
-@import "./assets/scss/main.scss";
+@import "./assets/scss/main.scss"; 
 @import "vue3-carousel/carousel.css";
 @import "@vuemap/vue-amap/dist/style.css";
 @import "@imengyu/vue-scroll-rect/lib/vue-scroll-rect.css";

+ 510 - 0
src/api/CommonContent.ts

@@ -0,0 +1,510 @@
+import { DataModel, transformArrayDataModel, type KeyValue, type NewDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from './RequestModules';
+import { transformSomeToArray } from './Utils';
+import { RequestApiConfig, RequestOptions, requireNotNull, type QueryParams } from '@imengyu/imengyu-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?: number;
+}
+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 ?: number;
+  /**
+   * 关键字查询
+   */
+  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 GetModelColumContentList 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 },
+      iscontribute: { clientSide: 'boolean' },
+    }
+  }
+
+  id = 0;
+  name = '';
+  modelId =  0;
+  image = '';
+  diyname = '';
+  iscontribute = false;
+  statusText = '';
+}
+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' },
+      brandType: { clientSide: 'splitCommaArray', serverSide: 'commaArrayMerge' },
+      type: { clientSide: 'number', serverSide: 'number' },
+    };
+    this._nameMapperServer = {
+      'column_name': 'mainBodyColumnName',
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Time'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      return undefined;
+    };
+  }
+  id = 0;
+  modelId = 0;
+  modelName = '';
+  mainBodyColumnId = 0;
+  mainBodyColumnName = '';
+  latitude = 0;
+  longitude = 0;
+  mapX = '';
+  mapY = '';
+  from = '';
+  title = '!title';
+  region = 0;
+  image = '';
+  thumbnail = '';
+  desc = '!desc';
+  content = '!content';
+  type = 0;
+  keywords ?: string[];
+  flag ?: string[];
+  tags ?: string[];
+  views = 0;
+  comments = 0;
+  likes = 0;
+  collects = 0;
+  dislikes = 0;
+  district = '';
+  publishAt = new Date();
+}
+export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
+  constructor() {
+    super(GetContentDetailItem, "内容详情");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._beforeSolveServer = (data) => {
+      if (!data.id && data.content_id)
+        data.id = Number(data.content_id);
+      return data;
+    }
+    this._convertTable = {
+      id: { 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' },
+      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._nameMapperServer = {
+      'column_name': 'mainBodyColumnName',
+    };
+    this._convertKeyType = (key, direction) => {
+      if (key.endsWith('Time'))
+        return {
+          clientSide: 'date',
+          serverSide: 'string',
+        };
+      else if (key.endsWith('List')) {
+        return [
+          { clientSide: 'map', serverSide: 'original'},
+          { clientSide: 'array', clientSideChildDataModel: GetContentDetailItem, serverSide: 'original' },
+        ]
+      }
+      return undefined;
+    };
+    this._afterSolveServer = () => {
+      if (!this.image && this.images && this.images && this.images.length > 0  ) {
+        this.image = this.images[0]||'';
+      }
+      if ((!this.images || this.images.length == 0) && this.image) {
+        this.images = [ this.image ]
+      }
+      if (this.publishVideo) {
+        this.video = this.publishVideo
+      }
+    }
+  }
+
+  id = 0;
+  from = '';
+  modelId = 0;
+  modelName = '';
+  mainBodyColumnId = 0;
+  mainBodyColumnName = '';
+  type = 0;
+  title = '';
+  region = 0;
+  image = '';
+  images = [] as string[];
+  audio = '';
+  video = '';
+  publishVideo?: string;
+  desc = '';
+  flag ?: string[];
+  tags ?: string[];
+  views = 0;
+  comments = 0;
+  likes = 0;
+  collects = 0;
+  dislikes = 0;
+  isLogin = false;
+  isGuest = false;
+  isComment = false;
+  isLike = false;
+  isCollect = false;
+  content = '';
+  publishAt = new Date();
+  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 FeedBackItem extends DataModel<FeedBackItem> {
+  constructor() {
+    super(FeedBackItem, "内容反馈");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {};
+    this._afterSolveClient = (data) => {
+      data.page_url = `${this.page}?modelId=${data.modelId}&mainBodyColumnId=${data.mainBodyColumnId}&contentId=${data.contentId}`;
+    }
+  }
+
+  type = null as number|null;
+  content = '';
+  images = [] as string[];
+  contact = '';
+  
+  contentId = 0;
+  title = '';
+  page = '';
+  modelId = 0;
+  modelName = '';
+  mainBodyColumnId = 0;
+  mainBodyColumnName = '';
+}
+
+export class CommonContentApi extends AppServerRequestModule<DataModel> {
+
+  constructor(
+    mainBodyId = 1, 
+    modelId = 0, debugName = 'CommonContent', 
+    mainBodyColumnId?: number|number[]) {
+    super();
+    this.modelId = modelId;
+    this.mainBodyId = mainBodyId;
+    this.mainBodyColumnId = mainBodyColumnId;
+    this.debugName = debugName;
+  }
+
+  public mainBodyId: number;
+  public mainBodyColumnId?: number|number[];
+  public modelId: number;
+  protected debugName: string;
+
+  private toStringArray(arr: number|number[]|undefined) {
+    if (typeof arr === 'undefined') 
+      return '';
+    return typeof arr === 'object' ? arr.join(',') : arr.toString();
+  }
+
+  /**
+   * 获取分类列表
+   * @param type 根级类型:1=区域、2=级别、3=文物类型、4=非遗类型、42=事件类型
+   * @param withself 是否返回包含自己:true=是,false=否 ,默认false
+   * @returns 
+   */
+  async getCategoryList(
+    type?: number,
+    withself?: boolean,
+  ) {
+    return (this.get<KeyValue[]>('/content/category/getCategoryList', '获取分类列表', {
+      type,
+      is_tree: false,
+      withself,
+    }))
+      .then(res => transformArrayDataModel<CategoryListItem>(CategoryListItem, res.data!, `获取分类列表`, 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.data), 
+        `获取分类列表`, 
+        true
+      ))
+      .catch(e => { throw e });
+  }
+  /**
+   * 模型的主体栏目列表
+   * @param params 参数 
+   * @param querys 额外参数
+   * @returns 
+   */
+  getModelColumList<T extends DataModel = GetModelColumContentList>(model_id: number, page: number, pageSize: number = 10,querys?: QueryParams) {
+    return this.get('/content/main_body_column/getColumnList', `${this.debugName} 模型的主体栏目列表`, {
+      main_body_id: this.mainBodyId,
+      model_id: model_id ?? this.modelId,
+      page,
+      pageSize,
+      ...querys
+    })
+      .then(res => transformArrayDataModel<T>(GetModelColumContentList, res.data as any, `${this.debugName} 模型的主体栏目列表`, true))
+      .catch(e => { throw e });
+  }
+  /**
+   * 主体栏目列表
+   * @param params 参数 
+   * @param querys 额外参数
+   * @returns 
+   */
+  getColumList<T extends DataModel = GetColumContentList>(params: GetColumListParams, modelClassCreator: NewDataModel = GetColumContentList, querys?: QueryParams) {
+    return this.get<{ list: T[], total: number }>('/content/content/getMainBodyColumnContentList', `${this.debugName} 主体栏目列表`, {
+      main_body_id: this.mainBodyId,
+      model_id: this.modelId,
+      ...params.toServerSide(),
+      ...querys,
+    })
+      .then(res => ({
+        list: transformArrayDataModel<T>(modelClassCreator, requireNotNull(res.data).list, `${this.debugName} 主体栏目列表`, true),
+        total: requireNotNull(res.data).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<{ list: T[], total: number }>('/content/content/getContentList', `${this.debugName} 模型内容列表`, {
+      ...params.toServerSide(),
+      model_id: params.modelId || this.modelId,
+      main_body_id: params.mainBodyId || this.mainBodyId,
+      main_body_column_id: this.toStringArray(params.mainBodyColumnId || this.mainBodyColumnId),
+      page,
+      pageSize,
+      ...querys,
+    })
+      .then(res => {
+        let resList : any = null;
+        let resTotal : any = null;
+        if (res.data?.list && Array.isArray(res.data.list)) {
+          resList = res.data.list;
+          resTotal = res.data.total ?? resList.length;
+        }
+        else if (res.data && Array.isArray(res.data)) {
+          resList = res.data;
+          resTotal = resList.length;
+        } else
+          resList = res.data;
+
+        if (resList === null)
+          return { list: [], total: 0 };
+        
+        return { 
+          list: transformArrayDataModel<T>(modelClassCreator, resList, `${this.debugName} 模型内容列表`, true),
+          total: resTotal as number,
+        }
+      })
+      .catch(e => { throw e });
+  }
+  /**
+   * 内容详情
+   * @param id id 
+   * @param querys 额外参数
+   * @returns 
+   */
+  getContentDetail<T extends DataModel = GetContentDetailItem>(id: number, modelClassCreator: NewDataModel = GetContentDetailItem, modelId?: number, querys?: QueryParams) {
+    return this.get('/content/content/getContentDetail', `${this.debugName} (${id}) 内容详情`, {
+      main_body_id: this.mainBodyId,
+      model_id: modelId ?? this.modelId,
+      id,
+      ...querys,
+    }, modelClassCreator)
+      .then(res => res.data as T)
+      .catch(e => { throw e });
+  }
+}
+
+export default new CommonContentApi(undefined, 0, '默认通用内容');

+ 12 - 0
src/api/NotConfigue.ts

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

+ 185 - 0
src/api/RequestModules.ts

@@ -0,0 +1,185 @@
+
+/**
+ * 这里写的是业务相关的:
+ * * 请求数据处理函数。
+ * * 自定义请求模块。
+ * * 自定义错误报告处理函数。
+ */
+
+import { 
+  RequestCoreInstance, RequestOptions, RequestApiError, RequestApiResult, type RequestApiErrorType,
+  defaultResponseDataGetErrorInfo, defaultResponseDataHandlerCatch,
+  RequestResponse,
+  appendGetUrlParams, 
+  appendPostParams,
+  type RequestApiInfoStruct,
+  WebFetchImplementer,
+} from "@imengyu/imengyu-utils";
+import type { DataModel, NewDataModel } from "@imengyu/js-request-transform";
+import { StringUtils } from "@imengyu/imengyu-utils";
+
+const ApiCofig = {
+  serverDev: 'https://mn.wenlvti.net/api',
+  serverProd: 'https://mn.wenlvti.net/api',
+  mainBodyId: 1,
+}
+
+/**
+ * 不报告错误的 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) {
+  if (req.method == 'GET') {
+    //追加GET参数
+    url = appendGetUrlParams(url, 'main_body_id', ApiCofig.mainBodyId);
+  } else {
+    req.data = appendPostParams(req.data,'main_body_id', ApiCofig.mainBodyId);
+  } 
+  return { newUrl: url, newReq: req };
+}
+//响应数据处理函数
+function responseDataHandler<T extends DataModel>(response: RequestResponse, req: RequestOptions, resultModelClass: NewDataModel | undefined, instance: RequestCoreInstance<T>, apiInfo: RequestApiInfoStruct): 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,
+            response.headers,
+            apiInfo
+          ));
+          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,
+          response.headers,
+          apiInfo
+        ));
+      }
+      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,
+          response.headers,
+          apiInfo
+        ));
+      }
+    }).catch((err) => {
+      //错误统一处理
+      defaultResponseDataHandlerCatch(method, req, response, null, err, apiInfo, 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) {
+  
+}
+
+/**
+ * App服务请求模块
+ */
+export class AppServerRequestModule<T extends DataModel> extends RequestCoreInstance<T> {
+  constructor() {
+    super(WebFetchImplementer);
+    this.config.baseUrl = ApiCofig.serverProd;
+    this.config.errCodes = []; //
+    this.config.requestInceptor = requestInceptor;
+    this.config.responseDataHandler = responseDataHandler;
+    this.config.responseErrReoprtInceptor = responseErrReoprtInceptor;
+    this.config.reportError = reportError;
+  }
+}

+ 15 - 0
src/api/Utils.ts

@@ -0,0 +1,15 @@
+export function transformSomeToArray(source: any) {
+  if (typeof source === 'string') 
+    return source.split(','); 
+  if (typeof source === 'object') {
+    if (source instanceof Array)
+      return source; 
+    else {
+      const arr = [];
+      for (const key in source)
+        arr.push(source[key]);
+      return arr;
+    }
+  }
+  return source;
+}

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

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

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

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

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

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

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

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

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

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

+ 20 - 1
src/assets/scss/main.scss

@@ -1,4 +1,5 @@
 @use "./fix.scss";
+@use "./news.scss";
 @use "sass:list";
 @use "sass:math";
 
@@ -6,14 +7,21 @@
 :root {
   --color-primary: #db5f46;
   --color-secondary: #ac361e;
+  --color-primary-dark: #ac361e;
   --color-text: #333;
+  --color-text-content: #333;
+  --color-text-content-second: #888;
   --color-text-light: #fff;
   --color-text-dark: #000;
   --color-text-secondary: #888;
+  --color-text-second: #888;
   --color-border: #ddd;
+  --color-border-grey: #ddd;
+  --color-border-split: #e5e7eb;
+  --color-border-active: #c4a29b;
   --color-box: #f9fafb;
   --color-box-inset: #e5e7eb;
-  --color-box-hover: #c4a29b;
+  --color-box-hover: #dbdbdb;
   --color-mask: rgba(0, 0, 0, 0.4);
 
   --swiper-navigation-color: var(--color-primary);
@@ -803,6 +811,17 @@ footer {
   color: var(--color-text-secondary);
   margin: 30px 0;
 }
+.tab-button {
+  background-color: var(--color-primary);
+  color: var(--color-text-light);
+  padding: 10px 15px;
+  margin-right: 8px;
+  cursor: pointer;
+  -webkit-user-select: none;
+  user-select: none;
+  outline: none;
+  flex-shrink: 0;
+}
 
 @media (max-width: 992px) {
   .featured-image {

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

@@ -0,0 +1,424 @@
+
+@use "sass:list";
+
+
+//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, .ImageTitleBlock {
+        width: 170px;
+        height: 130px;
+        margin-right: 25px;
+      }
+    }
+  }
+  .list {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .table-list {
+    margin-top: 10px;
+
+    table {
+      border-collapse: collapse;
+      width: 100%;
+      border: 1px solid var(--color-border-grey);
+
+  
+      td, th {
+        border: 1px solid var(--color-border-grey);
+        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: var(--color-box);
+    border: 1px solid var(--color-border-split);
+    width: 100%;
+    text-decoration: none;
+
+    &.row-type2 {
+      flex-wrap: wrap;
+
+      .TitleDescBlock h3 {
+        margin-top: 10px;
+      }
+
+      img, .ImageTitleBlock {
+        width: 100%;
+        height: 300px;
+        margin-right: 0;
+      }
+    }
+    &.row-type3 {
+      img, .ImageTitleBlock {
+        width: 270px;
+        height: 180px;
+      }
+    }
+      &.row-type4 {
+        img, .ImageTitleBlock {
+          object-fit: contain;
+          width: 270px;
+          height: 150px;
+        }
+      }
+    &.empty {
+      background-color: transparent;
+      border: none;
+    }
+
+    &:hover:not(.empty) { 
+      background-color: var(--color-box-hover);
+    }
+    &:active:not(.empty) {
+      transform: scale(0.95);
+    }
+
+    .tags {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: wrap;
+      column-gap: 10px;
+      row-gap: 10px;
+      margin-top: 15px;
+      margin-bottom: 10px;
+      font-size: 0.85rem;
+
+
+      > div {
+        border-radius: 15px;
+        padding: 0 15px;
+        background-color: var(--color-primary-dark);
+        color: var(--color-text-light);
+      }
+    }
+    .extra {
+      display: flex;
+      flex-direction: column;
+      flex-wrap: wrap;
+      margin-top: 15px;
+      font-size: 0.8rem;
+
+      .desc {
+        display: block;
+        min-width: 70px;
+        color: var(--color-text-second);
+      }
+    }
+
+
+    img, .ImageTitleBlock {
+      flex-shrink: 0;
+      width: 320px;
+      height: 180px;
+      margin-right: 25px;
+      border-radius: 5px;
+      object-fit: cover;
+    }
+
+   
+  }
+}
+
+@media (max-width: 768px) {
+  .news-list { 
+
+    .item {
+      display: flex;
+      flex-direction: row;
+      padding: 25px;
+      border-radius: 6px;
+      background-color: var(--color-box);
+
+      &.row-type2 {
+        img, .ImageTitleBlock {
+          width: 100%;
+          height: 250px;
+          margin-right: 0;
+        }
+      }
+      &.row-type3 {
+        img, .ImageTitleBlock {
+          width: 170px;
+          height: 90px;
+        }
+      }
+      &.row-type4 {
+        img, .ImageTitleBlock {
+          width: 180px;
+          height: 110px;
+        }
+      }
+
+      img, .ImageTitleBlock {
+        width: 200px;
+        height: 140px;
+        margin-right: 25px;
+      }
+    }
+
+    &.grid {
+      .item {
+        img, .ImageTitleBlock {
+          width: 120px;
+          height: 90px;
+          margin-right: 15px;
+        }
+      }
+    }
+  }
+}
+@media (max-width: 540px) {
+  .news-list {
+    .item {
+      flex-direction: column;
+
+      img, .ImageTitleBlock {
+        width: 100%;
+        height: 180px;
+        
+        margin-right: 0;
+        margin-bottom: 16px;
+      }
+    }
+  }
+}
+
+//Detail page
+
+.news-detail {
+  color: var(--color-text-content);
+
+  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: var(--color-box-inset);
+    padding: 4px 5px;
+    border-radius: 5px;
+    cursor: pointer;
+
+    img { 
+      width: 20px;
+      height: 20px;
+    }
+
+    &:hover {
+      background-color: var(--color-box-hover);
+    }
+  }
+  .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: var(--color-box-inset);
+    cursor: pointer;
+
+    &:hover {
+      background-color: var(--color-box-hover);
+    }
+
+    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: var(--color-box);
+    border-radius: 8px;
+    padding: 15px 20px;
+
+    .entry {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: nowrap;
+      width: 50%;
+
+      &.hidden {
+        display: none;
+      }
+
+      .label {
+        width: 120px;
+        color: var(--color-text-content-second);
+      }
+      .value {
+        color: var(--color-text);
+      }
+    }
+
+    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: var(--color-border-split);
+      }
+    }
+    &.large {
+      width: 100%;
+      height: 60vh;
+
+      img {
+        width: 50%;
+        height: 60vh;
+        object-fit: contain;
+        background-color: var(--color-border-split);
+      }
+    }
+
+  }
+}
+@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) {
+  
+}

+ 3 - 26
src/components/NavBar.vue

@@ -34,37 +34,14 @@
 <script setup lang="ts">
 import { ref } from 'vue';
 import { useRoute } from 'vue-router';
+import { solveChannelData } from '~/composeable/InternalData';
 import { useSSrSimpleDataLoader } from '~/composeable/SimpleDataLoader';
 
 const route = useRoute();
 
 const navItems = await useSSrSimpleDataLoader('navItems', async () => {
-  const data = (await $fetch('/api/channel/nav')).data || [];
-
-  const specialPages = [] as any[];
-  //特殊页
-  const indexPage = data.find(item => item.name === '首页');
-  if (indexPage) {
-    indexPage.url = '/';
-  }
-  const lawsPage = data.find(item => item.name === '政策法规');
-  if (lawsPage) {
-    lawsPage.url = '/channel/laws/?id=' + lawsPage.id;
-  }
-  const contactPage = data.find(item => item.name === '联系我们');
-  if (contactPage) {
-    contactPage.url = '/about/';
-  }
-  specialPages.push(lawsPage, contactPage, indexPage);
-
-  return data.map(item => {
-    const isSpeical = (specialPages.includes(item));
-    return {
-      ...item,
-      url: isSpeical ? item.url : 
-        (item.type === 'list' ? `/channel/${item.id}` : item.outlink),
-    }
-  });
+  const data = (await $fetch('/api/ecms/channel/nav')).data || [];
+  return solveChannelData(data);
 });
 const isMenuOpen = ref(false);
 const isSearchOpen = ref(false);

+ 49 - 0
src/components/PageContainer.vue

@@ -0,0 +1,49 @@
+<template>
+  <!-- 通用列表 -->
+  <div class="main-background">
+    <!-- SEO -->
+    <Head>
+      <Title>厦门市文化遗产保护中心 - {{ title }}</Title>
+      <Meta name="description" content="" />
+      <Meta name="keywords" content="" />
+    </Head>
+    <!-- 轮播 -->
+    <slot name="carousel" />
+
+    <!-- 主要内容 -->
+    <div class="main-content">
+      <div class="container">
+        <div class="row">
+          <!-- 左侧导航 -->
+          <div class="col-12 col-sm-12 col-md-4 col-lg-3">
+            <slot name="sidebar" />
+          </div>
+          
+          <!-- 右侧内容 -->
+          <div class="col-12 col-sm-12 col-md-8 col-lg-9">
+            <div class="content">
+              <div class="section-title">
+                <h2 class="icon">{{ title }}</h2>         
+                <div class="content mb-2">
+                  <!-- 路径 -->
+                  <slot name="breadcrumb" />
+                </div>
+              </div>
+              <slot name="content" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+const props = defineProps({
+  title: {
+    type: String,
+    default: '',
+  },
+});
+
+</script>

+ 61 - 0
src/components/Sidebar.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="sidebar">
+    <div v-if="title" class="title">
+      <h2>{{ title }}</h2>
+    </div>
+    <ul class="sidebar-menu">
+      <slot />
+      <li v-for="(item, key) in items" :key="key">
+        <router-link :to="item.link" :class="{ 'active': item.id == activeId }">
+          {{ item.text }}
+          <Icon name="material-symbols-light:chevron-right" />
+        </router-link>
+      </li>
+      <li v-if="showBackUpLevel">
+        <router-link :to="backUpLevelLink">
+          <div @click="emit('backUpLevel')">
+            <Icon name="material-symbols:undo" />
+            返回上一级
+          </div>
+        </router-link>
+      </li>
+      <li v-if="showEmpty && (!items || items.length === 0)" class="no-content">暂无相关子分类</li>
+    </ul>
+  </div>
+</template>
+
+<script setup lang="ts">
+
+const props = defineProps({
+  title: {
+    type: String,
+    default: '',
+  },
+  activeId: {
+    type: Number,
+    default: 0,
+  },
+  showBackUpLevel: {
+    type: Boolean,
+    default: false,
+  },
+  showEmpty: {
+    type: Boolean,
+    default: true,
+  },
+  backUpLevelLink: {
+    type: String,
+    default: '',
+  },
+  items: {
+    type: Object as PropType<Array<{
+      id: number;
+      text: string;
+      link: string;
+    }>>,
+    default: () => ([]),
+  },
+});
+const emit = defineEmits(['backUpLevel']);
+
+</script>

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

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

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

@@ -0,0 +1,427 @@
+<template>
+  <!-- 通用列表页详情 -->
+  <div v-show="show" >
+    <div class="content mb-2">
+      <!-- 搜素栏 -->
+      <div class="row mt-3 align-items-center">
+        <!-- 分类 -->
+        <TagBar 
+          :tags="tagsData || []"
+          :margin="[30, 70]" 
+          v-model:selectedTag="selectedTag"
+        />
+        <!-- 标题 -->
+        <div v-if="showNav" class="nav-back-title">
+            <Icon name="material-symbols-light:chevron-left" @click="router.back()" />
+          <h2>{{ title }}</h2>
+        </div>
+        <!-- 标题 -->
+        <div v-if="showTotal" class="nav-back-title">
+          共有 {{ newsLoader.total }} 个{{ title }}
+        </div>
+      </div>
+      <!-- 搜素栏 -->
+      <div class="d-flex flex-row justify-content-end align-items-start" 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: number) => handleChangeDropDownValue(k, v)"
+        />
+        <SimpleInput v-if="showSearch" v-model="searchText" placeholder="请输入关键词" @search="handleSearch">
+          <template #suffix>
+            <Icon 
+              v-if="searchText"
+              name="material-symbols-light:close"
+              class="clickable"
+              title="清空" 
+              :size="28"
+              @click="searchText='';newsLoader.loadData(undefined, true)" 
+            />
+            <Icon 
+              name="material-symbols-light:search"
+              class="clickable"
+              title="搜索" 
+              :size="28"
+              @click="newsLoader.loadData(undefined, true)" 
+            />
+          </template>
+        </SimpleInput>
+        <button class="tab-button" v-if="showTableSwitch" @click="tableListShow=!tableListShow">
+          ▼ 清单
+        </button>
+      </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 :class="{  'title-box': item.titleBox, }">{{ 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, ...props.detailsParams }}).href" />
+            <ImageTitleBlock
+              :image="item.image || defaultImage"
+            />
+            <TitleDescBlock
+              :title="item.title"
+              :titleBox="item.titleBox"
+              :desc="item.desc"
+            >
+              <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>
+          <div 
+            v-for="count of placeholderItemCount"
+            :key="count"
+            class="item empty"
+            :style="{ width: rowWidth }"
+          />
+        </div>
+      </SimplePageContentLoader>
+    </div>
+    <!-- 分页 -->
+    <Pagination
+      :currentPage="newsLoader.page.value"
+      :totalPages="newsLoader.totalPages.value"
+      @update:currentPage="handleChangePage"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, watch, type PropType } from 'vue';
+import { useSSrSimplePagerDataLoader } from '@/composeable/SimplePagerDataLoader';
+import TagBar from '../content/TagBar.vue';
+import Dropdown from '../controls/Dropdown.vue';
+import SimpleInput from '../controls/SimpleInput.vue';
+import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
+import Pagination from './SimplePagination.vue';
+import TitleDescBlock from './TitleDescBlock.vue';
+import IconSearch from '../icons/IconSearch.vue';
+import ImageTitleBlock from './ImageTitleBlock.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: '',
+  },
+  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'
+  },
+  detailModelId: {
+    type: Number,
+    default: 0,
+  }
+})
+
+const router = useRouter();
+
+const realRowCount = computed(() => {
+  if (import.meta.client)
+    if (window.innerWidth < 768) 
+      return 1;
+  return props.rowCount;
+});
+const rowWidth = computed(() => {
+  switch (realRowCount.value) {
+    case 2:
+      return `calc(50% - 13px)`;
+    case 3:
+      return `calc(33% - 13px)`;
+    case 4:
+      return `calc(25% - 13px)`;
+  }
+});
+const placeholderItemCount = computed(() => {
+  switch (realRowCount.value) {
+    case 2:
+    case 3:
+    case 4:
+      return newsLoader.list.value.length % realRowCount.value;
+  }
+  return 0;
+});
+const searchText = ref('');
+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);
+  router.push({ 
+    path: props.detailsPage,
+    query: {
+      id: item.id,
+      modelId: props.detailModelId,
+      ...props.detailsParams
+    }
+  });
+}
+
+function handleChangePage(page: number) {
+  router.replace({
+    query: {
+      ...route.query,
+      page,
+    }
+  })
+}
+
+//子分类
+const selectedTag = ref(props.defaultSelectTag);
+const pageSize = ref(props.pageSize);
+const route = useRoute();
+
+const newsLoader = await useSSrSimplePagerDataLoader(
+  route.fullPath + '/list' + props.subName, 
+  Number(route.query.page || 1), 
+  pageSize, 
+  (page, size) => props.load(
+    page, size, 
+    selectedTag.value, 
+    searchText.value,
+    dropDownValues.value,
+  )
+);
+
+watch(() => props.defaultSelectTag, (v) => {
+  selectedTag.value = v;
+})
+watch(() => props.dropDownNames, () => {
+  loadDropValues();
+})
+watch(selectedTag, () => {
+  router.replace({
+    query: {
+      ...route.query,
+      tag: selectedTag.value,
+    }
+  })
+  newsLoader.loadData(undefined, true);
+})
+watch(tableListShow, (v) => {
+  pageSize.value = v ? 100 : props.pageSize;
+  newsLoader.loadData(undefined, true);
+})
+
+function loadDropValues() {
+  dropDownValues.value = [];
+  if (props.dropDownNames)
+    for (const element of props.dropDownNames)
+      dropDownValues.value.push(element.defaultSelectedValue);
+  newsLoader.loadData(undefined, false);
+}
+
+onMounted(() => {
+  if (route.query.tag)
+    selectedTag.value = Number(route.query.tag);
+  setTimeout(() => {
+    loadDropValues();
+  }, 600);
+})
+watch(route, () => {
+  if (route.query.page)
+    newsLoader.page.value = Number(route.query.page);
+  loadDropValues();
+})
+
+defineExpose({
+  reload() {
+    newsLoader.loadData(undefined, true);
+  }
+})
+</script>
+
+<style lang="scss">
+.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: var(--color-primary);
+}
+</style>
+

+ 191 - 0
src/components/content/CommonListPage.vue

@@ -0,0 +1,191 @@
+<template>
+  <!-- 通用列表 -->
+  <PageContainer :title="title">
+    <template #carousel>
+      <Carousel v-if="false" v-bind="carouselConfig" class="main-header-image carousel-light">
+        <Slide 
+          v-for="(item, key) in carouselData.content.value"
+          :key="key"
+          class="main-header-image"
+        >
+          <img class="main-header-image" :src="item.image" />
+        </Slide>
+        <template #addons>
+          <Navigation />
+          <Pagination />
+        </template>
+      </Carousel>
+    </template>
+    <template #sidebar>
+      <Sidebar 
+        :title="title"
+        :showEmpty="false"
+        :items="channelData.content.value?.childs?.map((item) => ({
+          id: item.id,
+          text: item.name,
+          link: `${item.url}?parent_channel_id=${channelId}`,
+        }))"
+        :backUpLevelLink="`/channel/${channelData.content.value?.parent_id}`"
+      />
+    </template>
+    <template #breadcrumb>
+      <!-- 路径 -->
+      <a-breadcrumb>
+        <a-breadcrumb-item><router-link to="/">首页</router-link></a-breadcrumb-item>
+        <a-breadcrumb-item v-if="prevPage">
+          <router-link v-if="prevPage.url" :to="prevPage.url">{{ prevPage.title }}</router-link>
+          <a href="javascript:;" v-else @click="router.back()">{{ prevPage.title }}</a>
+        </a-breadcrumb-item>
+        <a-breadcrumb-item>{{ title }}</a-breadcrumb-item>
+      </a-breadcrumb>
+    </template>
+    <template #content>
+      <CommonListBlock v-bind="props"></CommonListBlock>
+    </template>
+  </PageContainer>
+</template>
+
+<script setup lang="ts">
+import { type PropType } from 'vue';
+import CommonListBlock from './CommonListBlock.vue';
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+import type { DropdownCommonItem, DropDownNames } from './CommonListBlock.vue';
+import { useSSrSimpleDataLoader } from '~/composeable/SimpleDataLoader';
+import type { IChannel } from '~~/server/api/ecms/channel/[id]';
+import { solveChannelData } from '~/composeable/InternalData';
+export type { DropdownCommonItem, DropDownNames }
+
+const carouselConfig : (typeof Carousel['props']) = {
+  itemsToShow: 1,
+  wrapAround: true,
+  autoplay: 5000,
+}
+
+const router = useRouter();
+const route = useRoute();
+
+const carouselData = await useSSrSimpleDataLoader('carousel', async () => {
+  const res = await $fetch(`/api/ecms/carousel`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+const channelId = parseInt(route.query.parent_channel_id as string);
+const channelData = await useSSrSimpleDataLoader('channel' + channelId, async () => {
+  const res = await $fetch(`/api/ecms/channel/${channelId}`);
+  if (!res.status)
+    throw new Error(res.message);
+  const data =  res.data as IChannel & {
+    childs: IChannel[];
+    parents: IChannel[];
+  };
+  return {
+    ...data,
+    childs: solveChannelData(data.childs),
+  };
+});
+
+const props = defineProps({	
+  title: {
+    type: String,
+    default: '',
+  },
+  prevPage: {
+    type: Object as PropType<{
+      title: string,
+      url?: string,
+    }>,
+    default: null,
+  },
+  dropDownNames: {
+    type: Object as PropType<DropDownNames[]>,
+    default: null,
+  },
+  showSearch: {
+    type: Boolean,
+    default: true,
+  },
+  showTableSwitch: {
+    type: Boolean,
+    default: false, 
+  },
+  tableSwitchOptions: {
+    type: Object,
+    default: () => ({}), 
+  },
+  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,
+  },
+  /**
+   * 点击详情跳转页面路径
+   */
+  detailsPage: {
+    type: String,
+    default: '/inheritor/details/default'
+  },
+  /**
+   * 详情跳转页面参数
+   */
+  detailsParams: {
+    type: Object as PropType<Record<string, any>>,
+    default: () => ({})
+  },
+  defaultImage: {
+    type: String,
+    default: 'https://mn.wenlvti.net/app_static/minnan/EmptyImage.png'
+  },
+  detailModelId: {
+    type: Number,
+    default: 0,
+  }
+})
+</script>
+
+<style lang="scss">
+
+.search-icon {
+  width: 25px;
+  height: 25px;
+  cursor: pointer;
+  color: var(--color-primary);
+}
+</style>
+

+ 56 - 0
src/components/content/ImageGrid.vue

@@ -0,0 +1,56 @@
+<template>
+  <div class="w-100 d-flex flex-row flex-wrap" :style="{ gap: gap }">
+    <slot 
+      name="item"
+      v-for="(v, k) in data"
+      :key="k"
+      :item="v"
+      :index="k"
+      :width="`calc(${100 / rowCount}% - ${gap})`"
+      :height="imageHeight"
+      :url="imagekey ? v[imagekey] : v" 
+    >
+      <img 
+        :src="imagekey ? v[imagekey] : v" 
+        :style="{ 
+          width: `calc(${100 / rowCount}% - ${gap})`,
+          height: imageHeight,
+          borderRadius: '5px',
+          objectFit: 'cover',
+        }"
+        @click="()=>emit('itemClick', v)"
+      />
+    </slot>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+
+defineProps({	
+  rowCount : {
+    type: Number,
+    default: 3,
+  },
+  imagekey : {
+    type: String,
+    default: undefined,
+  },
+  imageHeight : {
+    type: String,
+    default: undefined,
+  },
+  gap: {
+    type: String,
+    default: '10px',
+  },
+  data: {
+    type: Object as PropType<any[]>,
+    default: null,
+  },
+})
+
+const emit = defineEmits([	
+  "itemClick"	
+])
+</script>

+ 34 - 0
src/components/content/ImageSwiper.vue

@@ -0,0 +1,34 @@
+<template>
+  <Carousel 
+    v-bind="carouselConfig"
+    @slide-end="(i) => $emit('switch', i)"
+  >
+    <Slide v-for="(slide, index) in items" :key="index">
+      <slot name="item" :index="index" :item="slide" />
+    </Slide>
+    <template #addons>
+      <Navigation />
+      <Pagination />
+    </template>
+  </Carousel>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+
+defineEmits([	
+  "switch"	
+])
+
+const props = defineProps({	
+  items : {
+    type: Object as PropType<Array<any>>,
+    default: () => ([]),
+  },
+})
+const carouselConfig =  {
+  wrapAround: true,
+  ...props,
+}
+</script>

+ 112 - 0
src/components/content/ImageTitleBlock.vue

@@ -0,0 +1,112 @@
+<template>
+  <div 
+    :class="[
+      'ImageTitleBlock',
+      title ? 'has-title' : '',
+      fit? 'fit' : ''
+    ]" 
+    :style="{ backgroundImage: `url('${image || title}')` }"
+    @click="$emit('click')"
+  >
+    <img 
+      v-if="image"
+      :src="image"
+      alt=""
+      class="image"
+    >
+    <div class="desc">
+      <h5>{{ title }}</h5>
+      <p>{{ desc }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineProps({	
+  title: {
+    type: String,
+    default: '' 
+  },
+  desc: {
+    type: String,
+    default: '' ,
+  },
+  image: {
+    type: String,
+    default: '' 
+  },
+  fit: {
+    type: Boolean,
+    default: false
+  },
+})
+defineEmits([
+  'click',
+])
+</script>
+
+<style lang="scss">
+.ImageTitleBlock  {
+  position: relative;
+  padding: 24px;
+  background-size: cover;
+  background-position: top center;
+  width: 400px;
+  height: 270px;
+  margin-right: 24px;
+
+  img {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    object-fit: contain !important;
+    z-index: 1;
+  }
+
+  &.fit {
+    width: 100%;
+    margin-right: 0;
+  }
+  &.has-title {
+    &::before {
+      content: '';
+      display: block;
+      position: absolute;
+      right: 0;
+      left: 0;
+      bottom: 0;
+      height: 120px;
+      z-index: 20;
+      background: linear-gradient(
+        180deg,
+        rgba(#000, 0) 0%,
+        rgba(#000, 0.7) 100%
+      )
+    }
+  }
+
+  .desc {
+    position: absolute;
+    right: 0;
+    left: 0;
+    bottom: 0;
+    display: flex;
+    flex-direction: column;
+    padding: 24px;
+    color: #fff;
+    z-index: 20;
+
+    h5 {
+      font-family: SourceHanSerifCNBold;
+      font-size: 1.1rem;
+      margin-bottom: 5px;
+    }
+    p {
+      font-size: 0.8rem;
+      margin: 0;
+    }
+  }
+}
+</style>

+ 45 - 0
src/components/content/ImageTitleDescBlock.vue

@@ -0,0 +1,45 @@
+<template>
+  <div 
+    :class="[
+      'ImageTitleDescBlock',
+    ]"
+  >
+    <img :src="image" />
+    <TitleDescBlock 
+      :title="title"
+      :desc="desc"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import TitleDescBlock from './TitleDescBlock.vue';
+
+defineProps({	
+  title: {
+    type: String,
+    default: '' 
+  },
+  desc: {
+    type: String,
+    default: '' ,
+  },
+  image: {
+    type: String,
+    default: '' 
+  },
+})
+</script>
+
+<style lang="scss">
+.ImageTitleDescBlock  {
+  display: flex;
+  flex-direction: row;
+  padding: 24px;
+
+  img {
+    width: 18%;
+    margin-right: 24px;
+  }
+}
+</style>

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

@@ -0,0 +1,66 @@
+<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">
+.tag-button {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background-color: var(--color-box-inset);
+  color: var(--color-text);
+  padding: 10px 15px;
+  margin-right: 8px;
+  cursor: pointer;
+  user-select: none;
+
+  &:hover {
+    background-color: var(--color-box-hover);
+  }
+  &:active, &.active {
+    color: var(--color-text-light);
+    background-color: var(--color-primary);
+  }
+}
+</style>

+ 160 - 0
src/components/content/TitleDescBlock.vue

@@ -0,0 +1,160 @@
+<template>
+  <div class="TitleDescBlock">
+    
+    <div class="title-container">
+      <h3 :class="{  'title-box': titleBox, }">{{ title }}</h3>
+    </div>
+    <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?'折叠':'展开'}}
+        <Icon name="material-symbols-light:chevron-right" />
+      </div>
+      <div v-if="more" class="more" @click="moreLink ? undefined : emit('moreClick')">
+        <NuxtLink :to="moreLink">
+          更多
+          <Icon name="material-symbols-light:chevron-right" />
+        </NuxtLink>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import SimpleRichHtml from '../display/SimpleRichHtml.vue';
+
+const props = defineProps({	
+  title : {
+    type: String,
+    default: '',
+  },
+  titleBox: {
+    type: Boolean,
+    default: false,
+  },
+  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">
+.TitleDescBlock {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+
+  > .title-container {
+    display: flex;
+    flex-direction: row;
+    justify-content: flex-start;
+  }
+  h3 {
+    color: var(--color-text-content);
+    font-size: 1.2rem;
+    margin-top: 0;
+    margin-bottom: 8px;
+  }
+
+  .desc,
+  .time {
+    color: var(--color-text-secondary);
+    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: var(--color-text-secondary);
+      font-size: 0.85rem;
+      margin-top: 25px;
+      cursor: pointer;
+    
+      &:hover {
+        color: var(--color-primary);
+      }
+    }
+  }
+
+  .expand {
+    img {
+      width: 16px;
+      height: 16px;
+      transform: rotate(90deg);
+    }
+    &.on {
+      img {
+        transform: rotate(270deg); 
+      }
+    }
+  }
+
+  .more {
+    img {
+      width: 16px;
+      height: 16px;
+      margin-left: 8px;
+    }
+  }
+}
+</style>

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

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

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

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

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

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

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

@@ -0,0 +1,100 @@
+<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">
+.nana-simple-input {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 15px;
+  background-color: var(--color-box);
+  border: 1px solid var(--color-border);
+
+  &.focus {
+    border-color: var(--color-border-active); 
+  }
+
+  input {
+    font-size: 1rem;
+    color: var(--color-text);
+    border: none;
+    outline: none;
+    background-color: transparent;
+  }
+
+  .clickable {
+    cursor: pointer;
+  }
+
+  .prefix {
+    margin-right: 6px;
+
+    img {
+      width: 24px;
+      height: 24px;
+      vertical-align: middle;
+    }
+  }
+  .suffix {
+    margin-left: 10px;
+    margin-right: 10px;
+    height: 28px;
+  }
+}
+.nana-input-text {
+  color: var(--color-text);
+  font-size: 26px;
+}
+</style>

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

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

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

@@ -0,0 +1,147 @@
+<template>
+  <div v-show="show" ref="scrollContainer" class="nana-rich-html-container">
+    <div class="rich-html">
+      <slot name="prepend" />
+      <template 
+        v-for="(content, i) in contents"
+        :key="i"
+      >
+        <div 
+          v-if="content && content != 'null'"
+          :data-r-id="id"
+          class="content"
+          v-html="content"
+        />
+      </template>
+      <slot name="append" />
+    </div>
+    <div class="rich-html-catalog" v-if="catalog && catalogItems.length > 0">
+      <CommonCatalog
+        :items="catalogItems"
+        :scrollContainer="scrollContainer"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { RandomUtils } from '@imengyu/imengyu-utils';
+import CommonCatalog, { type CatalogItem } from '../content/CommonCatalog.vue';
+import { onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue';
+
+const props = defineProps({	
+  contents: {
+    type: Array as PropType<string[]>,
+    default: () => ([]),
+  },
+  tagStyle: {
+    type: Object as PropType<Record<string, string>>,
+    default: () => ({}),
+  },
+  catalog: {
+    type: Boolean,
+    default: true,
+  },
+  noStyle: {
+    type: Boolean,
+    default: false,
+  },
+  show: {
+    type: Boolean,
+    default: true,
+  },
+})
+
+const id = RandomUtils.genNonDuplicateIDHEX(12);
+const catalogItems = ref<CatalogItem[]>([]);
+const scrollContainer = ref<HTMLElement|null>(null);
+let lastStyleTag : HTMLElement|null = null;
+
+function genTagCss() {
+  if (Object.keys(props.tagStyle).length > 0) {
+    const style = document.createElement('style');
+    let css = '';
+    for (const key in props.tagStyle) {
+      css += `.rich-html div[data-r-id="${id}"] ${key} { ${props.tagStyle[key]} } `
+    }
+    style.innerHTML = css;
+    document.body.appendChild(style);
+    lastStyleTag = style;
+  }
+}
+function generateCatalog() {
+  catalogItems.value = [];
+
+  if (!props.catalog) 
+    return;
+
+  let anchrId = 0;
+  for (let i = 1; i <= 6; i++) {
+    const heades = document.querySelectorAll(`.rich-html div[data-r-id="${id}"] h${i}`);
+    for (const header of heades) {
+      anchrId++;
+      if (header instanceof HTMLHeadingElement) {
+        if (header.id == '')
+          header.id = 'header' + anchrId + 'a' + RandomUtils.genNonDuplicateIDHEX(12);
+        catalogItems.value.push({
+          title: header.textContent || '',
+          scrollPos: header.offsetTop,
+          level: i,
+          anchor: header.id,
+        });
+      }
+    }
+  }
+  catalogItems.value.sort((a, b) => {
+    return a.scrollPos - b.scrollPos;
+  })
+}
+
+watch(() => props.contents, () => {
+  if (import.meta.server)
+    return;
+  setTimeout(() => {
+    generateCatalog();
+  }, 200);
+}, { immediate: true })
+
+onBeforeUnmount(() => {
+  if (import.meta.server)
+    return;
+  if (lastStyleTag) {
+    lastStyleTag.parentElement?.removeChild(lastStyleTag);
+    lastStyleTag = null;
+  }
+})
+onMounted(() => {
+  if (import.meta.server)
+    return;
+  genTagCss()
+});
+</script>
+
+<style lang="scss">
+.nana-rich-html-container {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+
+  .rich-html {
+    flex: 1 1 100%;
+  }
+  .rich-html-catalog {
+    position: fixed;
+    top: 140px;
+    right: 0;
+    bottom: 0px;
+    width: 15rem;
+  }
+}
+
+
+@media (max-width: 1648px) {
+  .rich-html-catalog {
+    display: none;
+  }
+}
+</style>

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

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

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

@@ -0,0 +1,42 @@
+<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">
+.icon-menu {
+  path {
+    transition: all 0.3s;
+  }
+  &.open {
+    color: var(--color-primary);
+    .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


+ 37 - 0
src/composeable/InternalData.ts

@@ -0,0 +1,37 @@
+import type { IChannel } from "~~/server/api/ecms/channel/[id]";
+
+//处理系统内部使用的分类数据
+export function solveChannelData(data: IChannel[]) : (IChannel & {
+  url: string;
+})[] {
+  const specialPages = [] as any[];
+  //特殊页
+  const indexPage = data.find(item => item.name === '首页');
+  if (indexPage) indexPage.url = '/';
+  const lawsPage = data.find(item => item.name === '政策法规');
+  if (lawsPage) lawsPage.url = '/channel/laws/?id=' + lawsPage.id;
+  const contactPage = data.find(item => item.name === '联系我们');
+  if (contactPage) contactPage.url = '/about/';
+  const intangiblePage = data.find(item => item.name === '非遗项目');
+  if (intangiblePage) intangiblePage.url = '/inheritor/intangible';
+  const inheritorPage = data.find(item => item.name === '非遗传承人');
+  if (inheritorPage) inheritorPage.url = '/inheritor/inheritor';
+
+  specialPages.push(
+    lawsPage, 
+    contactPage, 
+    indexPage,
+    intangiblePage,
+    inheritorPage,
+  );
+
+  return data.map(item => {
+    const isSpeical = (specialPages.includes(item));
+    return {
+      ...item,
+      type: isSpeical ? 'list' : item.type,
+      url: isSpeical ? item.url : 
+        (item.type === 'list' ? `/channel/${item.id}` : item.outlink),
+    }
+  });
+}

+ 2 - 0
src/composeable/SimpleDataLoader.ts

@@ -32,6 +32,8 @@ export function useSimpleDataLoader<T, P = any>(
         loadStatus.value = 'finished';
       loadError.value = '';
     } catch(e) {
+      if (import.meta.env.DEV)
+        console.error(e);
       loadError.value = '' + e;
       loadStatus.value = 'error';
       console.log(e);

+ 5 - 1
src/composeable/SimplePagerDataLoader.ts

@@ -19,7 +19,7 @@ export interface ISimplePageListLoader<T, P> extends ILoaderCommon<P> {
  * 使用示例:
  * ```ts
  * const { data, page, total, loading } = useSimplePagerDataLoader(10, async (page, pageSize) => {
- *   const res = await fetch(`/api/data?page=${page}&pageSize=${pageSize}`);
+ *   const res = await fetch(`/api/ecms/data?page=${page}&pageSize=${pageSize}`);
  *   const data = await res.json();  
  *   return {
  *     data,
@@ -78,6 +78,8 @@ export function useSimplePagerDataLoader<T, P = any>(
       loadError.value = '';
       loading = false;
     } catch(e) {
+      if (import.meta.env.DEV)
+        console.error(e);
       loadError.value = '' + e;
       loadStatus.value = 'error';
       loading = false;
@@ -194,6 +196,8 @@ export async function useSSrSimplePagerDataLoader<T, P = any>(
       loadError.value = '';
       loading = false;
     } catch(e) {
+      if (import.meta.env.DEV)
+        console.error(e);
       loadError.value = '' + e;
       loadStatus.value = 'error';
       loading = false;

+ 70 - 99
src/pages/channel/[id].vue

@@ -1,100 +1,66 @@
 <template>
   <!-- 分类 -->
-  <div class="main-background">
-    <!-- SEO -->
-    <Head>
-      <Title>厦门市文化遗产保护中心 - {{ channelName }}</Title>
-      <Meta name="description" content="" />
-      <Meta name="keywords" content="" />
-    </Head>
-    <!-- 轮播 -->
-    <Carousel v-bind="carouselConfig" class="main-header-image carousel-light">
-      <Slide 
-        v-for="(item, key) in carouselData.content.value"
-        :key="key"
-        class="main-header-image"
-      >
-        <img class="main-header-image" :src="item.image" />
-      </Slide>
-      <template #addons>
-        <Navigation />
-        <Pagination />
-      </template>
-    </Carousel>
-
-    <!-- 主要内容 -->
-    <div class="main-content">
-      <div class="container">
-        <div class="row">
-          <!-- 左侧导航 -->
-          <div class="col-12 col-sm-12 col-md-4 col-lg-3">
-            <div class="sidebar">
-              <div class="title">
-                  <h2>{{ channelName }}</h2>
-                </div>
-                <ul class="sidebar-menu">
-                  <li v-for="(item, key) in channelData.content.value?.childs" :key="key">
-                    <a v-if="item.type === 'link'" :href="item.outlink" target="_blank">{{ item.name }}</a>
-                    <router-link v-else :to="`/channel/${item.id}`" :class="{ 'active': item.id == channelId }">
-                      {{ item.name }}
-                      <Icon name="material-symbols-light:chevron-right" />
-                    </router-link>
-                  </li>
-                  <li v-if="channelData.content.value?.parent_id !== 0">
-                    <router-link :to="`/channel/${channelData.content.value?.parent_id}`">
-                      <div>
-                        <Icon name="material-symbols:undo" />
-                        返回上一级
-                      </div>
-                    </router-link>
-                  </li>
-                  <li v-if="!channelData.content.value?.childs || channelData.content.value?.childs.length === 0" class="no-content">暂无相关子分类</li>
-                </ul>
-              </div>
-            </div>
-          
-          <!-- 右侧内容 -->
-          <div class="col-12 col-sm-12 col-md-8 col-lg-9">
-            <div class="content">
-              <div class="section-title">
-                <h2 class="icon">{{ channelName }}</h2>
-                
-                <nav aria-label="breadcrumb">
-                  <ol class="breadcrumb">
-                    <li class="breadcrumb-item"><router-link to="/">首页</router-link></li>
-                    <li v-for="(item, key) in channelData.content.value?.parents" :key="key" class="breadcrumb-item">
-                      <a v-if="item.type === 'link'" :href="item.outlink" target="_blank">{{ item.name }}</a>
-                      <router-link v-else :to="`/channel/${item.id}`">{{ item.name }}</router-link>
-                    </li>
-                    <li class="breadcrumb-item active" aria-current="page">{{ channelName }}</li>
-                  </ol>
-                </nav>
-              </div>
-              
-              <!-- 文章列表 -->
-              <SimplePageContentLoader :loader="articlesData">
-                <div class="news-list">
-                  <div v-for="(item, key) in articlesData.content.value?.items" :key="key" class="news-item">
-                    <a v-if="item.outlink" :href="item.outlink" target="_blank">{{ item.title }}</a>
-                    <router-link v-else :to="'/page/' + item.id" class="title">{{ item.title }}</router-link>
-                    <span class="date">{{ DateUtils.formatDate(new Date((item.createtime ||item.publishtime) * 1000), 'yyyy-MM-dd') }}</span>
-                  </div>
-                  <div v-if="!articlesData.content.value || articlesData.content.value.empty" class="no-news">暂无相关文章</div>
-                </div>
-                <!-- 分页 -->
-                <SimplePagination
-                  v-if="articlesData.content.value" 
-                  :currPage="articlesData.content.value.pageIndex"
-                  :allPage="articlesData.content.value.allPage"
-                  :maxCount="10"
-                />
-              </SimplePageContentLoader>
-            </div>
+  <PageContainer :title="channelName">
+    <template #carousel>
+      <!-- 轮播 -->
+      <Carousel v-bind="carouselConfig" class="main-header-image carousel-light">
+        <Slide 
+          v-for="(item, key) in carouselData.content.value"
+          :key="key"
+          class="main-header-image"
+        >
+          <img class="main-header-image" :src="item.image" />
+        </Slide>
+        <template #addons>
+          <Navigation />
+          <Pagination />
+        </template>
+      </Carousel>
+    </template>
+    <template #breadcrumb>
+      <a-breadcrumb>
+        <a-breadcrumb-item><router-link to="/">首页</router-link></a-breadcrumb-item>
+        <a-breadcrumb-item v-for="(item, key) in channelData.content.value?.parents" :key="key" >
+          <a v-if="item.type === 'link'" :href="item.outlink" target="_blank">{{ item.name }}</a>
+          <router-link v-else :to="`/channel/${item.id}`">{{ item.name }}</router-link>
+        </a-breadcrumb-item>
+        <a-breadcrumb-item>{{ channelName }}</a-breadcrumb-item>
+      </a-breadcrumb>
+    </template>
+    <template #sidebar>
+      <Sidebar
+        :title="channelName"
+        :activeId="channelId"
+        :showBackUpLevel="channelData.content.value?.parent_id !== 0"
+        :backUpLevelLink="`/channel/${channelData.content.value?.parent_id}`"
+        :items="channelData.content.value?.childs?.map((item) => ({
+          id: item.id,
+          text: item.name,
+          link: `${item.url}?parent_channel_id=${channelId}`,
+        }))"
+      />
+    </template>
+    <template #content>
+      <!-- 文章列表 -->
+      <SimplePageContentLoader :loader="articlesData">
+        <div class="news-list">
+          <div v-for="(item, key) in articlesData.content.value?.items" :key="key" class="news-item">
+            <a v-if="item.outlink" :href="item.outlink" target="_blank">{{ item.title }}</a>
+            <router-link v-else :to="'/page/' + item.id" class="title">{{ item.title }}</router-link>
+            <span class="date">{{ DateUtils.formatDate(new Date((item.createtime ||item.publishtime) * 1000), 'yyyy-MM-dd') }}</span>
           </div>
+          <div v-if="!articlesData.content.value || articlesData.content.value.empty" class="no-news">暂无相关文章</div>
         </div>
-      </div>
-    </div>
-  </div>
+        <!-- 分页 -->
+        <SimplePagination
+          v-if="articlesData.content.value" 
+          :currPage="articlesData.content.value.pageIndex"
+          :allPage="articlesData.content.value.allPage"
+          :maxCount="10"
+        />
+      </SimplePageContentLoader>
+    </template>
+  </PageContainer>
 </template>
 
 <script setup lang="ts">
@@ -102,8 +68,9 @@ import { computed } from 'vue';
 import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
 import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
 import { DateUtils } from '@imengyu/imengyu-utils';
-import type { IChannel } from '~~/server/api/channel/[id]';
+import type { IChannel } from '~~/server/api/ecms/channel/[id]';
 import SimplePagination from '~/components/content/SimplePagination.vue';
+import { solveChannelData } from '~/composeable/InternalData';
 
 const carouselConfig : (typeof Carousel['props']) = {
   itemsToShow: 1,
@@ -115,22 +82,26 @@ const route = useRoute();
 const channelId = parseInt(route.params.id as string);
 
 const channelData = await useSSrSimpleDataLoader('channel' + channelId, async () => {
-  const res = await $fetch(`/api/channel/${channelId}`);
+  const res = await $fetch(`/api/ecms/channel/${channelId}`);
   if (!res.status)
     throw new Error(res.message);
-  return res.data as IChannel & {
+  const data = res.data as IChannel & {
     childs: IChannel[];
     parents: IChannel[];
   };
+  return {
+    ...data,
+    childs: solveChannelData(data.childs),
+  };
 });
 const carouselData = await useSSrSimpleDataLoader('carousel' + channelId, async () => {
-  const res = await $fetch(`/api/carousel`);
+  const res = await $fetch(`/api/ecms/carousel`);
   if (!res.status)
     throw new Error(res.message);
   return res.data;
 });
 const articlesData = await useSSrSimpleDataLoader('articles' + channelId, async () => {
-  const res = await $fetch('/api/article/byChannel', {
+  const res = await $fetch('/api/ecms/article/byChannel', {
     method: 'GET',
     query: { 
       channelId: channelId,
@@ -143,7 +114,7 @@ const articlesData = await useSSrSimpleDataLoader('articles' + channelId, async
 
   if (res.data?.empty && channelData.content.value?.childs?.[0]) {
     // 没有文章时,并且有子分类时,尝试读取全部第一级子分类的文章
-    const res = await $fetch('/api/article/byChannelAndOnLevelChild', {
+    const res = await $fetch('/api/ecms/article/byChannelAndOnLevelChild', {
       method: 'GET',
       query: { 
         channelId: channelId,

+ 3 - 3
src/pages/channel/laws.vue

@@ -96,7 +96,7 @@ import { computed, ref } from 'vue';
 import { useRouter, useRoute } from 'vue-router';
 import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
 import { DateUtils } from '@imengyu/imengyu-utils';
-import type { IChannel } from '~~/server/api/channel/[id]';
+import type { IChannel } from '~~/server/api/ecms/channel/[id]';
 import SimplePagination from '~/components/content/SimplePagination.vue';
 
 const route = useRoute();
@@ -125,7 +125,7 @@ const updateQuery = () => {
 };
 
 const channelData = await useSSrSimpleDataLoader('channel' + channelId, async () => {
-  const res = await $fetch(`/api/channel/${channelId}`);
+  const res = await $fetch(`/api/ecms/channel/${channelId}`);
   if (!res.status)
     throw new Error(res.message);
   return res.data as IChannel & {
@@ -140,7 +140,7 @@ const selectedCategory = ref(
   (channelData.content.value?.childs[0]?.id || channelId)
 );
 const articlesData = await useSSrSimpleDataLoader('articles' + channelId, async () => {
-  const res = await $fetch('/api/article/byChannel', {
+  const res = await $fetch('/api/ecms/article/byChannel', {
     method: 'GET',
     query: { 
       channelId: selectedCategory.value,

+ 7 - 7
src/pages/index.vue

@@ -235,7 +235,7 @@ const carouselConfig = {
   autoplay: 5000,
 }
 const carouselData = await useSSrSimpleDataLoader('carousel', async () => {
-  const res = await $fetch(`/api/carousel`);
+  const res = await $fetch(`/api/ecms/carousel`);
   if (!res.status)
     throw new Error(res.message);
   return res.data;
@@ -253,41 +253,41 @@ const carousel2Data = await useSSrSimpleDataLoader('carousel2', async () => {
 const currentTab = ref('notices');
 
 const recommendArticles = await useSSrSimpleDataLoader('recommendArticles', async () => {
-  const res = await $fetch(`/api/article/recommend`, { query: { page: 1, pageSize: 6 } });
+  const res = await $fetch(`/api/ecms/article/recommend`, { query: { page: 1, pageSize: 6 } });
   if (!res.status)
     throw new Error(res.message);
   return res.data;
 });
 const notices = await useSSrSimpleDataLoader('notices', async () => {
-  const res = await $fetch(`/api/article/byChannelName?channelName=新闻公告`);
+  const res = await $fetch(`/api/ecms/article/byChannelName?channelName=新闻公告`);
   if (!res.status)
     throw new Error(res.message);
   return res.data;
 });
 // 工作动态数据
 const workUppublishtimes = await useSSrSimpleDataLoader('workUppublishtimes', async () => {
-  const res = await $fetch(`/api/article/byChannelName?channelName=工作动态`);
+  const res = await $fetch(`/api/ecms/article/byChannelName?channelName=工作动态`);
   if (!res.status)
     throw new Error(res.message);
   return res.data;
 });
 // 党建工作数据
 const partyBuilding = await useSSrSimpleDataLoader('partyBuilding', async () => {
-  const res = await $fetch(`/api/article/byChannelName?channelName=党建工作`);
+  const res = await $fetch(`/api/ecms/article/byChannelName?channelName=党建工作`);
   if (!res.status)
     throw new Error(res.message);
   return res.data;
 });
 // 热点新闻数据
 const hot = await useSSrSimpleDataLoader('hot', async () => {
-  const res = await $fetch(`/api/article/byChannelName?channelName=热门新闻`);
+  const res = await $fetch(`/api/ecms/article/byChannelName?channelName=热门新闻`);
   if (!res.status)
     throw new Error(res.message);
   return res.data;
 });
 // 精彩推荐数据
 const featured = await useSSrSimpleDataLoader('featured', async () => {
-  const res = await $fetch(`/api/article/byChannelName?channelName=热门新闻`);
+  const res = await $fetch(`/api/ecms/article/byChannelName?channelName=热门新闻`);
   if (!res.status)
     throw new Error(res.message);
   return res.data;

+ 103 - 0
src/pages/inheritor/artifact.vue

@@ -0,0 +1,103 @@
+<template>
+  <CommonListPage
+    :title="'不可移动文物'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :rowType="2"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    :defaultSelectTag="tagsData[0]?.id"
+    detailsPage="/inheritor/details/artifact"
+  />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue';
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import UnmoveableContent from '@/api/inheritor/UnmoveableContent';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+
+async function loadDetail(id: number, item: any) {
+  const res = await UnmoveableContent.getContentDetail(id);
+  res.addItems = [
+    { name: '地理位置', text: res.address, span: 12 },
+    { name: '建筑时间', text: res.age, span: 12 },
+    { name: '保护级别', text: res.levelText, span: 12 },
+  ];
+  return res;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await UnmoveableContent.getContentList(new GetContentListParams().setSelfValues({
+    crType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title ?? '!!title!!',
+        desc: item.desc,
+        image: item.thumbnail || item.image,
+        addItems: [
+          { name: '地理位置', text: item.address, span: 12 },
+          { name: '建筑时间', text: item.age, span: 12 },
+          { name: '保护级别', text: item.levelText, span: 12 },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+
+//子分类
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await CommonContent.getCategoryList(3)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(2)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>
+
+

+ 32 - 0
src/pages/inheritor/components/IntroBlock.vue

@@ -0,0 +1,32 @@
+<template>
+  <div class="info-list mb-2">
+    <div 
+      v-for="(it, k) in descItems" 
+      :key="k"
+      :class="['entry',Boolean(it.value)?'':'hidden']"
+    >
+      <div class="label">{{ it.label }}</div>
+      <div class="value">{{ it.value }}</div>
+    </div>
+    <slot />
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+
+const props = defineProps({	
+  title: {
+    type: String,
+    default: ''
+  },
+  address: {
+    type: String,
+    default: ''
+  },
+  descItems: {
+    type: Array as PropType<Array<{ label: string, value: any }>>,
+    default: () => []
+  },
+})
+</script>

+ 211 - 0
src/pages/inheritor/details/TabDetailView.vue

@@ -0,0 +1,211 @@
+<template>
+  <!-- 文物详情页 -->
+  <PageContainer :title="loader.content.value?.title">
+    <template #breadcrumb>
+      <div class="d-flex flex-row justify-content-start">
+        <div class="back-button2" @click="back">
+          <Icon name="material-symbols-light:chevron-left" />
+          <span>返回列表</span>
+        </div>
+      </div>
+    </template>
+    <template #sidebar>
+      <Sidebar 
+        :title="loader.content.value?.channel?.name"
+        :showBackUpLevel="true"
+        :showEmpty="false"
+        @backUpLevel="router.back()"
+      />
+    </template>
+    <template #content>    
+      <!-- 新闻 -->
+      <section class="main-section main-background main-background-type0 small-h">
+        <SimplePageContentLoader :loader="loader">
+          <div v-if="loader.content.value" class="content news-detail">
+            <div class="d-flex flex-row justify-content-start">
+              <h1 :class="{ 'title-box': loader.content.value.titleBox }">{{ loader.content.value.title }}</h1>
+            </div>
+            <p class="d-flex flex-row justify-content-between small-info">
+              <span>{{ loader.content.value.address }}</span>
+              <span v-if="loader.content.value.regionText">区域:{{ loader.content.value.regionText }}</span>
+              <span v-if="loader.content.value.from" >来源:{{ loader.content.value.from }}</span>
+            </p>
+
+            <!-- Tab -->
+            <TagBar
+              class="mb-3"
+              :tags="crrentVisibleTabs.map((p, i) => ({ id: p.id, name: p.text })) || []"
+              :margin="[30, 70]" 
+              v-model:selectedTag="currentTabId"
+            />
+            <!-- 基础信息 -->
+            <div v-show="currentTabId==0">
+              <SimpleRichHtml 
+                class="news-content"
+                :contents="[
+                  loader.content.value.intro,
+                  loader.content.value.content,
+                ]" 
+              >
+                <template #prepend>
+                  <!-- 轮播 -->
+                  <Carousel 
+                    :itemsToShow="1"
+                    wrapAround
+                    :autoPlay="5000"
+                    class="carousel float"
+                  >
+                    <Slide v-for="(image, key) in loader.content.value.images" :key="key">
+                      <img :src="image" />
+                    </Slide>
+                    <template #addons>
+                      <Navigation />
+                      <Pagination />
+                    </template>
+                  </Carousel>
+                  <!-- 额外内容 -->
+                  <slot name="extraInfo" :content="loader.content.value" />
+                </template>
+              </SimpleRichHtml>
+            </div>
+            <!-- 图片 -->
+            <div v-show="currentTabId==1">
+              <ImageGrid
+                v-if="loader.content.value.images && loader.content.value.images.length > 0"
+                :data="loader.content.value.images"
+                imageHeight="300px"
+                @itemClick="handleShowImage"
+              >
+              </ImageGrid>    
+              <a-empty v-else />
+            </div>
+            <!-- 音频 -->
+            <div v-show="currentTabId==2">
+              <video 
+                v-if="loader.content.value.video"
+                class="news-video mt-3"
+                controls
+                :src="loader.content.value.video" 
+              />
+              <video 
+                v-if="loader.content.value.audio"
+                class="news-video mt-3"
+                controls
+                :src="loader.content.value.audio" 
+              />
+              <a-empty v-if="!loader.content.value.video && !loader.content.value.audio" />
+            </div>
+            <!-- 视频 -->
+            <div v-show="currentTabId==3">
+              <video 
+                v-if="loader.content.value.video"
+                class="news-video mt-3"
+                controls
+                :src="loader.content.value.video" 
+              />
+              <video 
+                v-if="loader.content.value.audio"
+                class="news-video mt-3"
+                controls
+                :src="loader.content.value.audio" 
+              />
+              <a-empty v-if="!loader.content.value.video && !loader.content.value.audio" />
+            </div>
+            <!-- 其他 -->
+            <slot name="extraTab" :currentTabId="currentTabId" :content="loader.content.value" />
+
+            <div class="row d-flex justify-content-center">
+              <div class="back-button" @click="back">
+                <Icon name="material-symbols-light:chevron-left" :size="26" />
+                <span>返回列表</span>
+              </div>
+            </div>
+          </div>
+        </SimplePageContentLoader>
+      </section>
+      <a-image
+        :width="200"
+        :style="{ display: 'none' }"
+        :preview="{
+          visible: imagePreviewVisible,
+          onVisibleChange: (v: boolean) => imagePreviewVisible = v,
+        }"
+        :src="imagePreviewSrc"
+      />
+    </template>
+  </PageContainer>
+</template>
+
+<script setup lang="ts">
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+import type { GetContentDetailItem } from '@/api/CommonContent';
+import SimplePageContentLoader from '@/components/content/SimplePageContentLoader.vue';
+import SimpleRichHtml from '@/components/display/SimpleRichHtml.vue';
+import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { useRoute, useRouter } from 'vue-router';
+import { computed, onMounted, ref, watch, type PropType } from 'vue';
+import TagBar from '@/components/content/TagBar.vue';
+import ImageGrid from '@/components/content/ImageGrid.vue';
+
+const props = defineProps({
+  load: {
+    type: Function as PropType<(id: number) => Promise<GetContentDetailItem>>,
+    default: null,
+  }
+})
+
+
+const route = useRoute();
+const router = useRouter();
+
+const imagePreviewVisible = ref(false);
+const imagePreviewSrc = ref('');
+
+function handleShowImage(url: string) {
+  imagePreviewVisible.value = true;
+  imagePreviewSrc.value = url;
+}
+
+const loader = await useSSrSimpleDataLoader('details' + route.query.id, async () => {
+  if (!props.load)
+    throw new Error("!props.load");
+  return (await props.load(Number(route.query.id))).toJSON();
+}, false);
+
+watch(() => route.query.id, (v) => {
+  if (!v)
+    return;
+  loader.loadData(undefined, true);
+})
+
+const crrentVisibleTabs = computed(() => contentProps.value.tabs.filter((item) => item.visible));
+
+const currentTabId = ref(0);
+const contentProps = computed(() => {
+  return loader.content.value?.contentProps as {
+    tabs: { 
+      id: number,
+      text: string,
+      visible: boolean,
+    }[],
+  } ?? {
+    tabs: [],
+  };
+})
+
+watch(route, () => {
+  currentTabId.value = 0;
+});
+onMounted(() => {
+  currentTabId.value = 0;
+})
+
+function back() {
+  router.back();
+}
+</script>
+
+<style lang="scss">
+
+</style>
+

+ 77 - 0
src/pages/inheritor/details/artifact.vue

@@ -0,0 +1,77 @@
+<template>
+  <!-- 文物详情页 -->
+  <TabDetailView 
+    title="非遗文物"
+    :load="loadData"
+  >
+    <template #extraInfo="{ content }">
+      <IntroBlock
+        :descItems="[
+          {
+            label: '地址',
+            value: content.address
+          },
+          {
+            label: '开放时间',
+            value: content.openStatusText
+          },
+          {
+            label: '年代',
+            value: content.age 
+          },
+          {
+            label: '级别',
+            value: content.levelText 
+          },
+          {
+            label: '所属区域',
+            value: content.regionText ,
+          },
+          {
+            label: '文物类型',
+            value: content.crTypeText, 
+          },
+          {
+            label: '文物编码',
+            value: content.code,
+          }
+        ]"
+      />
+    </template>
+    <template #extraTab="{ currentTabId, content }">
+      <template v-if="currentTabId==4">
+        <!-- VR参观 -->
+        <iframe :src="(content.vr as string)" style="width: 100%;height: 80vh"/>
+      </template>
+      <SimpleRichHtml :show="currentTabId==5" :contents="[ content.protectedArea as string ]" /> 
+      <SimpleRichHtml :show="currentTabId==6" :contents="[ content.environment as string ]" /> 
+      <SimpleRichHtml :show="currentTabId==7" :contents="[ content.value as string ]" /> 
+    </template>
+  </TabDetailView>
+</template>
+
+<script setup lang="ts">
+import UnmoveableContent from '@/api/inheritor/UnmoveableContent';
+import TabDetailView from './TabDetailView.vue';
+import IntroBlock from '../components/IntroBlock.vue';
+import SimpleRichHtml from '@/components/display/SimpleRichHtml.vue';
+
+async function loadData(id: number) {
+  const d = await UnmoveableContent.getContentDetail(id);
+  d.contentProps = {
+    tabs: [
+      { id: 0, text: '文物基础信息', visible: true },
+      { id: 1, text: '文物相册', visible: true },
+      { id: 2, text: '文物音频', visible: Boolean(d.audio) },
+      { id: 3, text: '文物视频', visible: Boolean(d.video) },
+      { id: 4, text: 'VR参观', visible: Boolean(d.vr) },
+      { id: 5, text: '保护范围', visible: Boolean(d.protectedArea) },
+      { id: 6, text: '建筑环境', visible: Boolean(d.environment) },
+      { id: 7, text: '价值评估', visible: Boolean(d.value) },
+    ]
+  };
+  return d;
+}
+
+</script>
+

+ 124 - 0
src/pages/inheritor/details/default.vue

@@ -0,0 +1,124 @@
+<template>
+  <!-- 分类 -->
+  <PageContainer :title="articlesData.content.value?.title">
+    <template #carousel> 
+      <!-- 轮播 -->
+      <Carousel v-bind="carouselConfig" class="main-header-image carousel-light">
+        <Slide 
+          v-for="(item, key) in articlesData.content.value?.images || []"
+          :key="key"
+          class="main-header-image"
+        >
+          <img class="main-header-image" v-if="item" :src="item" />
+        </Slide>
+        <template #addons>
+          <Navigation />
+          <Pagination />
+        </template>
+      </Carousel>
+    </template>
+    <template #sidebar>
+      <Sidebar 
+        :title="articlesData.content.value?.channel?.name"
+        :activeId="articlesData.content.value?.channel?.id"
+        :showBackUpLevel="articlesData.content.value?.channel?.parent_id !== 0"
+        :showEmpty="false"
+        @backUpLevel="router.back()"
+      />
+    </template>
+    <template #breadcrumb>
+      <a-breadcrumb>
+        <a-breadcrumb-item><router-link to="/">首页</router-link></a-breadcrumb-item>
+        <a-breadcrumb-item>{{ articlesData.content.value?.channel?.name }}</a-breadcrumb-item>
+      </a-breadcrumb>
+    </template>
+    <template #content>
+      <SimplePageContentLoader :loader="articlesData">
+        <div v-if="articlesData.content.value"  class="news-detail">
+          <h1>{{ articlesData.content.value.title }}</h1>
+          <div class="times">
+            <p class="date">时间:{{ DateUtils.formatDate(new Date(
+              (articlesData.content.value.publishtime ||  
+              articlesData.content.value.createtime  || 0) * 1000), 'yyyy-MM-dd') }}</p>
+            <!-- <p class="views">浏览量: {{ articlesData.content.value.views }} </p> -->
+          </div>
+          <img 
+            v-if="!articlesData.content.value.content && articlesData.content.value.image" 
+            class="head-image"
+            :src="articlesData.content.value.image" 
+          />
+          <video 
+            v-if="articlesData.content.value.audio"
+            class="news-video mt-3"
+            controls
+            :src="articlesData.content.value.audio" 
+          />
+          <video 
+            v-else-if="articlesData.content.value.video"
+            class="news-video mt-3"
+            controls
+            :src="articlesData.content.value.video" 
+          />
+
+          <div class="content" v-html="articlesData.content.value.content">
+          </div>
+          <video v-if="articlesData.content.value.video" :src="articlesData.content.value.video" controls></video>
+          <a-empty v-if="isEmpty"
+            description="暂无内容"
+          />
+        </div>
+      </SimplePageContentLoader>
+    </template>
+  </PageContainer>
+</template>
+
+<script setup lang="ts">
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { DateUtils } from '@imengyu/imengyu-utils';
+import SimplePageContentLoader from '~/components/content/SimplePageContentLoader.vue';
+import CommonContent from '~/api/CommonContent';
+
+const carouselConfig = {
+  itemsToShow: 1,
+  wrapAround: true,
+  autoplay: 5000,
+}
+
+const route = useRoute();
+const router = useRouter();
+const articleId = parseInt(route.params.id as string);
+
+const carouselData = await useSSrSimpleDataLoader('carousel', async () => {
+  const res = await $fetch(`/api/ecms/carousel`);
+  if (!res.status)
+    throw new Error(res.message);
+  return res.data;
+});
+const articlesData = await useSSrSimpleDataLoader('detail', async () => {
+  const id = Number(route.query.id);
+  const modelId = route.query.modelId ? Number(route.query.modelId) : undefined;
+  if (!id)
+    throw new Error('参数错误');
+  const z = (await CommonContent.getContentDetail(id, undefined, modelId)).toJSON();
+  //console.log(z);
+  return z;
+}, false)
+
+const isEmpty = computed(() => articlesData.content.value 
+  && !articlesData.content.value.content 
+  && !articlesData.content.value.image 
+  && !articlesData.content.value.video);
+
+watch(() => route.query.id, async (newVal, oldVal) => {
+  if (newVal !== oldVal) {
+    articlesData.loadData(undefined, true);
+  }
+});
+</script>
+
+<style lang="scss">
+@use "sass:list";
+@use "sass:math";
+</style>
+

+ 231 - 0
src/pages/inheritor/details/intangible.vue

@@ -0,0 +1,231 @@
+<template>
+  <!-- 非遗详情页 -->
+  <TabDetailView
+    :title="route.query.groupTitle"
+    :load="loadData"
+  >
+    <template #extraInfo="{ content }">
+      <IntroBlock
+        :descItems="[
+          {
+            label: '地址',
+            value: content.address,
+          },
+          {
+            label: '项目级别',
+            value: content.levelText ,
+          },
+          {
+            label: '项目类别',
+            value: content.ichTypeText,
+          },
+          {
+            label: '批次时间',
+            value: content.batchText,
+          },
+          {
+            label: '所属区域',
+            value: content.regionText ,
+          },
+          {
+            label: '保护单位',
+            value: content.unit 
+          },
+          {
+            label: '其他级别项目',
+            value: content.otherLevel && content.otherLevel.length > 0 ? `${content.otherLevel.length}个` : ''
+          },
+        ]"
+      />
+      <!-- 同级别非遗项目显示 -->
+      <div v-if=" content.otherLevel && content.otherLevel.length > 0" class="mt-2 mb-3 d-flex flex-column">
+        <span class="mb-2">其他级别项目</span>
+        <NuxtLink 
+          v-for="(item, k) in content.otherLevel"
+          :key="k"
+          class="simple-link-item"
+          :to="{ path: '/inheritor/details/intangible', query: { id: item.id } }"
+        >
+          <div class="text">
+            <div class="tag-button active">
+              {{ item.levelText }}
+            </div>
+            <span class="ms-2">{{ item.title }}</span>
+            <span v-if="item.regionText" class="ms-2">({{ item.regionText }})</span>
+          </div>
+          >
+        </NuxtLink>
+      </div>
+    </template>
+    <template #extraTab="{ currentTabId, content }">
+      <!-- 非遗产品(作品) -->
+      <CommonListBlock 
+        subName="worksMeList"
+        :show="currentTabId==4"
+        :showTotal="true"
+        :showSearch="false"
+        :load="(page: number, pageSize: number) => loadSubList(page, pageSize, content, 'worksMeList')"
+        detailsPage="/inheritor/details/intangible"
+        :detailsParams="{
+          mainBodyColumnId: ProjectsContent.mainBodyColumnId,
+          modelId: ProjectsContent.modelId,
+          groupTitle: '非遗产品(作品)',
+        }"
+      />
+      <!-- 非遗传习中心 -->
+      <CommonListBlock 
+        subName="ichSitesList"
+        :show="currentTabId==5"
+        :showTotal="true"
+        :showSearch="false"
+        :load="(page: number, pageSize: number) => loadSubList(page, pageSize, content, 'ichSitesList')"
+        detailsPage="/inheritor/details/intangible"
+        :detailsParams="{
+          mainBodyColumnId: ProjectsContent.mainBodyColumnId,
+          modelId: ProjectsContent.modelId,
+          groupTitle: '非遗传习中心',
+        }"
+      />
+      <!-- 非遗传承人 -->
+      <CommonListBlock 
+        subName="inheritorsList"
+        :show="currentTabId==6"
+        :showTotal="true"
+        :showSearch="false"
+        :load="(page: number, pageSize: number) => loadSubList(page, pageSize, content, 'inheritorsList')"
+        detailsPage="/inheritor/details/intangible"
+        :detailsParams="{
+          mainBodyColumnId: ProjectsContent.mainBodyColumnId,
+          modelId: ProjectsContent.modelId,
+          groupTitle: '非遗传承人',
+        }"
+      />
+      <!-- 相关资讯 -->
+      <CommonListBlock 
+        subName="associationMeList"
+        :show="currentTabId==7"
+        :showTotal="true"
+        :showSearch="false"
+        :load="(page: number, pageSize: number) => loadSubList(page, pageSize, content, 'associationMeList')"
+        detailsPage="/inheritor/details/intangible"
+        :detailsParams="{
+          mainBodyColumnId: ProjectsContent.mainBodyColumnId,
+          modelId: ProjectsContent.modelId,
+          groupTitle: '相关资讯',
+        }"
+      />
+      <!-- 传承谱系 -->
+      <div v-show="currentTabId==10">
+        <SimpleRichHtml 
+          class="news-content"
+          :contents="[
+            content.pedigree
+          ]" 
+        />
+      </div>
+      <!-- 传承谱系 -->
+      <div v-show="currentTabId==11">
+        <SimpleRichHtml 
+          class="news-content"
+          :contents="[
+            content.prize
+          ]" 
+        />
+      </div>
+    </template>
+  </TabDetailView>
+</template>
+
+<script setup lang="ts">
+import TabDetailView from './TabDetailView.vue';
+import ProjectsContent from '@/api/inheritor/ProjectsContent';
+import CommonListBlock from '@/components/content/CommonListBlock.vue';
+import IntroBlock from '../components/IntroBlock.vue';
+import InheritorContent from '~/api/inheritor/InheritorContent';
+
+const route = useRoute();
+
+async function loadSubList(page: number, pageSize: number, content: any, subList: string) {
+  const list = content[subList] as any[] || [];
+  if (subList == 'associationMeList') {
+    list.forEach((p) => {
+      p.bottomTags = [
+        p.levelText,
+        p.ichTypeText,
+        p.batchText,
+      ];
+    })
+  } else if (subList == 'ichSitesList') {
+    list.forEach((p) => {
+      p.bottomTags = [
+        content.ichTypeText,
+      ];
+    })
+  } else if (subList == 'inheritorsList') {
+    list.forEach((p) => {
+      p.bottomTags = [
+        p.levelText,
+        p.nation,
+        content.ichTypeText,
+      ];
+    }) 
+  }
+  return {
+    page: page,
+    total: list.length,
+    data: list.slice((page - 1) * pageSize, page * pageSize)
+  };
+}
+
+async function loadData(id: number) {
+  const d = await ProjectsContent.getContentDetail(id, 
+    undefined,
+    route.query.modelId ? Number(route.query.modelId) : undefined
+  );
+  d.contentProps = {
+    tabs: [
+      { id: 0, text: '简介', visible: true },
+      { id: 1, text: '相册', visible: d.images.length > 0  },
+      { id: 2, text: '音频', visible: Boolean(d.audio) },
+      { id: 3, text: '视频', visible: Boolean(d.video) },
+      { id: 4, text: '非遗作品', visible: Boolean(d.worksList && (d.worksList as any[]).length > 0) },
+      { id: 5, text: '非遗传习中心', visible: Boolean(d.ichSitesList && (d.ichSitesList as any[]).length > 0) },
+      { id: 6, text: '非遗传承人', visible: Boolean(d.inheritorsList && (d.inheritorsList as any[]).length > 0) },
+      { id: 10, text: '传承谱系', visible: Boolean(d.pedigree) },
+      { id: 11, text: '荣誉奖项', visible: Boolean(d.prize) },
+      { id: 7, text: '相关资讯', visible: Boolean(d.associationMeList && (d.associationMeList as any[]).length > 0) },
+    ]
+  };
+  if (d.modelId === InheritorContent.modelId) {
+    d.titleBox = Boolean(d.deathBirth);
+  }
+  return d;
+}
+
+</script>
+
+<style lang="scss">
+.simple-link-item {
+  background-color: #fff;
+  border-radius: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  text-decoration: none;
+  color: #000;
+  padding: 10px 20px;
+  cursor: pointer;
+
+  .text {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+  }
+  .tag-button {
+    border-radius: 15px;
+    padding: 6px 10px;
+  }
+}
+
+</style>

+ 113 - 0
src/pages/inheritor/inheritor.vue

@@ -0,0 +1,113 @@
+<template>
+   <CommonListPage
+    :title="'非遗传承人'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    showTableSwitch
+    :detailModelId="InheritorContent.modelId"
+    :tableSwitchOptions="{
+      title: '传承人姓名',
+    }"
+    :defaultSelectTag="tagsData[0]?.id"
+    detailsPage="/inheritor/details/intangible"
+    :detailsParams="{
+      groupTitle: '非遗传承人',
+    }"
+  />
+</template>
+
+<script setup lang="ts">
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import InheritorContent from '@/api/inheritor/InheritorContent';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+import { onMounted, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+async function loadDetail(id: number, item: any) {
+  item = await InheritorContent.getContentDetail(id);
+  item.content = item.content || item.intro as string;
+  item.addItems = [
+    { name: '传承项目', text: item.ichName },
+    { name: '级别', text: item.levelText },
+    { name: '民族', text: item.nation },
+  ];
+  item.titleBox = Boolean(item.deathBirth);
+  return item;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await InheritorContent.getContentList(new GetContentListParams().setSelfValues({
+    ichType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title,
+        desc: item.desc,
+        image: item.thumbnail || item.image,
+        titleBox: Boolean(item.deathBirth),
+        addItems: [
+          { name: '传承项目', text: item.ichName },
+          { name: '级别', text: item.levelText },
+          { name: '民族', text: item.nation },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+const route = useRoute();
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await CommonContent.getCategoryList(4)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  const levels = await CommonContent.getCategoryList(2);
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat(levels.map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: route.query.level ? Number(route.query.level) : 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>
+

+ 112 - 0
src/pages/inheritor/intangible.vue

@@ -0,0 +1,112 @@
+<template>
+  <CommonListPage
+    :title="'非遗项目'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :load="loadData"
+    :loadDetail="loadDetail"
+    :tagsData="tagsData"
+    showTableSwitch
+    :tableSwitchOptions="{
+      title: '项目名称',
+    }"
+    :detailModelId="ProjectsContent.modelId"
+    :defaultSelectTag="tagsData[0]?.id"
+    detailsPage="/inheritor/details/intangible"
+    :detailsParams="{
+      groupTitle: '非遗项目',
+    }"
+  />
+</template>
+
+<script setup lang="ts">
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import ProjectsContent from '@/api/inheritor/ProjectsContent';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+import { onMounted, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+async function loadDetail(id: number, _item: any) {
+  const item = await ProjectsContent.getContentDetail(id);
+  item.addItems = [
+    { name: '非遗级别', text: item.levelText, span: 12 },
+    { name: '非遗类别', text: item.ichTypeText, span: 12 },
+    { name: '地区', text: item.district, span: 12 },
+    { name: '批次', text: item.batchText, span: 12 },
+  ];
+  return item;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+  const res = await ProjectsContent.getContentList(new GetContentListParams().setSelfValues({
+    ichType: selectedTag == 0 ? undefined: selectedTag,
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title ?? '!!title!!',
+        desc: item.desc,
+        image: item.thumbnail || item.image,
+        addItems: [
+          { name: '非遗级别', text: item.levelText, span: 12 },
+          { name: '非遗类别', text: item.ichTypeText, span: 12 },
+          { name: '地区', text: item.district, span: 12 },
+          { name: '批次', text: item.batchText, span: 12 },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+//子分类
+const tagsData = ref([
+  { id: 0, name: '全部' },
+]);
+const route = useRoute();
+
+onMounted(async () => {
+  tagsData.value = tagsData.value.concat((await CommonContent.getCategoryList(4)).map((item) => ({
+    id: item.id,
+    name: item.title,
+  })));
+  const levels = await CommonContent.getCategoryList(2);
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat(levels.map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: route.query.level ? Number(route.query.level) : 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>
+

+ 85 - 0
src/pages/inheritor/seminar.vue

@@ -0,0 +1,85 @@
+<template>
+  <CommonListPage
+    :title="'非遗传习所'"
+    :prevPage="{ title: '保护传承' }"
+    :dropDownNames="dropdownNames"
+    :pageSize="8"
+    :load="loadData"
+    :loadDetail="loadDetail"
+  />
+</template>
+
+<script setup lang="ts">
+import SeminarContent from '@/api/inheritor/SeminarContent';
+import CommonContent, { GetContentListParams } from '@/api/CommonContent';
+import { onMounted, ref } from 'vue';
+import type { DropDownNames } from '@/components/content/CommonListPage.vue';
+
+async function loadDetail(id: number, item: any) {
+  const res = await SeminarContent.getContentDetail(id);
+  res.addItems = [
+    { name: '地理位置', text: res.address, span: 12 },
+    { name: '保护级别', text: res.levelText, span: 12 },
+  ];
+  return res;
+}
+async function loadData(
+  page: number, 
+  pageSize: number,
+  selectedTag: number,
+  searchText: string,
+  dropDownValues: number[]
+) {
+
+  const res = await SeminarContent.getContentList(new GetContentListParams().setSelfValues({
+    level: dropDownValues[0] == 0 ? undefined: dropDownValues[0],
+    region: dropDownValues[1] == 0 ? undefined: dropDownValues[1],
+    keywords: searchText,
+  }), page, pageSize);
+
+  return { 
+    page: page,
+    total: res.total,
+    data: res.list.map((item, index) => {
+      return {
+        id: item.id,
+        title: item.title ?? '!!title!!',
+        desc: item.desc,
+        image: item.thumbnail || item.image,
+        addItems: [
+          { name: '地理位置', text: item.address, span: 12 },
+          { name: '保护级别', text: item.levelText, span: 12 },
+        ],
+      };
+    }),
+  }
+}
+
+const dropdownNames = ref<DropDownNames[]>([]);
+
+onMounted(async () => {
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(2)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+  dropdownNames.value.push({ 
+    options: [{
+      id: 0, 
+      name: '全部'
+    }].concat((await CommonContent.getCategoryList(1)).map((item) => ({
+      id: item.id,
+      name: item.title,
+    }))),
+    defaultSelectedValue: 0,
+  });
+})
+</script>
+
+<style>
+</style>

+ 9 - 15
src/pages/page/[id].vue

@@ -28,19 +28,13 @@
         <div class="row">
           <!-- 左侧导航 -->
           <div class="col-12 col-sm-12 col-md-4 col-lg-3">
-            <div class="sidebar">
-              <div class="title">
-                <h2>{{ articlesData.content.value?.channel?.name }}</h2>
-              </div>
-              <ul class="sidebar-menu">
-                <li @click="router.back()">
-                  <div>
-                    <Icon name="material-symbols:undo" />
-                    返回上一级
-                  </div>
-                </li>
-              </ul>
-            </div>
+            <Sidebar 
+              :title="articlesData.content.value?.channel?.name"
+              :activeId="articlesData.content.value?.channel?.id"
+              :showBackUpLevel="articlesData.content.value?.channel?.parent_id !== 0"
+              :showEmpty="false"
+              @backUpLevel="router.back()"
+            />
           </div>
           
           <!-- 右侧内容 -->
@@ -105,13 +99,13 @@ const router = useRouter();
 const articleId = parseInt(route.params.id as string);
 
 const carouselData = await useSSrSimpleDataLoader('carousel', async () => {
-  const res = await $fetch(`/api/carousel`);
+  const res = await $fetch(`/api/ecms/carousel`);
   if (!res.status)
     throw new Error(res.message);
   return res.data;
 });
 const articlesData = await useSSrSimpleDataLoader('articles' + articleId, async () => {
-  const res = await $fetch(`/api/article/${articleId}`, {
+  const res = await $fetch(`/api/ecms/article/${articleId}`, {
     method: 'GET',
   });
   if (!res.status)

+ 1 - 1
src/pages/search.vue

@@ -62,7 +62,7 @@ const route = useRoute();
 const keyword = route.query.keyword as string;
 
 const articlesData = await useSSrSimpleDataLoader('articles' + keyword, async () => {
-  const res = await $fetch('/api/article/search', {
+  const res = await $fetch('/api/ecms/article/search', {
     method: 'GET',
     query: { 
       search: keyword,