chenlei 2 years ago
parent
commit
e73c929237
86 changed files with 12687 additions and 1282 deletions
  1. 3 0
      .eslintrc.js
  2. 3 0
      .gitignore
  3. 1 0
      .npmrc
  4. 9 0
      auto-imports.d.ts
  5. 28 0
      components.d.ts
  6. 26 6
      package.json
  7. 9891 1070
      pnpm-lock.yaml
  8. 8 0
      public/config.js
  9. BIN
      public/fonts/SOURCEHANSERIFCN-BOLD.OTF
  10. BIN
      public/fonts/SOURCEHANSERIFCN-REGULAR.OTF
  11. 11 5
      public/index.html
  12. 43 0
      public/js/flexible.js
  13. 79 0
      src/App.scss
  14. 64 25
      src/App.vue
  15. 39 0
      src/api/index.ts
  16. 39 0
      src/api/types.ts
  17. BIN
      src/assets/imgs/bg3@2x.jpg
  18. BIN
      src/assets/imgs/bg4@2x.jpg
  19. BIN
      src/assets/imgs/label1@2x.png
  20. BIN
      src/assets/imgs/label2@2x.png
  21. BIN
      src/assets/logo.png
  22. 7 0
      src/assets/svg/icon_address.svg
  23. 7 0
      src/assets/svg/icon_date.svg
  24. 6 0
      src/assets/svg/icon_time.svg
  25. 86 0
      src/background.ts
  26. 0 142
      src/components/HelloWorld.vue
  27. BIN
      src/components/back-btn/icon_back@2x.png
  28. 37 0
      src/components/back-btn/index.vue
  29. 10 0
      src/components/drag-view/index.scss
  30. 216 0
      src/components/drag-view/index.vue
  31. 6 0
      src/components/index.ts
  32. 48 0
      src/components/screen-savers/index.vue
  33. 34 0
      src/components/svg-icon/index.vue
  34. 30 0
      src/components/svg-icon/plugin.ts
  35. 27 0
      src/el.d.ts
  36. 5 0
      src/global.d.ts
  37. 8 0
      src/img.d.ts
  38. 8 1
      src/main.ts
  39. 25 10
      src/router/index.ts
  40. 118 0
      src/utils/date.ts
  41. 23 0
      src/utils/index.ts
  42. 127 0
      src/utils/services.ts
  43. 0 5
      src/views/AboutView.vue
  44. 0 18
      src/views/HomeView.vue
  45. 86 0
      src/views/cloud-museum/components/card.vue
  46. BIN
      src/views/cloud-museum/imgs/btn_active@2x.png
  47. BIN
      src/views/cloud-museum/imgs/content01@2x.png
  48. BIN
      src/views/cloud-museum/imgs/icon_search.png
  49. BIN
      src/views/cloud-museum/imgs/label_search.png
  50. BIN
      src/views/cloud-museum/imgs/line01@2x.png
  51. BIN
      src/views/cloud-museum/imgs/line02@2x.png
  52. 168 0
      src/views/cloud-museum/index.scss
  53. 142 0
      src/views/cloud-museum/index.vue
  54. 28 0
      src/views/home/components/card/index.scss
  55. 35 0
      src/views/home/components/card/index.vue
  56. BIN
      src/views/home/imgs/bg2.jpg
  57. BIN
      src/views/home/imgs/btn_museum_active@2x.png
  58. BIN
      src/views/home/imgs/btn_museum_normal@2x.png
  59. BIN
      src/views/home/imgs/btn_reservation_active@2x.png
  60. BIN
      src/views/home/imgs/btn_reservation_normal@2x.png
  61. BIN
      src/views/home/imgs/btn_scenery_active@2x.png
  62. BIN
      src/views/home/imgs/btn_scenery_normal@2x.png
  63. BIN
      src/views/home/imgs/icon_gesture@2x.png
  64. BIN
      src/views/home/imgs/label@2x.png
  65. BIN
      src/views/home/imgs/tab1@2x.png
  66. BIN
      src/views/home/imgs/tab@2x.png
  67. 72 0
      src/views/home/index.scss
  68. 173 0
      src/views/home/index.vue
  69. 28 0
      src/views/iframe/index.vue
  70. 84 0
      src/views/museum-detail/components/card.vue
  71. BIN
      src/views/museum-detail/imgs/content02@2x.png
  72. 71 0
      src/views/museum-detail/index.scss
  73. 80 0
      src/views/museum-detail/index.vue
  74. BIN
      src/views/venue-reservation/imgs/btn_add@2x.png
  75. BIN
      src/views/venue-reservation/imgs/btn_chosen@2x.png
  76. BIN
      src/views/venue-reservation/imgs/btn_delete@2x.png
  77. BIN
      src/views/venue-reservation/imgs/btn_l_active@2x.png
  78. BIN
      src/views/venue-reservation/imgs/btn_l_normal@2x.png
  79. BIN
      src/views/venue-reservation/imgs/btn_m_active@2x.png
  80. BIN
      src/views/venue-reservation/imgs/btn_m_normal@2x.png
  81. BIN
      src/views/venue-reservation/imgs/btn_submit@2x.png
  82. BIN
      src/views/venue-reservation/imgs/content_l@2x.png
  83. BIN
      src/views/venue-reservation/imgs/content_m@2x.png
  84. 235 0
      src/views/venue-reservation/index.scss
  85. 313 0
      src/views/venue-reservation/index.vue
  86. 100 0
      vue.config.js

+ 3 - 0
.eslintrc.js

@@ -2,6 +2,7 @@ module.exports = {
   root: true,
   env: {
     node: true,
+    "vue/setup-compiler-macros": true,
   },
   extends: [
     "plugin:vue/vue3-essential",
@@ -15,5 +16,7 @@ module.exports = {
   rules: {
     "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
     "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
+    "@typescript-eslint/no-explicit-any": "off",
+    "vue/multi-word-component-names": "off",
   },
 };

+ 3 - 0
.gitignore

@@ -21,3 +21,6 @@ pnpm-debug.log*
 *.njsproj
 *.sln
 *.sw?
+
+#Electron-builder output
+/dist_electron

+ 1 - 0
.npmrc

@@ -1 +1,2 @@
 shamefully-hoist=true
+ ELECTRON_BUILDER_BINARIES_MIRROR=https://mirrors.huaweicloud.com/electron-builder-binaries/

+ 9 - 0
auto-imports.d.ts

@@ -0,0 +1,9 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+export {}
+declare global {
+  const ElMessage: typeof import('element-plus/es')['ElMessage']
+}

+ 28 - 0
components.d.ts

@@ -0,0 +1,28 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+declare module 'vue' {
+  export interface GlobalComponents {
+    BackBtn: typeof import('./src/components/back-btn/index.vue')['default']
+    DragView: typeof import('./src/components/drag-view/index.vue')['default']
+    ElCarousel: typeof import('element-plus/es')['ElCarousel']
+    ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
+    ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElForm: typeof import('element-plus/es')['ElForm']
+    ElFormItem: typeof import('element-plus/es')['ElFormItem']
+    ElImage: typeof import('element-plus/es')['ElImage']
+    ElInput: typeof import('element-plus/es')['ElInput']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    ScreenSavers: typeof import('./src/components/screen-savers/index.vue')['default']
+    SvgIcon: typeof import('./src/components/svg-icon/index.vue')['default']
+  }
+  export interface ComponentCustomProperties {
+    vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+  }
+}

+ 26 - 6
package.json

@@ -1,36 +1,56 @@
 {
-  "name": "sz-wlg-app",
+  "name": "js-wlg-app",
   "version": "0.1.0",
+  "description": "江苏文旅厅 16:9 大屏适配",
   "private": true,
   "scripts": {
-    "serve": "vue-cli-service serve",
-    "build": "vue-cli-service build",
-    "lint": "vue-cli-service lint"
+    "serve": "cross-env VUE_APP_BACKEND_URL=https://sit-jswl2.4dage.com vue-cli-service serve",
+    "build": "cross-env VUE_APP_BACKEND_URL=https://sit-jswl2.4dage.com vue-cli-service build",
+    "lint": "vue-cli-service lint",
+    "electron:build": "cross-env VUE_APP_BACKEND_URL=https://sit-jswl2.4dage.com vue-cli-service electron:build",
+    "electron:serve": "cross-env VUE_APP_BACKEND_URL=https://sit-jswl2.4dage.com vue-cli-service electron:serve",
+    "postinstall": "electron-builder install-app-deps",
+    "postuninstall": "electron-builder install-app-deps"
   },
+  "main": "background.js",
   "dependencies": {
+    "axios": "^1.4.0",
     "core-js": "^3.8.3",
+    "dayjs": "^1.11.9",
+    "element-plus": "^2.3.8",
+    "lodash": "^4.17.21",
     "vue": "^3.2.13",
     "vue-class-component": "^8.0.0-0",
     "vue-router": "^4.0.3",
     "vuex": "^4.0.0"
   },
   "devDependencies": {
+    "@types/electron-devtools-installer": "^2.2.0",
+    "@types/lodash": "^4.14.195",
     "@typescript-eslint/eslint-plugin": "^5.4.0",
     "@typescript-eslint/parser": "^5.4.0",
     "@vue/cli-plugin-babel": "~5.0.0",
     "@vue/cli-plugin-eslint": "~5.0.0",
     "@vue/cli-plugin-router": "~5.0.0",
-    "@vue/cli-plugin-typescript": "~5.0.0",
+    "@vue/cli-plugin-typescript": "~4.5.15",
     "@vue/cli-plugin-vuex": "~5.0.0",
     "@vue/cli-service": "~5.0.0",
     "@vue/eslint-config-typescript": "^9.1.0",
+    "cross-env": "^7.0.3",
+    "electron": "^13.0.0",
+    "electron-devtools-installer": "^3.1.0",
     "eslint": "^7.32.0",
     "eslint-config-prettier": "^8.3.0",
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-vue": "^8.0.3",
+    "lodash-webpack-plugin": "^0.11.6",
     "prettier": "^2.4.1",
     "sass": "^1.32.7",
     "sass-loader": "^12.0.0",
-    "typescript": "~4.5.5"
+    "svg-sprite-loader": "^6.0.11",
+    "typescript": "~4.5.5",
+    "unplugin-auto-import": "^0.16.6",
+    "unplugin-vue-components": "^0.25.1",
+    "vue-cli-plugin-electron-builder": "~2.1.1"
   }
 }

File diff suppressed because it is too large
+ 9891 - 1070
pnpm-lock.yaml


+ 8 - 0
public/config.js

@@ -0,0 +1,8 @@
+// 场馆 Record<场馆id, string>
+museum = { 3: "苏州博物馆(本馆),苏州博物馆(西馆)" };
+
+// 云游景区
+cloudScenicUrl = "https://www.baidu.com";
+
+// 单位
+company = "珠海四维看看";

BIN
public/fonts/SOURCEHANSERIFCN-BOLD.OTF


BIN
public/fonts/SOURCEHANSERIFCN-REGULAR.OTF


+ 11 - 5
public/index.html

@@ -1,17 +1,23 @@
 <!DOCTYPE html>
 <html lang="">
   <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <meta name="viewport" content="width=device-width,initial-scale=1.0">
-    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <meta charset="utf-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
     <title><%= htmlWebpackPlugin.options.title %></title>
   </head>
   <body>
     <noscript>
-      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+      <strong
+        >We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
+        properly without JavaScript enabled. Please enable it to
+        continue.</strong
+      >
     </noscript>
     <div id="app"></div>
     <!-- built files will be auto injected -->
+    <script src="<%= BASE_URL %>js/flexible.js"></script>
+    <script src="<%= BASE_URL %>config.js"></script>
   </body>
 </html>

+ 43 - 0
public/js/flexible.js

@@ -0,0 +1,43 @@
+(function flexible(window, document) {
+  var docEl = document.documentElement;
+  var dpr = window.devicePixelRatio || 1;
+
+  // adjust body font size
+  function setBodyFontSize() {
+    if (document.body) {
+      document.body.style.fontSize = 12 * dpr + "px";
+    } else {
+      document.addEventListener("DOMContentLoaded", setBodyFontSize);
+    }
+  }
+  setBodyFontSize();
+
+  // set 1rem = viewWidth / 10
+  function setRemUnit() {
+    var rem = docEl.clientWidth / 24;
+    docEl.style.fontSize = rem + "px";
+  }
+
+  setRemUnit();
+
+  // reset rem unit on page resize
+  window.addEventListener("resize", setRemUnit);
+  window.addEventListener("pageshow", function (e) {
+    if (e.persisted) {
+      setRemUnit();
+    }
+  });
+
+  // detect 0.5px supports
+  if (dpr >= 2) {
+    var fakeBody = document.createElement("body");
+    var testElement = document.createElement("div");
+    testElement.style.border = ".5px solid transparent";
+    fakeBody.appendChild(testElement);
+    docEl.appendChild(fakeBody);
+    if (testElement.offsetHeight === 1) {
+      docEl.classList.add("hairlines");
+    }
+    docEl.removeChild(fakeBody);
+  }
+})(window, document);

+ 79 - 0
src/App.scss

@@ -0,0 +1,79 @@
+body,
+ol,
+ul,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+th,
+td,
+dl,
+dd,
+form,
+fieldset,
+legend,
+input,
+textarea,
+select {
+  margin: 0;
+  padding: 0;
+}
+
+@font-face {
+  font-family: "Source Han Serif CN-Bold";
+  src: url("~/public/fonts/SOURCEHANSERIFCN-BOLD.OTF") format("opentype");
+  font-display: swap;
+}
+
+@font-face {
+  font-family: "Source Han Sans CN-Regular";
+  src: url("~/public/fonts/SOURCEHANSERIFCN-REGULAR.OTF") format("opentype");
+  font-display: swap;
+}
+
+body {
+  font-family: Source Han Sans CN-Regular;
+  font-size: 0.175rem /* 14/80 */;
+  font-weight: 400;
+  user-select: none;
+}
+
+:root {
+  --el-color-primary: #2e6f80 !important;
+}
+
+input::-webkit-outer-spin-button,
+input::-webkit-inner-spin-button {
+  -webkit-appearance: none;
+}
+
+.el-input .el-input__clear,
+.el-input .el-input__password {
+  font-size: 0.175rem /* 14/80 */ !important;
+}
+
+.page-loading {
+  .el-loading-mask {
+    background: transparent;
+  }
+}
+
+.empty {
+  padding: 0.5rem /* 40/80 */ 0;
+  color: #666;
+  text-align: center;
+  font-size: 0.2rem /* 16/80 */;
+}
+
+.limit-line {
+  display: -webkit-box;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+  word-break: break-all;
+  word-wrap: break-word;
+}

+ 64 - 25
src/App.vue

@@ -1,30 +1,69 @@
 <template>
-  <nav>
-    <router-link to="/">Home</router-link> |
-    <router-link to="/about">About</router-link>
-  </nav>
-  <router-view />
+  <el-config-provider :locale="zhCn">
+    <router-view
+      v-slot="{ Component }"
+      @click="clearTimer"
+      @mousemove="clearTimer"
+      @touchmove="clearTimer"
+    >
+      <keep-alive>
+        <component :is="Component" />
+      </keep-alive>
+    </router-view>
+
+    <ScreenSavers
+      v-show="showScreenSavers"
+      :autoPlay="autoPlay"
+      :list="screenList"
+      @onClick="closeScreen"
+    />
+  </el-config-provider>
 </template>
 
-<style lang="scss">
-#app {
-  font-family: Avenir, Helvetica, Arial, sans-serif;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-  text-align: center;
-  color: #2c3e50;
-}
-
-nav {
-  padding: 30px;
-
-  a {
-    font-weight: bold;
-    color: #2c3e50;
-
-    &.router-link-exact-active {
-      color: #42b983;
+<script lang="ts" setup>
+import zhCn from "element-plus/dist/locale/zh-cn.mjs";
+import { onMounted, ref } from "vue";
+import { ScreenSavers } from "@/components";
+import { getScreenConfigApi } from "./api";
+import { GetScreenConfigApiResponse } from "./api/types";
+
+const showScreenSavers = ref(false);
+const showScreenTime = ref(60 * 10);
+const autoPlay = ref(false);
+const screenList = ref<GetScreenConfigApiResponse["img"]>([]);
+let time = 0;
+
+onMounted(() => {
+  getScreenConfig();
+});
+
+const clearTimer = () => {
+  time = 0;
+};
+const closeScreen = () => {
+  clearTimer();
+  showScreenSavers.value = false;
+};
+
+const getScreenConfig = async () => {
+  const { data } = await getScreenConfigApi();
+  const c = JSON.parse(data.config.content);
+  autoPlay.value = c.autoPlay === "true";
+  showScreenTime.value = Number(c.time);
+  screenList.value = data.img.map((i) => ({
+    ...i,
+    thumb: process.env.VUE_APP_BACKEND_URL + i.thumb,
+  }));
+
+  setInterval(() => {
+    time++;
+    if (time >= showScreenTime.value && !showScreenSavers.value) {
+      showScreenSavers.value = true;
     }
-  }
-}
+  }, 1000);
+};
+</script>
+
+<style lang="scss">
+@import url("./App.scss");
 </style>

+ 39 - 0
src/api/index.ts

@@ -0,0 +1,39 @@
+import service from "@/utils/services";
+import {
+  CityItem,
+  CityMuseumItemType,
+  GetCityMuseumListApiRequest,
+  GetCityMuseumListApiResponse,
+  GetExhibitListApiRequest,
+  GetScreenConfigApiResponse,
+} from "./types";
+
+export const getCityApi = () => {
+  return service.get<CityItem[]>("/api/show/dict/getCity");
+};
+
+export const getCityMuseumListApi = (data: GetCityMuseumListApiRequest) => {
+  return service.post<GetCityMuseumListApiResponse>(
+    "/api/show/exhibition/pageCityList",
+    data
+  );
+};
+
+export const getExhibitListApi = (data: GetExhibitListApiRequest) => {
+  return service.post<GetCityMuseumListApiResponse>(
+    "/api/show/exhibition/pageList",
+    data
+  );
+};
+
+export const getMuseumDetailApi = (id: string) => {
+  return service.get<CityMuseumItemType>(`/api/show/exhibition/detail/${id}`);
+};
+
+export const reservationVenueApi = (data: any) => {
+  return service.post("/api/show/book/save", data);
+};
+
+export const getScreenConfigApi = () => {
+  return service.get<GetScreenConfigApiResponse>("/api/show/screen/getConfig");
+};

+ 39 - 0
src/api/types.ts

@@ -0,0 +1,39 @@
+export interface CityItem {
+  id: number;
+  name: string;
+}
+
+export interface CityMuseumItemType {
+  id: number;
+  thumb: string;
+  name: string;
+  address: string;
+  link: string;
+  tag: string;
+  parentName: string;
+  openTime: string;
+  description: string;
+}
+
+export interface GetCityMuseumListApiRequest {
+  type?: string;
+  cityId?: number;
+  searchKey?: string;
+  pageSize: number;
+  pageNum: number;
+}
+export interface GetCityMuseumListApiResponse {
+  total: number;
+  records: CityMuseumItemType[];
+}
+
+export interface GetExhibitListApiRequest {
+  pageSize: number;
+  pageNum: number;
+  parentId?: number;
+}
+
+export interface GetScreenConfigApiResponse {
+  config: { content: string };
+  img: { id: number; thumb: string }[];
+}

BIN
src/assets/imgs/bg3@2x.jpg


BIN
src/assets/imgs/bg4@2x.jpg


BIN
src/assets/imgs/label1@2x.png


BIN
src/assets/imgs/label2@2x.png


BIN
src/assets/logo.png


File diff suppressed because it is too large
+ 7 - 0
src/assets/svg/icon_address.svg


File diff suppressed because it is too large
+ 7 - 0
src/assets/svg/icon_date.svg


File diff suppressed because it is too large
+ 6 - 0
src/assets/svg/icon_time.svg


+ 86 - 0
src/background.ts

@@ -0,0 +1,86 @@
+"use strict";
+
+import { app, protocol, BrowserWindow } from "electron";
+import { createProtocol } from "vue-cli-plugin-electron-builder/lib";
+import installExtension, { VUEJS_DEVTOOLS } from "electron-devtools-installer";
+const isDevelopment = process.env.NODE_ENV !== "production";
+
+// Scheme must be registered before the app is ready
+protocol.registerSchemesAsPrivileged([
+  { scheme: "app", privileges: { secure: true, standard: true } },
+]);
+
+async function createWindow() {
+  // Create the browser window.
+  const win = new BrowserWindow({
+    width: 800,
+    height: 600,
+    fullscreen: true,
+    webPreferences: {
+      // Use pluginOptions.nodeIntegration, leave this alone
+      // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info
+      nodeIntegration: process.env
+        .ELECTRON_NODE_INTEGRATION as unknown as boolean,
+      contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
+    },
+  });
+
+  if (process.env.WEBPACK_DEV_SERVER_URL) {
+    // Load the url of the dev server if in development mode
+    await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string);
+    if (!process.env.IS_TEST) win.webContents.openDevTools();
+  } else {
+    createProtocol("app");
+    // Load the index.html when not in development
+    win.loadURL("app://./index.html");
+  }
+
+  win.setMenu(null);
+  win.webContents.openDevTools();
+}
+
+// Quit when all windows are closed.
+app.on("window-all-closed", () => {
+  // On macOS it is common for applications and their menu bar
+  // to stay active until the user quits explicitly with Cmd + Q
+  if (process.platform !== "darwin") {
+    app.quit();
+  }
+});
+
+app.on("activate", () => {
+  // On macOS it's common to re-create a window in the app when the
+  // dock icon is clicked and there are no other windows open.
+  if (BrowserWindow.getAllWindows().length === 0) createWindow();
+});
+
+// This method will be called when Electron has finished
+// initialization and is ready to create browser windows.
+// Some APIs can only be used after this event occurs.
+app.on("ready", async () => {
+  if (isDevelopment && !process.env.IS_TEST) {
+    // Install Vue Devtools
+    try {
+      await installExtension(VUEJS_DEVTOOLS);
+    } catch (e) {
+      // @ts-ignore
+      console.error("Vue Devtools failed to install:", e.toString());
+    }
+  }
+  createWindow();
+});
+
+// Exit cleanly on request from parent process in development mode.
+if (isDevelopment) {
+  if (process.platform === "win32") {
+    process.on("message", (data) => {
+      if (data === "graceful-exit") {
+        app.quit();
+      }
+    });
+  } else {
+    process.on("SIGTERM", () => {
+      app.quit();
+    });
+  }
+}

+ 0 - 142
src/components/HelloWorld.vue

@@ -1,142 +0,0 @@
-<template>
-  <div class="hello">
-    <h1>{{ msg }}</h1>
-    <p>
-      For a guide and recipes on how to configure / customize this project,<br />
-      check out the
-      <a href="https://cli.vuejs.org" target="_blank" rel="noopener"
-        >vue-cli documentation</a
-      >.
-    </p>
-    <h3>Installed CLI Plugins</h3>
-    <ul>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
-          target="_blank"
-          rel="noopener"
-          >babel</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
-          target="_blank"
-          rel="noopener"
-          >router</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex"
-          target="_blank"
-          rel="noopener"
-          >vuex</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
-          target="_blank"
-          rel="noopener"
-          >eslint</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
-          target="_blank"
-          rel="noopener"
-          >typescript</a
-        >
-      </li>
-    </ul>
-    <h3>Essential Links</h3>
-    <ul>
-      <li>
-        <a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
-      </li>
-      <li>
-        <a href="https://forum.vuejs.org" target="_blank" rel="noopener"
-          >Forum</a
-        >
-      </li>
-      <li>
-        <a href="https://chat.vuejs.org" target="_blank" rel="noopener"
-          >Community Chat</a
-        >
-      </li>
-      <li>
-        <a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
-          >Twitter</a
-        >
-      </li>
-      <li>
-        <a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
-      </li>
-    </ul>
-    <h3>Ecosystem</h3>
-    <ul>
-      <li>
-        <a href="https://router.vuejs.org" target="_blank" rel="noopener"
-          >vue-router</a
-        >
-      </li>
-      <li>
-        <a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-devtools#vue-devtools"
-          target="_blank"
-          rel="noopener"
-          >vue-devtools</a
-        >
-      </li>
-      <li>
-        <a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
-          >vue-loader</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/awesome-vue"
-          target="_blank"
-          rel="noopener"
-          >awesome-vue</a
-        >
-      </li>
-    </ul>
-  </div>
-</template>
-
-<script lang="ts">
-import { Options, Vue } from "vue-class-component";
-
-@Options({
-  props: {
-    msg: String,
-  },
-})
-export default class HelloWorld extends Vue {
-  msg!: string;
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-h3 {
-  margin: 40px 0 0;
-}
-ul {
-  list-style-type: none;
-  padding: 0;
-}
-li {
-  display: inline-block;
-  margin: 0 10px;
-}
-a {
-  color: #42b983;
-}
-</style>

BIN
src/components/back-btn/icon_back@2x.png


+ 37 - 0
src/components/back-btn/index.vue

@@ -0,0 +1,37 @@
+<template>
+  <div class="back-btn" @click="handleBack">
+    <img src="./icon_back@2x.png" />
+    返回
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from "vue-router";
+
+const router = useRouter();
+
+const handleBack = () => {
+  router.go(-1);
+};
+</script>
+
+<style lang="scss" scoped>
+.back-btn {
+  display: flex;
+  align-items: center;
+  position: absolute;
+  top: 1.0625rem /* 85/80 */;
+  left: 1rem /* 80/80 */;
+  font-size: 0.3125rem /* 25/80 */;
+  font-family: Source Han Serif CN-Bold;
+  font-weight: bold;
+  color: #fffae9;
+  text-shadow: 0px 0px 0.1875rem /* 15/80 */ #9f7b46;
+  z-index: 1000;
+
+  img {
+    margin-right: 0.075rem /* 6/80 */;
+    width: 0.625rem /* 50/80 */;
+  }
+}
+</style>

+ 10 - 0
src/components/drag-view/index.scss

@@ -0,0 +1,10 @@
+.drag-view {
+  width: 100%;
+  height: 100%;
+
+  &-container {
+    position: relative;
+    width: max-content;
+    cursor: pointer;
+  }
+}

+ 216 - 0
src/components/drag-view/index.vue

@@ -0,0 +1,216 @@
+<template>
+  <div
+    class="drag-view"
+    @mousedown="onMouseDown"
+    @mousemove="onMouseMove"
+    @mouseup="onMouseUp"
+    @mouseleave="onMouseLeave"
+    @touchstart.passive="onTouchStart"
+    @touchmove.prevent="onTouchMove"
+    @touchend="onTouchEnd"
+    @touchcancel="onTouchCancel"
+  >
+    <div
+      class="drag-view-container"
+      :style="{
+        top: `-${translateY}px`,
+        left: `-${translateX}px`,
+      }"
+    >
+      <slot />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, toRefs } from "vue";
+import "./index.scss";
+
+const props = withDefaults(
+  defineProps<{
+    scrollX?: boolean;
+    scrollY?: boolean;
+    maxTranslateXLength?: number;
+    maxTranslateYLength?: number;
+  }>(),
+  {
+    scrollX: true,
+    scrollY: true,
+    maxTranslateXLength: 0,
+    maxTranslateYLength: 0,
+  }
+);
+const { scrollX, scrollY, maxTranslateXLength, maxTranslateYLength } =
+  toRefs(props);
+
+const isMouseDown = ref(false);
+const lastMoveEventTimeStamp = ref(0);
+const moveSpeed = ref(0);
+const moveSpeedY = ref(0);
+const lastTouchPos = ref(0);
+const lastTouchPosY = ref(0);
+
+// 动画帧相关
+const lastAnimationTimeStamp = ref(0);
+const animationFrameId = ref(0);
+
+const isMove = ref(false);
+
+// 镜头平移相关
+const translateX = ref(0);
+const translateY = ref(0);
+
+const animationFrameTask = () => {
+  const timeStamp = Date.now();
+  const timeElapsed = timeStamp - lastAnimationTimeStamp.value;
+
+  // 速度减慢
+  if (moveSpeed.value > 0) {
+    moveSpeed.value -= 0.003 * timeElapsed;
+    if (moveSpeed.value < 0) {
+      moveSpeed.value = 0;
+    }
+  } else if (moveSpeed.value < 0) {
+    moveSpeed.value += 0.003 * timeElapsed;
+    if (moveSpeed.value > 0) {
+      moveSpeed.value = 0;
+    }
+  }
+
+  if (moveSpeedY.value > 0) {
+    moveSpeedY.value -= 0.003 * timeElapsed;
+    if (moveSpeedY.value < 0) {
+      moveSpeedY.value = 0;
+    }
+  } else if (moveSpeedY.value < 0) {
+    moveSpeedY.value += 0.003 * timeElapsed;
+    if (moveSpeedY.value > 0) {
+      moveSpeedY.value = 0;
+    }
+  }
+
+  // 根据速度更新距离
+  if (scrollY.value) {
+    translateY.value += moveSpeedY.value * timeElapsed;
+    if (translateY.value < 0) {
+      translateY.value = 0;
+    } else if (translateY.value > maxTranslateYLength.value) {
+      translateY.value = maxTranslateYLength.value;
+    }
+  }
+
+  if (scrollX.value) {
+    translateX.value += moveSpeed.value * timeElapsed;
+    if (translateX.value < 0) {
+      translateX.value = 0;
+    } else if (translateX.value > maxTranslateXLength.value) {
+      translateX.value = maxTranslateXLength.value;
+      moveSpeed.value = 0;
+    }
+  }
+
+  lastAnimationTimeStamp.value = timeStamp;
+  animationFrameId.value = requestAnimationFrame(animationFrameTask);
+};
+
+const onMouseDown = () => {
+  isMouseDown.value = true;
+  moveSpeed.value = 0;
+  moveSpeedY.value = 0;
+  lastMoveEventTimeStamp.value = 0;
+  lastAnimationTimeStamp.value = Date.now();
+};
+
+const onMouseMove = (e: MouseEvent) => {
+  if (isMouseDown.value) {
+    // 有些pc端浏览器比如firefox会有两次事件时间戳相同的情况发生。
+    if (
+      lastMoveEventTimeStamp.value &&
+      e.timeStamp - lastMoveEventTimeStamp.value > 1
+    ) {
+      isMove.value = true;
+      // 更新speed
+      const currentMoveSpeed =
+        -e.movementX / (e.timeStamp - lastMoveEventTimeStamp.value);
+      const currentMoveSpeedY =
+        -e.movementY / (e.timeStamp - lastMoveEventTimeStamp.value);
+      moveSpeed.value = moveSpeed.value * 0.9 + currentMoveSpeed * 0.1;
+      moveSpeedY.value = moveSpeedY.value * 0.9 + currentMoveSpeedY * 0.1;
+    }
+    lastMoveEventTimeStamp.value = e.timeStamp;
+  }
+};
+
+const onMouseUp = () => {
+  isMouseDown.value = false;
+  setTimeout(() => {
+    isMove.value = false;
+  });
+};
+
+const onMouseLeave = () => {
+  isMouseDown.value = false;
+  setTimeout(() => {
+    isMove.value = false;
+  });
+};
+
+const onTouchStart = (e: TouchEvent) => {
+  isMouseDown.value = true;
+  moveSpeed.value = 0;
+  moveSpeedY.value = 0;
+  lastMoveEventTimeStamp.value = 0;
+  lastAnimationTimeStamp.value = Date.now();
+  lastTouchPos.value = e.changedTouches[0].clientX;
+  lastTouchPosY.value = e.changedTouches[0].clientY;
+};
+
+const onTouchMove = (e: TouchEvent) => {
+  if (isMouseDown.value && e.changedTouches.length === 1) {
+    // 疯狂操作的极端情况下两个时间戳之间的时差会不合理,甚至为0
+    if (
+      lastMoveEventTimeStamp.value &&
+      e.timeStamp - lastMoveEventTimeStamp.value > 1
+    ) {
+      // 更新speed
+      isMove.value = true;
+      if (scrollX.value) {
+        const currentMoveSpeed =
+          (-(e.changedTouches[0].clientX - lastTouchPos.value) /
+            (e.timeStamp - lastMoveEventTimeStamp.value)) *
+          1.5;
+        moveSpeed.value = moveSpeed.value * 0.9 + currentMoveSpeed * 0.1;
+        lastTouchPos.value = e.changedTouches[0].clientX;
+      }
+
+      if (scrollY.value) {
+        const currentMoveSpeedY =
+          (-(e.changedTouches[0].clientY - lastTouchPosY.value) /
+            (e.timeStamp - lastMoveEventTimeStamp.value)) *
+          1.5;
+        moveSpeedY.value = moveSpeedY.value * 0.9 + currentMoveSpeedY * 0.1;
+        lastTouchPosY.value = e.changedTouches[0].clientY;
+      }
+    }
+    lastMoveEventTimeStamp.value = e.timeStamp;
+  }
+};
+
+const onTouchEnd = () => {
+  isMouseDown.value = false;
+  setTimeout(() => {
+    isMove.value = false;
+  });
+};
+
+const onTouchCancel = () => {
+  isMouseDown.value = false;
+  setTimeout(() => {
+    isMove.value = false;
+  });
+};
+
+onMounted(() => {
+  animationFrameId.value = requestAnimationFrame(animationFrameTask);
+});
+</script>

+ 6 - 0
src/components/index.ts

@@ -0,0 +1,6 @@
+import SvgIcon from "./svg-icon/index.vue";
+import DragView from "./drag-view/index.vue";
+import BackBtn from "./back-btn/index.vue";
+import ScreenSavers from "./screen-savers/index.vue";
+
+export { SvgIcon, DragView, BackBtn, ScreenSavers };

+ 48 - 0
src/components/screen-savers/index.vue

@@ -0,0 +1,48 @@
+<template>
+  <div class="screen-savers">
+    <el-carousel
+      trigger="click"
+      :height="windowHeight + 'px'"
+      :autoplay="autoPlay"
+      :interval="5000"
+    >
+      <el-carousel-item v-for="item in list" :key="item.id">
+        <el-image
+          style="width: 100%; height: 100%"
+          :src="item.thumb"
+          fit="cover"
+          @click="handleClick"
+        />
+      </el-carousel-item>
+    </el-carousel>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, toRefs } from "vue";
+import { GetScreenConfigApiResponse } from "@/api/types";
+
+const windowHeight = ref(window.innerHeight);
+
+const emits = defineEmits(["onClick"]);
+const props = defineProps<{
+  autoPlay: boolean;
+  list: GetScreenConfigApiResponse["img"];
+}>();
+const { autoPlay, list } = toRefs(props);
+
+const handleClick = () => {
+  emits("onClick");
+};
+</script>
+
+<style lang="scss" scoped>
+.screen-savers {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9999;
+}
+</style>

+ 34 - 0
src/components/svg-icon/index.vue

@@ -0,0 +1,34 @@
+<template>
+  <svg
+    class="svg-icon"
+    :style="{
+      color: color,
+      width: size + 'rem',
+      height: size + 'rem',
+    }"
+  >
+    <use :xlink:href="`#icon-${name}`" :fill="color" />
+  </svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "SvgIcon",
+  props: {
+    name: {
+      type: String,
+      required: true,
+    },
+    size: {
+      type: Number,
+      default: 0.25,
+    },
+    color: {
+      type: String,
+      default: "#000000",
+    },
+  },
+});
+</script>

+ 30 - 0
src/components/svg-icon/plugin.ts

@@ -0,0 +1,30 @@
+import SvgIcon from "./index.vue";
+
+export interface SvgIconOptions {
+  imports?: string[];
+}
+
+const svgIconPlugin = {
+  install: function (vue: any, options?: SvgIconOptions) {
+    if (options && options.imports) {
+      // 按需引入图标
+      const { imports } = options;
+      imports.forEach((name: any) => {
+        require(`@/assets/svg/${name}.svg`);
+      });
+    } else {
+      // 全量引入图标
+      const ctx = require.context("@/assets/svg", false, /\.svg$/);
+      ctx.keys().forEach((path) => {
+        const temp = path.match(/\.\/([A-Za-z0-9\-_]+)\.svg$/);
+        if (!temp) return;
+        const name = temp[1];
+        require(`@/assets/svg/${name}.svg`);
+      });
+    }
+
+    vue.component(SvgIcon.name, SvgIcon);
+  },
+};
+
+export default svgIconPlugin;

+ 27 - 0
src/el.d.ts

@@ -0,0 +1,27 @@
+import type {
+  ElMessageBoxOptions,
+  MessageBoxData,
+  MessageHandler,
+  MessageParams,
+} from "element-plus";
+import type { AppContext } from "vue";
+
+declare global {
+  declare namespace ElMessageBox {
+    function confirm(
+      message: ElMessageBoxOptions["message"],
+      options?: ElMessageBoxOptions,
+      appContext?: AppContext | null
+    ): Promise<MessageBoxData>;
+    function confirm(
+      message: ElMessageBoxOptions["message"],
+      title: ElMessageBoxOptions["title"],
+      options?: ElMessageBoxOptions,
+      appContext?: AppContext | null
+    ): Promise<MessageBoxData>;
+  }
+  declare function ElMessage(
+    options?: MessageParams,
+    appContext?: null | AppContext
+  ): MessageHandler;
+}

+ 5 - 0
src/global.d.ts

@@ -0,0 +1,5 @@
+interface Window {
+  museum: string;
+  cloudScenicUrl: string;
+  company: string;
+}

+ 8 - 0
src/img.d.ts

@@ -0,0 +1,8 @@
+declare module "*.svg";
+declare module "*.png";
+declare module "*.jpg";
+declare module "*.jpeg";
+declare module "*.gif";
+declare module "*.bmp";
+declare module "*.tiff";
+declare module "*.mjs";

+ 8 - 1
src/main.ts

@@ -2,5 +2,12 @@ import { createApp } from "vue";
 import App from "./App.vue";
 import router from "./router";
 import store from "./store";
+import svgIconPlugin from "@/components/svg-icon/plugin";
 
-createApp(App).use(store).use(router).mount("#app");
+const app = createApp(App);
+
+app.use(store);
+app.use(router);
+app.use(svgIconPlugin);
+
+app.mount("#app");

+ 25 - 10
src/router/index.ts

@@ -1,5 +1,9 @@
-import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
-import HomeView from "../views/HomeView.vue";
+import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
+import HomeView from "../views/home/index.vue";
+import MuseumDetail from "../views/museum-detail/index.vue";
+import CloudMuseum from "../views/cloud-museum/index.vue";
+import VenueReservation from "../views/venue-reservation/index.vue";
+import Iframe from "../views/iframe/index.vue";
 
 const routes: Array<RouteRecordRaw> = [
   {
@@ -8,18 +12,29 @@ const routes: Array<RouteRecordRaw> = [
     component: HomeView,
   },
   {
-    path: "/about",
-    name: "about",
-    // route level code-splitting
-    // this generates a separate chunk (about.[hash].js) for this route
-    // which is lazy-loaded when the route is visited.
-    component: () =>
-      import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
+    path: "/cloud-museum/:id",
+    name: "cloudMuseum",
+    component: CloudMuseum,
+  },
+  {
+    path: "/museum-detail/:id",
+    name: "museumDetail",
+    component: MuseumDetail,
+  },
+  {
+    path: "/venue-reservation",
+    name: "venueReservation",
+    component: VenueReservation,
+  },
+  {
+    path: "/iframe/:url",
+    name: "iframe",
+    component: Iframe,
   },
 ];
 
 const router = createRouter({
-  history: createWebHistory(process.env.BASE_URL),
+  history: createWebHashHistory(process.env.BASE_URL),
   routes,
 });
 

+ 118 - 0
src/utils/date.ts

@@ -0,0 +1,118 @@
+/**
+ * 日期和时间相关
+ *
+ * 更复杂的日期操作建议直接使用 dayjs https://dayjs.gitee.io/docs/zh-CN/installation/installation
+ */
+import dayjs from "dayjs";
+import customParseFormat from "dayjs/plugin/customParseFormat";
+
+dayjs.extend(customParseFormat);
+
+export { dayjs };
+
+/**
+ * @group 日期格式
+ * @groupOrder 4
+ */
+export const DATE_FORMAT = "YYYY-MM-DD";
+
+/**
+ * @group 日期格式
+ * @groupOrder 4
+ */
+export const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
+
+export const SECOND = 1000; // 毫秒
+export const MINUTE = 60 * SECOND;
+export const HOUR = 60 * MINUTE;
+export const DAY = 24 * HOUR;
+
+/**
+ * 日期解析
+ *
+ * @group 日期
+ * @groupOrder 5
+ *
+ * @remarks
+ * 如果传入数字,我们假定为`毫秒` <br/>
+ *` parseDate`会先尝试常见的日期格式(例如 YYYY/MM/DD HH:mm:ss、YYYY-MM-DD HH:mm:ss、YYYY-MM-DD),如果`parse`失败就会使用`format`
+ *
+ * @param dateString - 要转换成日期的数字和字符串.
+ * @param format - 用于指定日期的格式。如果你的日期格式不是常见的格式,可以显式指定
+ * @link https://dayjs.gitee.io/docs/zh-CN/parse/string-format
+ *
+ * @example
+ * 例子:
+ * ```typescript
+ * parseDate(3600)
+ * // => 1970-01-01T00:00:03.600Z
+ *
+ * parseDate('2012/12/10 12:12:12')
+ * // => 2012-02-10T04:12:12.000Z
+ * ```
+ * @public
+ */
+export function parseDate(
+  dateString: string | number | Date,
+  format?: string | string[]
+): Date | null {
+  if (typeof dateString === "number") {
+    return new Date(dateString);
+  } else if (dateString instanceof Date) {
+    return dateString;
+  }
+
+  const day = dayjs(dateString, format);
+
+  if (day.isValid()) {
+    return day.toDate();
+  } else {
+    if (!format) {
+      // 兼容 ios
+      dateString = dateString.replace(/-/g, "/");
+      dateString = dateString.replace(/T/g, " ");
+    }
+
+    const retry = dayjs(dateString);
+
+    if (retry.isValid()) {
+      return retry.toDate();
+    }
+
+    return null;
+  }
+}
+
+/**
+ * 格式化日期
+ *
+ * @group 日期
+ * @groupOrder 5
+ *
+ * @remarks
+ * 如果传入数字,我们假定为`毫秒`
+ *
+ * @param date - 日期.
+ * @param format - 详见 {@link https://dayjs.gitee.io/docs/zh-CN/parse/string-format}.
+ *
+ * @example
+ * 例子:
+ * ```
+ * const day = new Date('2012/12/12 12:12:12');
+ *
+ * const target = formatDate(day);
+ * // => target = "2012-02-10"
+ *
+ * const target2 = formatDate(day, 'YYYY-MM-DD HH:mm:ss');
+ * // => target2 = "2012-02-10 12:12:12"
+ * ```
+ * @public
+ */
+export function formatDate(
+  date: string | number | Date,
+  format: string = DATE_FORMAT,
+  parseFormat?: string | string[]
+): string {
+  const parsed = parseDate(date, parseFormat);
+  return dayjs(parsed).format(format);
+}

+ 23 - 0
src/utils/index.ts

@@ -0,0 +1,23 @@
+export const getImageRect: (
+  url: string
+) => Promise<{ width: number; height: number }> = (url: string) => {
+  return new Promise((res) => {
+    const img = new Image();
+    img.src = url;
+    if (img.complete) {
+      res({
+        width: img.width,
+        height: img.height,
+      });
+    } else {
+      img.onload = () => {
+        res({
+          width: img.width,
+          height: img.height,
+        });
+      };
+    }
+  });
+};
+
+export * from "./date";

+ 127 - 0
src/utils/services.ts

@@ -0,0 +1,127 @@
+import axios, {
+  type AxiosInstance,
+  type InternalAxiosRequestConfig,
+  type AxiosResponse,
+} from "axios";
+import type { EpPropMergeType } from "element-plus/es/utils";
+import { isNumber } from "lodash";
+
+export enum ResponseStatusCode {
+  SUCCESS = 0,
+}
+
+interface Config<D = any> extends InternalAxiosRequestConfig<D> {
+  /**
+   * 隐藏错误提醒
+   */
+  hidden?: boolean;
+}
+
+export interface DageResponse<T = any, D = any> extends AxiosResponse<T, D> {
+  code: number;
+  msg: string;
+  success: boolean;
+}
+
+interface Service extends AxiosInstance {
+  get<T = any, R = DageResponse<T>, D = any>(
+    url: string,
+    config?: Config<D>
+  ): Promise<R>;
+  post<T = any, R = DageResponse<T>, D = any>(
+    url: string,
+    data?: D,
+    config?: Config<D>
+  ): Promise<R>;
+}
+
+const service: Service = axios.create({
+  baseURL: process.env.VUE_APP_BACKEND_URL,
+  timeout: 60000,
+  headers: {
+    "Cache-Control": "no-cache",
+    "Content-Type": "application/json;charset=UTF-8",
+    "X-Requested-With": "XMLHttpRequest",
+  },
+});
+
+const showMessage = (
+  message: string,
+  type: EpPropMergeType<
+    StringConstructor,
+    "error" | "success" | "warning" | "info",
+    unknown
+  > = "error"
+) => {
+  ElMessage({
+    showClose: true,
+    type,
+    duration: 4000,
+    message,
+  });
+};
+
+/**
+ * 服务端接口empty字符串跟null返回的结果不同,过滤掉empty字符串
+ * @param params
+ * @param emptyString 是否过滤空字符串
+ */
+function filterEmptyKey(params: any, emptyString = false) {
+  if (Array.isArray(params) || params == null) {
+    return params;
+  }
+
+  Object.keys(params).forEach((key) => {
+    if (params[key] === null || (emptyString && params[key] === "")) {
+      delete params[key];
+    }
+  });
+}
+
+service.interceptors.request.use((config: Config) => {
+  if (config.method === "post") {
+    const params = {
+      ...config.data,
+    };
+    filterEmptyKey(params); // 过滤空字符串
+    config.data = params;
+  } else if (config.method === "get") {
+    config.params = {
+      _t: new Date().getTime() / 1000,
+      ...config.params,
+    };
+    filterEmptyKey(config.params, true);
+  }
+  return config;
+});
+
+service.interceptors.response.use(
+  (res) => {
+    const { data, config }: { config: Config; data: DageResponse } = res;
+
+    // data 有可能直接返回数据
+    if (isNumber(data.code) && data.code !== ResponseStatusCode.SUCCESS) {
+      const msg = data.msg || "加载失败";
+      const code = data.code || -1000;
+
+      // 未手动配置 隐藏 消息提示时,公共提醒错误
+      if (!config.hidden) {
+        setTimeout(() => {
+          showMessage(msg);
+        }, 0);
+      }
+
+      return Promise.reject({
+        code,
+        msg,
+      });
+    }
+
+    return data || {};
+  },
+  (error) => {
+    showMessage(error.message);
+  }
+);
+
+export default service as Required<Service>;

+ 0 - 5
src/views/AboutView.vue

@@ -1,5 +0,0 @@
-<template>
-  <div class="about">
-    <h1>This is an about page</h1>
-  </div>
-</template>

+ 0 - 18
src/views/HomeView.vue

@@ -1,18 +0,0 @@
-<template>
-  <div class="home">
-    <img alt="Vue logo" src="../assets/logo.png" />
-    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App" />
-  </div>
-</template>
-
-<script lang="ts">
-import { Options, Vue } from "vue-class-component";
-import HelloWorld from "@/components/HelloWorld.vue"; // @ is an alias to /src
-
-@Options({
-  components: {
-    HelloWorld,
-  },
-})
-export default class HomeView extends Vue {}
-</script>

+ 86 - 0
src/views/cloud-museum/components/card.vue

@@ -0,0 +1,86 @@
+<template>
+  <div class="cloud-museum-card" @click="handleClick">
+    <div class="cloud-museum-card__img">
+      <el-image
+        lazy
+        :src="thumb"
+        fit="cover"
+        style="width: 100%; height: 100%"
+      />
+    </div>
+    <div class="cloud-museum-card__title">
+      <span>{{ item.name }}</span>
+      <template v-if="!!item.tag">
+        <span v-for="tag in tags" :key="tag" class="cloud-museum-card__tag">{{
+          tag
+        }}</span>
+      </template>
+    </div>
+    <p>展览地点:{{ item.address || "--" }}</p>
+    <p>参展博物馆:{{ item.parentName || "--" }}</p>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { CityMuseumItemType } from "@/api/types";
+import { computed } from "vue";
+import { useRouter } from "vue-router";
+
+const props = defineProps<{
+  item: CityMuseumItemType;
+}>();
+
+const router = useRouter();
+const tags = computed(() => props.item.tag.split(","));
+const thumb = computed(
+  () => `${process.env.VUE_APP_BACKEND_URL}${props.item.thumb}`
+);
+
+const handleClick = () => {
+  router.push({
+    name: "iframe",
+    params: {
+      url: encodeURIComponent(props.item.link),
+    },
+  });
+};
+</script>
+
+<style lang="scss" scoped>
+.cloud-museum-card {
+  padding: 0.225rem /* 18/80 */;
+  width: calc(33.3333% - 0.75rem /* 60/80 */);
+  height: 5.45rem /* 436/80 */;
+  background-color: #faf4ea;
+  border-radius: 0.05rem /* 4/80 */;
+  box-shadow: 0px 0px 0.075rem /* 6/80 */ rgba(0, 0, 0, 0.25);
+
+  &__img {
+    height: 4.375rem /* 350/80 */;
+  }
+  &__title {
+    margin: 0.125rem /* 10/80 */ 0 0.05rem /* 4/80 */;
+    font-size: 0.25rem /* 20/80 */;
+    font-family: Source Han Serif CN-Bold;
+    font-weight: bold;
+    color: #5c594b;
+    line-height: 0.3375rem /* 27/80 */;
+  }
+  p {
+    color: #5c594b;
+    line-height: 0.2625rem /* 21/80 */;
+    font-size: 0.1875rem /* 15/80 */;
+  }
+  &__tag {
+    position: relative;
+    top: -0.025rem /* 2/80 */;
+    margin-left: 0.125rem /* 10/80 */;
+    padding: 0.025rem /* 2/80 */ 0.125rem /* 10/80 */;
+    font-size: 0.15rem /* 12/80 */;
+    color: #8f6c38;
+    background: rgba(239, 212, 152, 0.5);
+    border-radius: 0.0375rem /* 3/80 */;
+    border: 1px solid #8f6c38;
+  }
+}
+</style>

BIN
src/views/cloud-museum/imgs/btn_active@2x.png


BIN
src/views/cloud-museum/imgs/content01@2x.png


BIN
src/views/cloud-museum/imgs/icon_search.png


BIN
src/views/cloud-museum/imgs/label_search.png


BIN
src/views/cloud-museum/imgs/line01@2x.png


BIN
src/views/cloud-museum/imgs/line02@2x.png


+ 168 - 0
src/views/cloud-museum/index.scss

@@ -0,0 +1,168 @@
+.cloud-museum {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+  background: url("../../assets/imgs/bg3@2x.jpg") no-repeat center / cover;
+
+  &__search {
+    position: absolute;
+    top: 1rem /* 80/80 */;
+    right: 1.125rem /* 90/80 */;
+    display: flex;
+    align-items: center;
+    padding: 0.075rem /* 6/80 */ 0.2rem /* 16/80 */ 0;
+    width: 3.3125rem /* 265/80 */;
+    height: 0.6625rem /* 53/80 */;
+    box-sizing: border-box;
+    background: url("./imgs/label_search.png") no-repeat center / 100%;
+
+    .el-input {
+      --el-input-bg-color: transparent;
+      font-size: 0.25rem /* 20/80 */;
+
+      .el-input__wrapper {
+        box-shadow: none;
+      }
+    }
+    input::placeholder {
+      color: #5c594b;
+      font-family: Source Han Serif CN-Bold;
+      font-weight: bold;
+      font-size: 0.1875rem /* 15/80 */;
+    }
+    .btn {
+      margin-left: 10px;
+      width: 0.2125rem /* 17/80 */;
+      height: 0.2125rem /* 17/80 */;
+      background: url("./imgs/icon_search.png") no-repeat center / 100%;
+    }
+  }
+  &__title {
+    padding-top: 0.8625rem /* 69/80 */;
+    font-family: Source Han Serif CN-Bold;
+    font-size: 0.625rem /* 50/80 */;
+    font-weight: bold;
+    color: #fff;
+    text-align: center;
+    line-height: 0.85rem /* 68/80 */;
+    text-shadow: 0px 0px 0.1875rem /* 15/80 */ #9f7b46;
+  }
+  &-scroll {
+    &__container {
+      display: flex;
+    }
+  }
+  &-city {
+    position: relative;
+    display: flex;
+    justify-content: space-between;
+    padding-bottom: 0.1875rem /* 15/80 */;
+    margin: 1rem /* 80/80 */ 1.25rem /* 100/80 */ 0.3125rem /* 25/80 */;
+    color: rgba(92, 89, 75, 0.7);
+    font-size: 0.3125rem /* 25/80 */;
+
+    &::after {
+      content: "";
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      height: 2px;
+      background: url("./imgs/line01@2x.png") no-repeat center / contain;
+    }
+    &__item {
+      position: relative;
+      cursor: pointer;
+
+      &.active {
+        font-family: Source Han Serif CN-Bold;
+        font-weight: bold;
+        color: rgba(92, 89, 75, 0.7);
+
+        &::after {
+          content: "";
+          position: absolute;
+          top: 50%;
+          left: -0.3125rem /* 25/80 */;
+          width: calc(100% + 0.5875rem /* 47/80 */);
+          height: 0.1375rem /* 11/80 */;
+          transform: translateY(-50%);
+          background: url("./imgs/btn_active@2x.png") no-repeat center / cover;
+        }
+        &::before {
+          content: "";
+          position: absolute;
+          top: -0.375rem /* 30/80 */;
+          left: -0.375rem /* 30/80 */;
+          width: 0.625rem /* 50/80 */;
+          height: 0.625rem /* 50/80 */;
+          background: url("../../assets/imgs/label1@2x.png") no-repeat center /
+            cover;
+        }
+      }
+    }
+  }
+  &-type {
+    position: relative;
+    padding-bottom: 0.25rem /* 20/80 */;
+    margin: 0 5.1375rem /* 411/80 */;
+    display: flex;
+    justify-content: center;
+    color: rgba(92, 89, 75, 0.7);
+    font-size: 0.25rem /* 20/80 */;
+
+    &::after {
+      content: "";
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      height: 1px;
+      background: url("./imgs/line02@2x.png") no-repeat center / contain;
+    }
+    &__item {
+      position: relative;
+      cursor: pointer;
+      width: 1.525rem /* 122/80 */;
+
+      &.active {
+        font-family: Source Han Serif CN-Bold;
+        font-weight: bold;
+        color: rgba(92, 89, 75, 0.7);
+
+        &::after {
+          content: "";
+          position: absolute;
+          top: 50%;
+          left: -0.25rem /* 20/80 */;
+          width: 1.525rem /* 122/80 */;
+          height: 0.1375rem /* 11/80 */;
+          transform: translateY(-50%);
+          background: url("./imgs/btn_active@2x.png") no-repeat center / cover;
+        }
+        &::before {
+          content: "";
+          position: absolute;
+          top: -0.225rem /* 18/80 */;
+          right: 0.25rem /* 20/80 */;
+          width: 0.4125rem /* 33/80 */;
+          height: 0.4125rem /* 33/80 */;
+          background: url("../../assets/imgs/label2@2x.png") no-repeat center /
+            cover;
+        }
+      }
+    }
+  }
+  &-scroll {
+    padding: 0.075rem /* 6/80 */;
+    margin: 0.425rem /* 34/80 */ 0.7875rem /* 63/80 */ 0;
+    overflow: hidden;
+
+    &__container {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 0.375rem /* 30/80 */;
+      width: calc(100vw - 1.725rem /* 138/80 */);
+    }
+  }
+}

+ 142 - 0
src/views/cloud-museum/index.vue

@@ -0,0 +1,142 @@
+<template>
+  <div v-loading="loading" class="cloud-museum">
+    <BackBtn />
+
+    <DragView :scrollX="false" :maxTranslateYLength="maxHeight">
+      <div ref="scrollRef">
+        <div class="cloud-museum__title">云上博物</div>
+
+        <div class="cloud-museum__search">
+          <el-input
+            v-model="searchKey"
+            clearable
+            placeholder="请输入关键词"
+            @clear="getCityMuseumList"
+          />
+          <div class="btn" @click="getCityMuseumList" />
+        </div>
+
+        <div class="cloud-museum-city">
+          <div
+            v-for="item in cityList"
+            :key="item.id"
+            :class="[
+              'cloud-museum-city__item',
+              curCity === item.id && 'active',
+            ]"
+            @click="handleCity(item.id)"
+          >
+            {{ item.name }}
+          </div>
+        </div>
+
+        <div class="cloud-museum-type">
+          <div
+            v-for="label in DEFAULT_TYPE"
+            :key="label"
+            :class="['cloud-museum-type__item', curType === label && 'active']"
+            @click="handleType(label)"
+          >
+            {{ label }}
+          </div>
+        </div>
+
+        <div class="cloud-museum-scroll">
+          <div class="cloud-museum-scroll__container">
+            <Card v-for="item in list" :key="item.id" :item="item" />
+          </div>
+        </div>
+
+        <!-- <div v-loading="true" class="page-loading" style="height: 40px" /> -->
+
+        <div v-if="!list.length" class="empty">暂无博物馆</div>
+      </div>
+    </DragView>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { getCityApi, getCityMuseumListApi } from "@/api";
+import {
+  CityItem,
+  CityMuseumItemType,
+  GetCityMuseumListApiRequest,
+} from "@/api/types";
+import { BackBtn, DragView } from "@/components";
+import { nextTick, onActivated, ref } from "vue";
+import Card from "./components/card.vue";
+import { useRoute, useRouter } from "vue-router";
+import "./index.scss";
+
+const scrollRef = ref<null | HTMLElement>(null);
+const maxHeight = ref(0);
+const loading = ref(false);
+const cityList = ref<CityItem[]>([]);
+const curCity = ref(0);
+const searchKey = ref("");
+const DEFAULT_TYPE = ["全部类型", "基本陈列", "临时展览", "专题展览"];
+const curType = ref(DEFAULT_TYPE[0]);
+const route = useRoute();
+const router = useRouter();
+const list = ref<CityMuseumItemType[]>([]);
+
+onActivated(() => {
+  loading.value = true;
+  curCity.value = Number(route.params.id);
+  curType.value = DEFAULT_TYPE[0];
+  Promise.all([getCity(), getCityMuseumList()]).finally(() => {
+    loading.value = false;
+    nextTick(() => {
+      getScrollArea();
+    });
+  });
+});
+
+const getCity = async () => {
+  if (cityList.value.length) return;
+
+  const { data } = await getCityApi();
+  data.unshift({
+    id: 0,
+    name: "江苏省",
+  });
+  cityList.value = data;
+};
+
+const getScrollArea = async () => {
+  if (!scrollRef.value) return;
+  maxHeight.value = scrollRef.value.clientHeight - window.innerHeight + 60;
+};
+
+const handleCity = (id: number) => {
+  curCity.value = id;
+  router.replace({ name: "cloudMuseum", params: { id } });
+  getCityMuseumList();
+};
+
+const handleType = (name: string) => {
+  curType.value = name;
+  getCityMuseumList();
+};
+
+const getCityMuseumList = async () => {
+  const params: GetCityMuseumListApiRequest = {
+    pageSize: 0,
+    pageNum: 0,
+    searchKey: searchKey.value,
+  };
+  if (curType.value !== "全部类型") params.type = curType.value;
+  if (curCity.value) params.cityId = curCity.value;
+  loading.value = true;
+  try {
+    const { data } = await getCityMuseumListApi(params);
+    list.value = data.records;
+  } finally {
+    loading.value = false;
+  }
+
+  nextTick(() => {
+    getScrollArea();
+  });
+};
+</script>

+ 28 - 0
src/views/home/components/card/index.scss

@@ -0,0 +1,28 @@
+.home-card {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 2.125rem /* 170/80 */;
+  height: 1.5rem /* 120/80 */;
+  z-index: 1;
+
+  &__tag {
+    position: relative;
+    width: 0.4875rem /* 39/80 */;
+    height: 1.525rem /* 122/80 */;
+    font-size: 0.225rem /* 18/80 */;
+    font-family: Source Han Serif CN-Bold;
+    font-weight: bold;
+    color: #ffffff;
+    letter-spacing: 0.05rem /* 4/80 */;
+    line-height: 0.4875rem /* 39/80 */;
+    writing-mode: vertical-rl;
+    -webkit-text-stroke: 1px #654d2a;
+    background: url("../../imgs/tab@2x.png") no-repeat center / 100% 100%;
+
+    span {
+      position: relative;
+      top: 0.25rem /* 20/80 */;
+    }
+  }
+}

+ 35 - 0
src/views/home/components/card/index.vue

@@ -0,0 +1,35 @@
+<template>
+  <div
+    class="home-card"
+    :style="{ left: left / 80 + 'rem', top: top / 80 + 'rem' }"
+    @click="handleClick"
+  >
+    <div class="home-card__tag">
+      <span>{{ name }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { toRefs } from "vue";
+import "./index.scss";
+import { useRouter } from "vue-router";
+
+const props = defineProps<{
+  id: number;
+  name: string;
+  left: number;
+  top: number;
+}>();
+const { name, left, top, id } = toRefs(props);
+const router = useRouter();
+
+const handleClick = () => {
+  router.push({
+    name: "cloudMuseum",
+    params: {
+      id: id.value,
+    },
+  });
+};
+</script>

BIN
src/views/home/imgs/bg2.jpg


BIN
src/views/home/imgs/btn_museum_active@2x.png


BIN
src/views/home/imgs/btn_museum_normal@2x.png


BIN
src/views/home/imgs/btn_reservation_active@2x.png


BIN
src/views/home/imgs/btn_reservation_normal@2x.png


BIN
src/views/home/imgs/btn_scenery_active@2x.png


BIN
src/views/home/imgs/btn_scenery_normal@2x.png


BIN
src/views/home/imgs/icon_gesture@2x.png


BIN
src/views/home/imgs/label@2x.png


BIN
src/views/home/imgs/tab1@2x.png


BIN
src/views/home/imgs/tab@2x.png


+ 72 - 0
src/views/home/index.scss

@@ -0,0 +1,72 @@
+.home {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+
+  &__bg {
+    display: block;
+  }
+  // &-primary-card {
+  //   position: absolute;
+  //   top: 635px;
+  //   left: 779px;
+  //   font-size: 20px;
+  //   font-family: Source Han Serif CN-Bold;
+  //   font-weight: bold;
+  //   color: #ffffff;
+  //   letter-spacing: 8px;
+  //   writing-mode: vertical-rl;
+
+  //   &__tag {
+  //     position: relative;
+  //     width: 58px;
+  //     height: 280px;
+  //     line-height: 58px;
+  //     background: url("./imgs/tab1@2x.png") no-repeat center / 100% 100%;
+
+  //     span {
+  //       position: relative;
+  //       top: 30px;
+  //     }
+  //   }
+  // }
+  &-menu {
+    position: fixed;
+    right: 1.25rem /* 100/80 */;
+    bottom: calc(2.5rem /* 200/80 */ + 1.3125rem /* 105/80 */);
+    z-index: 2;
+
+    &-list {
+      display: flex;
+      flex-direction: column;
+
+      > img {
+        position: relative;
+        width: 2.85rem /* 228/80 */;
+        z-index: 1;
+      }
+    }
+    &__border {
+      position: absolute;
+      top: -1.5rem /* 120/80 */;
+      left: 50%;
+      height: 8.5125rem /* 681/80 */;
+      transform: translateX(-50%);
+    }
+  }
+  &-helper {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    position: fixed;
+    right: 1.875rem /* 150/80 */;
+    bottom: 6%;
+    color: #ffffff;
+    z-index: 2;
+
+    img {
+      width: 1.5625rem /* 125/80 */;
+    }
+  }
+}

+ 173 - 0
src/views/home/index.vue

@@ -0,0 +1,173 @@
+<template>
+  <div class="home">
+    <DragView :maxTranslateXLength="maxWidth" :maxTranslateYLength="maxHeight">
+      <img
+        ref="imgRef"
+        class="home__bg"
+        draggable="false"
+        :src="bgImg"
+        :style="{ width: IMG_WIDTH + 'px' }"
+      />
+
+      <Card
+        v-for="item in MUSEUM_POS"
+        :key="item.name"
+        :name="item.name"
+        :top="item.top"
+        :id="item.id"
+        :left="item.left"
+      />
+
+      <!-- <div
+        class="home-primary-card"
+        @click="
+          () =>
+            router.push({
+              name: 'museumDetail',
+              params: {
+                id: 2,
+              },
+            })
+        "
+      >
+        <div class="home-primary-card__tag">
+          <span>扬州中国大运河博物馆</span>
+        </div>
+      </div> -->
+
+      <div class="home-menu">
+        <div class="home-menu-list">
+          <img
+            draggable="false"
+            src="./imgs/btn_museum_active@2x.png"
+            @click="router.push({ name: 'cloudMuseum', params: { id: 0 } })"
+          />
+          <img
+            draggable="false"
+            src="./imgs/btn_scenery_normal@2x.png"
+            @click="goScenic"
+          />
+          <img
+            draggable="false"
+            src="./imgs/btn_reservation_normal@2x.png"
+            @click="router.push({ name: 'venueReservation' })"
+          />
+        </div>
+
+        <img
+          draggable="false"
+          class="home-menu__border"
+          src="./imgs/label@2x.png"
+        />
+      </div>
+
+      <div class="home-helper">
+        <img draggable="false" src="./imgs/icon_gesture@2x.png" />
+        <span>可通过滑动查看地图</span>
+      </div>
+    </DragView>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onBeforeUnmount, onMounted, ref } from "vue";
+import { DragView } from "@/components";
+import { getImageRect } from "@/utils";
+import bgImg from "./imgs/bg2.jpg";
+import Card from "./components/card/index.vue";
+import { useRouter } from "vue-router";
+import "./index.scss";
+
+const IMG_WIDTH = 1920;
+const maxWidth = ref(0);
+const maxHeight = ref(0);
+const imgRef = ref<null | HTMLElement>(null);
+const router = useRouter();
+
+onMounted(() => {
+  init();
+
+  window.addEventListener("resize", init);
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener("resize", init);
+});
+
+const init = async () => {
+  if (!imgRef.value) return;
+  await getImageRect(bgImg);
+  maxWidth.value = imgRef.value.clientWidth - window.innerWidth;
+  maxHeight.value = imgRef.value.clientHeight - window.innerHeight;
+};
+
+const MUSEUM_POS = [
+  {
+    id: 37,
+    name: "徐州市",
+    left: 300,
+    top: 140,
+  },
+  { id: 47, name: "宿迁市", left: 449, top: 290 },
+  {
+    id: 41,
+    name: "连云港市",
+    left: 690,
+    top: 106,
+  },
+  {
+    id: 42,
+    name: "淮安市",
+    left: 656,
+    top: 404,
+  },
+  { id: 43, name: "盐城市", left: 935, top: 376 },
+  {
+    id: 44,
+    name: "扬州市",
+    left: 725,
+    top: 512,
+  },
+  { id: 46, name: "泰州市", left: 896, top: 819 },
+  {
+    id: 35,
+    name: "南京市",
+    left: 469,
+    top: 952,
+  },
+  { id: 45, name: "镇江市", left: 706, top: 967 },
+  {
+    id: 40,
+    name: "南通市",
+    left: 1116,
+    top: 917,
+  },
+  {
+    id: 38,
+    name: "常州市",
+    left: 758,
+    top: 1077,
+  },
+  {
+    id: 36,
+    name: "无锡市",
+    left: 970,
+    top: 1100,
+  },
+  {
+    id: 39,
+    name: "苏州市",
+    left: 1131,
+    top: 1157,
+  },
+];
+
+const goScenic = () => {
+  router.push({
+    name: "iframe",
+    params: {
+      url: encodeURIComponent(window.cloudScenicUrl),
+    },
+  });
+};
+</script>

+ 28 - 0
src/views/iframe/index.vue

@@ -0,0 +1,28 @@
+<template>
+  <div class="iframe-page">
+    <BackBtn />
+    <iframe v-if="iframeUrl" frameborder="0" :src="iframeUrl" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from "vue";
+import { useRoute } from "vue-router";
+import { BackBtn } from "@/components";
+
+const route = useRoute();
+const iframeUrl = ref(decodeURIComponent(route.params.url as string));
+</script>
+
+<style lang="scss">
+.iframe-page {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+
+  > iframe {
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 84 - 0
src/views/museum-detail/components/card.vue

@@ -0,0 +1,84 @@
+<template>
+  <div class="museum-detail-card" @click="handleClick">
+    <div
+      class="museum-detail-card__img"
+      :style="{ backgroundImage: `url(${thumb})` }"
+    />
+    <div class="museum-detail-card__title">
+      <span>{{ item.name }}</span>
+      <template v-if="!!item.tag">
+        <span v-for="tag in tags" :key="tag" class="museum-detail-card__tag">{{
+          tag
+        }}</span>
+      </template>
+    </div>
+    <p>展览地点:{{ item.address || "--" }}</p>
+    <p>参展博物馆:{{ item.parentName || "--" }}</p>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { CityMuseumItemType } from "@/api/types";
+import { computed } from "vue";
+import { useRouter } from "vue-router";
+
+const props = defineProps<{
+  item: CityMuseumItemType;
+}>();
+
+const router = useRouter();
+const tags = computed(() => props.item.tag.split(","));
+const thumb = computed(
+  () => `${process.env.VUE_APP_BACKEND_URL}${props.item.thumb}`
+);
+
+const handleClick = () => {
+  router.push({
+    name: "iframe",
+    params: {
+      url: encodeURIComponent(props.item.link),
+    },
+  });
+};
+</script>
+
+<style lang="scss" scoped>
+.museum-detail-card {
+  padding: 0.3625rem /* 29/80 */ 0.4625rem /* 37/80 */ 0.3625rem /* 29/80 */
+    0.4375rem /* 35/80 */;
+  width: 7.15rem /* 572/80 */;
+  height: 5.4rem /* 432/80 */;
+  box-sizing: border-box;
+  background: url("../imgs/content02@2x.png") no-repeat center / cover;
+
+  &__img {
+    height: 3.8125rem /* 305/80 */;
+    background-size: cover;
+    background-position: center;
+  }
+  &__title {
+    margin: 0.075rem /* 6/80 */ 0 0.025rem /* 2/80 */;
+    font-size: 0.25rem /* 20/80 */;
+    font-family: Source Han Serif CN-Bold;
+    font-weight: bold;
+    color: #5c594b;
+    line-height: 0.3375rem /* 27/80 */;
+  }
+  p {
+    color: #5c594b;
+    line-height: 0.2625rem /* 21/80 */;
+    font-size: 0.1875rem /* 15/80 */;
+  }
+  &__tag {
+    position: relative;
+    top: -0.025rem /* 2/80 */;
+    margin-left: 0.125rem /* 10/80 */;
+    padding: 0.025rem /* 2/80 */ 0.125rem /* 10/80 */;
+    font-size: 0.15rem /* 12/80 */;
+    color: #8f6c38;
+    background: rgba(239, 212, 152, 0.5);
+    border-radius: 0.0375rem /* 3/80 */;
+    border: 1px solid #8f6c38;
+  }
+}
+</style>

BIN
src/views/museum-detail/imgs/content02@2x.png


+ 71 - 0
src/views/museum-detail/index.scss

@@ -0,0 +1,71 @@
+.museum-detail {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+  background: url("../../assets/imgs/bg3@2x.jpg") no-repeat center / cover;
+
+  &__title {
+    margin-top: 0.8625rem /* 69/80 */;
+    font-family: Source Han Serif CN-Bold;
+    font-size: 0.625rem /* 50/80 */;
+    font-weight: bold;
+    color: #fff;
+    text-align: center;
+    line-height: 0.85rem /* 68/80 */;
+    text-shadow: 0px 0px 0.1875rem /* 15/80 */ #9f7b46;
+  }
+  &__info {
+    margin: 1.5rem /* 120/80 */ 3.0625rem /* 245/80 */ 0.625rem /* 50/80 */;
+    font-size: 0.25rem /* 20/80 */;
+    color: #5c594b;
+    font-weight: 300;
+    text-align: center;
+    line-height: 0.375rem /* 30/80 */;
+
+    > div:not(:first-child) {
+      margin-top: 0.1625rem /* 13/80 */;
+    }
+    .svg-icon {
+      position: relative;
+      top: 0.05rem /* 4/80 */;
+      margin-right: 0.05rem /* 4/80 */;
+    }
+    &__story {
+      position: relative;
+
+      &::before {
+        content: "";
+        position: absolute;
+        top: -0.8375rem /* 67/80 */;
+        left: -0.625rem /* 50/80 */;
+        width: 1.1125rem /* 89/80 */;
+        height: 1.1125rem /* 89/80 */;
+        background: url("../../assets/imgs/label1@2x.png") no-repeat center /
+          cover;
+      }
+      &::after {
+        content: "";
+        position: absolute;
+        right: 0.625rem /* 50/80 */;
+        bottom: -0.375rem /* 30/80 */;
+        width: 0.7125rem /* 57/80 */;
+        height: 0.7125rem /* 57/80 */;
+        background: url("../../assets/imgs/label2@2x.png") no-repeat center /
+          cover;
+      }
+    }
+  }
+  &-scroll {
+    &__container {
+      display: flex;
+
+      > div {
+        margin-left: 1rem /* 80/80 */;
+
+        &:last-child {
+          margin-right: 1rem /* 80/80 */;
+        }
+      }
+    }
+  }
+}

+ 80 - 0
src/views/museum-detail/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <div v-loading="loading" class="museum-detail">
+    <BackBtn />
+
+    <div class="museum-detail__title">{{ info?.name }}</div>
+
+    <div class="museum-detail__info">
+      <div>
+        <SvgIcon name="icon_address" :size="0.3125" />
+        <span>地址:{{ info?.address }}</span>
+      </div>
+      <div>
+        <SvgIcon name="icon_time" :size="0.3125" />
+        <span>开放时间:{{ info?.openTime }}</span>
+      </div>
+      <div class="museum-detail__info__story">
+        <span>{{ info?.description }}</span>
+      </div>
+    </div>
+
+    <div class="museum-detail-scroll">
+      <DragView
+        :scrollY="false"
+        :maxTranslateXLength="maxWidth"
+        :maxTranslateYLength="0"
+      >
+        <div ref="scrollRef" class="museum-detail-scroll__container">
+          <Card v-for="item in list" :key="item.id" :item="item" />
+        </div>
+      </DragView>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { getExhibitListApi, getMuseumDetailApi } from "@/api";
+import { CityMuseumItemType } from "@/api/types";
+import { BackBtn, DragView, SvgIcon } from "@/components";
+import { nextTick, onMounted, ref } from "vue";
+import { useRoute } from "vue-router";
+import Card from "./components/card.vue";
+import "./index.scss";
+
+const loading = ref(false);
+const scrollRef = ref<null | HTMLElement>(null);
+const maxWidth = ref(0);
+const info = ref<CityMuseumItemType | null>(null);
+const route = useRoute();
+const list = ref<CityMuseumItemType[]>([]);
+
+onMounted(() => {
+  loading.value = true;
+  Promise.all([getMuseumDetail(), getExhibitList()]).finally(() => {
+    loading.value = false;
+    nextTick(() => {
+      getScrollArea();
+    });
+  });
+});
+
+const getScrollArea = async () => {
+  if (!scrollRef.value) return;
+  maxWidth.value = scrollRef.value.clientWidth - window.innerWidth;
+};
+
+const getMuseumDetail = async () => {
+  const { data } = await getMuseumDetailApi(route.params.id as string);
+  info.value = data;
+};
+
+const getExhibitList = async () => {
+  const {
+    data: { records },
+  } = await getExhibitListApi({
+    pageNum: 0,
+    pageSize: 0,
+  });
+  list.value = records;
+};
+</script>

BIN
src/views/venue-reservation/imgs/btn_add@2x.png


BIN
src/views/venue-reservation/imgs/btn_chosen@2x.png


BIN
src/views/venue-reservation/imgs/btn_delete@2x.png


BIN
src/views/venue-reservation/imgs/btn_l_active@2x.png


BIN
src/views/venue-reservation/imgs/btn_l_normal@2x.png


BIN
src/views/venue-reservation/imgs/btn_m_active@2x.png


BIN
src/views/venue-reservation/imgs/btn_m_normal@2x.png


BIN
src/views/venue-reservation/imgs/btn_submit@2x.png


BIN
src/views/venue-reservation/imgs/content_l@2x.png


BIN
src/views/venue-reservation/imgs/content_m@2x.png


+ 235 - 0
src/views/venue-reservation/index.scss

@@ -0,0 +1,235 @@
+.venue-reservation {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+  background: url("../../assets/imgs/bg4@2x.jpg") no-repeat center / cover;
+
+  &-container {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    overflow-y: auto;
+  }
+  &__title {
+    margin-top: 0.8625rem /* 69/80 */;
+    font-family: Source Han Serif CN-Bold;
+    font-size: 0.625rem /* 50/80 */;
+    font-weight: bold;
+    color: #fff;
+    text-align: center;
+    line-height: 0.85rem /* 68/80 */;
+    text-shadow: 0px 0px 0.1875rem /* 15/80 */ #9f7b46;
+  }
+  &-form {
+    $inputHeight: 0.5625rem /* 45/80 */;
+    margin: 1rem /* 80/80 */ 6.575rem /* 526/80 */ 0.25rem /* 20/80 */ 5.7rem
+      /* 456/80 */;
+    width: 11.4875rem /* 919/80 */;
+    font-family: Source Han Serif CN-Bold;
+    font-weight: bold;
+
+    .el-form-item__label {
+      padding-right: 0.275rem /* 22/80 */;
+      font-size: 0.3125rem /* 25/80 */;
+      color: #5c594b;
+      line-height: $inputHeight;
+      height: $inputHeight;
+      width: 1.7125rem /* 137/80 */;
+      text-align: right;
+    }
+    .el-form-item {
+      margin-bottom: 0.375rem /* 30/80 */;
+    }
+    .vr-input {
+      display: flex;
+      padding: 0 0.125rem /* 10/80 */;
+      width: 100%;
+      height: $inputHeight;
+      box-sizing: border-box;
+      background: url("./imgs/content_m@2x.png") no-repeat center / 100%;
+
+      &.textarea {
+        padding: 0.0625rem /* 5/80 */ 0.125rem /* 10/80 */;
+        height: 1.1625rem /* 93/80 */;
+        background-image: url("./imgs/content_l@2x.png");
+
+        .el-textarea__inner {
+          background-color: transparent;
+          box-shadow: none;
+        }
+      }
+      .el-input {
+        --el-input-bg-color: transparent;
+        --el-input-height: #{$inputHeight};
+        font-size: 0.1875rem /* 15/80 */;
+
+        .el-input__wrapper {
+          box-shadow: none;
+        }
+      }
+    }
+    .vr-visitor {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      position: relative;
+      width: 100%;
+
+      &__item {
+        position: relative;
+        display: flex;
+        justify-content: space-between;
+        width: 100%;
+
+        &:not(:first-child) {
+          margin-top: 0.075rem /* 6/80 */;
+        }
+      }
+      &__add {
+        margin-top: 0.1875rem /* 15/80 */;
+        width: 1.4625rem /* 117/80 */;
+        height: 0.4375rem /* 35/80 */;
+        cursor: pointer;
+        background: url("./imgs/btn_add@2x.png") no-repeat center / 100%;
+      }
+      &__del {
+        position: absolute;
+        top: 50%;
+        right: -0.6875rem /* 55/80 */;
+        transform: translateY(-50%);
+        width: 0.4375rem /* 35/80 */;
+        height: 0.4375rem /* 35/80 */;
+        cursor: pointer;
+        background: url("./imgs/btn_delete@2x.png") no-repeat center / 100%;
+      }
+      .vr-input {
+        width: 2.975rem /* 238/80 */;
+        background-image: url("./imgs/btn_l_normal@2x.png");
+      }
+    }
+  }
+  .vr-ex-flex {
+    display: flex;
+    gap: 0.375rem /* 30/80 */;
+
+    &__item {
+      position: relative;
+      width: 2.5rem /* 200/80 */;
+      height: 1.5625rem /* 125/80 */;
+      border-radius: 0.0375rem /* 3/80 */;
+
+      &::after {
+        content: "";
+        position: absolute;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        background: rgba(0, 0, 0, 0.5);
+        border-radius: 0.0375rem /* 3/80 */;
+      }
+      &.active {
+        &::after {
+          display: none;
+        }
+        &::before {
+          content: "";
+          position: absolute;
+          top: -0.075rem /* 6/80 */;
+          left: -0.125rem /* 10/80 */;
+          right: -0.125rem /* 10/80 */;
+          bottom: -0.075rem /* 6/80 */;
+          background: url("./imgs/btn_chosen@2x.png") no-repeat center / 100%;
+        }
+      }
+      span {
+        display: block;
+        position: absolute;
+        left: 0;
+        right: 0;
+        bottom: 0.0625rem /* 5/80 */;
+        color: #fff;
+        text-align: center;
+        font-size: 0.1875rem /* 15/80 */;
+        line-height: 0.2625rem /* 21/80 */;
+        z-index: 1;
+      }
+    }
+  }
+  .vr-flex {
+    display: flex;
+
+    &.exhibition {
+      .vr-flex__item {
+        width: 2.975rem /* 238/80 */;
+        background-image: url("./imgs/btn_l_normal@2x.png");
+
+        &.active {
+          background-image: url("./imgs/btn_l_active@2x.png");
+        }
+      }
+    }
+    &__item {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      color: #5c594b;
+      width: 1.7125rem /* 137/80 */;
+      height: 0.5625rem /* 45/80 */;
+      cursor: pointer;
+      background: url("./imgs/btn_m_normal@2x.png") no-repeat center / 100%;
+
+      &:not(:last-child) {
+        margin-right: 0.1625rem /* 13/80 */;
+      }
+      &.active {
+        color: #fff;
+        background-image: url("./imgs/btn_m_active@2x.png");
+
+        .el-input__inner {
+          color: #fff;
+        }
+      }
+      .el-input {
+        --el-date-editor-width: 1.0625rem /* 85/80 */ !important;
+      }
+      .el-input__wrapper {
+        padding: 0 0.075rem /* 6/80 */;
+        background: transparent;
+        box-shadow: none;
+        box-sizing: border-box;
+      }
+      .el-input__prefix-inner {
+        display: none;
+      }
+      .svg-icon {
+        flex-shrink: 0;
+      }
+    }
+  }
+
+  input::placeholder,
+  textarea::placeholder,
+  .el-input__inner {
+    color: #5c594b;
+    font-family: Source Han Serif CN-Bold;
+    font-weight: bold;
+    font-size: 0.1875rem /* 15/80 */;
+  }
+
+  .vr-submit {
+    margin: 0 auto 0.5rem /* 40/80 */;
+    width: 0.85rem /* 68/80 */;
+    height: 0.85rem /* 68/80 */;
+    background: url("./imgs/btn_submit@2x.png") no-repeat center / 100%;
+  }
+  .el-form-item__label:before {
+    display: none;
+  }
+  .el-form-item__error {
+    padding-top: 0.0625rem /* 5/80 */;
+    font-size: 0.15rem /* 12/80 */;
+  }
+}

+ 313 - 0
src/views/venue-reservation/index.vue

@@ -0,0 +1,313 @@
+<template>
+  <div v-loading="loading" class="venue-reservation">
+    <BackBtn />
+
+    <div class="venue-reservation-container">
+      <div class="venue-reservation__title">场馆预约</div>
+
+      <el-form
+        ref="formRef"
+        :rules="rules"
+        :model="form"
+        class="venue-reservation-form"
+      >
+        <el-form-item label="参观展馆">
+          <div class="vr-ex-flex">
+            <div
+              v-for="item in museumList"
+              :key="item.id"
+              :class="[
+                'vr-ex-flex__item',
+                form.exhibitionName === item.name && 'active',
+              ]"
+              :style="{ background: `url(${item.thumb})` }"
+              @click="handleExhibition(item)"
+            >
+              <span>{{ item.name }}</span>
+            </div>
+          </div>
+        </el-form-item>
+        <el-form-item v-if="venues.length" label="选择场馆">
+          <div class="vr-flex exhibition">
+            <div
+              v-for="str in venues"
+              :key="str"
+              :class="['vr-flex__item', str === form.venues && 'active']"
+              @click="form.venues = str"
+            >
+              {{ str }}
+            </div>
+          </div>
+        </el-form-item>
+        <el-form-item label="所在单位" prop="organ">
+          <div class="vr-input">
+            <el-input v-model="form.organ" placeholder="请输入内容" />
+          </div>
+        </el-form-item>
+        <el-form-item label="参观人员">
+          <div class="vr-visitor">
+            <div
+              v-for="(item, index) in visitorList"
+              :key="item.id"
+              class="vr-visitor__item"
+            >
+              <div class="vr-input">
+                <el-input v-model="item.name" placeholder="请输入姓名" />
+              </div>
+              <div class="vr-input">
+                <el-input
+                  v-model="item.phone"
+                  placeholder="请输入手机号"
+                  type="number"
+                />
+              </div>
+              <div class="vr-input">
+                <el-input v-model="item.idcard" placeholder="请输入身份证号" />
+              </div>
+
+              <div
+                v-if="item.id !== 0"
+                class="vr-visitor__del"
+                @click="() => visitorList.splice(index, 1)"
+              />
+            </div>
+
+            <div class="vr-visitor__add" @click="addVisitor" />
+          </div>
+        </el-form-item>
+        <el-form-item label="预约时间">
+          <div class="vr-flex">
+            <div
+              v-for="item in dateList"
+              :key="item.value"
+              :class="[
+                'vr-flex__item',
+                item.value === form.bookDay && 'active',
+              ]"
+              @click="form.bookDay = item.value"
+            >
+              {{ item.label }}
+            </div>
+
+            <div
+              :class="['vr-flex__item', bookDay === form.bookDay && 'active']"
+            >
+              <SvgIcon name="icon_date" />
+              <el-date-picker
+                v-model="bookDay"
+                format="MM月DD日"
+                placeholder="其他日期"
+                value-format="YYYY-MM-DD"
+                :clearable="false"
+                :disabled-date="disabledDate"
+                @focus="bookDay && (form.bookDay = bookDay)"
+                @change="form.bookDay = bookDay"
+              />
+            </div>
+          </div>
+        </el-form-item>
+        <el-form-item label="预约时段">
+          <div class="vr-flex">
+            <div
+              v-for="str in DEFAULT_TIME"
+              :key="str"
+              :class="['vr-flex__item', str === form.bootTimeScope && 'active']"
+              @click="form.bootTimeScope = str"
+            >
+              {{ str }}
+            </div>
+          </div>
+        </el-form-item>
+        <el-form-item label="备注">
+          <div class="vr-input textarea">
+            <el-input
+              v-model="form.description"
+              resize="none"
+              :rows="3"
+              type="textarea"
+              placeholder="请输入备注"
+            />
+          </div>
+        </el-form-item>
+      </el-form>
+
+      <div class="vr-submit" @click="submit" />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { getExhibitListApi, reservationVenueApi } from "@/api";
+import { CityMuseumItemType } from "@/api/types";
+import { BackBtn, SvgIcon } from "@/components";
+import { formatDate } from "@/utils";
+import { FormInstance, FormRules } from "element-plus";
+import { computed, onActivated, onMounted, reactive, ref, watch } from "vue";
+import { useRouter } from "vue-router";
+import "./index.scss";
+
+type DateType = {
+  label: string;
+  value: string;
+};
+
+let visitorId = 0;
+const router = useRouter();
+const DEFAULT_MUSEUM_ID = [1, 2, 3];
+const curExhibitionId = ref(0);
+const venues = computed(() => {
+  const str = window.museum[curExhibitionId.value];
+  return str ? str.split(",") : [];
+});
+const DEFAULT_TIME = ["08:00-11:30", "14:00-17:30"];
+const DEFAULT_FORM = {
+  bookDay: "",
+  bootTimeScope: DEFAULT_TIME[0],
+  description: "",
+  organ: window.company,
+  venues: "",
+  exhibitionName: "",
+};
+const DEFAULT_VISITOR = {
+  id: visitorId,
+  name: "",
+  idcard: "",
+  phone: "",
+};
+const visitorList = reactive<
+  {
+    id: number;
+    name: string;
+    idcard: string;
+    phone: string;
+  }[]
+>([
+  {
+    ...DEFAULT_VISITOR,
+  },
+]);
+const dateList = ref<DateType[]>([]);
+const museumList = ref<CityMuseumItemType[]>([]);
+const bookDay = ref("");
+const form = reactive({ ...DEFAULT_FORM });
+const rules = reactive<FormRules>({
+  organ: [{ required: true, message: "请填写单位", trigger: "blur" }],
+});
+const loading = ref(false);
+const formRef = ref<FormInstance>();
+
+onMounted(() => {
+  initDate();
+  getExhibitList();
+});
+
+onActivated(() => {
+  // 清除缓存数据
+  if (museumList.value.length) {
+    Object.assign(form, {
+      ...DEFAULT_FORM,
+      bookDay: dateList.value[0].value,
+      venues: venues.value[0],
+      exhibitionName: museumList.value[0].name,
+    });
+    visitorList.length = 0;
+    visitorList.push({ ...DEFAULT_VISITOR });
+    curExhibitionId.value = museumList.value[0].id;
+  }
+});
+
+watch(venues, (list) => {
+  form.venues = list[0];
+});
+
+const disabledDate = (time: Date) => {
+  const today = new Date();
+  today.setDate(today.getDate() + 4);
+  return time.getTime() < today.getTime();
+};
+
+const getExhibitList = async () => {
+  loading.value = true;
+  try {
+    const { data } = await getExhibitListApi({
+      pageNum: 0,
+      pageSize: 0,
+    });
+    museumList.value = data.records
+      .filter((item) => DEFAULT_MUSEUM_ID.includes(item.id))
+      .map((item) => ({
+        ...item,
+        thumb: process.env.VUE_APP_BACKEND_URL + item.thumb,
+      }));
+    curExhibitionId.value = museumList.value[0].id;
+    form.exhibitionName = museumList.value[0].name;
+  } finally {
+    loading.value = false;
+  }
+};
+
+const addVisitor = () => {
+  visitorList.push({
+    id: ++visitorId,
+    name: "",
+    idcard: "",
+    phone: "",
+  });
+};
+
+const initDate = () => {
+  const stack: DateType[] = [];
+  const today = new Date();
+  for (let i = 0; i < 4; i++) {
+    today.setDate(today.getDate() + 1);
+    stack.push({
+      value: formatDate(today),
+      label: `${i === 0 ? "明天" : ""}${formatDate(today, "MM月DD日")}`,
+    });
+  }
+  form.bookDay = stack[0].value;
+  dateList.value = stack;
+};
+
+const submit = async () => {
+  if (!formRef.value || !(await formRef.value.validate())) return;
+
+  for (let i = 0; i < visitorList.length; i++) {
+    const item = visitorList[i];
+    if (!item.name || !item.phone || !item.idcard) {
+      /* eslint-disable */
+      ElMessage({
+        type: "warning",
+        message: `参观人员第${i + 1}条数据请填写完整`,
+        duration: 4000,
+      });
+      return;
+    }
+  }
+
+  const params = {
+    ...form,
+    exhibitionName: `${form.exhibitionName}${
+      form.venues ? "-" + form.venues : ""
+    }`,
+    contact: visitorList
+      .map((item) => `${item.name}-${item.phone}-${item.idcard}`)
+      .join(","),
+  };
+
+  loading.value = true;
+  try {
+    await reservationVenueApi(params);
+  } finally {
+    loading.value = false;
+    router.replace({
+      name: "home",
+    });
+  }
+};
+
+const handleExhibition = (item: CityMuseumItemType) => {
+  form.exhibitionName = item.name;
+  curExhibitionId.value = item.id;
+};
+</script>

+ 100 - 0
vue.config.js

@@ -1,4 +1,104 @@
 const { defineConfig } = require("@vue/cli-service");
+const path = require("path");
+const LodashModuleReplacementPlugin = require("lodash-webpack-plugin");
+const AutoImport = require("unplugin-auto-import/webpack");
+const Components = require("unplugin-vue-components/webpack");
+const { ElementPlusResolver } = require("unplugin-vue-components/resolvers");
+
+function resolve(dir) {
+  return path.join(__dirname, ".", dir);
+}
+
+const IS_PRODUCTION = process.env.NODE_ENV === "production";
+
 module.exports = defineConfig({
+  publicPath: "./",
   transpileDependencies: true,
+  pluginOptions: {
+    electronBuilder: {
+      builderOptions: {
+        productName: "江苏省文旅厅",
+        asar: false,
+      },
+    },
+  },
+  configureWebpack: {
+    devtool: "source-map",
+    resolve: {
+      symlinks: false,
+      alias: {
+        "@": path.join(__dirname, "src"),
+      },
+    },
+    plugins: [
+      AutoImport({
+        resolvers: [ElementPlusResolver()],
+      }),
+      Components({
+        resolvers: [ElementPlusResolver()],
+      }),
+    ],
+  },
+  chainWebpack: (webpackConfig) => {
+    webpackConfig.module.rules.delete("svg");
+    webpackConfig.module
+      .rule("svg-sprite-loader")
+      .test(/.svg$/)
+      .include.add(resolve("src/assets/svg"))
+      .end()
+      .use("svg-sprite-loader")
+      .loader("svg-sprite-loader")
+      .options({
+        symbolId: "icon-[name]",
+      });
+
+    if (IS_PRODUCTION) {
+      webpackConfig
+        .plugin("loadshReplace")
+        .use(new LodashModuleReplacementPlugin());
+
+      webpackConfig.optimization.splitChunks({
+        cacheGroups: {
+          common: {
+            name: "chunk-common",
+            chunks: "initial",
+            minChunks: 2,
+            // 一个入口最大的并行请求数
+            maxInitialRequests: 4,
+            // 形成一个新代码块最小的体积
+            minSize: 5000,
+            // 缓存组打包的先后优先级
+            priority: 1,
+            // 如果当前的 chunk 已被从 split 出来,那么将会直接复用这个 chunk 而不是重新创建一个
+            reuseExistingChunk: true,
+          },
+          vendors: {
+            name: "chunk-vendors",
+            test: /[\\/]node_modules[\\/]/,
+            chunks: "initial",
+            priority: 2,
+            reuseExistingChunk: true,
+            // 总是为这个缓存组创建 chunks
+            enforce: true,
+          },
+          elementIcons: {
+            name: "element-icons",
+            test: /[\\/]node_modules[\\/]@element-plus[\\/]/,
+            chunks: "initial",
+            priority: 3,
+            reuseExistingChunk: true,
+            enforce: true,
+          },
+          elementPlus: {
+            name: "element-plus",
+            test: /[\\/]node_modules[\\/]element-plus[\\/]/,
+            chunks: "initial",
+            priority: 3,
+            reuseExistingChunk: true,
+            enforce: true,
+          },
+        },
+      });
+    }
+  },
 });