快乐的梦鱼 1 mēnesi atpakaļ
revīzija
109c293b5f
78 mainītis faili ar 19737 papildinājumiem un 0 dzēšanām
  1. 34 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 33 0
      README.md
  4. 9 0
      config.json.example
  5. 1 0
      env.d.ts
  6. 13 0
      index.html
  7. 60 0
      nuxt.config.ts
  8. 14417 0
      package-lock.json
  9. 61 0
      package.json
  10. BIN
      public/favicon.ico
  11. 2 0
      public/robots.txt
  12. 23 0
      server/api/channel/nav.ts
  13. 22 0
      server/api/health.ts
  14. 48 0
      server/config/db.ts
  15. 130 0
      server/db/CommonModel.ts
  16. 96 0
      server/db/DB.ts
  17. 109 0
      server/db/DBUtils.ts
  18. 73 0
      server/db/Query.ts
  19. 1071 0
      server/db/QueryGenerator.ts
  20. 3 0
      server/tsconfig.json
  21. 27 0
      server/utils/response.ts
  22. 60 0
      src/App.vue
  23. 12 0
      src/api/NotConfigue.ts
  24. 207 0
      src/api/RequestModules.ts
  25. 15 0
      src/api/Utils.ts
  26. 68 0
      src/api/auth/UserApi.ts
  27. BIN
      src/assets/fonts/PingFang Regular.ttf
  28. BIN
      src/assets/fonts/STSongti-SC-Black.ttf
  29. BIN
      src/assets/fonts/STSongti-SC-Black.woff
  30. BIN
      src/assets/fonts/STSongti-SC-Black.woff2
  31. BIN
      src/assets/images/about-logo.png
  32. BIN
      src/assets/images/badge.png
  33. BIN
      src/assets/images/box-service.png
  34. BIN
      src/assets/images/button-active.png
  35. BIN
      src/assets/images/button-deactive.png
  36. BIN
      src/assets/images/footer-bg.png
  37. BIN
      src/assets/images/icon-contract.png
  38. BIN
      src/assets/images/icon-explore.png
  39. BIN
      src/assets/images/icon-join.png
  40. BIN
      src/assets/images/tab-active.png
  41. BIN
      src/assets/images/test-header-1.png
  42. BIN
      src/assets/images/test-header-2.png
  43. BIN
      src/assets/images/test-header-3.png
  44. BIN
      src/assets/images/test-header-4.png
  45. BIN
      src/assets/images/test-header-5.png
  46. BIN
      src/assets/images/test-header-6.png
  47. BIN
      src/assets/images/test-header-7.png
  48. BIN
      src/assets/images/test-header-8.png
  49. BIN
      src/assets/images/xuexi.png
  50. 8 0
      src/assets/scss/fix.scss
  51. 36 0
      src/assets/scss/fonts.scss
  52. 836 0
      src/assets/scss/main.scss
  53. 63 0
      src/assets/scss/scroll.scss
  54. 1 0
      src/common/ConstStrings.ts
  55. 98 0
      src/common/ConvertRgeistry.ts
  56. 8 0
      src/common/EventBus.ts
  57. 9 0
      src/common/config/ApiCofig.ts
  58. 11 0
      src/common/config/AppCofig.ts
  59. 55 0
      src/components/Footer.vue
  60. 64 0
      src/components/NavBar.vue
  61. 80 0
      src/components/content/SimplePageContentLoader.vue
  62. 9 0
      src/composeable/LoaderCommon.ts
  63. 103 0
      src/composeable/PageAction.ts
  64. 38 0
      src/composeable/PageQuerys.ts
  65. 109 0
      src/composeable/SimpleDataLoader.ts
  66. 232 0
      src/composeable/SimplePagerDataLoader.ts
  67. 44 0
      src/pages/404.vue
  68. 107 0
      src/pages/about.vue
  69. 54 0
      src/pages/channel/[id].vue
  70. 50 0
      src/pages/index.vue
  71. 4 0
      src/scripts/.gitignore
  72. 22 0
      src/scripts/UpdateScript/deprecate.html
  73. 524 0
      src/scripts/UpdateScript/index.mjs
  74. 66 0
      src/scripts/UpdateScript/postConfig.mjs
  75. 430 0
      src/scripts/UpdateScript/postUpdate.mjs
  76. 74 0
      src/stores/auth.ts
  77. 1 0
      test
  78. 4 0
      tsconfig.json

+ 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"]
+}

+ 33 - 0
README.md

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

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

+ 60 - 0
nuxt.config.ts

@@ -0,0 +1,60 @@
+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' },
+      ]
+    }
+  }, 
+  css: ['bootstrap/dist/css/bootstrap.min.css'],
+  devtools: { enabled: true },
+  srcDir: 'src/',
+  modules: ['@pinia/nuxt', '@ant-design-vue/nuxt', '@nuxt/icon'],
+  components: [
+    {
+      path: '~/components',
+      pathPrefix: false,
+      extensions: ['.vue'],
+    }
+  ],
+  icon: {
+    provider: 'iconify',
+    serverBundle: false,
+  },
+  build: {
+    transpile: [
+      '@imengyu/vue-scroll-rect',
+      '@imengyu/imengyu-utils',
+    ],
+  },
+  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' } },
+    
+    /*
+    '/': { swr: 1800 },
+    */
+    '/about/': { swr: 1800 },
+    '/introduction/**': { swr: true },
+    '/inheritor/submit': { swr: false },
+  }
+})

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


+ 61 - 0
package.json

@@ -0,0 +1,61 @@
+{
+  "name": "wenbao-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 src/scripts/UpdateScript/index.mjs"
+  },
+  "dependencies": {
+    "@ant-design-vue/nuxt": "^1.4.6",
+    "@imengyu/imengyu-utils": "^0.0.19",
+    "@imengyu/js-request-transform": "^0.3.5",
+    "@imengyu/vue-dynamic-form": "^0.1.1",
+    "@imengyu/vue-scroll-rect": "^0.1.3",
+    "@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",
+    "axios": "^1.9.0",
+    "bootstrap": "^5.3.8",
+    "lodash-es": "^4.17.21",
+    "md5": "^2.3.0",
+    "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.5.3",
+    "@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": "^7.0.1",
+    "cli-progress": "^3.12.0",
+    "cli-table3": "^0.6.5",
+    "commander": "^14.0.0",
+    "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:

+ 23 - 0
server/api/channel/nav.ts

@@ -0,0 +1,23 @@
+import { defineEventHandler, EventHandlerRequest } from 'h3';
+import { DB } from '~~/server/db/DB';
+import { createErrorResponse, createSuccessResponse, IResponse } from '~~/server/utils/response';
+
+export default defineEventHandler<EventHandlerRequest, Promise<IResponse<{
+  id: number;
+  model_id: number;
+  name: string;
+  type: 'list'|'link',
+  url: string;
+  outlink: string;
+  diyname: string;
+}[]>>>(async (event) => {
+  try {
+    return createSuccessResponse(await DB.table('pr_cms_channel')
+            .where('status', 'normal')
+            .where('isnav', 1)
+            .select('*')
+            .get());
+  } catch (error) {
+    return createErrorResponse(error);
+  }
+});

+ 22 - 0
server/api/health.ts

@@ -0,0 +1,22 @@
+import { defineEventHandler } from 'h3';
+import { testConnection } from '../config/db';
+
+export default defineEventHandler(async (event) => {
+  try {
+    // 测试数据库连接
+    await testConnection();
+    
+    return {
+      status: 'ok',
+      timestamp: new Date().toISOString(),
+      message: '✅ 服务运行正常,数据库连接成功'
+    };
+  } catch (error) {
+    return {
+      status: 'error',
+      timestamp: new Date().toISOString(),
+      message: '❌ 服务运行异常',
+      error: error instanceof Error ? error.message : String(error)
+    };
+  }
+});

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

+ 60 - 0
src/App.vue

@@ -0,0 +1,60 @@
+<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 { useAuthStore } from './stores/auth';
+import { initAMapApiLoader } from '@vuemap/vue-amap';
+import { registryConvert } from './common/ConvertRgeistry'
+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',
+  });
+} 
+registryConvert();
+
+const authStore = useAuthStore();
+
+onMounted(() => {
+  if (import.meta.server)
+    return;
+  authStore.loadLoginState();
+});
+
+const route = useRoute();
+
+watch(route, () => {
+  window.scrollTo({
+    top: 0,
+    behavior: 'instant'
+  })
+});
+</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>

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

+ 207 - 0
src/api/RequestModules.ts

@@ -0,0 +1,207 @@
+
+/**
+ * 这里写的是业务相关的:
+ * * 请求数据处理函数。
+ * * 自定义请求模块。
+ * * 自定义错误报告处理函数。
+ */
+
+import AppCofig from "@/common/config/AppCofig";
+import ApiCofig from "@/common/config/ApiCofig";
+import { 
+  RequestApiConfig,
+  RequestApiError, RequestApiResult, type RequestApiErrorType, 
+  RequestCoreInstance, RequestOptions, 
+  defaultResponseDataGetErrorInfo, defaultResponseDataHandlerCatch, 
+  RequestResponse,
+  WebFetchImplementer,
+  StringUtils,
+  appendGetUrlParams, 
+  appendPostParams,
+} from "@imengyu/imengyu-utils";
+import type { DataModel, KeyValue, NewDataModel } from "@imengyu/js-request-transform";
+import { useAuthStore } from "@/stores/auth";
+import { Modal } from "ant-design-vue";
+
+/**
+ * 不报告错误的 code
+ */
+const notReportErrorCode = [401] as number[];
+const notReportMessages = [
+  /请授权绑定手机号/g,
+] as RegExp[];
+function matchNotReportMessage(str: string) {
+  for (let i = 0; i < notReportMessages.length; i++) {
+    if (notReportMessages[i]?.test(str))
+      return true;
+  }
+  return false;
+}
+
+//请求拦截器
+function requestInceptor(url: string, req: RequestOptions) {
+  //获取store中的token,追加到头;
+  const autoStore = useAuthStore();
+  if (StringUtils.isNullOrEmpty((req.header as KeyValue).token as string)) {
+    req.header['token'] = autoStore.token;
+    req.header['__token__'] = autoStore.token;
+  }
+  if (req.method == 'GET') {
+    //追加GET参数
+    url = appendGetUrlParams(url, 'main_body_id', ApiCofig.mainBodyId);
+  } 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>, apiName: string | undefined): Promise<RequestApiResult<T>> {
+  return new Promise<RequestApiResult<T>>((resolve, reject) => {
+    const method = req.method || 'GET';
+    response.json().then((json) => {
+      if (response.ok) {
+        if (!json) {
+          reject(new RequestApiError(
+            'businessError',
+            '后端未返回数据',
+            '',
+            response.status,
+            null,
+            null,
+            req,
+            apiName,
+            response.url
+          ));
+          return;
+        }
+
+        //code == 0 错误
+        if (json.code === 0) {
+          handleError();
+          return;
+        }
+
+        //处理后端的数据
+        let message = '未知错误';
+        let data = {} as any;
+
+        //后端返回格式不统一,所以在这里处理格式
+        if (typeof json.data === 'object') {
+          data = json.data;
+          message = json.data?.msg || response.statusText;
+        }
+        else {
+          //否则返回上层对象
+          data = json;
+          message = json.msg || response.statusText;
+        }
+
+        resolve(new RequestApiResult(
+          resultModelClass ?? instance.config.modelClassCreator,
+          json?.code || response.status,
+          message,
+          data,
+          json
+        ));
+      }
+      else {
+        handleError();
+      }
+
+      function handleError() {
+        let errType : RequestApiErrorType = 'unknow';
+        let errString = '';
+        let errCodeStr = '';
+
+        if (typeof json.message === 'string') 
+          errString = json.message;
+        if (typeof json.msg === 'string') 
+          errString += json.msg;
+
+        if (StringUtils.isStringAllEnglish(errString))
+          errString = '服务器返回:' + errString;
+
+        //错误处理
+        if (errString) {
+          //如果后端有返回错误信息,则收集错误信息并返回
+          errType = 'businessError';
+          if (typeof json.data === 'object' && json.data?.errmsg) {
+            errString += '\n' + json.data.errmsg;
+          }
+          if (typeof json.errors === 'object') {
+            for (const key in json.errors) {
+              if (Object.prototype.hasOwnProperty.call(json.errors, key)) {
+                errString += '\n' + json.errors[key];
+              }
+            }
+          }
+        } else {
+          const res = defaultResponseDataGetErrorInfo(response, json);
+          errType = res.errType;
+          errString = res.errString;
+          errCodeStr = res.errCodeStr;
+        }
+
+        reject(new RequestApiError(
+          errType,
+          errString,
+          errCodeStr,
+          response.status,
+          null,
+          null,
+          req,
+          apiName,
+          response.url
+        ));
+      }
+    }).catch((err) => {
+      //错误统一处理
+      defaultResponseDataHandlerCatch(method, req, response, null, err, apiName, response.url, reject, instance);
+    });
+  });
+}
+//错误报告处理
+function responseErrReoprtInceptor<T extends DataModel>(instance: RequestCoreInstance<T>, response: RequestApiError) {
+  return (
+    (response.errorType !== 'businessError' && response.errorType !== 'networkError') ||
+    notReportErrorCode.indexOf(response.code) >= 0 ||
+    matchNotReportMessage(response.errorMessage) === true
+  );
+}
+
+//错误报告处理
+export function reportError<T extends DataModel>(instance: RequestCoreInstance<T>, response: RequestApiError | Error) {
+  if (import.meta.env.DEV) {
+    if (response instanceof RequestApiError) {
+      
+    } else {
+    }
+  } else {    
+    let errMsg = '';
+    if (response instanceof RequestApiError)
+      errMsg = response.errorMessage + '。';
+      
+    errMsg += '服务出现了异常,请稍后重试或联系客服。';
+    errMsg += '版本:' + AppCofig.version;
+
+    Modal.error({
+      title: '抱歉',
+      content: errMsg,
+    });
+}
+}
+
+/**
+ * App服务请求模块
+ */
+export class AppServerRequestModule<T extends DataModel> extends RequestCoreInstance<T> {
+  constructor() {
+    super(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;
+}

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

@@ -0,0 +1,68 @@
+import { DataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+
+
+export class LoginResult extends DataModel<LoginResult> {
+  constructor() {
+    super(LoginResult, "登录结果");
+    this._convertTable = {
+      userInfo: { clientSide: 'object', clientSideChildDataModel: UserInfo },
+    };
+    this._nameMapperServer = {
+      'userinfo': 'userInfo',
+      'mainBodyUserInfo': 'userInfo',
+    }
+    this._afterSolveServer = () => {
+      if (this.mainBodyUserInfo) {
+        this.userInfo.token = this.mainBodyUserInfo.token;
+      }
+    };
+  }
+  userInfo !:UserInfo;
+  mainBodyUserInfo?:UserInfo;
+}
+export class UserInfo extends DataModel<UserInfo> {
+  constructor() {
+    super(UserInfo, "用户信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+  }
+
+  expiresIn = 0;
+  id = 0;
+  userId = 0;
+  mobile = '';
+  nickname = '';
+  avatar = '';
+  username = '';
+  token = '';
+}
+
+export class UserApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+  async loginAdmin(data?: {
+    account: string,
+    password: string,
+  }) {
+    const form = new FormData();
+    form.append('account', data?.account || '');
+    form.append('password', data?.password || '');
+    return (await this.post('/user/adminLogin', form, '登录', undefined, LoginResult)).data as LoginResult;
+  }
+  async getUserInfo(main_body_user_id: number) {
+    return (await this.post('/content/main_body_user/getMainBodyUser', {
+      main_body_user_id,
+    }, '获取用户信息', undefined, UserInfo)).data as UserInfo;
+  }
+  async refresh() {
+    return (await this.post('/content/main_body_user/refreshUser', {
+    }, '刷新用户', undefined, LoginResult)).data as LoginResult;
+  }
+}
+
+export default new UserApi();

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


BIN
src/assets/images/about-logo.png


BIN
src/assets/images/badge.png


BIN
src/assets/images/box-service.png


BIN
src/assets/images/button-active.png


BIN
src/assets/images/button-deactive.png


BIN
src/assets/images/footer-bg.png


BIN
src/assets/images/icon-contract.png


BIN
src/assets/images/icon-explore.png


BIN
src/assets/images/icon-join.png


BIN
src/assets/images/tab-active.png


BIN
src/assets/images/test-header-1.png


BIN
src/assets/images/test-header-2.png


BIN
src/assets/images/test-header-3.png


BIN
src/assets/images/test-header-4.png


BIN
src/assets/images/test-header-5.png


BIN
src/assets/images/test-header-6.png


BIN
src/assets/images/test-header-7.png


BIN
src/assets/images/test-header-8.png


BIN
src/assets/images/xuexi.png


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

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

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

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

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

@@ -0,0 +1,836 @@
+@use "./fonts.scss";
+@use "./fix.scss";
+@use "sass:list";
+@use "sass:math";
+
+/* 基础样式 */
+:root {
+  --color-primary: #db5f46;
+  --color-secondary: #ac361e;
+  --color-text: #333;
+  --color-text-light: #fff;
+  --color-text-dark: #000;
+  --color-text-secondary: #888;
+  --color-border: #ddd;
+  --color-box: #f9fafb;
+  --color-box-inset: #e5e7eb;
+  --color-box-hover: #c4a29b;
+  --color-mask: rgba(0, 0, 0, 0.4);
+
+  --swiper-navigation-color: var(--color-primary);
+  --swiper-pagination-color: var(--color-primary);
+  --container-width: 1200px;
+  --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;
+}
+
+a {
+  transition: color 0.3s;
+  text-decoration: none;
+  color: var(--color-text);
+
+  &:hover {
+    color: var(--color-primary);
+  }
+}
+ul {
+  list-style: none;
+}
+
+/* 头部样式 */
+header {
+  padding: 15px 0;
+  background-color: #fff;
+  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+
+  .inner {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+    max-width: var(--container-width);
+    margin: 0 auto;
+  }
+  .logo {
+    font-family: 'STSongtiSC', "Microsoft YaHei", sans-serif;
+    font-size: 24px;
+    font-weight: bold;
+    color: var(--color-primary);
+  }
+  .mobile-menu-toggle {
+    display: none;
+    width: 60px;
+    height: 60px;
+  }
+}
+
+button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  background-color: var(--color-primary);
+  color: var(--color-text-light);
+  
+  &:hover {
+  background-color: var(--color-secondary);
+  }
+  & i {
+    color: var(--color-text-light);
+  }
+
+  &.bordered {
+    color: var(--color-text);
+    background-color: #f3f3f3;
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    /* 九宫格切图实现 - 防止图片变形 */
+    border-image: url('@/assets/images/button-deactive.png') 25 25 25 25 stretch;
+    border-width: 10px;
+    border-style: solid;
+    background-clip: padding-box;
+    padding: 0 16px;
+    margin-right: 8px;
+    cursor: pointer;
+    font-size: 14px;
+    font-family: "STSongtiSC";
+    box-sizing: border-box;
+
+    &:hover,
+    &.active {
+      color: var(--color-text-light);
+      background-color: #d74a2e;
+      border-image: url('@/assets/images/button-active.png') 25 25 25 25 stretch;
+    }
+  }
+}
+.bordered-input {
+  color: var(--color-text);
+  border-style: solid;
+  border-width: 10px;
+  border-image: url('@/assets/images/button-deactive.png') 25 25 25 25 stretch;
+
+  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 20px;
+    font-weight: 500;
+    transition: background-color 0.3s;
+
+    &:hover, &:active {
+      color: var(--color-primary);
+    }
+  }
+}
+
+
+.search-bar {
+  position: relative;
+
+  button {
+    width: 60px;
+    height: 60px;
+    i {
+      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;
+}
+.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 {
+      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);
+      }
+    }
+    &.icon {
+      &::before, &::after {
+        display: inline-block;
+        content: '';
+        margin: 0 10px;
+        width: 20px;
+        height: 20px;
+        background-size: 20px 20px;
+        background-image: url('@/assets/images/badge.png');
+        background-position: center; 
+      }
+    } 
+  }
+  .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;
+  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);
+    }
+  }
+
+  .title {
+    flex: 1;
+    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;
+  }
+}
+
+/* 精彩推荐区域 */
+.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;
+  margin-top: -15px;
+  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
+  transition:
+    transform 0.3s,
+    box-shadow 0.3s;
+  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);
+  }
+}
+
+/* 底部区域 */
+footer {
+  color: #fff;
+  background-color: #f5f5f5;
+  background-image: url('@/assets/images/footer-bg.png');
+  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-image: url('@/assets/images/tab-active.png');
+    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 {
+        color: var(--color-primary);
+      }
+
+      &:hover {
+        color: var(--color-primary);
+      }
+    }
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+}
+
+.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 {
+  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;
+    }
+  }
+}
+
+/* 联系我们页面样式 */
+.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 {
+    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;
+}
+
+@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;
+    }
+  }
+}
+
+.swiper-slide {
+  img {
+    object-fit: cover;
+  }
+}
+
+@media (max-width: 768px) { 
+  header { 
+    .inner {
+      padding: 0 30px;
+    }
+    .logo {
+      font-size: 20px;
+    }
+    .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: 576px) {
+  .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;
+    }
+  }
+}

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

+ 1 - 0
src/common/ConstStrings.ts

@@ -0,0 +1 @@
+export const NO_CONTENT_STRING = '无内容,请添加内容!';

+ 98 - 0
src/common/ConvertRgeistry.ts

@@ -0,0 +1,98 @@
+import { 
+  DATA_MODEL_ERROR_REQUIRED_KEY_MISSING, 
+  DATA_MODEL_ERROR_TRY_CONVERT_BAD_TYPE, 
+  DATA_MODEL_ERROR_PRINT_SOURCE,
+  DATA_MODEL_ERROR_ARRAY_REQUIRED_KEY_MISSING,
+  DATA_MODEL_ERROR_ARRAY_IS_NOT_ARRAY,
+  DATA_MODEL_ERROR_MUST_PROVIDE_SIDE,
+  DATA_MODEL_ERROR_REQUIRED_KEY_NULL,
+  DataConverter, 
+  DataErrorFormatUtils, 
+  defaultDataErrorFormatHandler 
+} from "@imengyu/js-request-transform";
+
+function setErrorFormatter() {
+  DataErrorFormatUtils.setFormatHandler((error, data) => {
+    switch (error) {
+      case DATA_MODEL_ERROR_REQUIRED_KEY_MISSING: 
+        return `字段 ${data.sourceKey} 必填但未提供。 来源 ${data.source}; 对象 ${data.objectName} ${data.serverKey ? ('服务器应传字段: ' + data.serverKey) : ''}`;
+      case DATA_MODEL_ERROR_TRY_CONVERT_BAD_TYPE:
+        return `尝试将 ${data.sourceType} 转换为 ${data.targetType}。`;   
+      case DATA_MODEL_ERROR_PRINT_SOURCE:
+        return `来源: ${data.objectName}.`;
+      case DATA_MODEL_ERROR_ARRAY_REQUIRED_KEY_MISSING:
+        return `转换数组模型失败: 需要的字段 ${data.sourceKey} 未提供。`
+      case DATA_MODEL_ERROR_ARRAY_IS_NOT_ARRAY:
+        return `转换数组模型失败: 需要的字段 ${data.sourceKey} 不是数组类型。`
+      case DATA_MODEL_ERROR_MUST_PROVIDE_SIDE:
+        return `转换字段 ${data.key} 失败: 必须提供 ${data.direction} 侧数据。`;
+      case DATA_MODEL_ERROR_REQUIRED_KEY_NULL:
+        return `转换字段 ${data.key} 失败: 必填字段 ${data.key} 未提供或者为 null。`;
+    }
+    return defaultDataErrorFormatHandler(error, data);
+  });
+}
+
+export function registryConvert() {
+  setErrorFormatter();
+  DataConverter.registerConverter({
+    key: 'SplitCommaArray',
+    targetType: 'splitCommaArray',
+    converter: (source, key, type) => {
+      if (typeof source === 'string') 
+        return {
+          success: true,
+          result: source?.split(',') || [],
+        }
+      return {
+        success: false,
+        convertFailMessage: `[${key}] 不是字符串类型`,
+      };
+    }
+  })
+  DataConverter.registerConverter({
+    key: 'CommaArrayMerge',
+    targetType: 'commaArrayMerge',
+    converter: (source, key, type) => {
+      if (source instanceof Array) 
+        return {
+          success: true,
+          result: source?.join(',') || '',
+        }
+      return {
+        success: false,
+        convertFailMessage: `[${key}] 不是数组类型`,
+      };
+    }
+  })
+  DataConverter.registerConverter({
+    key: 'ForceArray',
+    targetType: 'forceArray',
+    converter: (source, key, type) => {
+      if (source instanceof Array) 
+        return {
+          success: true,
+          result: source,
+        }
+      if (typeof source === 'object' && source !== null) {
+        const arr = []
+        for (const key in source) {
+          arr.push((source as Record<string, any>)[key])
+        }
+        return {
+          success: true,
+          result: arr,
+        }
+      }
+      if (typeof source === 'string')
+        return {
+          success: true,
+          result: source.split(','), 
+        }
+      return {
+        success: false,
+        convertFailMessage: `[${key}] 不是数组类型`,
+      };
+    }
+  })
+}

+ 8 - 0
src/common/EventBus.ts

@@ -0,0 +1,8 @@
+import mitt from 'mitt'
+
+export type EventBusOnPageBackData = { name: string, data: any }
+type Events = {
+  pageActionListenOnPageBack: EventBusOnPageBackData,
+}
+
+export const EventBus = mitt<Events>();

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

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

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

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

+ 55 - 0
src/components/Footer.vue

@@ -0,0 +1,55 @@
+<template>
+  <!-- 底部 -->
+  <footer>
+    <div class="container">
+      <div class="footer-select">
+        <select name="related-links" id="related-links" @change="handleLinkChange">
+          <option value="">相关网站链接</option>
+          <option value="http://www.ncha.gov.cn/index.html">国家文物局</option>
+          <option value="https://wlt.fujian.gov.cn/">福建省文化和旅游厅</option>
+          <option value="https://www.xm.gov.cn/">厦门市人民政府</option>
+        </select>
+      </div>
+      
+      <div class="footer-links">
+        <a v-for="link in footerInfo.links" :key="link.url" :href="link.url" class="footer-link">{{ link.name }}</a>
+      </div>
+      
+      <div class="footer-info">
+        <p>闽公网安备35020302036685号 | <a href="https://beian.miit.gov.cn/">闽ICP备2025115303号-1</a> | 
+          <!-- <script type="text/javascript">document.write(unescape("%3Cspan id='_ideConac' %3E%3C/span%3E%3Cscript src='https://dcs.conac.cn/js/14/215/0000/60539030/CA142150000605390300003.js' type='text/javascript'%3E%3C/script%3E"));</script> -->
+        </p>
+        <p>联系地址: {{ footerInfo.address }}</p>
+        <p>联系电话: {{ footerInfo.contactPhone }} | 工作电话: {{ footerInfo.workPhone }}</p>
+      </div>
+    </div>
+  </footer>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+// 底部信息
+const footerInfo = ref({
+  contactPhone: "0592-5024292",
+  workPhone: "0592-5045291",
+  address: "厦门市思明区体育路95号 邮编:361012",
+  links: [
+    { name: "关于我们", url: "#" },
+    { name: "联系我们", url: "#" },
+    { name: "网站声明", url: "#" },
+    { name: "隐私声明", url: "#" },
+    { name: "使用帮助", url: "#" },
+    { name: "网站地图", url: "#" }
+  ]
+});
+
+// 相关网站链接选择框事件处理
+function handleLinkChange(event: Event) {
+  const selectElement = event.target as HTMLSelectElement;
+  const selectedValue = selectElement.value;
+  if (selectedValue) {
+    window.location.href = selectedValue;
+  }
+}
+</script>

+ 64 - 0
src/components/NavBar.vue

@@ -0,0 +1,64 @@
+<template>
+  <!-- 头部 -->
+  <header>
+    <div class="inner">
+      <div class="logo">厦门市文化遗产保护中心</div>
+      <!-- 导航 -->
+      <nav class="main-nav" :class="{ show: isMenuOpen }">
+        <div class="container">
+          <ul class="nav-list">
+            <li v-for="item in navItems.content.value" :key="item.url" class="nav-item" :class="{ active: isActive(item.url) }">
+              <router-link :to="item.url">{{ item.name }}</router-link>
+            </li>
+          </ul>
+        </div>
+      </nav>
+      <div class="d-flex flex-row align-items-center">
+        <button class="mobile-menu-toggle" @click="toggleMenu"><Icon name="material-symbols:menu" /></button>
+        <div class="search-bar">
+          <button @click="toggleSearch"><Icon name="material-symbols:search-rounded" /></button>
+          <input type="text" placeholder="搜索..." v-model="keyword" @blur="hideSearch" @keydown.enter="goSearch" :class="{ show: isSearchOpen }">
+        </div>
+      </div>
+    </div>
+  </header>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { useRoute } from 'vue-router';
+import { useSSrSimpleDataLoader } from '~/composeable/SimpleDataLoader';
+
+const route = useRoute();
+
+const navItems = await useSSrSimpleDataLoader('navItems', async () => {
+  const data = (await $fetch('/api/channel/nav')).data || [];
+  return data.map(item => {
+    return {
+      ...item,
+      url: item.type === 'list' ? `/channel/${item.diyname}` : item.outlink,
+    }
+  });
+});
+const isMenuOpen = ref(false);
+const isSearchOpen = ref(false);
+const keyword = ref('');
+
+function isActive(url: string): boolean {
+  return route.path.startsWith(url);
+}
+function toggleMenu() {
+  isMenuOpen.value = !isMenuOpen.value;
+}
+function toggleSearch() {
+  isSearchOpen.value = !isSearchOpen.value;
+}
+function hideSearch() {
+  isSearchOpen.value = false;
+}
+function goSearch() {
+  if (keyword.value.trim()) {
+    window.location.href = `/search/?keyword=${encodeURIComponent(keyword.value.trim())}`;
+  }
+}
+</script>

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

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

+ 9 - 0
src/composeable/LoaderCommon.ts

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

+ 103 - 0
src/composeable/PageAction.ts

@@ -0,0 +1,103 @@
+import { EventBus } from "@/common/EventBus";
+import { onBeforeUnmount, onMounted, type App } from "vue";
+import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
+
+/**
+ * 说明:页面导航相关函数封装。
+ */
+
+type OnPageBackCb = (data: Record<string, unknown>) => void
+
+const EventBusName = 'pageActionListenOnPageBack';
+
+export function useOnPageBack() {
+
+  const route = useRoute();
+  const cbs : OnPageBackCb[]  = [] 
+
+  function onPageBack(cb: OnPageBackCb) {
+    cbs.push(cb)
+  }
+
+  onMounted(() => {
+    EventBus.on(EventBusName, (e) => {
+      if (e.name === route.name) {
+        cbs.forEach((cb) => {
+          cb(e.data);
+        })
+      }
+    });
+  });
+  onBeforeUnmount(() => {
+    EventBus.off(EventBusName);
+  });
+
+  return {
+    onPageBack,
+  }
+}
+
+export function usePageAction() {
+  const router = useRouter();
+
+  /**
+   * 页面跳转: 后退至上一个页面。
+   */
+  function back() {
+    router.back();
+  }
+  /**
+   * 页面跳转: 后退并返回数据至上一个页面的 onPageBack 方法。
+   * @param data 要返回的数据
+   */
+  function backReturnData(data: Record<string, unknown>) {
+    callPrevOnPageBack('' + router.options.history.state.back, data);
+    router.back();
+  }
+  /**
+   * 页面跳转: 跳转到指定页面
+   * @param url 页面路径
+   * @param data 要传递的数据
+   */
+  function navTo(url: string, data: Record<string, unknown> = {}) {
+    const data2 : LocationQueryRaw = {}
+
+    for (const key in data) {
+      if (Object.prototype.hasOwnProperty.call(data, key))
+        data2[key] = data[key] as string;
+    }
+
+    router.push({
+      path: url,
+      query: data2,
+    });
+  }
+  /**
+   * 页面数据传递: 调用上一个页面的 onPageBack 方法
+   * @param name 方法名
+   * @param data 要传递的数据
+   */
+  function callPrevOnPageBack(name: string, data: Record<string, unknown>) {
+    EventBus.emit(EventBusName, {
+      name,
+      data,
+    })
+  }
+  /**
+   * 页面跳转: 调用上一个页面的 onPageBack 方法并返回至上一个页面
+   * @param name 方法名
+   * @param data 要传递的数据
+   */
+  function backAndCallOnPageBack(name: string, data: Record<string, unknown>) {
+    router.back();
+    callPrevOnPageBack(name, data);
+  }
+ 
+  return {
+    back,
+    backReturnData,
+    backAndCallOnPageBack, 
+    navTo,
+    callPrevOnPageBack,
+  }
+}

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

+ 109 - 0
src/composeable/SimpleDataLoader.ts

@@ -0,0 +1,109 @@
+import { onMounted, ref, type Ref } from "vue";
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+
+export interface ISimpleDataLoader<T, P> extends ILoaderCommon<P> {
+  content: Ref<T|null>;
+  getLastParams: () => P | undefined;
+}
+
+export function useSimpleDataLoader<T, P = any>(
+  loader: (params?: P) => Promise<T>,
+  loadWhenMounted = true,
+  emptyIfArrayEmpty = true,
+)  : ISimpleDataLoader<T, P>
+ {
+
+  const content = ref<T|null>(null) as Ref<T|null>;
+  const loadStatus = ref<LoaderLoadType>('loading');
+  const loadError = ref('');
+
+  let lastParams: P | undefined;
+
+  async function loadData(params?: P) {
+    if (params)
+      lastParams = params;
+    loadStatus.value = 'loading';
+    try {
+      const res = (await loader(params ?? lastParams)) as T;
+      content.value = res;
+      if (Array.isArray(res) && emptyIfArrayEmpty && (res as any[]).length === 0)
+        loadStatus.value = 'nomore';
+      else
+        loadStatus.value = 'finished';
+      loadError.value = '';
+    } catch(e) {
+      loadError.value = '' + e;
+      loadStatus.value = 'error';
+      console.log(e);
+      
+    }
+  }
+
+  onMounted(() => {
+    if (loadWhenMounted) {
+      setTimeout(() => {
+        loadData();
+      }, (0.5 + Math.random()) * 500);
+    }
+  })
+
+  return {
+    content,
+    loadStatus,
+    loadError,
+    loadData,
+    getLastParams: () => lastParams,
+  }
+}
+
+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 = '' + e;
+      loadStatus.value = 'error';
+      console.log(e);
+    }
+  }
+
+  watch(error, (e) => {
+    if (e) {
+      loadError.value = '' + e;
+      loadStatus.value = 'error';
+    }
+  }, { immediate: true });
+  
+  return {
+    content: content as Ref<T|null>,
+    loadStatus,
+    loadError,
+    loadData,
+    getLastParams: () => lastParams,
+  }
+}

+ 232 - 0
src/composeable/SimplePagerDataLoader.ts

@@ -0,0 +1,232 @@
+import { watch, ref, computed, type Ref } from "vue"
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+
+export interface ISimplePageListLoader<T, P> extends ILoaderCommon<P> {
+  list: Ref<T[]>;
+  page: Ref<number>;
+  next: () => Promise<void>;
+  prev: () => Promise<void>;
+  total: Ref<number>;
+  totalPages: Ref<number>;
+}
+
+/**
+ * 简单分页数据封装。
+ * 
+ * 该封装了分页数据的加载、分页、上一页、下一页等功能。当页码发生变化时,会自动调用加载函数。
+ * 简单分页同时只能显示一页数据,重新加载会覆盖之前的数据。
+ * 
+ * 使用示例:
+ * ```ts
+ * const { data, page, total, loading } = useSimplePagerDataLoader(10, async (page, pageSize) => {
+ *   const res = await fetch(`/api/data?page=${page}&pageSize=${pageSize}`);
+ *   const data = await res.json();  
+ *   return {
+ *     data,
+ *     page: res.page,
+ *     total: res.total,
+ *   };
+ * });
+ * ```
+ *
+ * @param pageSize 一页的数量
+ * @param loader 加载函数
+ * @returns 
+ */
+export function useSimplePagerDataLoader<T, P = any>(
+  pageSize: number|Ref<number>, 
+  loader: (page: number, pageSize: number, params?: P) => Promise<{
+    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) {
+      loadError.value = '' + 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>('finished');
+  const loadError = ref('');
+
+  if (error.value) {
+    loadError.value = '' + (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 = 'finished';
+  }
+
+  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 ? 'finished' : 'nomore';
+      loadError.value = '';
+      loading = false;
+    } catch(e) {
+      loadError.value = '' + 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,
+  }
+}

+ 44 - 0
src/pages/404.vue

@@ -0,0 +1,44 @@
+<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;
+  font-family: SourceHanSerifCNBold;
+  min-height: 66vh;
+
+  img {
+    width: 150px;
+    height: 150px;
+    margin-bottom: 20px;
+  }
+  h1 {
+    font-size: 2rem;
+  }
+  a {
+    text-decoration: none;
+    font-size: 1.2rem;
+    cursor: pointer;
+    color: #333;
+
+    &:hover {
+      color: var(--color-primary);
+    }
+  }
+}
+</style>

+ 107 - 0
src/pages/about.vue

@@ -0,0 +1,107 @@
+<template>
+  <!-- 联系我们 -->
+  <div class="main-background">
+    <!-- SEO -->
+    <Head>
+      <Title>厦门市文化遗产保护中心 - 联系我们</Title>
+      <Meta name="description" content="" />
+      <Meta name="keywords" content="" />
+    </Head>
+
+    <!-- 轮播图 -->
+    <img class="main-header-image" src="http://xmswhycbhzx.cn/uploads/20251013/47d9f2bcd627ab15d4e5014b50166440.jpg">
+
+    <!-- 主要内容 -->
+    <div class="main-content">
+      <div class="container">
+        <div class="section-title center large mt-4">
+          <h2 class="icon">联系方式</h2>
+        </div>
+        <div class="row">
+          <div class="col col-sm-12 col-md-12 col-lg-6">
+            
+            <ul class="info-list">
+              <li class="info-item">
+                <i class="fa fa-phone"></i>
+                电话: 0592-2085737
+              </li>
+              <li class="info-item">
+                <i class="fa fa-envelope"></i>
+                邮箱: 734381242@qq.com
+              </li>
+              <li class="info-item">
+                <i class="fa fa-fax"></i>
+                传真: 0592-2090633
+              </li>
+              <li class="info-item">
+                <i class="fa fa-clock"></i>
+                办公时间: 周一至周五 8:00-17:00
+              </li>
+              <li class="info-item">
+                <i class="fa fa-map-marker"></i>
+                地址: 厦门市思明区体育路95号文化艺术中心共享楼
+              </li>
+            </ul>
+          </div>
+          <div class="col col-sm-12 col-md-12 col-lg-6">
+            <div id="map-container" style="width:100%; height:200px;"></div>
+          </div>
+        </div>
+        <div class="section-title center large mt-5">
+          <h2 class="icon">关于我们</h2>
+        </div>
+        <div class="row">
+          <div class="col col-sm-12 col-md-6 col-lg-8">
+            <h3>厦门市文化遗产保护中心</h3>
+            <p>厦门市文化遗产保护中心(厦门市非物质文化遗产保护中心、厦门市闽南文化生态保护中心)为全额拨款公益一类事业单位,隶属厦门市文化和旅游局,机构规格为副处级,内设办公室、文物保护部、非遗保护部、考古部、信息科技部、安全管理部。</p>
+          </div>
+          <div class="col col-sm-12 col-md-6 col-lg-4 d-flex flex-row justify-content-end">
+            <img src="@/assets/images/about-logo.png" alt="厦门市文化遗产保护中心">
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+
+onMounted(() => {
+  if (import.meta.client)
+    initMap();
+});
+
+// 初始化地图
+function initMap() {
+  setTimeout(() => {
+    // 初始化地图
+    const map = new AMap.Map('map-container', {
+      zoom: 16,
+      center: [118.1086912,24.49078560] // 厦门市中心坐标
+    });
+    // 添加标记
+    const marker = new AMap.Marker({
+      position: [118.10869122,24.490785603],
+      title: '厦门市文化遗产保护中心'
+    });
+    // 将标记添加到地图
+    marker.setMap(map);
+    // 添加信息窗口
+    const infoWindow = new AMap.InfoWindow({
+      content: '<div style="padding: 10px;">厦门市文化遗产保护中心</div>',
+      offset: new AMap.Pixel(0, -30)
+    });
+    // 点击标记时显示信息窗口
+    marker.on('click', function() {
+      infoWindow.open(map, marker.getPosition() as unknown as AMap.Vector2);
+    });
+    // 初始就打开信息窗口
+    infoWindow.open(map, marker.getPosition() as unknown as AMap.Vector2);
+  }, 200);
+}
+</script>
+
+<style lang="scss">
+
+</style>
+

+ 54 - 0
src/pages/channel/[id].vue

@@ -0,0 +1,54 @@
+<template>
+  <!-- 分类 -->
+  <div class="main-background">
+    <!-- SEO -->
+    <Head>
+      <Title>厦门市文化遗产保护中心 - {{ channelName }}</Title>
+      <Meta name="description" content="" />
+      <Meta name="keywords" content="" />
+    </Head>
+    <!-- 轮播 -->
+    <Carousel v-bind="carouselConfig" class="main-header-box small carousel-light">
+      <Slide 
+        v-for="(item, key) in newsData.content.value"
+        :key="key"
+        class="main-header-box small"
+      >
+        <img :src="item.image" />
+      </Slide>
+      <template #addons>
+        <Navigation />
+        <Pagination />
+      </template>
+    </Carousel>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+
+const carouselConfig = {
+  itemsToShow: 1,
+  wrapAround: true,
+  autoPlay: 5000,
+}
+
+const route = useRoute();
+const channelName = route.params.id;
+
+const newsData = await useSSrSimpleDataLoader('news', async () => {
+  throw new Error('newsData is not implemented');
+});
+
+
+
+
+</script>
+
+<style lang="scss">
+@use "sass:list";
+@use "sass:math";
+</style>
+

+ 50 - 0
src/pages/index.vue

@@ -0,0 +1,50 @@
+<template>
+  <!-- 首页 -->
+  <div class="main-background">
+    <!-- SEO -->
+    <Head>
+      <Title>厦门市文化遗产保护中心</Title>
+      <Meta name="description" content="" />
+      <Meta name="keywords" content="" />
+    </Head>
+    <!-- 轮播 -->
+    <Carousel v-bind="carouselConfig" class="main-header-box small carousel-light">
+      <Slide 
+        v-for="(item, key) in newsData.content.value"
+        :key="key"
+        class="main-header-box small"
+      >
+        <img :src="item.image" />
+      </Slide>
+      <template #addons>
+        <Navigation />
+        <Pagination />
+      </template>
+    </Carousel>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+import { useSSrSimpleDataLoader } from '@/composeable/SimpleDataLoader';
+import { DataDateUtils } from '@imengyu/js-request-transform';
+import { ScrollRect } from '@imengyu/vue-scroll-rect';
+
+const carouselConfig = {
+  itemsToShow: 1,
+  wrapAround: true,
+  autoPlay: 5000,
+}
+const newsData = await useSSrSimpleDataLoader('news', async () => {
+  throw new Error('newsData is not implemented');
+});
+</script>
+
+<style lang="scss">
+@use "sass:list";
+@use "sass:math";
+</style>
+

+ 4 - 0
src/scripts/.gitignore

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

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

+ 524 - 0
src/scripts/UpdateScript/index.mjs

@@ -0,0 +1,524 @@
+/**
+ * 更新发布工具
+ * 
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { program } from 'commander';
+import { password, input, confirm, select } from '@inquirer/prompts';
+import { writeFile, readFile } from 'node:fs/promises';
+import { postAppUpdate, postWebUpdate } from './postUpdate.mjs';
+import Table from 'cli-table3';
+import md5 from 'md5';
+import axios from 'axios';
+import path from 'path';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const constant = {
+  ServerUrl: 'http://update-server1.imengyu.top/',
+  TokenSave: path.resolve(__dirname, './_token.json'),
+};
+let currentData = {
+  token: '',
+  identifier: '',
+};
+
+readFile(constant.TokenSave).then((res) => {
+  currentData = JSON.parse(res);
+  if (!currentData.identifier) 
+    currentData.identifier = `commandClient${Math.floor(Math.random() * 1000)}`;
+  start();
+}).catch(() => {
+  start();
+})
+
+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;
+});
+axiosInstance.interceptors.response.use((value) => {
+  if (value.data.success) 
+    return value.data;
+  else
+    return Promise.reject(value.data);
+});
+
+function getErrorMessage(e) {
+  return e instanceof Error ? e.message : (typeof e === 'object' ? e : ('' + e));
+}
+
+//登录相关
+//========================================
+
+async function checkLogged() {
+  try {
+    await axiosInstance.get('/auth');
+    console.log('已登录');
+  } catch (e) {
+    console.error('获取状态失败:', getErrorMessage(e));
+  }
+}
+async function login(user) {
+  try {
+    const pass = await password({ message: '输入密码' });
+    const res = await axiosInstance.post('/auth?rember=true', {
+      method: 'key',
+      key: `${user}@${md5(pass)}`,
+    })
+    currentData.token = {
+      authName: res.data.authName,
+      authKey: res.data.authKey,
+    };
+    writeFile(constant.TokenSave, JSON.stringify(currentData));
+    console.log('登录成功');
+  } catch (e) {
+    console.error('登录失败', getErrorMessage(e));
+  }
+}
+async function logout() {
+  currentToken = '';
+  writeFile(constant.TokenSave, JSON.stringify(currentData));
+  try {
+    await axiosInstance.delete('/auth');
+    console.log('退出登录成功');
+  } catch(e) {
+    console.error('退出登录失败', getErrorMessage(e));
+  }
+}
+
+//版本相关
+//========================================
+
+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) {
+      console.error('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());
+  }
+}
+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: "{}",
+        });
+        console.log('Add version success');
+      } catch (e) {
+        console.error('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);
+        console.log('删除版本成功');
+      } catch (e) {
+        console.error('删除版本失败', 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
+        });
+        console.log('设置状态成功');
+      } catch (e) {
+        console.error('设置状态失败', 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,
+        });
+        console.log('设置配置成功');
+      } catch (e) {
+        console.error('设置配置失败', 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 });
+        console.log('成功');
+      } catch (e) {
+        console.error('失败', 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 });
+        console.log('成功');
+      } catch (e) {
+        console.error('失败', e);
+      }
+      break;
+    }
+    default:
+      console.error('未知参数', action);
+      break;
+  }
+}
+
+//选择方法
+//========================================
+
+export async function selectVersion(requireString = false, defaultVersionId = null) {
+  const data = (await axiosInstance.get('/version/list')).data;
+  if (data.length === 0) {
+    console.error('没有版本');
+    return;
+  }
+  const resultId = (await select({
+    choices: data.map(p => ({
+      value: p.id,
+      name: p.version,
+    })),
+    default: defaultVersionId,
+    message: '选择一个版本',
+  }));
+  if (requireString) {
+    return data.find(p => p.id === resultId).version
+  }
+  return resultId;
+}
+export async function selectUpdate() {
+  const versionId = await selectVersion();
+  const data = (await axiosInstance.get('/version/update?search=' + JSON.stringify({ versionId }))).data;
+  if (data.length === 0) {
+    console.error('没有更新');
+    return;
+  }
+  const resultId = (await select({
+    choices: data.map(p => ({
+      value: p.id,
+      name: p.version,
+    })),
+    default: defaultVersionId,
+    message: '选择一个更新',
+  }));
+  return resultId;
+}
+
+//更新相关
+//========================================
+
+const stateConstant = [ 'Deleted', 'Normal', 'Deprecated' ];
+const typeConstant = [ 'Unknow', 'Web', 'app' ];
+const storageTypeConstant = [ 'Unknow', 'LocalStorage', 'AliOSS' ];
+
+async function postUpdate(type, options) {
+  switch (type) {
+    case 'web': {
+      await postWebUpdate(axiosInstance, options);
+      break;
+    }
+    case 'app': {
+      await postAppUpdate(axiosInstance, options);
+      break;
+    }
+    default:
+      console.error('Unknow type', type);
+      break;
+  }
+}
+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) {
+    console.log('只有Web更新允许弃用');
+    return;
+  }
+  if (currentUpdateInfo.status === 0) {
+    console.log(`当前状态 ${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 });
+    console.log(`${deprecate ? '弃用' : '删除'} 成功`);
+  } catch (e) {
+    console.error(`操作失败`, e);
+  }
+}
+async function viewUpdate(updateId) {
+  
+  let currentUpdateInfo = null
+  let currentVersionInfo = null
+
+  try {
+    currentUpdateInfo = (await axiosInstance.get('/update/' + updateId)).data;
+  } catch (e) {
+    console.error('加载更新信息失败', updateId);
+  }
+  try {
+    currentVersionInfo = (await axiosInstance.get('/version/' + currentUpdateInfo.versionId)).data;
+  } catch (e) {
+    console.error('加载版本信息失败', 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,
+  }
+}
+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);
+      console.log('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: 
+        console.error('Unknow action', action);
+        break;
+    }
+  }
+}
+async function testVersion(version) {
+  try {
+    const data = await axiosInstance.get('/update-get-info?version=' + version);
+    console.log('版本信息', data.data);
+  } catch(e) {
+    console.log('获取失败', e);
+  }
+}
+
+//程序入口
+//============================================
+
+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);
+}
+
+process.on('unhandledRejection', (reason, p) => {
+  console.error('Promise: ', p, 'Reason: ', reason)
+})

+ 66 - 0
src/scripts/UpdateScript/postConfig.mjs

@@ -0,0 +1,66 @@
+import { readFileSync } from 'node:fs';
+import { pad } from './postUpdate.mjs';;
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+//提交更新配置
+//========================================
+
+export const config = {
+  submitKey: '',
+  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: '', //版本号输出目录,输出版本号至文件以供项目使用。相对于当前文件目录
+  /**
+   * 自定义生成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, lastTodaySubVersion) => {
+    //构建App
+    throw new Error('未实现buildAppCallback方法');
+  }, 
+  buildAppGetUploadFile: async (param) => {
+    //获取上传文件路径
+    throw new Error('未实现buildAppGetUploadFile方法');
+  }, 
+  buildAppGetOSSFileName: async (param) => {
+    //生成OSS保存路径
+    throw new Error('buildAppGetOSSFileName');
+  },//构建命令
+  buildAppOutDir: './dist', //构建输出目录。相对于当前文件目录
+  buildAppGetVersion: async () => {
+    //获取版本号
+    throw new Error('未实现buildAppGetVersion方法');
+  },
+} 

+ 430 - 0
src/scripts/UpdateScript/postUpdate.mjs

@@ -0,0 +1,430 @@
+/**
+ * 更新发布工具
+ * 
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { confirm , input } from '@inquirer/prompts';
+import { writeFile, readFile, access, unlink, readdir, stat, constants } from 'node:fs/promises';
+import { exec } from 'node:child_process';
+import fs from 'fs';
+import archiver from 'archiver';
+import path, { resolve } from 'node:path';
+import OSS from 'ali-oss';
+import cliProgress from 'cli-progress';
+import { selectVersion } from './index.mjs';
+import { config } from './postConfig.mjs';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+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;
+}
+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 updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo });
+  if (updateInfo === 'git') {
+    console.log('开始获取git提交信息');
+    updateInfo = await new Promise((resolve, reject) => {
+      exec('git log -1 --pretty=format:"%h %s"', (error, stdout, stderr) => {
+        if (error) {
+          reject(error);
+          return;
+        }
+        resolve(stdout);
+      });
+    });
+    console.log('使用git提交信息作为更新信息');
+  }
+  return updateInfo;
+}
+
+//App更新与提交
+//========================================
+
+export async function postAppUpdate(axiosInstance, param) {
+  const postConfig = await getConfig();
+  const versionId = await selectVersion(false, postConfig.lastVersion);
+  const updateInfo = await getUpdateInfo(postConfig);
+
+  await axiosInstance.post('/update-post', { config: { type: 2, test: true, versionId, uploadWebConfig: config.uploadWebConfig, submitKey: config.submitKey } });
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = 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 = hotfix ? false : await confirm({ message: '作为下个版本?', default: false });
+  const versionCode = config.buildAppGetVersion();
+
+  await saveConfig(postConfig);
+
+  if (updateSource === 'rebuild') 
+    await config.buildAppCallback(param, versionCode, postConfig.lastTodaySubVersion);
+  else if (updateSource === 'uploaded-alioss') {
+    const fileName = await input({ message: '输入已上传的阿里OSS文件路径' });
+    try {
+      const result = (await axiosInstance.post('/update-post', {
+        type: 2,
+        ossConfig: {
+          ossPath: fileName,
+          ossPublic: '',
+        },
+        uploadAppConfig: {
+          updateAsNext: updateNext,
+        },
+        versionId,
+        updateInfo,
+        versionCode: versionCode
+      })).data;
+      console.log('上传成功');
+      console.log('新更新ID: ' + result.updateId);
+    } catch (e) {
+      console.error('上传失败', e);
+    }
+    return;
+  }
+  else {
+    console.error('错误的选择');
+    return;
+  }
+
+  const uploadFile = await config.buildAppGetUploadFile(param);
+  try {
+    await access(uploadFile, constants.R_OK)
+  } catch {
+    console.error(`Failed to access ${uploadFile}, did you created it?`);
+    return;
+  }
+
+  console.log('开始上传');
+
+  //小于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("type", 2);
+    formData.append("versionId", versionId);
+    formData.append("updateInfo", updateInfo);
+    formData.append("uploadAppConfig", { updateAsNext: updateNext });
+    formData.append("force", force);
+    formData.append("versionCode", versionCode);
+
+    try {
+      const result = (await axiosInstance.post('/update-post', formData, {
+        headers: { 'Content-Type': 'multipart/form-data' }
+      })).data;
+      console.log('上传成功');
+      console.log('新更新ID: ' + result.updateId);
+      console.log('删除构建文件');
+
+      await unlink(uploadFile);
+    } catch (e) {
+      console.error('上传失败', e);
+    }
+
+  } else {
+    //阿里OSS上传
+    //请求STS进行临时授权
+    const stsToken = (await axiosInstance.post('/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-ali-oss-sts")).data;
+        return {
+          accessKeyId: refreshToken.AccessKeyId,
+          accessKeySecret: refreshToken.AccessKeySecret,
+          stsToken: refreshToken.SecurityToken,
+        };
+      },
+    });
+    
+    //小于96mb则直接上传,否则分片上传
+    const fileName = `/${await config.buildAppGetOSSFileName(param)}`;
+
+    console.log('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);
+      await aliOSSMultipartUpload(client, fileName, uploadFile, (p) => {
+        bar1.update(p * 100);
+      });
+      bar1.update(100);
+      bar1.stop();
+    }
+
+    console.log('Upload to ali oss done');
+  
+    try {
+      const result = (await axiosInstance.post('/update-post', {
+        type: 2,
+        ossConfig: {
+          ossPath: fileName,
+          ossPublic: returnUrl,
+        },
+        uploadAppConfig: {
+          updateAsNext: updateNext,
+        },
+        versionId,
+        updateInfo,
+        versionCode: versionCode
+      })).data;
+      console.log('上传成功');
+      console.log('新更新ID: ' + result.updateId);
+    } catch (e) {
+      console.error('上传失败', e);
+    }
+  }
+}
+
+//Web更新与提交
+//========================================
+
+export async function postWebUpdate(axiosInstance, param) {
+  const skipBuild = param.skip
+  const noDelete = param.ndelete;
+  
+  const postConfig = await getConfig();
+  const versionId = await selectVersion(false, postConfig.lastVersion);
+  const updateInfo = await getUpdateInfo(postConfig);
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = updateInfo;
+  postConfig.lastTodaySubVersion++;
+
+  await axiosInstance.post('/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) {
+    console.log('正在执行构建...');
+
+    await new Promise((resolve, reject) => {
+      exec(config.buildWebCommand, function(err, stdout) {
+        if (err)
+          reject(err);
+        else 
+          resolve();
+      });
+    });
+
+    console.log('构建完成');
+  }
+
+  const distDir = path.resolve(__dirname, config.buildWebOutDir);
+
+  try {
+    await access(distDir, constants.R_OK)
+  } catch {
+    console.error(`Failed to access ${distDir}`);
+    return;
+  }
+
+  const outputPath = __dirname + '/upload.zip';
+  const skipFiles = config?.buildWebOptions?.skipFiles ?? [];
+
+  function checkPathSkip(path) {
+    if (!skipFiles || skipFiles.length === 0)
+      return;
+    return skipFiles.find((item) => path.startsWith(item));
+  }
+
+  if (!skipBuild) {
+    console.log('开始压缩zip...');
+
+    const output = fs.createWriteStream(outputPath);
+    const archive = archiver('zip', {
+      zlib: { level: 9 } // Sets the compression level.
+    });
+    archive.pipe(output);
+
+    async function loopDir(path, subPrefix) {
+      const files = await readdir(path);
+      for (const file of files) {
+        const subPath = subPrefix + '/' + file;
+        if (checkPathSkip(subPath))
+          continue;
+        const filestat = await stat(distDir + subPath);
+        if (filestat.isDirectory()) {
+          await loopDir(distDir + subPath, subPath);
+        } else {
+          archive.file(distDir + subPath, { name: subPath });
+        }
+      }
+    }
+    await loopDir(distDir, '')
+
+    console.log('等待压缩zip...');
+
+    const waitArchive = new Promise((resolve, reject) => {
+      output.on('close', function() {
+        console.log(archive.pointer() + ' total bytes');
+        console.log('archiver has been finalized and the output file descriptor has closed.');
+        resolve();
+      });
+      archive.on('error', function(err) {
+        reject(err);
+      });
+    })
+
+    await archive.finalize();
+    await waitArchive;
+  }
+
+  console.log('开始上传zip');
+
+  let success = false;
+
+  //小于8mb则小文件上传,否则分片上传
+  const fileInfo = await stat(outputPath);
+  const formData = new FormData();
+  const submitConfig = {
+    type: 1,
+    versionId,
+    updateInfo,
+    versionCode,
+    uploadWebConfig: config.uploadWebConfig,
+  }
+
+  if (fileInfo.size < 8 * 1024 * 1024) {
+    const uploadZipContent = await readFile(outputPath);
+    formData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
+  } else {
+    const multuploadInfo = (await axiosInstance.post('/update-large-token', {
+      fileSize: fileInfo.size,
+      fileName: path.basename(outputPath)
+    })).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(outputPath, 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-large', subFormData, {
+        headers: { 'Content-Type': 'multipart/form-data' }
+      })).data;
+      
+      bar1.update(Math.floor(i / multuploadInfo.allChunks * 100));
+    }
+
+    bar1.update(100);
+    bar1.stop();
+
+    submitConfig.multuploadedKey = multuploadInfo.key;
+  }
+
+  try {
+    formData.append("config", JSON.stringify(submitConfig));
+    const result = (await axiosInstance.post('/update-post', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })).data;
+    console.log('上传成功');
+    console.log('新更新ID: ' + result.updateId);
+    success = true;
+  } catch (e) {
+    console.error('上传失败', e);
+  }
+  if (!success || noDelete) 
+    return;
+    
+  console.log('删除zip');
+  await unlink(outputPath);
+}

+ 74 - 0
src/stores/auth.ts

@@ -0,0 +1,74 @@
+import UserApi, { LoginResult, UserInfo } from "@/api/auth/UserApi";
+import { defineStore } from "pinia"
+
+const STORAGE_KEY = 'authInfo';
+
+export const useAuthStore = defineStore('auth', {
+  state: () => ({
+    token: '',
+    expireAt: 0,
+    userId: 0,
+    userInfo: null as null|UserInfo,
+  }),
+  actions: {
+    async loadLoginState() {
+      try {
+        const res = localStorage.getItem(STORAGE_KEY);
+        if (!res)
+          throw 'no storage';
+        const authInfo = JSON.parse(res);
+        this.token = authInfo.token;
+        this.userId = authInfo.userId;
+        this.expireAt = authInfo.expireAt;
+
+        // 检查token是否过期,如果快要过期,则刷新token
+        if (Date.now() > this.expireAt + 1000 * 3600 * 5) {
+          const refreshResult = await UserApi.refresh();
+          this.loginResultHandle(refreshResult);
+          this.userInfo = refreshResult.userInfo;
+        } else {
+          this.userInfo = await UserApi.getUserInfo(this.userId);
+        }
+      } catch (error) {
+        this.token = '';
+        this.userId = 0;
+        this.userInfo = null;
+
+        console.log('loadLoginState', error);
+      }
+    },
+    async loginAdmin(account: string, password: string) {
+      const loginResult = await UserApi.loginAdmin({
+        account,
+        password,
+      })
+      this.loginResultHandle(loginResult);
+    },
+    async loginResultHandle(loginResult: LoginResult) {
+      this.token = loginResult.userInfo.token;
+      this.userId = loginResult.userInfo.id;
+      this.userInfo = loginResult.userInfo;
+      this.expireAt = loginResult.userInfo.expiresIn + Date.now();
+
+      localStorage.setItem(STORAGE_KEY, 
+        JSON.stringify({ 
+          token: this.token, 
+          userId: this.userId ,
+          expireAt: this.expireAt,
+        }) 
+      );
+    },
+    async logout() {
+      this.token = '';
+      this.userId = 0;
+      this.userInfo = null;
+
+      localStorage.removeItem(STORAGE_KEY);
+    }
+  },
+  getters: {
+    isLogged(state) {
+      return state.token != '' && state.userId != 0
+    },
+  },
+})

+ 1 - 0
test

@@ -0,0 +1 @@
+Subproject commit 2d48ba9ace084d31ed9aae89ccaa2dac8521ea7b

+ 4 - 0
tsconfig.json

@@ -0,0 +1,4 @@
+{
+  // https://nuxt.com/docs/guide/concepts/typescript
+  "extends": "./.nuxt/tsconfig.json"
+}