快乐的梦鱼 1 mēnesi atpakaļ
revīzija
79aab0f7da
100 mainītis faili ar 25344 papildinājumiem un 0 dzēšanām
  1. 34 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 80 0
      README.md
  4. 34 0
      config.conf
  5. 9 0
      config.json.example
  6. 1 0
      env.d.ts
  7. 13 0
      index.html
  8. 68 0
      nuxt.config.ts
  9. 15923 0
      package-lock.json
  10. 66 0
      package.json
  11. BIN
      public/favicon.ico
  12. 2 0
      public/robots.txt
  13. 27 0
      refer/cultural-exchange.html
  14. 26 0
      refer/cultural-tourism.html
  15. 55 0
      refer/culture-news.html
  16. 64 0
      refer/heritage-projects.html
  17. 65 0
      refer/heritage.html
  18. 162 0
      refer/home.html
  19. 19 0
      refer/policy.html
  20. 29 0
      refer/protected-area-overview.html
  21. 5 0
      scripts/.gitignore
  22. 395 0
      scripts/UpdateScript/app.mjs
  23. 273 0
      scripts/UpdateScript/auth.mjs
  24. 22 0
      scripts/UpdateScript/deprecate.html
  25. 63 0
      scripts/UpdateScript/index.mjs
  26. 75 0
      scripts/UpdateScript/postConfig.mjs
  27. 424 0
      scripts/UpdateScript/postUpdate.mjs
  28. 141 0
      scripts/UpdateScript/utils.mjs
  29. 48 0
      server/config/db.ts
  30. 130 0
      server/db/CommonModel.ts
  31. 96 0
      server/db/DB.ts
  32. 109 0
      server/db/DBUtils.ts
  33. 73 0
      server/db/Query.ts
  34. 1071 0
      server/db/QueryGenerator.ts
  35. 3 0
      server/tsconfig.json
  36. 27 0
      server/utils/response.ts
  37. 45 0
      src/App.vue
  38. 510 0
      src/api/CommonContent.ts
  39. 12 0
      src/api/NotConfigue.ts
  40. 185 0
      src/api/RequestModules.ts
  41. 15 0
      src/api/Utils.ts
  42. 10 0
      src/api/inheritor/InheritorContent.ts
  43. 10 0
      src/api/inheritor/ProjectsContent.ts
  44. 10 0
      src/api/inheritor/SeminarContent.ts
  45. 10 0
      src/api/inheritor/UnitContent.ts
  46. 20 0
      src/api/inheritor/UnmoveableContent.ts
  47. 65 0
      src/api/introduction/IndexContent.ts
  48. 3 0
      src/assets/css/tailwind.css
  49. BIN
      src/assets/fonts/PingFang Regular.ttf
  50. BIN
      src/assets/fonts/STSongti-SC-Black.ttf
  51. BIN
      src/assets/fonts/STSongti-SC-Black.woff
  52. BIN
      src/assets/fonts/STSongti-SC-Black.woff2
  53. 1 0
      src/assets/images/404.svg
  54. 8 0
      src/assets/scss/fix.scss
  55. 950 0
      src/assets/scss/main.scss
  56. 423 0
      src/assets/scss/news.scss
  57. 63 0
      src/assets/scss/scroll.scss
  58. 29 0
      src/components/DynamicsItem.vue
  59. 39 0
      src/components/FeatureCard.vue
  60. 49 0
      src/components/Footer.vue
  61. 106 0
      src/components/NavBar.vue
  62. 51 0
      src/components/PageContainer.vue
  63. 34 0
      src/components/PageHero.vue
  64. 37 0
      src/components/RelatedLinks.vue
  65. 16 0
      src/components/SectionTitle.vue
  66. 67 0
      src/components/Sidebar.vue
  67. 142 0
      src/components/content/CommonCatalog.vue
  68. 426 0
      src/components/content/CommonListBlock.vue
  69. 193 0
      src/components/content/CommonListPage.vue
  70. 56 0
      src/components/content/ImageGrid.vue
  71. 34 0
      src/components/content/ImageSwiper.vue
  72. 112 0
      src/components/content/ImageTitleBlock.vue
  73. 45 0
      src/components/content/ImageTitleDescBlock.vue
  74. 80 0
      src/components/content/SimplePageContentLoader.vue
  75. 66 0
      src/components/content/SimplePagination.vue
  76. 78 0
      src/components/content/TagBar.vue
  77. 160 0
      src/components/content/TitleDescBlock.vue
  78. 65 0
      src/components/controls/Check.vue
  79. 5 0
      src/components/controls/CheckIcon.vue
  80. 139 0
      src/components/controls/Dropdown.vue
  81. 5 0
      src/components/controls/DropdownIcon.vue
  82. 100 0
      src/components/controls/SimpleInput.vue
  83. 24 0
      src/components/display/SimpleRemoveRichHtml.vue
  84. 147 0
      src/components/display/SimpleRichHtml.vue
  85. 57 0
      src/components/display/SimpleScrollView.vue
  86. 42 0
      src/components/icons/IconMenu.vue
  87. 5 0
      src/components/icons/IconSearch.vue
  88. 15 0
      src/components/link/ChannelLink.vue
  89. 46 0
      src/composeable/InternalData.ts
  90. 9 0
      src/composeable/LoaderCommon.ts
  91. 38 0
      src/composeable/PageQuerys.ts
  92. 112 0
      src/composeable/SimpleDataLoader.ts
  93. 237 0
      src/composeable/SimplePagerDataLoader.ts
  94. 43 0
      src/pages/404.vue
  95. 26 0
      src/pages/index.vue
  96. 66 0
      src/pages/index/components/CultureStats.vue
  97. 71 0
      src/pages/index/components/FeatureTopics.vue
  98. 142 0
      src/pages/index/components/HeroBanner.vue
  99. 155 0
      src/pages/index/components/NewsSection.vue
  100. 0 0
      src/pages/web/cultural-exchange.vue

+ 34 - 0
.gitignore

@@ -0,0 +1,34 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+/config.json
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+.output
+.nuxt
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo

+ 3 - 0
.vscode/extensions.json

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

+ 80 - 0
README.md

@@ -0,0 +1,80 @@
+# wenbao-website
+
+[闽南文化(漳州)生态保护区](https://xmswhycbhzx.cn/)前端项目。
+
+## 技术栈
+
+### 前端核心技术
+
+- **框架**: Nuxt.js v4 (Vue 3 框架)
+- **核心库**: Vue 3, Vue Router
+- **状态管理**: Pinia
+- **类型系统**: TypeScript
+- **样式预处理器**: SCSS
+- **构建工具**: Vite
+- **UI 组件库**: Ant Design Vue
+
+## 项目目录组织
+
+```
+src/
+  api/              # API 相关代码
+  assets/           # 静态资源
+    fonts/          # 字体文件
+    images/         # 图片资源
+    scss/           # SCSS 样式文件
+  components/       # 通用组件
+    content/        # 内容显示相关组件
+    controls/       # 小组件
+    display/        # 显示基础组件
+    icons/          # 图标组件
+  composeable/      # 组合式函数
+  pages/            # 页面
+    channel/        # 文章分类页面
+    page/           # 文章详情页面
+    inheritor/      # 传承人相关页面(由闽南文化项目迁移)
+server/
+  api/              # 服务器端 API
+    ecms/           # CMS 相关 API
+      article/      # 文章相关 API
+      channel/      # 分类相关 API
+  config/           # 服务器配置
+  db/               # 数据库相关代码
+  utils/            # 服务器工具函数
+scripts/
+  UpdateScript/     # 更新脚本
+```
+
+## 开发与构建
+
+### 开发命令
+
+```bash
+# 启动开发服务器
+npm run dev
+
+# 启动 Nuxt 开发服务器
+npm run nuxt-dev
+```
+
+### 构建命令
+
+```bash
+# 构建项目
+npm run build
+
+# 构建项目然后打包并推送线上
+npm run updater update post web
+
+# 预览构建结果
+npm run nuxt-preview
+```
+
+推送至线上后请在宝塔面板中重启项目。
+
+### 项目准备
+
+```bash
+# 安装依赖后执行的准备命令
+npm run nuxt-postinstall
+```

+ 34 - 0
config.conf

@@ -0,0 +1,34 @@
+server
+{
+    # 匹配路径:/ 、/about/ 、/.nuxt/* 、/channel/* 、/page/*
+    # 用 ~* 实现不区分大小写匹配(可选,如需严格大小写可改为 ~)
+    location ~* ^(/|/about/|/inheritor/.*|/\.nuxt/.*|/\_nuxt/.*|/channel/.*|/page/.*|/api/ecms/.*)$ {
+        # 1. 核心:反向代理至目标服务 localhost:3106
+        proxy_pass http://localhost:3106;
+        
+        # 2. 关键:禁用反向代理缓存(多维度兜底,确保无缓存)
+        # 2.1 告诉后端服务不缓存当前请求
+        proxy_cache off;  # 直接关闭 Nginx 代理缓存(核心配置)
+        proxy_no_cache 1; # 强制不缓存响应内容
+        proxy_cache_bypass 1; # 强制绕过缓存,直接请求后端
+        
+        # 2.2 响应头:告诉客户端(浏览器)不缓存
+        add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
+        add_header Pragma "no-cache";
+        add_header Expires "0";
+        add_header Surrogate-Control "no-store";
+        
+        # 3. 可选:优化反向代理的常规配置(保证服务稳定性)
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_connect_timeout 30s; # 连接超时时间
+        proxy_send_timeout 30s;    # 发送超时时间
+        proxy_read_timeout 30s;    # 读取超时时间
+        proxy_buffering off;       # 关闭代理缓冲(实时响应,同时避免缓存)
+    }
+    
+    access_log  /www/wwwlogs/xmswhycbhzx.cn.log;
+    error_log  /www/wwwlogs/xmswhycbhzx.cn.error.log;
+}

+ 9 - 0
config.json.example

@@ -0,0 +1,9 @@
+{
+  "db": {
+    "host": "localhost",
+    "port": 3306,
+    "user": "root",
+    "password": "your_password",
+    "database": "wenbao"
+  }
+}

+ 1 - 0
env.d.ts

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

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>闽南文化生态保护区(厦门市)</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 68 - 0
nuxt.config.ts

@@ -0,0 +1,68 @@
+import { defineNuxtConfig } from "nuxt/config";
+
+// https://nuxt.com/docs/api/configuration/nuxt-config
+export default defineNuxtConfig({
+  compatibilityDate: '2025-05-15',
+  app: {
+    head: {
+      title: '闽南文化(漳州)生态保护区',
+      viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
+      htmlAttrs: {
+        lang: 'zh',
+      },
+      link: [
+        { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
+      ]
+    }
+  }, 
+  devtools: { enabled: false },
+  srcDir: 'src/',
+  ignore: [
+    'src/pages/**/components/**',
+  ],
+  modules: ['@pinia/nuxt', '@ant-design-vue/nuxt', '@nuxt/icon', '@nuxtjs/tailwindcss'],
+  components: [
+    {
+      path: '~/components',
+      pathPrefix: false,
+      extensions: ['.vue'],
+    }
+  ],
+  icon: {
+    provider: 'iconify',
+    serverBundle: false,
+  },
+  tailwindcss: {
+    cssPath: '~/assets/css/tailwind.css',
+  },
+  build: {
+    transpile: [
+      '@imengyu/vue-scroll-rect',
+      '@imengyu/imengyu-utils',
+      '@imengyu/js-request-transform',
+    ],
+  },
+  vite: {
+    build: {
+      minify: 'terser',
+      terserOptions: {
+        compress: {
+          drop_console: true,
+          drop_debugger: true,
+        },
+      },
+    },
+  },
+  routeRules: {
+    '/**': { swr: false, isr: false, headers: { 'cache-control': 'no-store, max-age=0' } },
+    /* '/uploads/**': {
+      proxy: 'https://xmswhycbhzx.cn/uploads/**'
+    }, */
+    /*
+    '/': { swr: 1800 },
+    '/about/': { swr: 1800 },
+    '/channel/**': { swr: 1800 },
+    '/page/submit': {swr: 1800 },
+    '/search/**': { swr: false },*/
+  }
+})

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 15923 - 0
package-lock.json


+ 66 - 0
package.json

@@ -0,0 +1,66 @@
+{
+  "name": "zhangzhou-website",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "nuxt-build": "nuxt build",
+    "nuxt-dev": "nuxt dev",
+    "nuxt-generate": "nuxt generate",
+    "nuxt-preview": "nuxt preview",
+    "nuxt-postinstall": "nuxt prepare",
+    "dev": "nuxt dev",
+    "build": "nuxt build",
+    "updater": "node scripts/UpdateScript/index.mjs"
+  },
+  "dependencies": {
+    "@ant-design-vue/nuxt": "^1.4.6",
+    "@imengyu/imengyu-utils": "^0.0.26",
+    "@imengyu/js-request-transform": "^0.4.0",
+    "@imengyu/vue-dynamic-form": "^0.1.1",
+    "@imengyu/vue-scroll-rect": "^0.1.8",
+    "@nuxt/icon": "^2.1.1",
+    "@pinia/nuxt": "^0.11.1",
+    "@popperjs/core": "^2.11.8",
+    "@vuemap/vue-amap": "^2.1.12",
+    "ant-design-vue": "^4.2.6",
+    "lodash-es": "^4.17.21",
+    "mitt": "^3.0.1",
+    "mysql2": "^3.16.0",
+    "nuxt": "^4.2.2",
+    "pinia": "^3.0.1",
+    "tslib": "^2.8.1",
+    "vue": "^3.5.13",
+    "vue-router": "^4.5.0",
+    "vue3-carousel": "^0.15.0"
+  },
+  "devDependencies": {
+    "@iconify-json/material-symbols": "^1.2.50",
+    "@inquirer/prompts": "^7.10.1",
+    "@nuxtjs/tailwindcss": "^6.14.0",
+    "@tsconfig/node22": "^22.0.1",
+    "@types/node": "^22.14.0",
+    "@vitejs/plugin-vue": "^5.2.3",
+    "@vitejs/plugin-vue-jsx": "^4.1.2",
+    "@vue/tsconfig": "^0.7.0",
+    "ali-oss": "^6.23.0",
+    "archiver": "^8.0.0",
+    "axios": "^1.20.0",
+    "cli-progress": "^3.12.0",
+    "cli-table3": "^0.6.5",
+    "commander": "^14.0.3",
+    "consola": "^3.4.2",
+    "form-data": "^4.0.6",
+    "json5": "^2.2.3",
+    "log-symbols": "^7.0.1",
+    "md5": "^2.3.0",
+    "minimatch": "^10.2.6",
+    "npm-run-all2": "^7.0.2",
+    "sass": "^1.87.0",
+    "terser": "^5.44.1",
+    "typescript": "~5.8.0",
+    "vite": "^6.2.4",
+    "vite-plugin-vue-devtools": "^7.7.2",
+    "vue-tsc": "^2.2.8"
+  }
+}

BIN
public/favicon.ico


+ 2 - 0
public/robots.txt

@@ -0,0 +1,2 @@
+User-Agent: *
+Disallow:

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 27 - 0
refer/cultural-exchange.html


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 26 - 0
refer/cultural-tourism.html


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 55 - 0
refer/culture-news.html


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 64 - 0
refer/heritage-projects.html


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 65 - 0
refer/heritage.html


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 162 - 0
refer/home.html


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 19 - 0
refer/policy.html


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 29 - 0
refer/protected-area-overview.html


+ 5 - 0
scripts/.gitignore

@@ -0,0 +1,5 @@
+node_modules
+_config.json
+_localConfig.json
+_token.json
+upload.zip

+ 395 - 0
scripts/UpdateScript/app.mjs

@@ -0,0 +1,395 @@
+/**
+ * 更新发布工具
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { program } from 'commander';
+import { input, confirm, select } from '@inquirer/prompts';
+import { postAppUpdate, postWebUpdate } from './postUpdate.mjs';
+import Table from 'cli-table3';
+import { config } from './postConfig.mjs';
+import {
+  axiosInstance,
+  initAuth,
+  login,
+  logout,
+  checkLogged,
+} from './auth.mjs';
+import consola from 'consola';
+import { logError } from './utils.mjs';
+
+// 版本相关
+//========================================
+
+export async function viewVersion(type) {
+  if (type === 'all' || !type) {
+    const data = await axiosInstance.get('/version/list');
+    const table = new Table({
+      head: ['ID', '版本'], 
+      colWidths: [10, 20 ]
+    });
+
+    data.data.forEach((d) => {
+      table.push([ d.id, d.version ]);
+    })
+    console.log(table.toString());
+  } else {
+    let data = null;
+    try {
+      if (Number.isNaN(Number(type)))
+        data = await axiosInstance.get('/version/get-by-name?name=' + type);
+      else
+        data = await axiosInstance.get('/version/' + type);
+    }
+    catch (e) {
+      logError('Failed to load version info', e);
+      return;
+    }
+    const table = new Table({
+      head: ['key', 'data'], 
+      colWidths: [30, 60]
+    });
+    table.push(
+      [ 'ID', data.data.id ],
+      [ '状态', stateConstant[data.data.status] ],
+      [ '版本号', data.data.version ],
+      [ '创建时间', new Date(data.data.createAt).toString() ],
+      [ '设置', data.data.config ],
+      [ '激活的Web更新ID', data.data.webUpdateId ],
+      [ '激活的App更新ID', data.data.appUpdateId ],
+      [ '激活的下一个App更新ID', data.data.appUpdateNextId ],
+    );
+    console.log(table.toString());
+  }
+}
+export async function getVersion(action, type) {
+  switch(action) {
+    case 'view':
+      await viewVersion(type);
+      break;
+    case 'new': {
+      const version = await input({ message: 'Enter version name, like (1.0.0)' });
+      try {
+        await axiosInstance.post('/version', {
+          version: version,
+          status: 1,
+          config: "{}",
+        });
+        consola.success('Add version success');
+      } catch (e) {
+        logError('Failed to add version', e);
+      }
+      break;
+    }
+    case 'delete': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      if (!await confirm({ message: `确定删除版本 ${versionId}?`, default: false }))
+        return;
+      if (!await confirm({ message: '确认删除版本?此操作会删除所属版本的所有更新项目、存储等,无法恢复,是否确定删除?', default: false }))
+        return;
+      try {
+        await axiosInstance.delete('/version/' + versionId);
+        consola.success('删除版本成功');
+      } catch (e) {
+        logError('删除版本失败', e);
+      }
+      break;
+    }
+    case 'set-state': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      const state = await select({
+        message: '设置状态',
+        choices: [
+          {
+            name: 'NotEnable',
+            value: 0,
+          },
+          {
+            name: 'Normal',
+            value: 1,
+          },
+          {
+            name: 'Deprecated',
+            value: 2,
+          },
+        ],
+      });
+
+      try {
+        await axiosInstance.put('/version/' + versionId, {
+          status: state
+        });
+        consola.success('设置状态成功');
+      } catch (e) {
+        logError('设置状态失败', e);
+      }
+      break;
+    }
+    case 'set-config': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      const config = await input({ message: '输入配置Json' });
+      try {
+        await axiosInstance.put('/version/' + versionId, {
+          config: config,
+        });
+        consola.success('设置配置成功');
+      } catch (e) {
+        logError('设置配置失败', e);
+      }
+      break;
+    }
+    case 'set-active-app-update': {
+      const versionId = await input({ message: '输入版本ID' });
+      const updateId = await input({ message: '输入更新ID' });
+      const isNext = await confirm({ message: 'Set as next active?', default: false });
+
+      try {
+        await axiosInstance.post('/update/active/app', { versionId, updateId, isNext });
+        consola.success('成功');
+      } catch (e) {
+        logError('失败', e);
+      }
+      break;
+    }
+    case 'set-active-web-update': {
+      const versionId = await input({ message: '输入版本ID' });
+      const updateId = await input({ message: '输入更新ID' });
+
+      try {
+        await axiosInstance.post('/update/active/web', { versionId, updateId });
+        consola.success('成功');
+      } catch (e) {
+        logError('失败', e);
+      }
+      break;
+    }
+    default:
+      logError('未知参数', action);
+      break;
+  }
+}
+
+//选择方法
+//========================================
+
+export async function selectVersion(defaultVersionId = null) {
+  const data = (await axiosInstance.get('/version/list?full=true&search=' + JSON.stringify({ 
+    appId: config.appId 
+  }))).data;
+  if (data.length === 0) {
+    logError('没有版本,请在管理后台创建 appId ' + config.appId + ' 的版本');
+    return;
+  }
+  const versionId = (await select({
+    choices: data.map(p => ({
+      value: p.id,
+      name: p.version,
+    })),
+    default: defaultVersionId,
+    message: '选择一个版本',
+  }));
+  const versionName = data.find(p => p.id === versionId).version;
+  return { versionId, versionName };
+}
+export async function selectUpdate() {
+  const { versionId } = await selectVersion();
+  const data = (await axiosInstance.get('/version/update?search=' + JSON.stringify({ versionId }))).data;
+  if (data.length === 0) {
+    logError('没有更新');
+    return;
+  }
+  const resultId = (await select({
+    choices: data.map(p => ({
+      value: p.id,
+      name: p.version,
+    })),
+    message: '选择一个更新',
+  }));
+  return resultId;
+}
+
+//更新相关
+//========================================
+
+const stateConstant = [ 'Deleted', 'Normal', 'Deprecated' ];
+const typeConstant = [ 'Unknow', 'Web', 'app' ];
+const storageTypeConstant = [ 'Unknow', 'LocalStorage', 'AliOSS' ];
+
+export async function postUpdate(type, options) {
+  switch (type) {
+    case 'web': {
+      await postWebUpdate(axiosInstance, options);
+      break;
+    }
+    case 'app': {
+      await postAppUpdate(axiosInstance, options);
+      break;
+    }
+    default:
+      logError('Unknow type', type);
+      break;
+  }
+}
+export async function deprecateOrDeleteUpdate(updateId) {
+  if (!updateId)
+    updateId = await selectUpdate();
+
+  const { currentUpdateInfo, currentVersionInfo } = await viewUpdate(updateId);
+
+  const deprecate = (await select({
+    choices: [
+      {
+        name: 'Deprecate',
+        value: 0,
+      },
+      {
+        name: 'Delete',
+        value: 1,
+      },
+    ],
+    message: '删除或弃用?',
+  })) === 0;
+
+  if (deprecate && currentUpdateInfo.type !== 1) {
+    consola.warn('只有Web更新允许弃用');
+    return;
+  }
+  if (currentUpdateInfo.status === 0) {
+    consola.warn(`当前状态 ${stateConstant[currentUpdateInfo.status]} 无法弃用`);
+    return;
+  }
+
+  if (deprecate) {
+    if (!await confirm({ message: `确定弃用当前版本 ${currentUpdateInfo.versionCode} ?弃用会删除存储文件以及备份。此操作无法恢复!`, default: false }))
+      return;
+  } else {
+    if (!await confirm({ message: `确定删除当前版本 ${currentUpdateInfo.versionCode} ?此操作无法恢复!`, default: false }))
+      return;
+  }
+
+  try {
+    await axiosInstance.post(`/update/${deprecate ? 'deprecate' : 'delete'}`, { updateId });
+    consola.success(`${deprecate ? '弃用' : '删除'} 成功`);
+  } catch (e) {
+    logError(`操作失败`, e);
+  }
+}
+export async function viewUpdate(updateId) {
+  
+  let currentUpdateInfo = null
+  let currentVersionInfo = null
+
+  try {
+    currentUpdateInfo = (await axiosInstance.get('/update/' + updateId)).data;
+  } catch (e) {
+    logError('加载更新信息失败', updateId);
+  }
+  try {
+    currentVersionInfo = (await axiosInstance.get('/version/' + currentUpdateInfo.versionId)).data;
+  } catch (e) {
+    logError('加载版本信息失败', currentUpdateInfo.versionId);
+  }
+
+  const table = new Table({
+    head: ['key', 'data'], 
+    colWidths: [20, 40]
+  });
+  table.push(
+    [ 'ID', currentUpdateInfo.id ],
+    [ '所属应用', currentUpdateInfo.name ],
+    [ '更新信息', currentUpdateInfo.updateInfo ],
+    [ '版本号', currentUpdateInfo.versionCode ],
+    [ '创建时间', new Date(currentUpdateInfo.createAt).toString() ],
+    [ '类型', typeConstant[currentUpdateInfo.type] ],
+    [ '状态', stateConstant[currentUpdateInfo.status] ],
+    [ '强制更新', currentUpdateInfo.force ],
+    [ '公共访问路径', currentUpdateInfo.publicUrl ],
+    [ '存储类型', storageTypeConstant[currentUpdateInfo.storageType] ],
+    [ '存储路径', currentUpdateInfo.storagePath ],
+  );
+
+  console.log(table.toString());
+
+  if (currentUpdateInfo.activeWebVersionName) {
+    table.push(
+      [ '使用中的Web版本', currentUpdateInfo.activeWebVersionName ],
+    );
+  }
+  if (currentUpdateInfo.activeAppVersionName) {
+    table.push(
+      [ '使用中的App版本', currentUpdateInfo.activeAppVersionName ],
+    );
+  }
+
+  return {
+    currentUpdateInfo,
+    currentVersionInfo,
+  }
+}
+export async function getUpdate(action, type, all, options) {
+  const typeNotANumber = isNaN(new Number(type)); 
+
+  if (action === 'view' && (!type || typeNotANumber)) {
+
+    let hasSerch = false;
+    const search = {};
+    const sort = {
+      field: "createAt",
+      order: "descend"
+    }
+    if (typeNotANumber && type !== 'all') {
+      hasSerch = true;
+      search.version = type;
+    }
+
+    const data = await axiosInstance.get('/update/list?full=true' + (hasSerch ? ('&search=' + JSON.stringify(search)) : '') + '&sort=' + JSON.stringify(sort));
+    const table = new Table({
+      head: ['ID', '版本', '版本号', '类型', '状态'], 
+      colWidths: [10, 10, 20, 10, 15 ]
+    });
+
+    if (type !== 'all' && all !== 'all' && data.data.length > 10) {
+      data.data = data.data.slice(0, 10);
+      consola.info('filter!', all);
+    }
+
+    data.data.forEach((d) => {
+      table.push([ 
+        d.id, 
+        (d.activeAppVersionName ? `${d.activeAppVersionName} (App)` : (
+          d.activeWebVersionName? `${d.activeWebVersionName} (Web)` : '无'
+        )),  
+        d.versionCode, typeConstant[d.type], stateConstant[d.status] 
+      ]);
+    })
+    
+    console.log(table.toString());
+  } else {
+    switch (action) {
+      case 'post': {
+        await postUpdate(type, options);
+        break;
+      }
+      case 'delete': {
+        await deprecateOrDeleteUpdate(type);
+        break;
+      }
+      case 'view': {
+        viewUpdate(type);
+        break;
+      }
+      default: 
+        logError('Unknow action', action);
+        break;
+    }
+  }
+}
+export async function testVersion(version) {
+  try {
+    const data = await axiosInstance.get('/update-get-info?version=' + version);
+    consola.info('版本信息', data.data);
+  } catch(e) {
+    logError('获取失败', e);
+  }
+}

+ 273 - 0
scripts/UpdateScript/auth.mjs

@@ -0,0 +1,273 @@
+/**
+ * 更新发布工具 - 认证与服务器连接模块
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import axios from 'axios';
+import md5 from 'md5';
+import { password, input } from '@inquirer/prompts';
+import { config } from './postConfig.mjs';
+import consola from 'consola';
+import { logError } from './utils.mjs';
+
+// 基础配置
+// ========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+export const constant = {
+  ServerUrl: config.server,
+  TokenSave: path.resolve(__dirname, './_token.json'),
+};
+
+let currentData = {
+  token: '',
+  refreshToken: '',
+  identifier: '',
+};
+
+function initIdentifier() {
+  if (!currentData.identifier)
+    currentData.identifier = `commandClient${Math.floor(Math.random() * 1000)}`;
+}
+
+function getErrorMessage(e) {
+  return e instanceof Error ? e.message : (typeof e === 'object' ? e : '' + e);
+}
+
+async function saveCurrentData() {
+  await writeFile(constant.TokenSave, JSON.stringify(currentData));
+}
+
+// Axios 实例
+// ========================================
+
+export const axiosInstance = axios.create({
+  baseURL: constant.ServerUrl,
+  timeoutErrorMessage: '请求超时,请检查网络连接',
+  responseType: 'json',
+  withCredentials: false,
+  validateStatus: () => true,
+});
+
+axiosInstance.interceptors.request.use((value) => {
+  value.headers['authorization'] = JSON.stringify({
+    auth: currentData?.token?.authName,
+    validity: currentData?.token?.authKey,
+    nonce: 'aaaaaaaaaa',
+    identifier: currentData.identifier,
+    key: 'abc123',
+  });
+  value.url =
+    value.url +
+    (value.url.includes('?') ? '&' : '?') +
+    `identifier=${currentData.identifier}`;
+  return value;
+});
+
+let refreshPromise = null;
+
+async function tryRefreshToken() {
+  if (!currentData.refreshToken) return false;
+  if (refreshPromise) return refreshPromise;
+  refreshPromise = (async () => {
+    try {
+      const res = await axios.post(
+        `${constant.ServerUrl}auth/refresh?identifier=${currentData.identifier}`,
+        { refreshToken: currentData.refreshToken },
+        { responseType: 'json', validateStatus: () => true }
+      );
+      if (res.data && res.data.success) {
+        currentData.token = {
+          authName: res.data.data.authName,
+          authKey: res.data.data.authKey,
+        };
+        if (res.data.data.refreshToken)
+          currentData.refreshToken = res.data.data.refreshToken;
+        await saveCurrentData();
+        return true;
+      } else
+        return false;
+    } catch {
+      // 刷新失败
+    }
+    return false;
+  })();
+  try {
+    return await refreshPromise;
+  } finally {
+    refreshPromise = null;
+  }
+}
+
+axiosInstance.interceptors.response.use((value) => {
+  if (value.data.success) 
+    return value.data;
+  const err = value.data;
+  err.__config = value.config;
+  return Promise.reject(err);
+}, (error) => {
+  return Promise.reject(error);
+});
+
+axiosInstance.interceptors.response.use(null, async (error) => {
+  const code = error?.code;
+  if (
+    (code === 40102 || code === 40103) &&
+    currentData.refreshToken &&
+    !error.__retried
+  ) {
+    const refreshed = await tryRefreshToken();
+    if (refreshed) {
+      const cfg = error.__config || error.config;
+      if (cfg) {
+        error.__retried = true;
+        return axiosInstance.request(cfg);
+      }
+    }
+  }
+  return Promise.reject(error);
+});
+
+// 认证初始化
+// ========================================
+
+/**
+ * 初始化认证模块,从本地加载 token,并检查登录状态
+ * @returns {Promise<void>}
+ */
+export async function initAuth() {
+  try {
+    const res = await readFile(constant.TokenSave);
+    const parsed = JSON.parse(res);
+    if (parsed && typeof parsed === 'object') {
+      currentData = { ...currentData, ...parsed };
+    }
+  } catch {
+    // 无 token 文件时使用默认值
+  }
+  initIdentifier();
+
+  if (currentData.token) {
+    try {
+      const res = await axiosInstance.get('/user/user-info');
+      if (res?.data) {
+        consola.info(`当前登录用户: ${res.data.name || res.data.user} (ID: ${res.data.id})`);
+        return;
+      }
+    } catch {
+      // 登录已过期,尝试刷新 token
+    }
+    if (currentData.refreshToken) {
+      consola.info('尝试刷新 token');
+      try {
+        const res = await tryRefreshToken();
+        if (res) {
+          consola.success('刷新登录成功');
+          return;
+        }
+      } catch (e) {
+        logError('刷新 token 失败', e);
+      }
+    }
+  }
+
+  consola.warn('未登录,进入登录界面');
+  await interactiveLogin();
+}
+
+/**
+ * 获取当前认证数据(只读)
+ */
+export function getCurrentData() {
+  return { ...currentData };
+}
+
+// 登录相关
+// ========================================
+
+/**
+ * 执行登录请求并保存 token
+ * @param {string} user 用户名
+ * @param {string} pass 密码
+ */
+async function doLogin(user, pass) {
+  const res = await axiosInstance.post('/auth?rember=true', {
+    method: 'key',
+    key: `${user}@${md5(pass)}`,
+  });
+  currentData.token = {
+    authName: res.data.authName,
+    authKey: res.data.authKey,
+  };
+  if (res.data.refreshToken)
+    currentData.refreshToken = res.data.refreshToken;
+  await saveCurrentData();
+}
+
+/**
+ * 交互式登录,循环直至登录成功或用户取消
+ */
+async function interactiveLogin() {
+  while (true) {
+    try {
+      const user = await input({ message: '输入用户名' });
+      const pass = await password({ message: '输入密码' });
+      await doLogin(user, pass);
+      consola.success('登录成功');
+      return;
+    } catch (e) {
+      if (e?.name === 'ExitPromptError' || e?.name === 'CancelPromptError')
+        return;
+      logError('登录失败', getErrorMessage(e));
+    }
+  }
+}
+
+/**
+ * 检查登录状态
+ */
+export async function checkLogged() {
+  try {
+    await axiosInstance.get('/auth');
+    consola.success('已登录');
+  } catch (e) {
+    logError('获取状态失败:', getErrorMessage(e));
+  }
+}
+
+/**
+ * 登录
+ * @param {string} user 用户名
+ */
+export async function login(user) {
+  try {
+    const username = user || await input({ message: '输入用户名' });
+    const pass = await password({ message: '输入密码' });
+    await doLogin(username, pass);
+    consola.success('登录成功');
+  } catch (e) {
+    logError('登录失败', getErrorMessage(e));
+  }
+}
+
+/**
+ * 退出登录
+ */
+export async function logout() {
+  currentData.token = '';
+  currentData.refreshToken = '';
+  await saveCurrentData();
+  try {
+    await axiosInstance.delete('/auth');
+    consola.success('退出登录成功');
+  } catch (e) {
+    logError('退出登录失败', getErrorMessage(e));
+  }
+}

+ 22 - 0
scripts/UpdateScript/deprecate.html

@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>提示</title>
+    <style>.global-error{position:fixed;left:0;right:0;bottom:100px;top:0;display:flex;flex-direction:column;justify-content:center;align-items:center;background-color:#fff;-webkit-app-region:drag}.global-error span{margin-top:20px;font-size:16px}button{margin-top:20px;padding:0;display:inline-block;-webkit-app-region:no-drag;height:40px;width:120px;appearance:none;outline:none;border:none;background-color:#0083da;color:#fff;cursor:pointer}button:hover{background-color:#005c99}</style>
+  </head>
+  <body>
+    <script>
+      function relaunch() {
+        if (typeof api.relaunch == 'function') 
+          api.relaunch();
+        else
+          location.reload();
+      }
+    </script>
+    <div class="global-error">
+      <span>系统已更新,请重启</span>
+      <button id="load-confirm-button" onclick="relaunch()">确定</button>
+    </div>
+</html>

+ 63 - 0
scripts/UpdateScript/index.mjs

@@ -0,0 +1,63 @@
+/**
+ * 更新发布工具
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { program } from 'commander';
+import { login, checkLogged, logout, initAuth } from './auth.mjs';
+import { testVersion, getVersion, getUpdate } from './app.mjs';
+import { logError } from './utils.mjs';
+
+//程序入口
+//============================================
+
+program
+  .command('login <user>')
+  .description('登录')
+  .action(login);
+program
+  .command('logstate')
+  .description('检查登录状态')
+  .action(checkLogged);
+program
+  .command('logout')
+  .description('退出登录')
+  .action(logout);
+
+program
+  .command('test <version>')
+  .description('测试更新入口')
+  .action(testVersion);
+
+program
+  .command('version <action> [type]')
+  .description('查看版本信息/发布版本/删除版本/设置版本, action 可选 view/new/delete/set-web-update/set-app-update/set-next-app-update')
+  .action(getVersion);
+program
+  .command('update <action> [type] [all]')
+  .description('查看更新信息/发布更新, action 可选 view/post, view type 可选 id/all; post type 可选 web/app')
+  .option('--skip', '跳过构建')
+  .option('--ndelete', '不删除构建文件')
+  .action(getUpdate);
+
+function start() {
+  program.parse(process.argv);
+}
+
+initAuth().then(start);
+
+process.on('unhandledRejection', (reason) => {
+  let reasonStr = '';
+  if (typeof reason === 'string') {
+    reasonStr = reason;
+  } else if (typeof reason === 'object') {
+    if (reason.message) {
+      reasonStr = reason.message;
+    }
+  } else {
+    reasonStr = reason.toString();
+  }
+  logError('UnhandledRejection:', reasonStr)
+})
+

+ 75 - 0
scripts/UpdateScript/postConfig.mjs

@@ -0,0 +1,75 @@
+import { readFileSync, existsSync } from 'node:fs';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pad } from './utils.mjs';
+import path from 'node:path';
+import JSON5 from 'json5';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const localConfigPath = path.resolve(__dirname, './_localConfig.json');
+const localConfig = existsSync(localConfigPath) ? JSON.parse(readFileSync(localConfigPath, 'utf8')) : {};
+
+//提交更新配置
+//========================================
+
+export const config = {
+  server: localConfig.updateServer || 'https://update-server1.imengyu.top/',
+  submitKey: '',
+  appId: 2,
+  uploadWebConfig: {
+    storageAction: 'override',
+    storageProps: {
+      overrideMode: 'overrideAll',
+      overrideFiles: [ ],
+      newFolderNameGenerateType: 'hash',
+    },
+    deprecateConfig: {
+      indexFile: 'index.html',
+      updateHtml: readFileSync(path.resolve(__dirname, './deprecate.html'), 'utf8')
+    },
+  },
+  buildWebCommand: 'npm run nuxt-build', //构建命令
+  buildWebOutDir: '../../.output', //构建输出目录。相对于当前文件目录
+  buildWebOptions: {
+    skipFiles: [
+      '/server/node_modules',
+    ],
+  },
+  buildWebOutVersionPath: '', //版本号输出目录,输出版本号至文件以供项目使用。相对于当前文件目录
+  getCustomConfig: async (param, isApp, versionName) => {
+    return {}//额外的自定义配置
+  },
+  /**
+   * 自定义生成Web版本号的方法。
+   * @param {Date} now 当前日期
+   * @param {Number} lastTodaySubVersion 今天上传的之前版本数量
+   * @returns 
+   */
+  buildWebVersionGenerateCommand: async (now, lastTodaySubVersion) => {
+    //生成Web版本号
+    const version = `${now.getFullYear().toString().substring(2)}${pad(now.getMonth() + 1, 2)}${pad(now.getDate(), 2)}.${pad(lastTodaySubVersion, 2)}`;
+    return version;
+  },
+  buildAppCallback: async (param, versionCode, versionName, lastTodaySubVersion) => {
+    //构建App
+    throw new Error('未实现buildAppCallback方法');
+  }, 
+  buildAppGetUploadFile: async (param, versionCode, versionName) => {
+    //获取上传文件路径
+    throw new Error('未实现buildAppGetUploadFile方法');
+  }, 
+  buildAppGetOSSFileName: async (param, versionCode, versionName) => {
+    //生成OSS保存路径
+    throw new Error('buildAppGetOSSFileName');
+  },//构建命令
+  buildAppOutDir: './dist', //构建输出目录。相对于当前文件目录
+  buildAppGetVersion: async (versionName) => {
+    //获取版本号
+    throw new Error('未实现buildAppGetVersion方法');
+  },
+} 

+ 424 - 0
scripts/UpdateScript/postUpdate.mjs

@@ -0,0 +1,424 @@
+/**
+ * 更新发布工具
+ * 
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { confirm, select, input } from '@inquirer/prompts';
+import { writeFile, readFile, access, unlink, stat, constants } from 'node:fs/promises';
+import path from 'node:path';
+import OSS from 'ali-oss';
+import cliProgress from 'cli-progress';
+import { selectVersion } from './app.mjs';
+import { config } from './postConfig.mjs';
+import { readFileRange, compressZip, execAsync, logError } from './utils.mjs';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import consola from 'consola';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const UPLOAD_APP_TYPE_LOCAL = 1;
+const UPLOAD_APP_TYPE_ALI_OSS = 2;
+
+async function getConfig() {
+  let postConfig = {
+    lastVersion: '',
+    lastUpdateInfo: '',
+    lastSubmitDay: '',
+    lastTodaySubVersion: 0,
+  };
+  try {
+    postConfig = JSON.parse(await readFile(path.resolve(__dirname, './_config.json')));
+  } catch {
+    //
+  }
+  if (postConfig.lastSubmitDay != new Date().getDate()) 
+    postConfig.lastTodaySubVersion = 0;
+  return postConfig;
+}
+async function saveConfig(postConfig) {
+  postConfig.lastSubmitDay = new Date().getDate();
+  await writeFile(path.resolve(__dirname, './_config.json'), JSON.stringify(postConfig));
+}
+
+async function getUpdateInfo(postConfig) {
+  let lastUseGit = false;
+  let updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo });
+  if (updateInfo === 'git') {
+    consola.info('开始获取git提交信息');
+    lastUseGit = true;
+    updateInfo = await execAsync('git log -1 --pretty=format:"%h %s"');
+    consola.info('使用git提交信息 "' + updateInfo + '" 作为更新信息');
+  }
+  return { updateInfo, lastUseGit };
+}
+export async function uploadMulitPartLarge(axiosInstance, fileInfo, filePath) {
+
+  consola.start('开始分片上传');
+
+  const multuploadInfo = (await axiosInstance.post('/update/update-large-token', {
+    fileSize: fileInfo.size,
+    fileName: path.basename(filePath)
+  })).data;
+  const chunkSize = multuploadInfo.splitPartSize;
+
+  const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
+  bar1.start(100, 0);
+
+  for (let i = 0; i < multuploadInfo.allChunks; i++) {
+    const start = i * chunkSize;
+    const len = Math.min(start + chunkSize, fileInfo.size) - start;
+    const uploadZipContent = await readFileRange(filePath, start, len);
+    const subFormData = new FormData();
+    subFormData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
+    subFormData.append("key", multuploadInfo.key);
+    subFormData.append("info", JSON.stringify({}));
+    
+    (await axiosInstance.post('/update/update-large', subFormData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })).data;
+    
+    bar1.update(Math.floor(i / multuploadInfo.allChunks * 100));
+  }
+
+  bar1.update(100);
+  bar1.stop();
+
+  return multuploadInfo.key;
+}
+async function aliOSSMultipartUpload(client, fileName, uploadFile, progressCallback) {
+  
+  const fileInfo = await stat(uploadFile); // 获取文件信息
+  const partSize = 10 * 1024 * 1024; // 分片大小,这里使用10MB
+  const partCount = Math.ceil(fileInfo.size / partSize); // 计算总分片数
+  
+  consola.start(`开始分片上传到阿里OSS,文件大小: ${(fileInfo.size / 1024 / 1024).toFixed(2)}MB,分片数: ${partCount}`);
+  
+  try {
+    // 初始化分片上传
+    const multipartUpload = await client.initMultipartUpload(fileName);
+    const uploadId = multipartUpload.uploadId;
+    
+    // 上传分片
+    const parts = [];
+    for (let i = 0; i < partCount; i++) {
+      const start = i * partSize;
+      const len = Math.min(partSize, fileInfo.size - start);
+      
+      // 读取文件分片
+      const fileData = await readFileRange(uploadFile, start, len);
+      
+      // 上传分片
+      const partResult = await client.uploadPart(fileName, fileData, uploadId, i + 1);
+      parts.push({
+        ETag: partResult.res.headers.etag,
+        PartNumber: i + 1
+      });
+      
+      // 计算并回调进度
+      const progress = (i + 1) / partCount;
+      if (progressCallback) {
+        progressCallback(progress);
+      }
+      
+      consola.info(`已上传分片 ${i + 1}/${partCount} (${(progress * 100).toFixed(2)}%)`);
+    }
+    
+    // 完成分片上传
+    await client.completeMultipartUpload(fileName, uploadId, parts);
+    
+    consola.success('阿里OSS分片上传完成');
+    
+    // 获取上传后的文件URL
+    const url = await client.generateObjectUrl(fileName);
+    return url;
+  } catch (error) {
+    logError('阿里OSS分片上传失败:', error);
+    throw error;
+  }
+}
+
+//App更新与提交
+//========================================
+
+export async function postAppUpdate(axiosInstance, param) {
+  const postConfig = await getConfig();
+  const { versionId, versionName } = await selectVersion(postConfig.lastVersion);
+  const { updateInfo, lastUseGit } = await getUpdateInfo(postConfig);
+
+  const serverConfig = await axiosInstance.post('/update/update-post', { 
+    config: { 
+      type: 2, 
+      test: true, 
+      versionId, 
+      uploadAppConfig: {}, 
+      submitKey: config.submitKey 
+    } 
+  });
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = lastUseGit ? 'git': updateInfo;
+  postConfig.lastTodaySubVersion++;
+
+  const updateSource = (await select({
+    choices: [
+      {
+        name: '重新构建',
+        value: 'rebuild',
+      },
+      {
+        name: '已上传的阿里OSS文件路径',
+        value: 'uploaded-alioss',
+      },
+    ],
+    message: '选择上传来源',
+    default: 'rebuild',
+  }));
+
+  const force = await confirm({ message: '强制更新?', default: false });
+  const updateNext = await confirm({ message: '作为下个版本?', default: false });
+  const versionCode = await config.buildAppGetVersion(versionName);
+
+  await saveConfig(postConfig);
+
+  if (updateSource === 'rebuild') 
+    await config.buildAppCallback(param, versionCode, versionName, postConfig.lastTodaySubVersion);
+  else if (updateSource === 'uploaded-alioss') {
+    const fileName = await input({ message: '输入已上传的阿里OSS文件路径' });
+    try {
+      const result = (await axiosInstance.post('/update/update-post', {
+        type: 2,
+        ossConfig: {
+          ossPath: fileName,
+          ossPublic: '',
+        },
+        uploadAppConfig: {
+          updateAsNext: updateNext,
+        },
+        versionId,
+        updateInfo,
+        versionCode: versionCode
+      })).data;
+      consola.success('上传成功');
+      consola.info('新更新ID: ' + result.updateId);
+    } catch (e) {
+      logError('上传失败', e);
+    }
+    return;
+  }
+  else {
+    logError('错误的选择');
+    return;
+  }
+
+  const uploadFile = await config.buildAppGetUploadFile(param, versionCode, versionName);
+  const fileName = `${await config.buildAppGetOSSFileName(param, versionCode, path.basename(uploadFile), uploadFile)}`;
+  try {
+    await access(uploadFile, constants.R_OK)
+  } catch {
+    logError(`Failed to access ${uploadFile}, did you created it?`);
+    return;
+  }
+
+  consola.start('开始上传');
+
+  //小于8mb则小文件上传,否则使用阿里OSS上传
+  const fileInfo = await stat(uploadFile);
+  if (fileInfo.size < 8 * 1024 * 1024) {
+    const appData = (await readFile(uploadFile));
+
+    const formData = new FormData();
+    formData.append("file", new Blob([ appData ], { type : 'application/zip' }));
+    formData.append("config", JSON.stringify({
+      type: 2,
+      versionId,
+      versionCode,
+      updateInfo,
+      updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+      uploadAppConfig: { updateAsNext: updateNext },
+      force,
+      fileName,
+    }));
+
+    try {
+      const result = (await axiosInstance.post('/update/update-post', formData, {
+        headers: { 'Content-Type': 'multipart/form-data' }
+      })).data;
+      consola.success('上传成功');
+      consola.info('新更新ID: ' + result.updateId);
+    } catch (e) {
+      logError('上传失败', e);
+    }
+  } else {
+
+    let ossConfig;
+    let multuploadedKey;
+
+    if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_LOCAL) {
+      //本地大文件上传
+      multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, uploadFile);
+    } else if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_ALI_OSS) {
+      //阿里OSS上传
+      //请求STS进行临时授权
+      const stsToken = (await axiosInstance.post('/update/update-ali-oss-sts')).data;
+      const client = new OSS({
+        region: stsToken.Region,
+        accessKeyId: stsToken.AccessKeyId,
+        accessKeySecret: stsToken.AccessKeySecret,
+        stsToken: stsToken.SecurityToken,
+        bucket: stsToken.Bucket,
+        refreshSTSToken: async () => {
+          const refreshToken = (await axiosInstance.get("/update/update-ali-oss-sts")).data;
+          return {
+            accessKeyId: refreshToken.AccessKeyId,
+            accessKeySecret: refreshToken.AccessKeySecret,
+            stsToken: refreshToken.SecurityToken,
+          };
+        },
+      });
+      
+      //小于96mb则直接上传,否则分片上传
+
+      consola.start('Start upload to ali oss');
+
+      let returnUrl = '';
+      if (fileInfo.size < 96 * 1024 * 1024) {
+        const result = await client.put(fileName, uploadFile);
+        returnUrl = result.url;
+      } else {
+        const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
+        bar1.start(100, 0);
+        returnUrl = await aliOSSMultipartUpload(client, fileName, uploadFile, (p) => {
+          bar1.update(p * 100);
+        });
+        bar1.update(100);
+        bar1.stop();
+      }
+
+      ossConfig = {
+        ossPath: fileName,
+        ossPublic: returnUrl,
+      };
+
+      consola.success('Upload to ali oss done');
+    } else {
+      logError('错误的 uploadAppType 配置 ' + serverConfig.uploadAppType);
+    }
+  
+    try {
+      const result = (await axiosInstance.post('/update/update-post', {
+        config: {
+          type: 2,
+          ossConfig,
+          multuploadedKey,
+          uploadAppConfig: {
+            updateAsNext: updateNext,
+          },
+          updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+          versionId,
+          updateInfo,
+          versionCode: versionCode,
+          fileName
+        },
+      })).data;
+      consola.success('上传成功');
+      consola.info('新更新ID: ' + result.updateId);
+    } catch (e) {
+      logError('上传失败', e);
+    }
+  }
+}
+
+//Web更新与提交
+//========================================
+
+export async function postWebUpdate(axiosInstance, param) {
+  const skipBuild = param.skip
+  const noDelete = param.ndelete;
+  
+  const postConfig = await getConfig();
+  const { versionId, versionName } = await selectVersion(postConfig.lastVersion);
+  const { updateInfo, lastUseGit } = await getUpdateInfo(postConfig);
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = lastUseGit ? 'git': updateInfo;
+  postConfig.lastTodaySubVersion++;
+
+  await axiosInstance.post('/update/update-post', { config: { type: 1, test: true, versionId, uploadWebConfig: config.uploadWebConfig, submitKey: config.submitKey } });
+
+  const now = new Date();
+  const versionCode = await config.buildWebVersionGenerateCommand(now, postConfig.lastTodaySubVersion);
+
+  if (config.buildWebOutVersionPath)
+    await writeFile(path.resolve(__dirname, config.buildWebOutVersionPath), versionCode);
+
+  await saveConfig(postConfig);
+  
+  if (!skipBuild && config.buildWebCommand) {
+    consola.start('正在执行构建...');
+    await execAsync(config.buildWebCommand);
+    consola.success('构建完成');
+  }
+
+  const distDir = path.resolve(__dirname, config.buildWebOutDir);
+
+  try {
+    await access(distDir, constants.R_OK)
+  } catch {
+    logError(`Failed to access ${distDir}`);
+    return;
+  }
+
+  const outputPath = __dirname + '/upload.zip';
+  const skipFiles = config?.buildWebOptions?.skipFiles ?? [];
+
+  if (!skipBuild) {
+    consola.start('开始压缩zip...');
+    await compressZip(distDir, outputPath, skipFiles);
+  }
+
+  consola.start('开始上传zip');
+
+  let success = false;
+
+  //小于8mb则小文件上传,否则分片上传
+  const fileInfo = await stat(outputPath);
+  const formData = new FormData();
+  const submitConfig = {
+    type: 1,
+    versionId,
+    updateInfo,
+    versionCode,
+    uploadWebConfig: config.uploadWebConfig,
+    updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+  }
+
+  if (fileInfo.size < 8 * 1024 * 1024) {
+    const uploadZipContent = await readFile(outputPath);
+    formData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
+  } else {
+    submitConfig.multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, outputPath);
+  }
+
+  try {
+    formData.append("config", JSON.stringify(submitConfig));
+    const result = (await axiosInstance.post('/update/update-post', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })).data;
+    consola.success('上传成功');
+    consola.info('新更新ID: ' + result.updateId);
+    success = true;
+  } catch (e) {
+    logError('上传失败', e);
+  }
+  if (!success || noDelete) 
+    return;
+    
+  consola.info('删除zip');
+  await unlink(outputPath);
+}

+ 141 - 0
scripts/UpdateScript/utils.mjs

@@ -0,0 +1,141 @@
+import fs from 'fs';
+import { ZipArchive } from 'archiver';
+import { exec } from 'child_process';
+import { readdir, stat } from 'fs/promises';
+import consola from 'consola';
+
+export function readFileRange(file, start, length) {
+  return new Promise((resolve, reject) => {
+    fs.open(file, 'r', (err, fd) => {
+      if (err) {
+        reject('Error opening file:', er);
+        return;
+      }
+      const buffer = Buffer.alloc(length);
+      fs.read(fd, buffer, 0, length, start, (err, bytesRead, buffer) => {
+        if (err) {
+          reject('Error reading file:', err)
+          return;
+        }
+        fs.close(fd, (err) => {
+          if (err) {
+            reject('Error closing file:', err)
+            return ;
+          }
+          resolve(buffer);
+        });
+      });
+    });
+  })
+  
+}
+export function pad(num, n) {
+  var strNum = num.toString();
+  var len = strNum.length;
+  while (len < n) {
+    strNum = "0" + strNum;
+    len++;
+  }
+  return strNum;
+}
+
+/**
+ * 异步执行命令
+ * @param {string} command 命令
+ * @returns {Promise<string>} 命令执行结果
+ */
+export function execAsync(command) {
+  return new Promise((resolve, reject) => {
+    consola.info('[CMD] ' + command);
+    exec(command, (error, stdout, stderr) => {
+      if (error) {
+        reject(error);
+        return;
+      }
+      resolve(stdout);
+    });
+  })
+}
+
+/**
+ * 记录错误信息
+ * @param {string} message 错误信息
+ * @param {*} e 错误对象
+ */
+export function logError(message, e) {
+  consola.error(message, e || '');
+}
+
+/**
+ * 获取错误信息
+ * @param {*} e 错误对象
+ * @returns {string} 错误信息
+ */
+export function getErrorMessage(e) {
+  if (e instanceof Error) 
+    return e.message;
+  if (typeof e === 'object' && e.message)
+    return e.message;
+  return '' + e;
+}
+
+/**
+ * 压缩zip
+ * @param {string} dir 压缩目录
+ * @param {string} targetFilePath 压缩文件路径
+ * @param {string[]} skipFiles 压缩忽略文件 支持按开头匹配,和文件类型通配符匹配,如:'*.js', '*.css', '*.html'
+ * @returns {Promise<void>}
+ */
+export async function compressZip(dir, targetFilePath, skipFiles) {
+  const output = fs.createWriteStream(targetFilePath);
+  const archive = new ZipArchive({
+    zlib: { level: 9 } // Sets the compression level.
+  });
+  archive.pipe(output);
+
+  function checkPathSkip(pathString) {
+    if (!pathString)
+      return true;
+    if (pathString.startsWith('.') || pathString.startsWith('/.'))
+      return true;
+    if (!skipFiles || skipFiles.length === 0)
+      return;
+    return skipFiles.find((item) => (
+      pathString.startsWith(item) || pathString.startsWith(`/${item}`)
+      || (item.includes('*.') && pathString.endsWith(item.split('*')[1]))
+    ));
+  }
+
+  async function loopDir(path, subPrefix) {
+    const files = await readdir(path);
+    for (const file of files) {
+      const subPath = subPrefix + '/' + file;
+      if (checkPathSkip(subPath))
+        continue;
+      consola.info('File: ' + subPath);
+      const filestat = await stat(dir + subPath);
+      if (filestat.isDirectory()) {
+        await loopDir(dir + subPath, subPath);
+      } else {
+        archive.file(dir + subPath, { name: subPath });
+      }
+    }
+  }
+  await loopDir(dir, '')
+
+  consola.start('等待压缩zip...');
+
+  const waitArchive = new Promise((resolve, reject) => {
+    output.on('close', function() {
+      consola.info(archive.pointer() + ' total bytes');
+      consola.success('archiver has been finalized and the output file descriptor has closed.');
+      resolve();
+    });
+    archive.on('error', function(err) {
+      reject(err);
+    });
+  })
+
+  await archive.finalize();
+  await waitArchive;
+}

+ 48 - 0
server/config/db.ts

@@ -0,0 +1,48 @@
+import mysql from 'mysql2/promise';
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+// 尝试从config.json文件读取配置
+let jsonConfig: any = {};
+
+try {
+  // 从项目根目录读取config.json文件
+  const configPath = join(process.cwd(), 'config.json');
+  const configContent = readFileSync(configPath, 'utf-8');
+  jsonConfig = JSON.parse(configContent);
+} catch (error) {
+  console.log('⚠️ 无法读取config.json,将使用环境变量或默认值');
+}
+
+// 数据库配置
+const config = {
+  host: process.env.DB_HOST || jsonConfig.db?.host || 'localhost',
+  port: Number(process.env.DB_PORT) || jsonConfig.db?.port || 3306,
+  user: process.env.DB_USER || jsonConfig.db?.user || 'root',
+  password: process.env.DB_PASSWORD || jsonConfig.db?.password || '',
+  database: process.env.DB_NAME || jsonConfig.db?.database || 'protection_center',
+  waitForConnections: true,
+  connectionLimit: 10,
+  queueLimit: 0
+};
+
+// 创建连接池
+const pool = mysql.createPool(config);
+
+// 测试数据库连接
+async function testConnection() {
+  try {
+    const connection = await pool.getConnection();
+    console.log('✅ 数据库连接成功');
+    connection.release();
+  } catch (error) {
+    console.error('❌ 数据库连接失败:', error);
+    throw error;
+  }
+}
+
+export {
+  pool,
+  testConnection,
+  config
+};

+ 130 - 0
server/db/CommonModel.ts

@@ -0,0 +1,130 @@
+import { type ConvertItemOptions, type ConvertTable, DataConverter } from "@imengyu/js-request-transform";
+
+interface CommonModelConfig {
+  idKey?: string;
+  convertTable?: ConvertTable;
+}
+
+export interface NewCommonModel<T extends CommonModel> {
+  new(): T;
+}
+export type ValidCommonModel<T> = T extends CommonModel ? NewCommonModel<T> : undefined;
+
+export class CommonModel {
+  public _tableName: string;
+  public _config: CommonModelConfig;
+
+  public constructor(tableName: string, config: CommonModelConfig) { 
+    this._tableName = tableName;
+    this._config = config;
+    this._config.convertTable = {
+      ...this._config.convertTable,
+      createdAt: { clientSide: 'date', serverSide: 'string' },
+      updatedAt: { clientSide: 'date', serverSide: 'string' },
+      deletedAt: { clientSide: 'date', serverSide: 'string' },
+    }
+  }
+
+  createdAt = new Date();
+  updatedAt : Date | null = null;
+  deletedAt : Date | null = null;
+
+  public setValue(key: keyof this, value: any) {
+    (this as any)[key] = value;
+    return this;
+  }
+  public setValues(at: {
+    [key in keyof this]?: any;
+  }) {
+    for (const key in at)
+      this.setValue(key, at[key]);
+    return this;
+  }
+
+  public fromServerSide(data: any) {
+    const self = this as Record<string, any>;
+    const options : ConvertItemOptions = {
+      direction: 'client',
+      defaultDateFormat: 'YYYY-MM-DD HH:mm:ss',
+      policy: 'default',
+    }
+    for (const key in data) {
+      if (key.startsWith('_'))
+        continue;
+      const convert = this._config.convertTable?.[key];
+      if (convert) {
+        let value = data[key];
+        if (convert instanceof Array)
+          for (const convertItem of convert) 
+            value = DataConverter.convertDataItem(value, key, convertItem, options, `${key}`, this._tableName);
+        else
+          value = DataConverter.convertDataItem(value, key, convert, options, `${key}`, this._tableName)
+        self[key] = value;
+      } else {
+        self[key] = data[key];
+      }
+    }
+    return this;
+  }
+  public toServerSide() {
+    const self = this as Record<string, any>;
+    const options : ConvertItemOptions = {
+      direction: 'server',
+      defaultDateFormat: 'YYYY-MM-DD HH:mm:ss',
+      policy: 'default',
+    }
+    const result : Record<string, any> = {};
+    for (const key in self) {
+      if (key.startsWith('_'))
+        continue;
+      const convert = this._config.convertTable?.[key];
+      if (convert) {
+        let value = self[key];
+        if (convert instanceof Array)
+          for (const convertItem of convert)
+            value = DataConverter.convertDataItem(value, key, convertItem, options, `${key}`, this._tableName);
+        else
+          value = DataConverter.convertDataItem(value, key, convert, options, `${key}`, this._tableName);
+        result[key] = value;
+      } else {
+        result[key] = self[key];
+      }
+    }
+    return result;
+  }
+}
+
+/**
+ * 通用分页返回
+ */
+export class CommonPageResult<T> implements ICommonPageResult<T> {
+  public items : T[];
+  public pageIndex : number;
+  public pageSize : number;
+  public allCount : number;
+  public allPage : number;
+  public empty : boolean;
+
+  public constructor(model: ValidCommonModel<T>|undefined, data : any[], pageIndex : number, pageSize : number, allCount : number) {
+    this.items = data.map((item) => {
+      return model ? new model().fromServerSide(item) as T : item as T;
+    });
+    this.pageIndex = pageIndex;
+    this.pageSize = pageSize;
+    this.allCount = allCount;
+    this.allPage = Math.floor(allCount / pageSize) || 0;
+    this.empty = data.length == 0;
+  }
+}
+
+/**
+ * 通用分页返回结构
+ */
+export interface ICommonPageResult<T> {
+  items : T[];
+  pageIndex : number;
+  pageSize : number;
+  allCount : number;
+  allPage : number;
+  empty : boolean;
+}

+ 96 - 0
server/db/DB.ts

@@ -0,0 +1,96 @@
+import { CommonModel, NewCommonModel } from './CommonModel';
+import { solveSqlPlaceholders, splicingSQL, SqlStatcParams } from './DBUtils'
+import { query, queryAndReturnInsertId } from './Query';
+import { QueryGenerator } from './QueryGenerator';
+
+/**
+ * 数据库操作工具类 (SQLITE)
+ * 如何使用:
+ * DB.table('表名') 构造一个查询器
+ * DB.select/update/insert/delete 进行SQL查询,其中:
+ *      SQL中参数使用: {?} {参数索引} 来代表参数占位符,参数在args中传入
+ */
+export class DB {
+
+  /**
+   * 构造查询器
+   * @param name 当前表名
+   * @returns 返回构造查询器
+   */
+  static table<T extends CommonModel = any>(name : string|NewCommonModel<T>) {
+    return new QueryGenerator<T>(name);
+  }
+
+  /**
+   * 进行 select 查询
+   * @param sql SQL语句
+   * @param args 参数数组
+   * @param staticParams 静态参数
+   * @returns promise 成功则返回查询到的数组,错误则返回错误信息
+   */
+  static async select<T>(sql : string, args : unknown[], staticParams : SqlStatcParams = {}) {
+    let placeholders = solveSqlPlaceholders(sql);
+    let targetSql = splicingSQL(sql, placeholders, args, null, staticParams);
+    return await query(targetSql);
+  }
+  /**
+   * 执行 update 查询
+   * @param sql SQL语句
+   * @param args 参数数组
+   * @returns promise 成功则返回受影响的行数,错误则返回错误信息
+   */
+  static async update(sql : string, args : unknown[]) {
+    let placeholders = solveSqlPlaceholders(sql);
+    let targetSql = splicingSQL(sql, placeholders, args, null, {});
+    return await query(targetSql);
+  }
+  /**
+   * 执行 insert 查询
+   * @param sql SQL语句
+   * @param args 参数数组
+   * @returns promise 成功则返回新插入的ID,错误则返回错误信息
+   */
+  static async insert(sql : string, args : unknown[]) {
+    let placeholders = solveSqlPlaceholders(sql);
+    let targetSql = splicingSQL(sql, placeholders, args, null, {});
+    return await queryAndReturnInsertId(targetSql);
+  }
+  /**
+   * 执行 delete 查询
+   * @param sql SQL语句
+   * @param args 参数数组
+   * @returns promise 成功则返回受影响的行数,错误则返回错误信息
+   */
+  static async delete(sql : string, args : unknown[]) {
+    let placeholders = solveSqlPlaceholders(sql);
+    let targetSql = splicingSQL(sql, placeholders, args, null, {});
+    return await query(targetSql);
+  }
+
+  /**
+   * 手动开始事务
+   */
+  static beginTransaction() {
+    return this.actionSql('BEGIN');
+  }
+  /**
+   * 回滚事务
+   */
+  static rollBack() {
+    return this.actionSql('ROLLBACK');
+  }
+  /**
+   * 提交事务
+   */
+  static commit() {
+    return this.actionSql('COMMIT');
+  }
+  /**
+   * 异步执行sql
+   * @param sql 
+   * @returns 
+   */
+  static async actionSql(sql : string) {
+    return await query(sql);
+  }
+}

+ 109 - 0
server/db/DBUtils.ts

@@ -0,0 +1,109 @@
+import { StringUtils } from "@imengyu/imengyu-utils";
+import { DataModel } from "@imengyu/js-request-transform";
+
+export interface SqlPlaceholder {
+  index: number,
+  length: number,
+  name: string
+}
+
+/**
+ * SQL静态参数的结构
+ */
+export interface SqlStatcParams {
+  [index: string]: any;
+}
+
+const sqlPlaceholderCache = new Map<string, SqlPlaceholder[]>();
+
+/**
+ * 分割sql中的参数占位符
+ * @param sql 原始sql
+ * @returns 
+ */
+export function solveSqlPlaceholders(sql : string) {
+  const hash = StringUtils.stringHashCode(sql);
+
+  let result : SqlPlaceholder[]|null = null;
+
+  if (sqlPlaceholderCache.has(hash)) 
+    result = sqlPlaceholderCache.get(hash)!;
+  if (result)
+    return result;
+
+  result = [];
+
+  let currentBracketsStart = -1;
+  let currentBracketsEnd = -1;
+  let chr = '';
+
+  for(let i = 0; i < sql.length; i++) {
+    chr = sql.charAt(i);
+    if(currentBracketsStart < 0) {
+      if(chr === '{') currentBracketsStart = i;
+    } else {
+      if(chr === '}') {
+        currentBracketsEnd = i;
+        if(currentBracketsEnd - currentBracketsStart > 0) {
+          result.push({
+            index: currentBracketsStart,
+            length: currentBracketsEnd - currentBracketsStart + 1,
+            name: sql.substring(currentBracketsStart + 1, currentBracketsEnd)
+          });
+        } else { 
+          console.warn('Bad sql placeholder, the brackets dose not contains a name , at ' + currentBracketsStart);
+        }
+        currentBracketsEnd = -1;
+        currentBracketsStart = -1;
+      }
+    }
+
+    if(i === sql.length - 1 && currentBracketsStart >= 0) {
+      console.warn('Bad sql placeholder, not found end brackets, at ' + currentBracketsStart);
+    }
+  }
+
+  sqlPlaceholderCache.set(hash, result);
+  return result;
+}
+/**
+ * 按占位符和参数拼接最终sql
+ * @param sql 原始sql
+ * @param placeholders 占位符数据
+ * @param args 传入参数
+ * @returns 
+ */
+export function splicingSQL(sql : string, placeholders : SqlPlaceholder[], args: unknown[], fn : Function|null, staticParams : SqlStatcParams) {
+  let result = '';
+  let startOffset = 0;
+  let funcArgNames = (fn ? (fn.toString()
+    .replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/)|(\s))/mg,'')
+    .match(/^function\s*[^\(]*\(\s*([^\)]*)\)/m)?.[1]
+    .split(/,/)) : null) || null;
+
+  for (let i = 0; i < args.length; i++) {
+    const item = args[i];
+    if (item instanceof DataModel) 
+      args[i] = item.toServerSide();
+  }
+
+  for(let i = 0; i < placeholders.length; i++) {
+    let p = placeholders[i];
+    let index = p.name === '?' ? i : parseInt(p.name);
+    let argval = null;
+    if(index >= 0) argval = args[index];
+    else {
+      let ii = funcArgNames ? funcArgNames.indexOf(p.name) : -1;
+      argval = ii >= 0 ? args[ii] : staticParams[p.name];
+    }
+
+    result = result.concat(sql.substring(startOffset, p.index), (argval !== null ? argval : ''));
+    startOffset = p.index + p.length;
+
+    if(i === placeholders.length - 1) {
+      result = result.concat(sql.substring(startOffset));
+    }
+  }
+
+  return result;
+}

+ 73 - 0
server/db/Query.ts

@@ -0,0 +1,73 @@
+import { QueryResult } from 'mysql2';
+import { pool } from '../config/db';
+
+/**
+ * 执行SELECT查询
+ * @param sql SQL查询语句
+ * @param params 查询参数
+ * @returns 查询结果
+ */
+export async function query<T extends QueryResult = any>(sql: string, params: any[] = []): Promise<T[]> {
+  const [rows] = await pool.execute<T>(sql, params);
+  return rows as T[];
+}
+
+export async function queryAndReturnInsertId(sql: string, params: any[] = []) : Promise<number> {
+  const [rows] = await pool.execute(sql, params);
+  const [insertIdRows] = await query('SELECT LAST_INSERT_ID() AS id');
+  return insertIdRows.id;
+}
+
+/**
+ * 执行INSERT查询
+ * @param sql SQL插入语句
+ * @param params 查询参数
+ * @returns 插入结果,包含insertId
+ */
+export async function insert(sql: string, params: any[] = []): Promise<{ insertId: number }> {
+  const [result] = await pool.execute(sql, params);
+  return result as { insertId: number };
+}
+
+/**
+ * 执行UPDATE查询
+ * @param sql SQL更新语句
+ * @param params 查询参数
+ * @returns 更新结果,包含affectedRows
+ */
+export async function update(sql: string, params: any[] = []): Promise<{ affectedRows: number }> {
+  const [result] = await pool.execute(sql, params);
+  return result as { affectedRows: number };
+}
+
+/**
+ * 执行DELETE查询
+ * @param sql SQL删除语句
+ * @param params 查询参数
+ * @returns 删除结果,包含affectedRows
+ */
+export async function remove(sql: string, params: any[] = []): Promise<{ affectedRows: number }> {
+  const [result] = await pool.execute(sql, params);
+  return result as { affectedRows: number };
+}
+
+/**
+ * 执行事务
+ * @param callback 事务回调函数
+ * @returns 事务执行结果
+ */
+export async function transaction<T>(callback: (connection: any) => Promise<T>): Promise<T> {
+  const connection = await pool.getConnection();
+  
+  try {
+    await connection.beginTransaction();
+    const result = await callback(connection);
+    await connection.commit();
+    return result;
+  } catch (error) {
+    await connection.rollback();
+    throw error;
+  } finally {
+    connection.release();
+  }
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1071 - 0
server/db/QueryGenerator.ts


+ 3 - 0
server/tsconfig.json

@@ -0,0 +1,3 @@
+{
+  "extends": "../.nuxt/tsconfig.server.json"
+}

+ 27 - 0
server/utils/response.ts

@@ -0,0 +1,27 @@
+export interface IResponse<T> {
+  status: boolean;
+  message: string;
+  data?: T;
+}
+
+export function createSuccessResponse<T>(data?: T, message?: string): IResponse<T> {
+  return {
+    status: true,
+    message: message || '成功',
+    data
+  };
+}
+export function createErrorResponse( error?: any, message?: string): IResponse<any> {
+  return {
+    status: false,
+    message: message || '错误',
+    data: error
+  };
+}
+export function createResponse<T>(status: boolean, message: string, data?: T): IResponse<T> {
+  return {
+    status,
+    message,
+    data
+  };
+}

+ 45 - 0
src/App.vue

@@ -0,0 +1,45 @@
+<template>
+  <a-config-provider
+    :locale="zhCN"
+    :theme="{
+      token: {
+        colorPrimary: '#bd4b36',
+      },
+    }"
+  >
+    <NuxtLoadingIndicator />
+    <NavBar />
+    <main>
+      <NuxtPage />
+    </main>
+    <Footer />
+  </a-config-provider>
+</template>
+
+<script setup lang="ts">
+import { onMounted, watch } from 'vue';
+import { useRoute } from 'vue-router'
+import { initAMapApiLoader } from '@vuemap/vue-amap';
+import zhCN from 'ant-design-vue/es/locale/zh_CN';
+import NavBar from './components/NavBar.vue';
+import Footer from './components/Footer.vue';
+
+if (import.meta.client) {
+  initAMapApiLoader({
+    key: '212b86dc49a5116a34e945d31da7ad14',
+    securityJsCode: '46cae8205a707cfaf5801abcc4301566',
+  });
+}
+
+onMounted(() => {
+  if (import.meta.server)
+    return;
+});
+</script>
+
+<style>
+@import "./assets/scss/main.scss"; 
+@import "vue3-carousel/carousel.css";
+@import "@vuemap/vue-amap/dist/style.css";
+@import "@imengyu/vue-scroll-rect/lib/vue-scroll-rect.css";
+</style>

+ 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: '/api',
+  serverProd: '/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();

+ 65 - 0
src/api/introduction/IndexContent.ts

@@ -0,0 +1,65 @@
+import { DataModel, transformArrayDataModel } from '@imengyu/js-request-transform';
+import { CommonContentApi } from '../CommonContent';
+
+export class IndexStats extends DataModel<IndexStats> {
+  constructor() {
+    super(IndexStats, "内容详情");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      crData: { clientSide: 'forceArray' },
+      minnanCr: { clientSide: 'forceArray' },
+      historyData: { clientSide: 'forceArray' },
+      inheritorData: { clientSide: 'forceArray' },
+      ichData: { clientSide: 'forceArray' },
+      ichCenter: { clientSide: 'forceArray' },
+      villageData: { clientSide: 'forceArray' }
+    }
+  }
+
+  crData: any;
+  minnanCr: any;
+  historyData: any;
+  inheritorData: any;
+  ichData: any;
+  ichCenter: any;
+  villageData: any;
+}
+export class IndexBanner extends DataModel<IndexBanner> {
+  constructor() {
+    super(IndexBanner, "首页轮播");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {}
+  }
+
+  id = '';
+  title = '';
+  image = '';
+  typeText = '';
+  sceneText = '';
+  openTypeText = '';
+  statusText = '';
+}
+
+
+export class IndexContentApi extends CommonContentApi {
+
+  constructor() {
+    super(undefined, 3, "闽南文化概况", 288);
+  }
+
+  async getStats() {
+    return (await this.get('/volunteer/statistics/webData', '闽南文化首页数据统计', {
+    }, IndexStats)).data as IndexStats
+  }
+  async getBanner() {
+    return transformArrayDataModel<IndexBanner>(
+      IndexBanner,
+      (await this.get('/content/banner_function/getotherBanner', '首页轮播图列表', {
+        open_type: 3,
+      })).data as any, 
+      '首页轮播图列表'
+    );
+  }
+}
+
+export default new IndexContentApi();

+ 3 - 0
src/assets/css/tailwind.css

@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;

BIN
src/assets/fonts/PingFang Regular.ttf


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


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


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


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 0
src/assets/images/404.svg


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

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

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

@@ -0,0 +1,950 @@
+@use "./fix.scss";
+@use "./news.scss";
+@use "sass:list";
+@use "sass:math";
+
+/* 基础样式 */
+:root {
+  --color-primary: #8f0805;
+  --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: #dbdbdb;
+  --color-mask: rgba(0, 0, 0, 0.4);
+
+  --swiper-navigation-color: var(--color-primary);
+  --swiper-pagination-color: var(--color-primary);
+  --container-width: 1250px;
+  --selection-max-width: 1250px;
+}
+
+@font-face {
+  font-family: 'PingFang SC';
+  src: url('@/assets/fonts/PingFang Regular.ttf') format('truetype');
+}
+
+@font-face {
+  font-synthesis: none;
+  font-family: "STSongtiSC";
+  src: url('@/assets/fonts/STSongti-SC-Black.woff2') format('woff2');
+}
+
+html, body {
+  position: relative;
+  color: var(--color-text);
+  line-height: 1.6;
+  font-family: 'PingFang SC', 'STSongtiSC', "Microsoft YaHei", sans-serif !important;
+  margin: 0;
+  padding: 0;
+}
+
+/* 楷体书法字,用于页面标题 */
+.brush {
+  font-family: "STKaiti", "KaiTi", "STSongtiSC", serif;
+  font-weight: 700;
+}
+
+a {
+  transition: color 0.3s;
+  text-decoration: none;
+  color: var(--color-text);
+
+  &:hover {
+    color: var(--color-primary);
+  }
+}
+ul {
+  list-style: none;
+}
+
+/* 头部样式 */
+header {
+  position: sticky;
+  padding: 15px 0;
+  background-color: #fff;
+  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+  top: 0;
+  z-index: 100;
+
+  .inner {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+    max-width: var(--container-width);
+    margin: 0 auto;
+  }
+  .logo {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    font-family: 'STSongtiSC', "Microsoft YaHei", sans-serif;
+    font-size: 26px;
+    font-weight: bold;
+    color: var(--color-primary);
+
+    .col {
+      position: relative;
+      display: flex;
+      flex-direction: column;
+      padding-left: 10px;
+
+      &::before {
+        content: '';
+        position: absolute;
+        top: calc(50% - 17px);
+        left: -1px;
+        width: 1px;
+        height: 35px;
+        background-color: var(--color-border);
+      }
+
+      span {
+        font-size: 14px;
+        margin-right: 0;
+        margin-bottom: 2px;
+      }
+    }
+    span {
+      margin-right: 10px;
+    }
+  }
+  .mobile-menu-toggle {
+    display: none;
+    width: 60px;
+    height: 60px;
+  }
+}
+
+.bordered-input {
+  color: var(--color-text);
+
+  input {
+    background-color: #f3f3f3;
+    border: none;
+    font-size: 15px;
+    font-family: "STSongtiSC";
+    color: var(--color-text);
+    width: 220px;
+
+    &:focus {
+      outline: none;
+    }
+  }
+}
+
+/* 导航样式 */
+nav.main-nav {
+  position: relative;
+
+  .nav-list {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    margin-bottom: 0;
+  }
+  .nav-item {
+    position: relative;
+
+    &.active a {
+      font-weight: bold;
+      color: var(--color-primary);
+    }
+  }
+  a {
+    display: block;
+    color: var(--color-text);
+    padding: 12px 10px;
+    font-weight: 500;
+    white-space: nowrap;
+    transition: background-color 0.3s;
+
+    &:hover, &:active {
+      color: var(--color-primary);
+    }
+  }
+}
+
+
+.search-bar {
+  position: relative;
+
+  button {
+    width: 60px;
+    height: 60px;
+    i,svg,.iconify {
+      color: var(--color-text-light);
+    }
+  }
+  input {
+    position: absolute;
+    right: 0;
+    padding: 8px 15px;
+    border: 1px solid var(--color-border);
+    outline: none;
+    width: 200px;
+    height: 60px;
+    transition: all 0.3s;
+    opacity: 0;
+    z-index: 20;
+
+    &.show {
+      width: 250px;
+      opacity: 1;
+      border-color: var(--color-primary);
+    }
+  }
+}
+
+/* 主要内容区域 */
+.main-content {
+  position: relative;
+  width: 100%;
+  padding: 40px 0;
+}
+.main-box {
+  background-color: var(--color-box);
+  border-radius: 10px;
+  padding: 26px;
+
+  &.fill {
+    height: 100%;
+  }
+}
+.main-hr {
+  border-color: var(--color-border);
+  margin: 50px 0;
+}
+.main-header-image {
+  width: 100%;
+  height: 400px;
+  object-fit: cover;
+}
+
+/* 标题样式 */
+.section-title {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20px;
+  padding-bottom: 10px;
+  border-bottom: 2px solid var(--color-primary);
+
+  &.center {
+    justify-content: center;
+  }
+  &.large {
+    border-bottom: none;
+
+    h2 {
+      font-size: 30px;
+
+      &.icon {
+        &::before, &::after {
+          width: 28px;
+          height: 28px;
+          background-size: 28px 28px;
+        }
+      }
+    }
+  }
+
+  h2 {
+    position: relative;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    font-family: "STSongtiSC";
+    font-synthesis: none;
+    font-size: 20px;
+    color: var(--color-primary);
+    margin-right: 15px;
+
+    i,svg,.iconify {
+      margin-right: 10px;
+    }
+
+    &.button {
+      cursor: pointer;
+      color: var(--color-text);
+      
+      &.icon {
+        &::before, &::after {
+          display: none;
+        }
+      }
+
+      &.active {
+        &.icon {
+          &::before, &::after {
+            display: inline-block;
+          }
+        }
+        color: var(--color-primary);
+      }
+    }
+  }
+  .sub-title {
+    color: #666;
+    padding-left: 15px;
+    border-left: 1px solid #ddd;
+  }
+}
+.section-more {
+  margin-left: auto;
+  color: #666;
+
+  &:hover {
+    color: var(--color-primary);
+  }
+}
+
+/* 通知公告区域 */
+.notices-section {
+  display: flex;
+  gap: 30px;
+  margin-bottom: 40px;
+}
+.notice-item {
+  position: relative;
+  padding: 15px 0;
+  border-bottom: 1px dashed #ddd;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  > div {
+    max-width: 86%;
+    flex: 1;
+    flex-direction: column;
+    margin-right: 15px;
+    overflow: hidden;
+  } 
+  p {
+    opacity: 0;
+    height: 0px;
+    margin-bottom: 0;
+    transition: all ease-in-out 0.25s;
+  }
+
+  &:last-child {
+    border-bottom: none;
+  }
+  &:hover {
+    /* .notice-title {
+      font-size: 20px;
+      opacity: 1;
+    } */
+    /* p {
+      opacity: 1;
+      height: 85px;
+    } */
+  }
+}
+.notice-title {
+  display: block;
+  width: 100%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  transition: all 0.3s;
+}
+.notice-date {
+  color: #999;
+  font-size: 14px;
+}
+.featured-image {
+  border-radius: 6px;
+  overflow: hidden;
+  position: relative;
+  min-height: 400px;
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    transition: transform 0.5s;
+  }
+  .caption {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    background-color: var(--color-mask);
+    color: var(--color-text-light);
+    padding: 8px 20px;
+    h3 {
+      font-size: 18px;
+      margin-bottom: 5px;
+    }
+    p {
+      font-size: 14px;
+      opacity: 0.9;
+    }
+  }
+
+  &:hover img {
+    transform: scale(1.05);
+  }
+}
+
+.news-item {
+  padding: 12px 0;
+  border-bottom: 1px dashed #ddd;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  &:last-child {
+    border-bottom: none;
+  }
+  &.dark {
+    .title {
+      color: var(--color-text-dark);
+    }
+  }
+
+  .no-image,
+  img {
+    width: 100px;
+    height: 70px;
+    object-fit: contain;
+    border-radius: 5px;
+    margin-right: 15px;
+    background-color: var(--color-border-split);
+    flex-shrink: 0;
+  }
+
+  .no-image {
+    font-size: 11px;
+    color: var(--color-text-secondary);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .content {
+    flex: 1;
+    display: flex;
+    flex-direction: row;
+
+    .desc {
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+    }
+  }
+
+  .title {
+    margin-right: 15px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    transition: color 0.3s;
+
+    &:hover {
+      color: var(--color-primary);
+    }
+  }
+  .date {
+    color: #999;
+    font-size: 14px;
+  }
+  p {
+    color: #999;
+    margin: 0;
+  }
+}
+
+/* 精彩推荐区域 */
+.featured-section {
+  margin-bottom: 40px;
+}
+.featured-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 20px;
+}
+.featured-card {
+  position: relative;
+  border: 1px solid #eee;
+  border-radius: 5px;
+  overflow: hidden;
+  transition:
+    transform 0.3s,
+    box-shadow 0.3s;
+  cursor: pointer;
+
+  p {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    background-color: var(--color-mask);
+    color: var(--color-text-light);
+    padding: 15px;
+    z-index: 1;
+    margin-bottom: 0;
+  }
+  img {
+    width: 100%;
+    height: 200px;
+    object-fit: cover;
+  }
+  &:hover {
+    transform: translateY(-5px);
+    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
+  }
+}
+
+/* 公共服务区域 */
+.services-section {
+  margin-bottom: 40px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  .title {
+    width: 220px;
+    height: 220px;
+    z-index: 10;
+  }
+}
+.services-grid {
+  flex: 1;
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 30px;
+  margin-top: 60px;
+  background-color: #fff1ee;
+  padding: 0px 30px 40px 230px;
+  border-radius: 15px;
+  margin-left: -180px;
+  z-index: 0;
+  z-index: 0;
+}
+.service-card {
+  background-color: #fff;
+  border-radius: 8px;
+  padding: 30px 20px;
+  padding-bottom: 10px;
+  margin-top: -15px;
+  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
+  transition:
+    transform 0.3s,
+    box-shadow 0.3s;
+  cursor: pointer;
+
+  h3 {
+    font-size: 15px;
+  }
+  .icon {
+    width: 40px;
+    height: 40px;
+    margin-bottom: 10px;
+  }
+  &:hover {
+    transform: translateY(-5px);
+    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
+  }
+
+  a .iconify {
+    width: 30px;
+    height: 30px;
+  }
+}
+
+/* 底部区域 */
+footer {
+  color: #fff;
+  background-color: #f5f5f5;
+  background-position: center;
+  background-size: cover;
+  padding: 40px 0 20px;
+
+  a {
+    color: #ddd;
+
+    &:hover, &:active {
+      color: #fff;
+    }
+  }
+}
+.footer-links {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 20px;
+  flex-wrap: wrap;
+}
+.footer-link {
+  margin: 0 15px;
+  color: #eee;
+
+  &:hover {
+    color: #fcacb9;
+  }
+}
+.footer-info {
+  text-align: center;
+  color: #eee;
+  font-size: 14px;
+  line-height: 1.8;
+}
+.footer-select {
+  text-align: center;
+  margin-bottom: 20px;
+  color: var(--color-text-light);
+
+  select {
+    color: #666;
+    padding: 8px 45px 8px 15px;
+    border: 1px solid rgba(221, 221, 221, 0.6);
+    border-radius: 6px;
+    background-color: rgba(255, 255, 255, 0.25);
+    cursor: pointer;
+  }
+}
+
+.sidebar {
+  .title {
+    position: relative;
+    padding: 20px;
+    background-color: var(--color-primary);
+    color: var(--color-text-light);
+    text-align: center;
+    border-radius: 4px 4px 0 0;
+    background-size: 100% 100%;
+    background-repeat: no-repeat;
+    height: 100px;
+    margin-bottom: 20px;
+    border: 1px solid var(--color-border);
+    border-radius: 4px;
+
+    h2 {
+      position: relative;
+      margin: 0;
+      font-size: 30px;
+      font-weight: bold;
+      font-family: "STSongtiSC";
+      line-height: 50px;
+      z-index: 2;
+    }
+  }
+}
+.sidebar-menu {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+  
+  li {
+    border-bottom: 1px solid var(--color-border);
+
+
+    &.active a {
+      color: var(--color-primary);
+    }
+    a {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      align-items: center;
+      padding: 15px 20px;
+      color: var(--color-text);
+      text-decoration: none;
+      transition: all 0.3s;
+      
+      i,svg,.iconify {
+        color: var(--color-primary);
+      }
+
+      &:hover {
+        color: var(--color-primary);
+      }
+    }
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  .no-content {
+    padding: 15px 20px;
+    color: var(--color-text-secondary);
+  }
+}
+
+.pagination {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 20px;
+
+  li {
+    margin: 0 5px;
+    //border-radius: 6px;
+
+    a {
+      padding: 6px 10px;
+      color: var(--color-text-secondary)!important;
+    }
+    
+    &.active {
+
+      a {
+        border-color: var(--color-primary)!important;
+        background-color: var(--color-primary)!important;
+        color: var(--color-text-light)!important;
+      }
+    }
+  }
+}
+
+.news-detail {
+  position: relative;
+
+  h1 {
+    text-align: center;
+    font-size: 30px;
+  }
+  .times {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-around;
+    align-items: center;
+    color: var(--color-text-secondary);
+    padding: 20px 0;
+    margin-bottom: 20px;
+    border-bottom: 1px solid var(--color-border);
+
+    p {
+      margin-bottom: 0;
+      font-size: 15px;
+    }
+  }
+
+  .head-image {
+    width: 100%;
+    height: auto;
+    min-height: 300px;
+    border-radius: 8px;
+    margin-bottom: 20px;
+  }
+
+  .content {
+    position: relative;
+
+    img, video {
+      max-width: 100%;
+    }
+  }
+
+  video {
+    max-width: 100%;
+  }
+}
+
+/* 联系我们页面样式 */
+.contact-info {
+  padding-right: 30px;
+}
+.info-list {
+  padding-left: 0;
+}
+.info-item {
+  display: flex;
+  flex-direction: row;
+  padding: 10px 0;
+  align-items: center;
+  transition: transform 0.3s, box-shadow 0.3s;
+
+  &:hover {
+    transform: translateY(-3px);
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  }
+  i,svg,.iconify {
+    color: var(--color-primary);
+    font-size: 20px;
+    margin-right: 15px;
+    width: 30px;
+    text-align: center;
+  }
+}
+.map-section {
+  position: relative;
+  height: 100%;
+}
+#map-container {
+  border-radius: 8px;
+  overflow: hidden;
+  margin-bottom: 20px;
+}
+
+/* 小组件 */
+
+.no-news {
+  text-align: center;
+  font-size: 18px;
+  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;
+}
+.stats-item {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 10px 0;
+  flex: 1;
+
+  &:not(:last-child) {
+    margin-right: 20px;
+    border-right: 1px solid var(--color-border);
+  }
+
+  h3 {
+    font-size: 44px;
+    font-weight: bold;
+    color: var(--color-primary);
+  }
+  span {
+    font-size: 14px;
+  }
+}
+
+@media (max-width: 992px) {
+  .featured-image {
+    width: 100%;
+    height: 300px;
+  }
+  .featured-grid {
+    grid-template-columns: repeat(2, 1fr);
+  }
+  header { 
+    .inner {
+      padding: 0 30px;
+    }
+    .logo {
+      font-size: 20px;
+    }
+  }
+  nav.main-nav .nav-item {
+    padding: 0;
+    a {
+      padding: 10px 5px;
+    }
+  }
+}
+@media (max-width: 1190px) {
+  header .logo .col span {
+    width: 40px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.swiper-slide {
+  img {
+    object-fit: cover;
+  }
+}
+
+@media (max-width: 956px) { 
+  header { 
+    .inner {
+      padding: 0 30px;
+    }
+    .logo {
+      font-size: 20px;
+      
+      .col span {
+        width: unset;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+    }
+    .mobile-menu-toggle {
+      display: unset;
+      width: 40px;
+      height: 40px;
+    }
+    .search-bar button {
+      width: 40px;
+      height: 40px;
+    }
+  }
+  nav.main-nav {
+    position: absolute;
+    top: 0;
+    opacity: 0;
+    left: 0;
+    right: 0;
+    padding: 20px;
+    background-color: var(--color-box);
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+    transition: all 0.3s;
+    transform: translateY(-100%);
+    z-index: 100;
+
+    &.show {
+      display: block;
+      opacity: 1;
+      transform: translateY(0%);
+    }
+    .nav-list {
+      flex-direction: column;
+    }
+    .nav-item {
+      margin-bottom: 10px;
+    }
+  }
+  .services-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 676px) {
+  .featured-grid {
+    grid-template-columns: 1fr;
+  }
+  .main-box {
+    padding: 15px;
+  }
+  .services-section {
+    align-items: flex-start;
+  }
+  .services-grid {
+    padding: 210px 30px 40px 30px;
+  }
+  header { 
+    .inner {
+      padding: 0 20px;
+    }
+    .logo {
+      font-size: 18px;
+    }
+  }
+}

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

@@ -0,0 +1,423 @@
+
+@use "sass:list";
+
+
+//List page
+
+.news-list {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+
+  &.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) {
+  
+}

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

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

+ 29 - 0
src/components/DynamicsItem.vue

@@ -0,0 +1,29 @@
+<template>
+  <article class="flex items-center gap-4 border border-[#eadfd2] bg-white p-4 shadow-sm sm:gap-5">
+    <div class="flex h-14 w-14 shrink-0 flex-col items-center justify-center bg-[#a30b06] text-white">
+      <b>{{ date }}</b>
+    </div>
+    <div class="min-w-0 flex-1">
+      <h3 class="font-bold text-[#704336]">{{ title }}</h3>
+      <p class="mt-1 text-sm text-[#8b786d]">{{ desc }}</p>
+    </div>
+    <Icon class="shrink-0 text-xl text-[#c9a98f]" name="solar:arrow-right-linear" />
+  </article>
+</template>
+
+<script setup lang="ts">
+defineProps({
+  date: {
+    type: String,
+    required: true,
+  },
+  title: {
+    type: String,
+    required: true,
+  },
+  desc: {
+    type: String,
+    default: '',
+  },
+});
+</script>

+ 39 - 0
src/components/FeatureCard.vue

@@ -0,0 +1,39 @@
+<template>
+  <article class="overflow-hidden rounded-lg border border-[#dfb489] bg-[#fffdf9] shadow-sm">
+    <div class="relative overflow-hidden" :class="imageHeight" :style="{ backgroundColor: item.bg }">
+      <img :alt="item.title" class="absolute inset-0 h-full w-full object-cover" :src="item.image" />
+      <Icon v-if="item.icon" class="absolute bottom-4 right-5 text-6xl text-[#ffe0a4]/75" :name="item.icon" />
+      <span class="absolute left-5 top-5 border border-white/35 bg-[#80180e]/70 px-3 py-1 text-xs text-white">{{ item.badge }}</span>
+    </div>
+    <div class="p-5">
+      <h3 class="brush text-2xl tracking-[0.1em] text-[#941b11]">{{ item.title }}</h3>
+      <p class="mt-3 text-sm leading-6 text-[#715d52]">{{ item.desc }}</p>
+      <div class="mt-4 text-xs text-[#a66b4a]">{{ item.tagline }}</div>
+    </div>
+  </article>
+</template>
+
+<script setup lang="ts">
+import { type PropType } from 'vue';
+
+export interface IFeatureItem {
+  badge: string;
+  icon: string;
+  image: string;
+  bg: string;
+  title: string;
+  desc: string;
+  tagline: string;
+}
+
+defineProps({
+  item: {
+    type: Object as PropType<IFeatureItem>,
+    required: true,
+  },
+  imageHeight: {
+    type: String,
+    default: 'h-36',
+  },
+});
+</script>

+ 49 - 0
src/components/Footer.vue

@@ -0,0 +1,49 @@
+<template>
+  <!-- 底部 -->
+  <footer class="relative overflow-hidden bg-[#8f0805] !bg-none text-white !p-0">
+    <div class="absolute -bottom-20 left-5 h-52 w-52 rounded-full border-[22px] border-[#c55b2d]/45"></div>
+    <div class="relative mx-auto flex min-h-[170px] w-full max-w-[1200px] flex-col items-start justify-between gap-6 px-4 py-8 md:flex-row md:items-center md:py-5">
+      <!-- 品牌 -->
+      <div class="flex items-center gap-4">
+        <div>
+          <div class="brush text-xl font-bold tracking-wider">漳州闽南文化生态保护区</div>
+          <div class="mt-1 text-[9px] tracking-wider text-[#f2c995]">ZHANGZHOU MINNAN CULTURE<br />ECOLOGICAL PROTECTED AREA</div>
+          <div class="mt-2 flex gap-3">
+            <div class="h-12 w-12 bg-white p-1">
+              <div class="h-full w-full" style="background: repeating-linear-gradient(45deg,#333 0,#333 2px,#fff 2px,#fff 4px)"></div>
+            </div>
+            <div class="h-12 w-12 bg-white p-1">
+              <div class="h-full w-full" style="background: repeating-linear-gradient(-45deg,#333 0,#333 2px,#fff 2px,#fff 4px)"></div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 备案信息 -->
+      <div class="text-xs leading-6 text-[#f9ddd0] md:border-l md:border-white/25 md:pl-8">
+        <p>主办单位:漳州市文化和旅游局(漳州市文物局)</p>
+        <p>承办单位:漳州闽南文化生态保护区管理中心</p>
+        <p>联系电话:0596-2023456</p>
+        <p>联系地址:福建省漳州市芗城区胜利西路118号</p>
+        <p>网站标识码:3506000032 闽ICP备05012345号-1</p>
+      </div>
+
+      <!-- 链接与标识 -->
+      <div class="text-xs leading-7 text-[#f9ddd0] md:text-right">
+        <p>
+          <a class="text-[#f9ddd0] hover:text-white" href="#">网站声明</a>&nbsp; | &nbsp;<a class="text-[#f9ddd0] hover:text-white" href="#">联系我们</a>&nbsp; | &nbsp;<a class="text-[#f9ddd0] hover:text-white" href="#">诚聘英才</a>
+        </p>
+        <p>
+          <a class="text-[#f9ddd0] hover:text-white" href="#">网站使用帮助</a>&nbsp; | &nbsp;<a class="text-[#f9ddd0] hover:text-white" href="#">意见建议</a>
+        </p>
+        <div class="mt-2 flex justify-start gap-3 md:justify-end">
+          <span class="border border-white/50 px-3 py-1 text-sm">政务网站<br />找错</span>
+          <span class="border border-[#f5d276] px-3 py-2 text-[#f5d276]">闽</span>
+        </div>
+      </div>
+    </div>
+  </footer>
+</template>
+
+<script setup lang="ts">
+</script>

+ 106 - 0
src/components/NavBar.vue

@@ -0,0 +1,106 @@
+<template>
+  <!-- 头部 -->
+  <header class="relative z-[100] w-full !p-0 shadow-none">
+    <div class="h-[94px] border-b border-[#eadfd1] bg-[radial-gradient(circle_at_96%_38%,#f4e9d9_0,transparent_16%),linear-gradient(90deg,#fffdf9,#f9f1e6)]">
+      <div class="mx-auto flex h-full w-full max-w-[1200px] items-center justify-between gap-4 px-4">
+        <a aria-label="漳州闽南文化生态保护区首页" class="flex shrink-0 items-center gap-3 whitespace-nowrap sm:gap-4" href="/">
+          <div v-if="!logoError" class="hidden h-[68px] w-[122px] shrink-0 items-center justify-center sm:flex" role="img">
+            <img
+              alt="官方闽南文化标识"
+              class="h-full w-full object-contain mix-blend-multiply"
+              referrerpolicy="no-referrer"
+              src="https://wlt.fujian.gov.cn/wldt/btdt/202512/W020251204462589781979.png"
+              @error="logoError = true"
+            />
+          </div>
+          <div class="shrink-0 whitespace-nowrap">
+            <div class="brush whitespace-nowrap text-lg font-bold tracking-[0.08em] text-[#8c1109] sm:text-[27px]">闽南文化(漳州)生态保护区</div>
+            <div class="mt-0.5 whitespace-nowrap text-[8px] tracking-[0.075em] text-[#a17662]">ZHANGZHOU MINNAN CULTURE ECOLOGICAL PROTECTED AREA</div>
+          </div>
+        </a>
+
+        <!-- 桌面导航 -->
+        <nav class="hidden h-full shrink-0 flex-nowrap items-end gap-1 whitespace-nowrap pb-5 text-sm font-medium text-[#332d28] lg:flex">
+          <NuxtLink
+            v-for="item in navItems"
+            :key="item.url"
+            :to="item.url"
+            class="shrink-0 rounded-t-sm px-4 py-2.5 transition"
+            :class="isActive(item.url) ? 'bg-[#a30805] text-white' : 'hover:text-[#a30805]'"
+          >
+            {{ item.name }}
+          </NuxtLink>
+        </nav>
+
+        <button aria-label="打开菜单" class="flex h-10 w-10 shrink-0 items-center justify-center text-[#8c1109] lg:hidden" @click="toggleMenu">
+          <Icon name="material-symbols:menu" />
+        </button>
+      </div>
+    </div>
+
+    <!-- 移动端菜单 -->
+    <transition name="fade">
+      <nav v-if="isMenuOpen" class="border-b border-[#eadfd1] bg-[#fffdf9] shadow-sm lg:hidden">
+        <ul class="flex flex-col px-4 py-2">
+          <li v-for="item in navItems" :key="item.url">
+            <NuxtLink
+              :to="item.url"
+              class="block border-b border-[#f2e8dc] py-3 text-sm font-medium last:border-b-0"
+              :class="isActive(item.url) ? 'text-[#a30805]' : 'text-[#332d28]'"
+              @click="isMenuOpen = false"
+            >
+              {{ item.name }}
+            </NuxtLink>
+          </li>
+        </ul>
+      </nav>
+    </transition>
+  </header>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+const route = useRoute();
+
+interface INavItem {
+  name: string;
+  url: string;
+}
+
+const navItems: INavItem[] = [
+  { name: '首页', url: '/' },
+  { name: '保护区概况', url: '/web/protected-area-overview' },
+  { name: '文化资讯', url: '/web/cultural-news' },
+  { name: '保护传承', url: '/web/heritage' },
+  { name: '文化交流', url: '/web/cultural-exchange' },
+  { name: '文旅融合', url: '/web/cultural-tourism' },
+  { name: '政策法规', url: '/web/policy' },
+];
+
+const isMenuOpen = ref(false);
+const logoError = ref(false);
+
+function isActive(url: string): boolean {
+  if (url === '/') {
+    return route.path === '/';
+  }
+  return route.path.startsWith(url);
+}
+
+function toggleMenu() {
+  isMenuOpen.value = !isMenuOpen.value;
+}
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.2s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 51 - 0
src/components/PageContainer.vue

@@ -0,0 +1,51 @@
+<template>
+  <!-- 通用列表 -->
+  <div class="main-background">
+    <!-- SEO -->
+    <Head>
+      <Title>闽南文化(漳州)生态保护区 - {{ title }}</Title>
+      <Meta name="description" :content="title" />
+      <Meta name="keywords" content="厦门市文化遗产保护,厦门历史建筑保护,鼓浪屿文化遗产,南音艺术传承,厦门传统手工艺,厦门非物质文化遗产,海沧民俗文化保护,厦门古迹游览,文化遗产教育推广,厦门历史文化研究,闽南文化保护中心,厦门文物保护项目,文化遗产修复技术,厦门民间艺术展示,厦门文化节庆活动" />
+      <Meta name="author" content="闽南文化(漳州)生态保护区" />
+      <Meta name="robots" content="index, follow" />
+    </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>

+ 34 - 0
src/components/PageHero.vue

@@ -0,0 +1,34 @@
+<template>
+  <section class="relative isolate h-[230px] overflow-hidden border-b border-[#cf9e71] bg-[#7b2116] py-12 md:h-[330px]">
+    <div class="absolute inset-0 bg-[radial-gradient(circle_at_82%_22%,rgba(244,190,105,.35),transparent_24%),linear-gradient(110deg,#65170f,#9a331d_55%,#d68a4b)]"></div>
+    <img v-if="image" :alt="title" class="absolute inset-0 h-full w-full object-cover opacity-45" :src="image" />
+    <div class="relative mx-auto flex h-full w-full max-w-[1200px] items-center px-4">
+      <div class="mt-3 border-l-4 border-[#f4d28b] pl-4 md:pl-5">
+        <p v-if="eyebrow" class="text-xs tracking-[0.28em] text-[#f6d990]">{{ eyebrow }}</p>
+        <h1 class="brush mt-1 text-3xl tracking-[0.16em] text-white md:mt-3 md:text-[46px]">{{ title }}</h1>
+        <p v-if="subtitle" class="mt-4 max-w-[720px] text-sm leading-7 text-[#fff0d8]">{{ subtitle }}</p>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+defineProps({
+  title: {
+    type: String,
+    required: true,
+  },
+  eyebrow: {
+    type: String,
+    default: '',
+  },
+  subtitle: {
+    type: String,
+    default: '',
+  },
+  image: {
+    type: String,
+    default: '',
+  },
+});
+</script>

+ 37 - 0
src/components/RelatedLinks.vue

@@ -0,0 +1,37 @@
+<template>
+  <section id="links" class="border-t border-[#eadfce] bg-[#fffaf2] py-4">
+    <div class="mx-auto w-full max-w-[1200px] px-4">
+      <SectionTitle class="mb-3 !text-[23px]" title="相关链接" />
+      <div class="flex flex-wrap items-stretch gap-3">
+        <a
+          v-for="link in links"
+          :key="link.name"
+          class="flex h-[54px] shrink-0 items-center gap-3 whitespace-nowrap border-2 border-transparent px-4 text-sm font-bold text-[#111111] transition hover:border-[#e8c9a9]"
+          :style="{ background: 'linear-gradient(135deg,#ffffff,#dce7ee)' }"
+          :href="link.url"
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <Icon class="shrink-0 text-2xl text-[#d21b12]" :name="link.icon" />
+          <span class="whitespace-nowrap">{{ link.name }}</span>
+        </a>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+interface ILink {
+  name: string;
+  url: string;
+  icon: string;
+}
+
+const links: ILink[] = [
+  { name: '中华人民共和国文化和旅游部', url: 'https://www.mct.gov.cn/', icon: 'solar:shield-check-linear' },
+  { name: '福建省文化和旅游厅', url: 'https://wlt.fujian.gov.cn/', icon: 'solar:buildings-3-linear' },
+  { name: '漳州市人民政府', url: 'http://www.zhangzhou.gov.cn/', icon: 'solar:buildings-2-linear' },
+  { name: '中国非物质文化遗产网', url: 'https://www.ihchina.cn/', icon: 'solar:medal-ribbons-star-linear' },
+  { name: '福建非遗数字博物馆', url: '#', icon: 'solar:gallery-wide-linear' },
+];
+</script>

+ 16 - 0
src/components/SectionTitle.vue

@@ -0,0 +1,16 @@
+<template>
+  <h2 class="brush flex items-center justify-center gap-5 text-center text-[25px] font-bold tracking-[0.18em] text-[#81170f]">
+    <span class="hidden h-px w-20 shrink-0 bg-[#d8b488] sm:block"></span>
+    <slot>{{ title }}</slot>
+    <span class="hidden h-px w-20 shrink-0 bg-[#d8b488] sm:block"></span>
+  </h2>
+</template>
+
+<script setup lang="ts">
+defineProps({
+  title: {
+    type: String,
+    default: '',
+  },
+});
+</script>

+ 67 - 0
src/components/Sidebar.vue

@@ -0,0 +1,67 @@
+<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">
+        <a v-if="item.link.startsWith('http')" :href="item.link" target="_blank">
+          {{ item.text }}
+          <Icon name="material-symbols-light:chevron-right" />
+        </a>
+        <router-link v-else :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-else-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>

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

@@ -0,0 +1,426 @@
+<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
+      :currPage="newsLoader.page.value"
+      :allPage="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 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>
+

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

@@ -0,0 +1,193 @@
+<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}`,
+        }))"
+        :showBackUpLevel="true"
+        :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>
+      <slot name="contentPre"></slot>
+      <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>

+ 80 - 0
src/components/content/SimplePageContentLoader.vue

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

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

@@ -0,0 +1,66 @@
+<template>
+  <nav aria-label="List Page navigation">
+    <ul class="pagination mt-4">
+      <li class="prev">
+        <router-link :to="{
+          path: route.path,
+          query: {
+            ...route.query,
+            page: (currPage > 1 ? currPage - 1 : 1),
+          }
+        }">
+          &lt;
+        </router-link>
+      </li>
+      <li v-for="i in showItems" :key="i" :class="{ 'active': i == currPage }">
+        <router-link :to="{
+          path: route.path,
+          query: {
+            ...route.query,
+            page: i,
+          }
+        }">
+          {{ i }}
+        </router-link>
+      </li>
+      <li class="next">
+        <router-link :to="{
+          path: route.path,
+          query: {
+            ...route.query,
+            page: Math.max(currPage < allPage ? currPage + 1 : allPage, 1),
+          }
+        }">
+          &gt;
+        </router-link>
+      </li>
+    </ul>
+  </nav>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+const props = withDefaults(defineProps<{
+  currPage?: number,
+  allPage?: number,
+  maxCount?: number,
+}>(), {
+  currPage: 1,
+  allPage: 1,
+  maxCount: 10,
+});
+
+const route = useRoute();
+
+const showItems = computed(() => {
+  const items = []
+  const start = Math.max(1, props.currPage - Math.floor(props.maxCount / 2))
+  const end = Math.min(props.allPage, start + props.maxCount - 1)
+  for (let i = start; i <= end; i++) {
+    items.push(i)
+  }
+  return items
+})
+
+</script>

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

@@ -0,0 +1,78 @@
+<template>
+  <!-- 单选标签选择按钮条,可显示一行标签,然后高亮选中项 -->
+  <ScrollRect scroll="horizontal">
+    <div class="tags">
+      <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>
+  </ScrollRect>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+import { ScrollRect } from '@imengyu/vue-scroll-rect';
+
+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 scoped lang="scss">
+.tags {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  flex-wrap: nowrap;
+  overflow-x: auto;
+  margin-bottom: 10px;
+  min-width: 1000px;
+}
+.tag-button {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background-color: var(--color-box-inset);
+  color: var(--color-text);
+  padding: 10px;
+  margin-right: 4px;
+  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>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 5 - 0
src/components/icons/IconSearch.vue


+ 15 - 0
src/components/link/ChannelLink.vue

@@ -0,0 +1,15 @@
+<template>
+  <a v-if="item.type === 'link'" :href="item.outlink" target="_blank">{{ item.name }}</a>
+  <router-link v-else :to="item.url">{{ item.name }}</router-link>
+</template>
+
+<script setup lang="ts">
+import type { IChannel } from '~~/server/api/ecms/channel/[id]';
+
+const props = defineProps({
+  item: {
+    type: Object as PropType<IChannel>,
+    required: true,
+  },
+});
+</script>

+ 46 - 0
src/composeable/InternalData.ts

@@ -0,0 +1,46 @@
+import type { IChannel } from "~~/server/api/ecms/channel/[id]";
+
+export function getNeedJumpToInternalPage(data: IChannel[]) {
+  const intangiblePage = data.find(item => item.name === '非遗项目');
+  if (intangiblePage)
+    return '/inheritor/intangible';
+  const inheritorPage = data.find(item => item.name === '非遗传承人');
+  if (inheritorPage) 
+    return '/inheritor/inheritor';
+  return undefined;
+}
+//处理系统内部使用的分类数据
+export function solveChannelData<T extends IChannel>(data: T[]) : (T & {
+  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),
+    }
+  });
+}

+ 9 - 0
src/composeable/LoaderCommon.ts

@@ -0,0 +1,9 @@
+import type { Ref } from "vue";
+
+export type LoaderLoadType = 'loading' | 'finished' | 'nomore' | 'error' | 'empty';
+
+export interface ILoaderCommon<P> {
+  loadError: Ref<string>;
+  loadStatus: Ref<LoaderLoadType>;
+  loadData: (params?: P, refresh?: boolean) => Promise<void>;
+}

+ 38 - 0
src/composeable/PageQuerys.ts

@@ -0,0 +1,38 @@
+import { nextTick, onMounted, ref, watch, type Ref } from "vue";
+import { useRoute } from "vue-router";
+
+export function useLoadQuerys<T extends Record<string, any>>(
+  defaults: T, 
+  afterLoad?: (querys: T) => void
+) {
+
+  const querys = ref<T>(defaults) as Ref<T>; 
+  const route = useRoute();
+
+  function loadQuerys() {
+    const _querys = route.query;
+    if (_querys) {
+      for (const key in querys.value) {
+        if (typeof defaults[key] === 'number')
+          (querys.value as Record<string, any>)[key] = Number(_querys[key]); 
+        else
+          querys.value[key] = _querys[key] as any;
+      }
+    }
+    afterLoad?.(querys.value);
+  }
+
+  watch(route, () => {
+    loadQuerys();
+  });
+
+  onMounted(() => {
+    nextTick(() => {
+      loadQuerys();
+    });
+  });
+
+  return {
+    querys,
+  }
+}

+ 112 - 0
src/composeable/SimpleDataLoader.ts

@@ -0,0 +1,112 @@
+import { onMounted, ref, type Ref } from "vue";
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+import { formatError } from "@imengyu/imengyu-utils";
+
+export interface ISimpleDataLoader<T, P> extends ILoaderCommon<P> {
+  content: Ref<T|null>;
+  getLastParams: () => P | undefined;
+}
+
+export function useSimpleDataLoader<T, P = any>(
+  loader: (params?: P) => Promise<T>,
+  loadWhenMounted = true,
+  emptyIfArrayEmpty = true,
+)  : ISimpleDataLoader<T, P>
+ {
+
+  const content = ref<T|null>(null) as Ref<T|null>;
+  const loadStatus = ref<LoaderLoadType>('loading');
+  const loadError = ref('');
+
+  let lastParams: P | undefined;
+
+  async function loadData(params?: P) {
+    if (params)
+      lastParams = params;
+    loadStatus.value = 'loading';
+    try {
+      const res = (await loader(params ?? lastParams)) as T;
+      content.value = res;
+      if (Array.isArray(res) && emptyIfArrayEmpty && (res as any[]).length === 0)
+        loadStatus.value = 'empty';
+      else
+        loadStatus.value = 'finished';
+      loadError.value = '';
+    } catch(e) {
+      if (import.meta.env.DEV)
+        console.error(e);
+      loadError.value = formatError(e);
+      loadStatus.value = 'error';
+      console.log(e);
+      
+    }
+  }
+
+  onMounted(() => {
+    if (loadWhenMounted) {
+      setTimeout(() => {
+        loadData();
+      }, (0.5 + Math.random()) * 500);
+    }
+  })
+
+  return {
+    content,
+    loadStatus,
+    loadError,
+    loadData,
+    getLastParams: () => lastParams,
+  }
+}
+
+export async function useSSrSimpleDataLoader<T, P = any>(
+  name: string,
+  loader: (params?: P) => Promise<T>,
+  params : P|undefined = undefined,
+  emptyIfArrayEmpty = true,
+)  : Promise<ISimpleDataLoader<T, P>>
+ {
+  const route = useRoute();
+
+  let lastParams: P | undefined = params;
+  const loadStatus = ref<LoaderLoadType>('finished');
+  const loadError = ref('');
+  const { data: content, error } = (await useAsyncData(route.fullPath + '/' + name, () => loader(lastParams)))
+
+
+  async function loadData(params?: P, refresh: boolean = false) {
+    if (!import.meta.client)
+      return;
+    if (params)
+      lastParams = params;
+    loadStatus.value = 'loading';
+    try {
+      const res = await loader(params ?? lastParams) as T;
+      content.value = res as any;
+      if (Array.isArray(res) && emptyIfArrayEmpty && (res as any[]).length === 0)
+        loadStatus.value = 'nomore';
+      else
+        loadStatus.value = 'finished';
+      loadError.value = '';
+    } catch(e) {
+      loadError.value = formatError(e);
+      loadStatus.value = 'error';
+      console.log(e);
+    }
+  }
+
+  watch(error, (e) => {
+    if (e) {
+      loadError.value = formatError(e);
+      loadStatus.value = 'error';
+    }
+  }, { immediate: true });
+  
+  return {
+    content: content as Ref<T|null>,
+    loadStatus,
+    loadError,
+    loadData,
+    getLastParams: () => lastParams,
+  }
+}

+ 237 - 0
src/composeable/SimplePagerDataLoader.ts

@@ -0,0 +1,237 @@
+import { watch, ref, computed, type Ref } from "vue"
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+import { formatError } from "@imengyu/imengyu-utils";
+
+export interface ISimplePageListLoader<T, P> extends ILoaderCommon<P> {
+  list: Ref<T[]>;
+  page: Ref<number>;
+  next: () => Promise<void>;
+  prev: () => Promise<void>;
+  total: Ref<number>;
+  totalPages: Ref<number>;
+}
+
+/**
+ * 简单分页数据封装。
+ * 
+ * 该封装了分页数据的加载、分页、上一页、下一页等功能。当页码发生变化时,会自动调用加载函数。
+ * 简单分页同时只能显示一页数据,重新加载会覆盖之前的数据。
+ * 
+ * 使用示例:
+ * ```ts
+ * const { data, page, total, loading } = useSimplePagerDataLoader(10, async (page, pageSize) => {
+ *   const res = await fetch(`/api/ecms/data?page=${page}&pageSize=${pageSize}`);
+ *   const data = await res.json();  
+ *   return {
+ *     data,
+ *     page: res.page,
+ *     total: res.total,
+ *   };
+ * });
+ * ```
+ *
+ * @param pageSize 一页的数量
+ * @param loader 加载函数
+ * @returns 
+ */
+export function useSimplePagerDataLoader<T, P = any>(
+  pageSize: number|Ref<number>, 
+  loader: (page: number, pageSize: number, params?: P) => Promise<{
+    data: T[],
+    total: number,
+  }>)  : ISimplePageListLoader<T, P>
+{
+  const page = ref(0);
+  const list = ref<T[]>([]) as Ref<T[]>;
+  const total = ref(0);
+  const totalPages = computed(() => Math.ceil(total.value / getPageSize()));
+  const loadStatus = ref<LoaderLoadType>('loading');
+  const loadError = ref('');
+
+  function getPageSize() {
+    return typeof pageSize == 'object'? pageSize.value : pageSize;
+  }
+  
+  watch(page, async () => {
+    await loadData(lastParams, false);
+  });
+
+  let lastParams: P | undefined;
+  let loading = false;
+
+  async function loadData(params?: P, refresh: boolean = false) {
+    if (loading) 
+      return;
+    if (params)
+      lastParams = params;
+    if (refresh) {
+      page.value = 1;
+    }
+    list.value = []; 
+    loadStatus.value = 'loading';
+    loading = true;
+
+    try {
+      const res = (await loader(page.value, getPageSize(), lastParams));
+      list.value = list.value.concat(res.data);
+      total.value = res.total;
+      loadStatus.value = res.data.length > 0 ? 'finished' : 'nomore';
+      loadError.value = '';
+      loading = false;
+    } catch(e) {
+      if (import.meta.env.DEV)
+        console.error(e);
+      loadError.value = formatError(e);
+      loadStatus.value = 'error';
+      loading = false;
+    }
+  }
+  /**
+   * 下一页
+   */
+  async function next() {
+    if (page.value > total.value)
+      return;
+    page.value++;
+    await loadData(lastParams, false);
+  }
+  /**
+   * 上一页
+   */
+  async function prev() {
+    if (page.value <= 1)
+      return;   
+    page.value--;
+    await loadData(lastParams, false);
+  }
+
+  return {
+    loadData,
+    next,
+    prev,
+    /**
+     * 数据
+     */
+    list,
+    /**
+     * 当前页码
+     */
+    page,
+    /**
+     * 总数据条数
+     */
+    total,
+    /**
+     * 总页数
+     */
+    totalPages,
+    loadError,
+    loadStatus,
+  }
+}
+
+export async function useSSrSimplePagerDataLoader<T, P = any>(
+  name: string,
+  startPage: number,
+  pageSize: number|Ref<number>, 
+  loader: (page: number, pageSize: number, params?: P) => Promise<{
+    data: T[],
+    total: number,
+  }>,
+  params : P|undefined = undefined,
+) : Promise<ISimplePageListLoader<T, P>>
+{
+  const route = useRoute();
+
+  let lastParams: P | undefined = params;
+
+  const page = ref(startPage);
+  const { 
+    data, 
+    error 
+  } = (await useAsyncData(route.fullPath + '/' + name, () => loader(page.value, getPageSize(), lastParams)))
+
+  const list = ref<T[]>([]) as Ref<T[]>;
+  const total = ref(0);
+  const totalPages = computed(() => Math.ceil(total.value / getPageSize()));
+  const loadStatus = ref<LoaderLoadType>('loading');
+  const loadError = ref('');
+
+  if (error.value) {
+    loadError.value = formatError(error.value);
+    loadStatus.value = 'error';
+  } else if (data.value) {
+    list.value = data.value.data as any;
+    total.value = data.value.total as any;
+    loadError.value = '';
+    loadStatus.value = total.value > 0 ? 'finished' : 'empty';
+  }
+
+  function getPageSize() {
+    return typeof pageSize == 'object'? pageSize.value : pageSize;
+  }
+
+  watch(page, async () => {
+    await loadData(lastParams, false);
+  });
+
+  let loading = false;
+
+  async function loadData(params?: P, refresh: boolean = false) {
+    if (loading) 
+      return;
+    if (params)
+      lastParams = params;
+    if (refresh) {
+      page.value = 1;
+    }
+    list.value = []; 
+    loadStatus.value = 'loading';
+    loading = true;
+
+    try {
+      const res = (await loader(page.value, getPageSize(), lastParams));
+      list.value = list.value.concat(res.data);
+      total.value = res.total;
+      loadStatus.value = list.value.length > 0 ? (res.data.length > 0 ? 'finished' : 'nomore') : 'empty';
+      loadError.value = '';
+      loading = false;
+    } catch(e) {
+      if (import.meta.env.DEV)
+        console.error(e);
+      loadError.value = formatError(e);
+      loadStatus.value = 'error';
+      loading = false;
+    }
+  }
+  /**
+   * 下一页
+   */
+  async function next() {
+    if (page.value > total.value)
+      return;
+    page.value++;
+    await loadData(lastParams, false);
+  }
+  /**
+   * 上一页
+   */
+  async function prev() {
+    if (page.value <= 1)
+      return;   
+    page.value--;
+    await loadData(lastParams, false);
+  }
+
+  return {
+    loadData,
+    next,
+    prev,
+    list: list as any as Ref<T[]>,
+    page,
+    total,
+    totalPages,
+    loadError,
+    loadStatus,
+  }
+}

+ 43 - 0
src/pages/404.vue

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

+ 26 - 0
src/pages/index.vue

@@ -0,0 +1,26 @@
+<template>
+  <!-- 首页 -->
+  <div class="min-h-screen bg-[#fbf8f1] text-[#49352b]">
+    <!-- SEO -->
+    <Head>
+      <Title>漳州闽南文化生态保护区</Title>
+      <Meta name="description" content="漳州闽南文化生态保护区以闽南文化为根脉,保护传承、活化利用、创新发展,留住乡愁记忆,赓续文化基因。" />
+      <Meta name="keywords" content="漳州,闽南文化,生态保护区,非物质文化遗产,南音,布袋木偶戏,木版年画,漳州土楼,闽南文化生态保护区" />
+      <Meta name="author" content="漳州闽南文化生态保护区" />
+      <Meta name="robots" content="index, follow" />
+    </Head>
+
+    <HeroBanner />
+    <CultureStats />
+    <FeatureTopics />
+    <NewsSection />
+    <RelatedLinks />
+  </div>
+</template>
+
+<script setup lang="ts">
+import HeroBanner from './index/components/HeroBanner.vue';
+import CultureStats from './index/components/CultureStats.vue';
+import FeatureTopics from './index/components/FeatureTopics.vue';
+import NewsSection from './index/components/NewsSection.vue';
+</script>

+ 66 - 0
src/pages/index/components/CultureStats.vue

@@ -0,0 +1,66 @@
+<template>
+  <section id="heritage" class="border-y border-[#eee3d4] bg-[linear-gradient(135deg,#fffdf9,#f8f0e4)] py-6">
+    <div class="mx-auto w-full max-w-[1200px] px-4">
+      <SectionTitle title="文化资源·数据总览" />
+      <div class="mt-5 grid grid-cols-1 gap-4 rounded-lg border border-[#ead6ba] bg-[#f7ead8]/60 p-3 shadow-[0_1px_0_#fffaf1_inset] sm:grid-cols-2 lg:grid-cols-5">
+        <article
+          v-for="item in stats"
+          :key="item.title"
+          class="relative h-[164px] overflow-hidden rounded-md border border-[#d9a971] bg-[#fffaf1] p-5 shadow-[0_0_0_3px_#f5e7d1_inset]"
+        >
+          <div class="text-[17px] font-bold leading-6 text-[#5f3425]">{{ item.title }}</div>
+          <div class="mt-2 text-[36px] font-medium leading-none text-[#980c08]">
+            {{ item.value }}<span class="ml-1 text-sm text-[#6b4535]">{{ item.unit }}</span>
+          </div>
+          <div v-if="item.sub" class="absolute inset-x-4 bottom-4 grid grid-cols-3 border-t border-[#dfc39f] bg-[#f2e2ca] py-1 text-center text-[10px] text-[#6b4535]">
+            <span v-for="sub in item.sub" :key="sub.label">{{ sub.label }}<br /><b class="text-[#98120b]">{{ sub.value }}</b> {{ sub.unit }}</span>
+          </div>
+        </article>
+      </div>
+      <p class="mt-2 text-center text-[11px] text-[#8d847d]">
+        <Icon class="align-middle" name="solar:clock-circle-linear" />&nbsp; 数据统计截止:2025年5月23日
+      </p>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+interface IStatSub {
+  label: string;
+  value: number;
+  unit: string;
+}
+
+interface IStat {
+  title: string;
+  value: number;
+  unit: string;
+  sub?: IStatSub[];
+}
+
+const stats: IStat[] = [
+  {
+    title: '非遗代表性项目',
+    value: 236,
+    unit: '项',
+    sub: [
+      { label: '国家级', value: 18, unit: '项' },
+      { label: '省级', value: 62, unit: '项' },
+      { label: '市级', value: 156, unit: '项' },
+    ],
+  },
+  {
+    title: '非遗代表性传承人',
+    value: 328,
+    unit: '人',
+    sub: [
+      { label: '国家级', value: 8, unit: '人' },
+      { label: '省级', value: 20, unit: '人' },
+      { label: '市级', value: 300, unit: '人' },
+    ],
+  },
+  { title: '不可移动文物', value: 421, unit: '处' },
+  { title: '传承所', value: 87, unit: '个' },
+  { title: '重点保护区域', value: 29, unit: '处' },
+];
+</script>

+ 71 - 0
src/pages/index/components/FeatureTopics.vue

@@ -0,0 +1,71 @@
+<template>
+  <section id="feature" class="bg-[linear-gradient(180deg,#fffaf2,#f9f0e4)] py-5">
+    <div class="mx-auto w-full max-w-[1200px] px-4">
+      <SectionTitle title="重点专题·精彩呈现" />
+      <div class="mt-4 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
+        <article
+          v-for="item in topics"
+          :key="item.title"
+          class="overflow-hidden rounded border border-[#e8d6bd] bg-[#fffdf9] p-1.5 shadow-sm"
+        >
+          <div class="relative h-[132px] overflow-hidden" :class="item.bg">
+            <img :alt="item.alt" class="h-full w-full object-cover opacity-45" :src="item.image" />
+          </div>
+          <div class="p-2.5">
+            <h3 class="text-lg font-bold text-[#9d1309]">{{ item.title }}</h3>
+            <p class="mt-1 h-10 text-xs leading-5 text-[#725d50]">{{ item.desc }}</p>
+            <a class="mt-2 inline-flex items-center gap-2 rounded bg-[#a30b06] px-3 py-1.5 text-xs text-white" :href="item.href">
+              查看更多 <Icon name="solar:arrow-right-linear" />
+            </a>
+          </div>
+        </article>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+interface ITopic {
+  title: string;
+  alt: string;
+  desc: string;
+  image: string;
+  bg: string;
+  href: string;
+}
+
+const topics: ITopic[] = [
+  {
+    title: '非遗代表性项目展示',
+    alt: '南音非遗展演',
+    desc: '南音、布袋木偶戏、木版年画等非遗瑰宝,匠心传承。',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-20/9a7d24c35be442fa8b8395c8e93302e0.png',
+    bg: 'bg-[#5e2e1a]',
+    href: '#heritage',
+  },
+  {
+    title: '非遗代表性传承人风采',
+    alt: '数字化文化展厅',
+    desc: '展现非遗代表性传承人的匠人风貌,记录技艺坚守与文化薪传。',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-20/2383568ef60b452ba4468a4e3e07d588.png',
+    bg: 'bg-[#242a2b]',
+    href: '#feature',
+  },
+  {
+    title: '文物保护与活化利用',
+    alt: '福建土楼',
+    desc: '古厝古建、庙宇祠堂、古街古道,文物保护焕新生。',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-20/75337f6933384318b69ca427586120d9.png',
+    bg: 'bg-[#7c5941]',
+    href: '#heritage',
+  },
+  {
+    title: '文旅融合推荐',
+    alt: '文旅节庆巡游',
+    desc: '文化旅游线路、节庆活动推荐,开启闽南文化之旅。',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-20/b5370ed6815d4631a54816d066dccb54.png',
+    bg: 'bg-[#874323]',
+    href: '#feature',
+  },
+];
+</script>

+ 142 - 0
src/pages/index/components/HeroBanner.vue

@@ -0,0 +1,142 @@
+<template>
+  <section
+    id="about"
+    class="relative overflow-hidden bg-cover bg-center bg-[radial-gradient(circle_at_76%_40%,#dcb384_0,transparent_25%),linear-gradient(120deg,#f5dfbd,#d28b5b_52%,#7b2615)]"
+    :style="{ backgroundImage: `url('${heroImage}')` }"
+  >
+    <div class="absolute inset-0 bg-gradient-to-r from-[#fff4df]/80 via-[#fff4df]/45 to-[#5e1d0d]/15"></div>
+
+    <div class="relative mx-auto w-full max-w-[1200px] px-4 pt-12 pb-40 sm:pb-44 md:pt-16">
+      <p class="brush whitespace-nowrap text-4xl leading-none tracking-[0.03em] text-[#71180d] sm:text-5xl md:text-[58px]">乐享非遗 开漳大吉</p>
+      <p class="mt-2.5 max-w-[555px] text-[15px] font-medium leading-7 text-[#4a3429]">
+        漳州闽南文化生态保护区以闽南文化为根脉<br />
+        保护传承、活化利用、创新发展,留住乡愁记忆,赓续文化基因。
+      </p>
+      <div class="mt-5 flex flex-wrap gap-4 sm:gap-5">
+        <a class="flex items-center gap-2.5 rounded-md bg-[#a70b06] px-7 py-3.5 text-sm font-medium text-white shadow-[0_4px_12px_rgba(102,20,7,.25)] transition hover:bg-[#850704]" href="#heritage">
+          走进保护区 <Icon class="text-lg" name="solar:arrow-right-linear" />
+        </a>
+        <a class="flex items-center gap-2 rounded-md border-2 border-[#a23c2b] bg-[#fff9ed]/65 px-7 py-[11px] text-sm font-medium text-[#8e2115] shadow-sm transition hover:bg-white" href="/web/protected-area-overview">
+          查看文化地图 <Icon class="text-lg" name="solar:map-point-linear" />
+        </a>
+      </div>
+    </div>
+
+    <!-- 文化主题轮播 -->
+    <div class="absolute inset-x-0 bottom-5 mx-auto w-full max-w-[1140px] px-2 sm:px-0">
+      <div class="relative overflow-hidden rounded-[18px] border border-[#f8e7c9]/85 bg-[#fff8e9]/58 px-4 pt-4 pb-5 shadow-[0_14px_30px_rgba(71,26,13,.27)] backdrop-blur-[2px]">
+        <div class="absolute inset-0 bg-[radial-gradient(circle_at_14%_25%,rgba(255,240,195,.82)_0,transparent_20%),radial-gradient(circle_at_88%_35%,rgba(136,35,18,.44)_0,transparent_25%),repeating-linear-gradient(90deg,rgba(129,39,21,.18)_0,rgba(129,39,21,.18)_2px,transparent_2px,transparent_32px),repeating-linear-gradient(0deg,rgba(129,39,21,.12)_0,rgba(129,39,21,.12)_2px,transparent_2px,transparent_16px)]"></div>
+
+        <div class="relative flex items-center justify-between gap-2">
+          <button
+            aria-label="上一张文化主题"
+            class="hidden h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/80 bg-[#9e1a0d]/90 text-white shadow-md transition hover:bg-[#ba2616] sm:flex"
+            @click="scrollBy(-195)"
+          >
+            <Icon name="solar:alt-arrow-left-linear" />
+          </button>
+
+          <div ref="carouselRef" class="flex w-full flex-1 gap-3 overflow-hidden rounded-md bg-[#fff9ee]/36 p-1">
+            <article
+              v-for="(item, index) in themes"
+              :key="item.title"
+              class="w-[160px] flex-none rounded-[3px] bg-[#fffaf2] p-1 shadow-[0_3px_10px_rgba(30,15,10,.45)] sm:w-[180px]"
+              :class="index === activeIndex ? 'border-[3px] border-[#b11d12]' : 'border-[3px] border-white'"
+            >
+              <div class="relative h-[72px] overflow-hidden" :class="item.bg">
+                <img :alt="item.alt" class="h-full w-full object-cover" :src="item.image" />
+              </div>
+              <p class="py-1 text-center text-xs font-bold" :class="index === activeIndex ? 'text-[#9f1d12]' : 'text-[#7d2619]'">{{ item.title }}</p>
+            </article>
+          </div>
+
+          <button
+            aria-label="下一张文化主题"
+            class="hidden h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/80 bg-[#9e1a0d]/90 text-white shadow-md transition hover:bg-[#ba2616] sm:flex"
+            @click="scrollBy(195)"
+          >
+            <Icon name="solar:alt-arrow-right-linear" />
+          </button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 指示点 -->
+    <div class="absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 gap-2">
+      <span
+        v-for="(item, index) in themes"
+        :key="index"
+        class="h-2.5 w-2.5 rounded-full"
+        :class="index === activeIndex ? 'border border-white/70 bg-[#a30a05]' : 'bg-white/75'"
+      ></span>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const heroImage = 'https://modao.cc/agent-py/media/user_assets/2026-08-13/ef45b35837d4436691c34400f8a531a1.png';
+
+interface ITheme {
+  title: string;
+  alt: string;
+  image: string;
+  bg: string;
+}
+
+const themes: ITheme[] = [
+  {
+    title: '漳州古城',
+    alt: '漳州古城红砖古厝、燕尾脊与红灯笼',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-19/47de11ea3a714af0927ab9f5c589ce1a.png',
+    bg: 'bg-[#874120]',
+  },
+  {
+    title: '南音雅韵',
+    alt: '南音传统音乐演奏',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-19/30d073d835f74532b2f3b36e43e5fd2b.png',
+    bg: 'bg-[#4b251b]',
+  },
+  {
+    title: '布袋木偶戏',
+    alt: '布袋木偶戏表演',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-19/cd892a79401e44b2adc85f9c484f3eae.png',
+    bg: 'bg-[#504c38]',
+  },
+  {
+    title: '漳州土楼',
+    alt: '福建土楼圆形建筑',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-19/8bd1638cbec347a39b6b26a84dbbc509.png',
+    bg: 'bg-[#7c5941]',
+  },
+  {
+    title: '木版年画',
+    alt: '木版年画',
+    image: 'https://modao.cc/agent-py/media/user_assets/2026-08-19/262d7c255ee844b796dd5478ea104e84.png',
+    bg: 'bg-[#9d3422]',
+  },
+];
+
+const carouselRef = ref<HTMLElement | null>(null);
+const activeIndex = ref(0);
+
+function scrollBy(delta: number) {
+  carouselRef.value?.scrollBy({ left: delta, behavior: 'smooth' });
+}
+
+function onScroll() {
+  const el = carouselRef.value;
+  if (!el) return;
+  const itemWidth = (el.querySelector('article') as HTMLElement | null)?.offsetWidth ?? 195;
+  activeIndex.value = Math.min(themes.length - 1, Math.max(0, Math.round(el.scrollLeft / itemWidth)));
+}
+
+onMounted(() => {
+  carouselRef.value?.addEventListener('scroll', onScroll, { passive: true });
+});
+
+onBeforeUnmount(() => {
+  carouselRef.value?.removeEventListener('scroll', onScroll);
+});
+</script>

+ 155 - 0
src/pages/index/components/NewsSection.vue

@@ -0,0 +1,155 @@
+<template>
+  <section id="news" class="bg-[#fffdf9] py-5">
+    <div class="mx-auto w-full max-w-[1200px] px-4">
+      <SectionTitle title="资讯动态·权威发布" />
+      <div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_200px]">
+        <!-- 政务要闻 -->
+        <article class="rounded border border-[#eadfd2] bg-white p-4 shadow-sm">
+          <div class="flex items-center justify-between border-b border-[#d9a59d] pb-2">
+            <h3 class="font-bold text-[#a11309]">政务要闻</h3>
+            <a class="text-[10px] text-[#87766b]" href="#">更多 &gt;</a>
+          </div>
+          <ul class="mt-2 space-y-2.5 text-[11px] text-[#64564e]">
+            <li v-for="item in govNews" :key="item.title" class="flex justify-between gap-2">
+              <a class="truncate" href="#">{{ item.title }}</a>
+              <span class="shrink-0">{{ item.date }}</span>
+            </li>
+          </ul>
+        </article>
+
+        <!-- 通知公告 -->
+        <article class="rounded border border-[#eadfd2] bg-white p-4 shadow-sm">
+          <div class="flex items-center justify-between border-b border-[#d9a59d] pb-2">
+            <h3 class="font-bold text-[#a11309]">通知公告</h3>
+            <a class="text-[10px] text-[#87766b]" href="#">更多 &gt;</a>
+          </div>
+          <ul class="mt-2 space-y-2.5 text-[11px] text-[#64564e]">
+            <li v-for="item in notices" :key="item.title" class="flex justify-between gap-2">
+              <a class="truncate" href="#">{{ item.title }}</a>
+              <span class="shrink-0">{{ item.date }}</span>
+            </li>
+          </ul>
+        </article>
+
+        <!-- 文化资讯 -->
+        <article class="rounded border border-[#eadfd2] bg-white p-4 shadow-sm">
+          <div class="flex items-center justify-between border-b border-[#d9a59d] pb-2">
+            <h3 class="font-bold text-[#a11309]">文化资讯</h3>
+            <a class="text-[10px] text-[#87766b] transition hover:text-[#a11309]" href="/web/cultural-news">更多 &gt;</a>
+          </div>
+          <ul class="mt-2 space-y-1.5">
+            <li v-for="item in newsLoader.list.value" :key="item.title">
+              <a class="group block rounded border border-transparent px-2 py-1.5 transition hover:border-[#e4c4a1] hover:bg-[#fff7eb]" href="/web/cultural-news">
+                <div class="flex items-center justify-between gap-2">
+                  <span class="rounded bg-[#f9e7d0] px-1.5 py-0.5 text-[9px] font-bold text-[#a11309]">{{ item.category }}</span>
+                  <time class="text-[10px] text-[#9a887d]">{{ item.date }}</time>
+                </div>
+                <h4 class="mt-1 text-[11px] font-bold text-[#65483b] transition group-hover:text-[#a11309]">{{ item.title }}</h4>
+                <p class="mt-0.5 text-[10px] leading-4 text-[#8b786d]">{{ item.desc }}</p>
+              </a>
+            </li>
+          </ul>
+        </article>
+
+        <!-- 近期活动预告 -->
+        <aside id="activities" class="rounded border border-[#dcb889] bg-[#fffaf1] p-3 shadow-sm">
+          <div class="flex items-center justify-between border-b border-[#d9a59d] pb-2">
+            <h3 class="font-bold text-[#614435]">近期活动预告</h3>
+            <a class="text-[10px] text-[#87766b]" href="#">更多 &gt;</a>
+          </div>
+          <div class="mt-2 space-y-3">
+            <div v-for="activity in activities" :key="activity.title" class="flex gap-2">
+              <div class="flex h-11 w-10 flex-col items-center justify-center rounded bg-[#a5160a] text-white">
+                <b class="text-lg leading-4">{{ activity.day }}</b>
+                <span class="text-[9px]">{{ activity.month }}月</span>
+              </div>
+              <p class="text-[11px] leading-4">
+                <b>{{ activity.title }}</b><br />
+                <span class="text-[#88776c]">时间:{{ activity.time }}<br />地点:{{ activity.place }}</span>
+              </p>
+            </div>
+          </div>
+        </aside>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { useSSrSimplePagerDataLoader } from '~/composeable/SimplePagerDataLoader';
+
+interface INewsItem {
+  category: string;
+  date: string;
+  title: string;
+  desc: string;
+}
+
+const newsLoader = await useSSrSimplePagerDataLoader<INewsItem>(
+  'home-news-list',
+  1,
+  10,
+  async () => {
+    return {
+      data: [
+        {
+          category: '专题',
+          date: '06-18',
+          title: '古厝守护:传统营造技艺的社区传承',
+          desc: '以修缮故事为线索,呈现传统工艺与当代保护理念的相互呼应。',
+        },
+        {
+          category: '非遗',
+          date: '06-12',
+          title: '南音新声:一场面向青少年的体验课',
+          desc: '从曲牌赏析到乐器体验,感受千年雅乐的活态传承。',
+        },
+        {
+          category: '数字保护',
+          date: '06-06',
+          title: '方言记忆采集:让乡音留在数字档案中',
+          desc: '用数字技术记录闽南乡音,留存方言文化的时代记忆。',
+        },
+      ],
+      total: 3,
+    };
+  },
+);
+
+interface ILinkItem {
+  title: string;
+  date: string;
+}
+
+const govNews: ILinkItem[] = [
+  { title: '漳州闽南文化生态保护区建设工作推进会召开', date: '05-22' },
+  { title: '我市非遗保护工作再获表彰', date: '05-20' },
+  { title: '漳州古城保护修缮项目正式启动', date: '05-19' },
+  { title: '闽南文化遗产系列活动走进漳州高校', date: '05-18' },
+  { title: '漳州泉州遗址展示展览交流会召开', date: '05-17' },
+  { title: '市领导调研非遗传承与保护工作', date: '05-16' },
+];
+
+const notices: ILinkItem[] = [
+  { title: '关于开展闽南文化生态保护区项目申报的通知', date: '05-21' },
+  { title: '漳州闽南文化生态保护区评估人选公示', date: '05-20' },
+  { title: '国家级名录文物保护单位安全检查通告', date: '05-19' },
+  { title: '关于征集闽南文化影像资料的公告', date: '05-18' },
+  { title: '漳州遗址保护处理情况公示', date: '05-17' },
+  { title: '保护区建设资金使用情况公开', date: '05-16' },
+];
+
+interface IActivity {
+  day: string;
+  month: string;
+  title: string;
+  time: string;
+  place: string;
+}
+
+const activities: IActivity[] = [
+  { day: '25', month: '05', title: '南音雅集展演活动', time: '05.25 14:30', place: '漳州古城南门广场' },
+  { day: '31', month: '05', title: '闽南古厝开放日', time: '05.31 09:00', place: '漳州市金峰文古厝' },
+  { day: '08', month: '06', title: '非遗购物节', time: '06.08 10:00', place: '漳州文庙广场' },
+];
+</script>

+ 0 - 0
src/pages/web/cultural-exchange.vue


Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels