wangfumin 4 дней назад
Сommit
6132ffa032
65 измененных файлов с 14212 добавлено и 0 удалено
  1. 8 0
      .editorconfig
  2. 18 0
      .env.development
  3. 16 0
      .env.production
  4. 1 0
      .gitattributes
  5. 30 0
      .gitignore
  6. 6 0
      .prettierrc.json
  7. 9 0
      .vscode/extensions.json
  8. 41 0
      README.md
  9. 32 0
      eslint.config.js
  10. 13 0
      index.html
  11. 8 0
      jsconfig.json
  12. 7626 0
      package-lock.json
  13. 43 0
      package.json
  14. 4492 0
      pnpm-lock.yaml
  15. BIN
      public/favicon.png
  16. 7 0
      src/App.vue
  17. 64 0
      src/api/index.js
  18. 7 0
      src/assets/element.scss
  19. BIN
      src/assets/fonts/SOURCEHANSERIFCN-BOLD.OTF
  20. BIN
      src/assets/fonts/SOURCEHANSERIFCN-REGULAR.OTF
  21. BIN
      src/assets/fonts/SourceHanSansCN-Regular.otf
  22. 3 0
      src/assets/img/DeleteOutlined.svg
  23. 3 0
      src/assets/img/Inbox.svg
  24. BIN
      src/assets/img/home-bg.png
  25. 3 0
      src/assets/img/icon_data.svg
  26. 4 0
      src/assets/img/icon_time.svg
  27. BIN
      src/assets/img/log_eye_normal.png
  28. BIN
      src/assets/img/paper_l.png
  29. BIN
      src/assets/img/pic_bg.png
  30. BIN
      src/assets/img/pic_camera.png
  31. 3 0
      src/assets/img/push.svg
  32. 4 0
      src/assets/img/quit.svg
  33. BIN
      src/assets/img/search.png
  34. 7 0
      src/assets/img/search.svg
  35. BIN
      src/assets/img/title.png
  36. BIN
      src/assets/img/top-logo.png
  37. 177 0
      src/assets/main.css
  38. 83 0
      src/assets/styles/cut-corner.scss
  39. 33 0
      src/assets/styles/element-variables.scss
  40. 44 0
      src/assets/styles/variable.scss
  41. 11 0
      src/assets/svgs/icon_comment_yellow.svg
  42. 5 0
      src/assets/svgs/icon_copy.svg
  43. 5 0
      src/assets/svgs/icon_delete.svg
  44. 5 0
      src/assets/svgs/icon_eyes.svg
  45. 16 0
      src/assets/svgs/icon_fullscreen_yellow.svg
  46. 6 0
      src/assets/svgs/icon_like_yellow.svg
  47. 8 0
      src/assets/svgs/icon_mark_yellow.svg
  48. 8 0
      src/assets/svgs/icon_menu_yellow.svg
  49. 5 0
      src/assets/svgs/icon_search.svg
  50. 8 0
      src/assets/svgs/icon_search_yellow.svg
  51. 8 0
      src/assets/svgs/icon_setting_yellow.svg
  52. 10 0
      src/assets/svgs/icon_share_yellow.svg
  53. 5 0
      src/assets/svgs/icon_time.svg
  54. 7 0
      src/assets/svgs/icon_upload_yellow.svg
  55. 140 0
      src/components/DragVerify.vue
  56. 26 0
      src/main.js
  57. 33 0
      src/router/index.js
  58. 18 0
      src/store/index.js
  59. 0 0
      src/utils/index.js
  60. 180 0
      src/utils/request.js
  61. 339 0
      src/views/Home/index.vue
  62. 203 0
      src/views/Login/index.vue
  63. 332 0
      src/views/pushSystem/index.vue
  64. 35 0
      vite.config.js
  65. 14 0
      vitest.config.js

+ 8 - 0
.editorconfig

@@ -0,0 +1,8 @@
+[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
+charset = utf-8
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+end_of_line = lf
+max_line_length = 100

+ 18 - 0
.env.development

@@ -0,0 +1,18 @@
+# 是否使用Hash路由
+VITE_USE_HASH = 'true'
+
+# 资源公共路径,需要以 /开头和结尾
+VITE_PUBLIC_PATH = './'
+
+# Axios 基础路径
+VITE_AXIOS_BASE_URL = '/api'  # 用于代理
+# VITE_AXIOS_BASE_URL = 'https://mock.apipark.cn/m1/3776410-0-default'  # apifox云端mock
+# VITE_AXIOS_BASE_URL = 'http://192.168.0.73:8085'
+# 代理配置-target
+# VITE_PROXY_TARGET = 'http://192.168.0.73:8080'
+VITE_PROXY_TARGET = 'https://wwjc.cchicc.org.cn/'
+
+# 图片基础
+VITE_COS_BASE_URL = 'https://hybgc.4dage.com/ArtCMS/'
+# 模型基础
+VITE_MODEL_URL = 'https://hybgc.4dage.com/'

+ 16 - 0
.env.production

@@ -0,0 +1,16 @@
+# 是否使用Hash路由
+VITE_USE_HASH = 'true'
+
+# 资源公共路径,需要以 /开头和结尾
+VITE_PUBLIC_PATH = './'
+
+VITE_AXIOS_BASE_URL = '/api'  # 用于代理
+
+# 代理配置-target
+# VITE_PROXY_TARGET = 'http://localhost:8085'
+VITE_PROXY_TARGET = 'https://sit-huyaobangjng.4dage.com'
+
+# 图片基础
+VITE_COS_BASE_URL = 'https://hybgc.4dage.com/ArtCMS/'
+# 模型基础
+VITE_MODEL_URL = 'https://hybgc.4dage.com/'

+ 1 - 0
.gitattributes

@@ -0,0 +1 @@
+* text=auto eol=lf

+ 30 - 0
.gitignore

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

+ 6 - 0
.prettierrc.json

@@ -0,0 +1,6 @@
+{
+  "$schema": "https://json.schemastore.org/prettierrc",
+  "semi": false,
+  "singleQuote": true,
+  "printWidth": 100
+}

+ 9 - 0
.vscode/extensions.json

@@ -0,0 +1,9 @@
+{
+  "recommendations": [
+    "Vue.volar",
+    "vitest.explorer",
+    "dbaeumer.vscode-eslint",
+    "EditorConfig.EditorConfig",
+    "esbenp.prettier-vscode"
+  ]
+}

+ 41 - 0
README.md

@@ -0,0 +1,41 @@
+# hhbang-book
+
+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).
+
+## Customize configuration
+
+See [Vite Configuration Reference](https://vite.dev/config/).
+
+## Project Setup
+
+```sh
+pnpm install
+```
+
+### Compile and Hot-Reload for Development
+
+```sh
+pnpm dev
+```
+
+### Compile and Minify for Production
+
+```sh
+pnpm build
+```
+
+### Run Unit Tests with [Vitest](https://vitest.dev/)
+
+```sh
+pnpm test:unit
+```
+
+### Lint with [ESLint](https://eslint.org/)
+
+```sh
+pnpm lint
+```

+ 32 - 0
eslint.config.js

@@ -0,0 +1,32 @@
+import { defineConfig, globalIgnores } from 'eslint/config'
+import globals from 'globals'
+import js from '@eslint/js'
+import pluginVue from 'eslint-plugin-vue'
+import pluginVitest from '@vitest/eslint-plugin'
+import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
+
+export default defineConfig([
+  {
+    name: 'app/files-to-lint',
+    files: ['**/*.{js,mjs,jsx,vue}'],
+  },
+
+  globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
+
+  {
+    languageOptions: {
+      globals: {
+        ...globals.browser,
+      },
+    },
+  },
+
+  js.configs.recommended,
+  ...pluginVue.configs['flat/essential'],
+  
+  {
+    ...pluginVitest.configs.recommended,
+    files: ['src/**/__tests__/*'],
+  },
+  skipFormatting,
+])

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="UTF-8">
+    <link rel="icon" href="/favicon.png">
+    <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.js"></script>
+  </body>
+</html>

+ 8 - 0
jsconfig.json

@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "exclude": ["node_modules", "dist"]
+}

Разница между файлами не показана из-за своего большого размера
+ 7626 - 0
package-lock.json


+ 43 - 0
package.json

@@ -0,0 +1,43 @@
+{
+  "name": "hhbang-book",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "engines": {
+    "node": "^20.19.0 || >=22.12.0"
+  },
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "test:unit": "vitest",
+    "lint": "eslint . --fix",
+    "format": "prettier --write src/"
+  },
+  "dependencies": {
+    "axios": "^1.9.0",
+    "element-plus": "^2.9.11",
+    "jsencrypt": "^3.5.4",
+    "lodash": "^4.17.21",
+    "sass-embedded": "^1.89.2",
+    "v-distpicker": "^2.1.0",
+    "vue": "^3.5.18",
+    "vue-router": "^4.5.1",
+    "vuex": "^4.1.0"
+  },
+  "devDependencies": {
+    "@eslint/js": "^9.31.0",
+    "@vitejs/plugin-vue": "^6.0.1",
+    "@vitest/eslint-plugin": "^1.3.4",
+    "@vue/eslint-config-prettier": "^10.2.0",
+    "@vue/test-utils": "^2.4.6",
+    "eslint": "^9.31.0",
+    "eslint-plugin-vue": "~10.3.0",
+    "globals": "^16.3.0",
+    "jsdom": "^26.1.0",
+    "prettier": "3.6.2",
+    "vite": "^7.0.6",
+    "vite-plugin-vue-devtools": "^8.0.0",
+    "vitest": "^3.2.4"
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 4492 - 0
pnpm-lock.yaml


BIN
public/favicon.png


+ 7 - 0
src/App.vue

@@ -0,0 +1,7 @@
+<script setup></script>
+
+<template>
+  <RouterView :key="$route.fullPath" />
+</template>
+
+<style scoped></style>

+ 64 - 0
src/api/index.js

@@ -0,0 +1,64 @@
+import request from '@/utils/request';
+
+const hhbangBookApi = {
+  // 登录
+  loginApi(data = {}) {
+    return request({
+      url: '/historyrical/auth/login',
+      method: 'post',
+      data
+    })
+  },
+  // 场景列表
+  getSceneListApi(params = {}) {
+    return request({
+      url: '/historyrical/scene/list',
+      method: 'get',
+      params: {
+        pageNum: 1,
+        pageSize: 10,
+        orderBy: '',
+        sortBy: '',
+        title: '',
+        ...params
+      }
+    })
+  },
+  // 删除场景
+  deleteSceneApi(id) {
+    return request({
+      url: `/historyrical/scene/${id}`,
+      method: 'delete',
+    })
+  },
+  // 首页获取推荐列表
+  getRecommendListApi(params = {}) {
+    return request({
+      url: '/hyb/artArtworks/index/page',
+      method: 'get',
+      params: {
+        pageNo: Math.floor(Math.random() * 10) + 1, // 随机页码
+        pageSize: 30,
+        ...params
+      }
+    })
+  },
+
+  // 获取文物列表 - 用于收藏页面和详情页上下页切换
+  getArtifactListApi(params = {}) {
+    return request({
+      url: '/hyb/artArtworks/list',
+      method: 'get',
+      params: {
+        agetype: params.era || '', // 年代
+        category: params.category || '', // 分类
+        grade: params.level || '', // 级别
+        searchText: params.keyword || '', // 关键词
+        texture: params.material || '', // 材质
+        ...params
+      }
+    })
+  }
+}
+
+export default hhbangBookApi;

+ 7 - 0
src/assets/element.scss

@@ -0,0 +1,7 @@
+@forward "element-plus/theme-chalk/src/common/var.scss" with (
+  $colors: (
+    "primary": (
+      "base": #93795D,
+    ),
+  )
+);

BIN
src/assets/fonts/SOURCEHANSERIFCN-BOLD.OTF


BIN
src/assets/fonts/SOURCEHANSERIFCN-REGULAR.OTF


BIN
src/assets/fonts/SourceHanSansCN-Regular.otf


Разница между файлами не показана из-за своего большого размера
+ 3 - 0
src/assets/img/DeleteOutlined.svg


Разница между файлами не показана из-за своего большого размера
+ 3 - 0
src/assets/img/Inbox.svg


BIN
src/assets/img/home-bg.png


Разница между файлами не показана из-за своего большого размера
+ 3 - 0
src/assets/img/icon_data.svg


+ 4 - 0
src/assets/img/icon_time.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.5 4C7.77614 4 8 4.22386 8 4.5V8H10.5C10.7761 8 11 8.22386 11 8.5C11 8.77614 10.7761 9 10.5 9H7.5C7.22386 9 7 8.77614 7 8.5V4.5C7 4.22386 7.22386 4 7.5 4Z" fill="#999999"/>
+<path d="M8 1.5C11.5899 1.5 14.5 4.41015 14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8C1.5 4.41015 4.41015 1.5 8 1.5ZM8 2.5C4.96243 2.5 2.5 4.96243 2.5 8C2.5 11.0376 4.96243 13.5 8 13.5C11.0376 13.5 13.5 11.0376 13.5 8C13.5 4.96243 11.0376 2.5 8 2.5Z" fill="#999999"/>
+</svg>

BIN
src/assets/img/log_eye_normal.png


BIN
src/assets/img/paper_l.png


BIN
src/assets/img/pic_bg.png


BIN
src/assets/img/pic_camera.png


Разница между файлами не показана из-за своего большого размера
+ 3 - 0
src/assets/img/push.svg


Разница между файлами не показана из-за своего большого размера
+ 4 - 0
src/assets/img/quit.svg


BIN
src/assets/img/search.png


+ 7 - 0
src/assets/img/search.svg

@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="search">
+<g id="Union">
+<path d="M7 0C10.866 0 14 3.13401 14 7C14 8.57231 13.4807 10.0228 12.6055 11.1914L15.207 13.793C15.5975 14.1835 15.5975 14.8165 15.207 15.207C14.8165 15.5975 14.1835 15.5975 13.793 15.207L11.1914 12.6055C10.0228 13.4807 8.57231 14 7 14C3.13401 14 0 10.866 0 7C0 3.13401 3.13401 0 7 0ZM7 2C4.23858 2 2 4.23858 2 7C2 9.76142 4.23858 12 7 12C9.76142 12 12 9.76142 12 7C12 4.23858 9.76142 2 7 2Z" fill="#93795D"/>
+</g>
+</g>
+</svg>

BIN
src/assets/img/title.png


BIN
src/assets/img/top-logo.png


+ 177 - 0
src/assets/main.css

@@ -0,0 +1,177 @@
+html,
+body,
+span,
+applet,
+object,
+iframe,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+blockquote,
+pre,
+a,
+abbr,
+acronym,
+address,
+big,
+cite,
+code,
+del,
+dfn,
+em,
+img,
+ins,
+kbd,
+q,
+s,
+samp,
+small,
+strike,
+strong,
+sub,
+sup,
+tt,
+var,
+b,
+u,
+i,
+center,
+dl,
+dt,
+dd,
+ol,
+ul,
+li,
+fieldset,
+form,
+label,
+legend,
+table,
+caption,
+tbody,
+tfoot,
+thead,
+tr,
+th,
+td,
+article,
+aside,
+canvas,
+details,
+embed,
+figure,
+figcaption,
+footer,
+header,
+hgroup,
+menu,
+nav,
+output,
+ruby,
+section,
+summary,
+time,
+mark,
+audio,
+video {
+  margin: 0;
+  padding: 0;
+  border: 0;
+  font-size: 100%;
+  font: inherit;
+  vertical-align: baseline;
+  overflow: hidden;
+}
+/* HTML5 display-role reset for older browsers */
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+menu,
+nav,
+section {
+  display: block;
+}
+body,
+input {
+  font-size: 14px;
+  color: #464646;
+  font-family: "Source Han Sans CN-Regular";
+}
+ol,
+ul {
+  list-style: none;
+}
+blockquote,
+q {
+  quotes: none;
+}
+blockquote:before,
+blockquote:after,
+q:before,
+q:after {
+  content: "";
+  content: none;
+}
+table {
+  border-collapse: collapse;
+  border-spacing: 0;
+}
+a {
+  color: inherit;
+  text-decoration: none;
+}
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+  -webkit-appearance: none;
+  margin: 0;
+}
+
+.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;
+}
+
+.line-2 {
+  -webkit-line-clamp: 2;
+}
+
+.w100 {
+  width: 100%;
+}
+
+.w350 {
+  width: 350px !important;
+}
+
+.w1100 {
+  margin: 0 auto;
+  width: 1100px;
+  overflow: hidden;
+}
+
+@font-face {
+  font-family: "Source Han Sans CN-Regular";
+  src: url("@/assets/fonts/SourceHanSansCN-Regular.otf");
+}
+@font-face {
+  font-family: "Source Han Serif CN-Bold";
+  src: url("@/assets/fonts/SOURCEHANSERIFCN-BOLD.otf");
+}
+@font-face {
+  font-family: "Source Han Serif CN-Regular";
+  src: url("@/assets/fonts/SOURCEHANSERIFCN-REGULAR.otf");
+}

+ 83 - 0
src/assets/styles/cut-corner.scss

@@ -0,0 +1,83 @@
+// 公共的切角样式
+.cut-corner-select,
+.cut-corner-input {
+  .el-input__wrapper,
+  .el-select__wrapper {
+    position: relative;
+    border: none;
+    border-radius: 0;
+    padding: 2px 8px;
+    box-shadow: none;
+    .el-select__selection{
+      height: 30px;
+    }
+    .el-input__inner,
+    .el-select__selected-label {
+      background: transparent;
+      border: none;
+      color: #333;
+    }
+  }
+  .el-input__wrapper{
+    background-image: url('@/assets/img/btn_stoke_02.png');
+    background-size: 100% 100%;
+    background-repeat: no-repeat;
+    background-position: center;
+  }
+  .el-select__wrapper{
+    background-image: url('@/assets/img/btn_stoke_01.png');
+    background-size: 100% 100%;
+    background-repeat: no-repeat;
+    background-position: center;
+  }
+}
+
+// el-select 宽度设置
+.cut-corner-select {
+  width: 140px;
+}
+
+// 搜索关键词输入框宽度
+.search-keyword-input {
+  width: 350px;
+}
+
+// 切角按钮样式
+.cut-corner-button {
+  width: 58px;
+  height: 40px;
+  border: none;
+  border-radius: 0;
+  background-image: url('@/assets/img/btn_normal.png')!important;
+  background-size: 100% 100%;
+  background-repeat: no-repeat;
+  background-position: center;
+  color: #333;
+  font-size: 14px;
+  font-weight: 500;
+  box-shadow: none;
+  padding: 0;
+
+  &:hover {
+    background-image: url('@/assets/img/btn_active.png')!important;
+    color: #fff;
+  }
+
+  &:focus {
+    background-image: url('@/assets/img/btn_active.png')!important;
+    color: #fff;
+  }
+
+  // 覆盖Element Plus默认样式
+  &.el-button {
+    background: transparent;
+    border: none;
+
+    &:hover,
+    &:focus {
+      background: transparent;
+      border: none;
+      box-shadow: none;
+    }
+  }
+}

+ 33 - 0
src/assets/styles/element-variables.scss

@@ -0,0 +1,33 @@
+/* 改变主题色 */
+:root {
+  --el-color-primary: #B49D7E;
+
+  /* 自定义border颜色 */
+  --el-border-color: #584735;
+  --el-border-color-light: #584735;
+  --el-border-color-lighter: #584735;
+  --el-border-color-extra-light: #584735;
+  --el-border-color-dark: #584735;
+  --el-border-color-darker: #584735;
+
+  /* Input组件border颜色 */
+  --el-input-border-color: #584735;
+  --el-input-hover-border-color: #e5c890;
+  --el-input-focus-border-color: #e5c890;
+
+  /* Select组件border颜色 */
+  --el-select-border-color-hover: #e5c890;
+  --el-select-input-focus-border-color: #e5c890;
+
+  /* Form组件border颜色 */
+  --el-form-border-color: #584735;
+
+  /* 其他组件的border颜色 */
+  --el-card-border-color: #584735;
+  --el-table-border-color: #584735;
+  --el-menu-border-color: #584735;
+
+  --el-button-hover-bg-color: #B49D7E;
+
+  --el-fill-color-light: transparent;
+}

+ 44 - 0
src/assets/styles/variable.scss

@@ -0,0 +1,44 @@
+// bem.scss
+$namespace: 'xm' !default;
+$block-sel: '-' !default;
+$elem-sel: '__' !default;
+$mod-sel: '--' !default;
+
+@mixin bfc {
+  height: 100%;
+  overflow: hidden;
+}
+
+// block
+@mixin b($block) {
+  $B: #{$namespace + $block-sel + $block};
+  .#{$B} {
+    // 内容占位符
+    @content;
+  }
+}
+
+@mixin e($el) {
+  $selector: &;
+  // @at-root 平铺,编译后不会加父级选择器
+  @at-root {
+    #{$selector + $elem-sel + $el} {
+      @content;
+    }
+  }
+}
+
+@mixin m($m) {
+  $selector: &;
+  @at-root {
+    #{$selector + $mod-sel + $m} {
+      @content;
+    }
+  }
+}
+body {
+  overflow: hidden;
+}
+html {
+  -webkit-tap-highlight-color: transparent;
+}

Разница между файлами не показана из-за своего большого размера
+ 11 - 0
src/assets/svgs/icon_comment_yellow.svg


Разница между файлами не показана из-за своего большого размера
+ 5 - 0
src/assets/svgs/icon_copy.svg


Разница между файлами не показана из-за своего большого размера
+ 5 - 0
src/assets/svgs/icon_delete.svg


Разница между файлами не показана из-за своего большого размера
+ 5 - 0
src/assets/svgs/icon_eyes.svg


Разница между файлами не показана из-за своего большого размера
+ 16 - 0
src/assets/svgs/icon_fullscreen_yellow.svg


Разница между файлами не показана из-за своего большого размера
+ 6 - 0
src/assets/svgs/icon_like_yellow.svg


Разница между файлами не показана из-за своего большого размера
+ 8 - 0
src/assets/svgs/icon_mark_yellow.svg


Разница между файлами не показана из-за своего большого размера
+ 8 - 0
src/assets/svgs/icon_menu_yellow.svg


Разница между файлами не показана из-за своего большого размера
+ 5 - 0
src/assets/svgs/icon_search.svg


Разница между файлами не показана из-за своего большого размера
+ 8 - 0
src/assets/svgs/icon_search_yellow.svg


Разница между файлами не показана из-за своего большого размера
+ 8 - 0
src/assets/svgs/icon_setting_yellow.svg


Разница между файлами не показана из-за своего большого размера
+ 10 - 0
src/assets/svgs/icon_share_yellow.svg


Разница между файлами не показана из-за своего большого размера
+ 5 - 0
src/assets/svgs/icon_time.svg


Разница между файлами не показана из-за своего большого размера
+ 7 - 0
src/assets/svgs/icon_upload_yellow.svg


+ 140 - 0
src/components/DragVerify.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="box" ref="boxRef">
+    <a v-if="success">√</a>
+    <div class="rec" :style="recStyle">
+      <div class="rect" ref="rectRef">
+        {{ labelText }}
+        <div class="silde" ref="sildeRef" :style="sildeStyle" @mousedown="onMouseDown">
+          <p class="silde-text">>></p>
+        </div>
+      </div>
+    </div>
+  </div>
+  
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+
+const props = defineProps({
+  modelValue: { type: Boolean, default: false }
+})
+const emit = defineEmits(['update:modelValue', 'passed'])
+
+const boxRef = ref(null)
+const rectRef = ref(null)
+const sildeRef = ref(null)
+
+const changeX = ref(0)
+const success = ref(false)
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    success.value = !!val
+    if (!val) changeX.value = 0
+  },
+  { immediate: true }
+)
+
+const recStyle = computed(() => ({
+  width: `${changeX.value}px`,
+  color: success.value ? 'white' : ''
+}))
+
+const sildeStyle = computed(() => ({
+  left: `${changeX.value}px`,
+  background: success.value ? 'white' : 'white'
+}))
+
+const labelText = computed(() => (success.value ? '验证成功' : '拖拽验证'))
+
+function onMouseDown(e) {
+  if (success.value) return
+  const rectWidth = rectRef.value?.offsetWidth || 400
+  const sildeWidth = sildeRef.value?.offsetWidth || 40
+  const maxX = rectWidth - sildeWidth
+  const initX = e.clientX
+
+  const onMouseMove = (ev) => {
+    const delta = ev.clientX - initX
+    changeX.value = Math.max(0, Math.min(delta, maxX))
+    if (changeX.value >= maxX) {
+      success.value = true
+      emit('update:modelValue', true)
+      emit('passed')
+      cleanup()
+    }
+  }
+
+  const onMouseUp = () => {
+    if (!success.value) {
+      changeX.value = 0
+      emit('update:modelValue', false)
+    }
+    cleanup()
+  }
+
+  const cleanup = () => {
+    document.removeEventListener('mousemove', onMouseMove)
+    document.removeEventListener('mouseup', onMouseUp)
+  }
+
+  document.addEventListener('mousemove', onMouseMove)
+  document.addEventListener('mouseup', onMouseUp)
+}
+</script>
+
+<style scoped>
+.box {
+  cursor: pointer;
+  height: 40px;
+  width: 400px;
+  line-height: 40px;
+  text-align: center;
+  position: relative;
+  background-color: #e8e8e8;
+  border: 1px solid #D9D9D9;
+}
+
+.rec {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 0;
+  height: 100%;
+  background: rgb(118, 198, 29);
+}
+
+.rect {
+  position: relative;
+  width: 400px;
+  height: 100%;
+}
+
+.silde {
+  position: absolute;
+  height: 38px;
+  width: 40px;
+  background: #fff;
+  left: 1px;
+  top: 1px;
+  transform: translateY(0px);
+}
+.silde-text {
+  font-size: 14px;
+  color: #000000;
+  user-select: none;
+}
+.silde img{
+  width: 100%;
+}
+a{
+  width: 40px;
+  height: 100%;
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  display: inline-block;
+}
+</style>

+ 26 - 0
src/main.js

@@ -0,0 +1,26 @@
+import './assets/main.css'
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router'
+import store from './store'
+import api from './api'
+
+const app = createApp(App)
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import './assets/styles/element-variables.scss'
+// import './assets/styles/cut-corner.scss'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import VDistpicker from 'v-distpicker'
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+  app.component(key, component)
+}
+app.component('v-distpicker', VDistpicker)
+app.use(ElementPlus)
+app.use(router)
+app.use(store)
+
+// 全局注册API
+app.config.globalProperties.$api = api
+
+app.mount('#app')

+ 33 - 0
src/router/index.js

@@ -0,0 +1,33 @@
+import { createRouter, createWebHashHistory } from "vue-router";
+
+const router = createRouter({
+  history: createWebHashHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: "/",
+      redirect: "/login",
+    },
+    {
+      path: "/login",
+      name: "login",
+      component: () => import("@/views/Login/index.vue"),
+    },
+    {
+      path: "/home",
+      name: "home",
+      component: () => import("@/views/Home/index.vue"),
+    },
+    // {
+    //   path: "/pushSystem",
+    //   name: "pushSystem",
+    //   component: () => import("@/views/pushSystem/index.vue"),
+    // },
+  ],
+});
+
+router.beforeEach((to, from, next) => {
+  next();
+});
+
+export default router;
+

+ 18 - 0
src/store/index.js

@@ -0,0 +1,18 @@
+import { createStore } from 'vuex'
+
+const store = createStore({
+  state: {
+
+  },
+  mutations: {
+
+  },
+  actions: {
+
+  },
+  getters: {
+
+  },
+})
+
+export default store

+ 0 - 0
src/utils/index.js


+ 180 - 0
src/utils/request.js

@@ -0,0 +1,180 @@
+import axios from 'axios'
+import JSEncrypt from 'jsencrypt'
+
+// 简易认证存取(取消本地过期时间判断)
+const TOKEN_KEY = 'historicTOKEN'
+
+function getToken() {
+  return localStorage.getItem(TOKEN_KEY) || ''
+}
+
+function clearAuth() {
+  localStorage.removeItem(TOKEN_KEY)
+}
+
+function redirectToLogin() {
+  try {
+    if (window.location.hash !== '#/login') {
+      window.location.hash = '#/login'
+    }
+  } catch (e) {}
+}
+
+// 签名所需常量
+const APP_ID = 'bf6acc5386b1106428fb1096b506661c'
+const PUBLIC_KEY = `MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCaKAIIFQjNK1mziSQkXh1WEg/keWjWlYEOn+LDwjzvpN1wEm+UFGNrv5orccS5iingtIMJxPPFlKBEMPk11TxQKHwFMJqwWDcXCIdarInjdxZXy3cbUOuE6OFgSGh9tBUANcHAiIIOKplIQLdvVc5DpVi5MGmpQVcCNOSQ4KsaDwIDAQAB`
+
+function generateSign(appId) {
+  const payload = {
+    appId,
+    timestamp: Date.now(),
+  }
+  const encryptor = new JSEncrypt()
+  encryptor.setPublicKey(PUBLIC_KEY)
+  const encrypted = encryptor.encrypt(JSON.stringify(payload))
+  if (!encrypted) {
+    throw new Error('签名加密失败')
+  }
+  return encrypted
+}
+
+// 创建axios实例
+const request = axios.create({
+  baseURL: '', // 使用代理前缀
+  timeout: 10000, // 请求超时时间
+  headers: {
+    'Content-Type': 'application/json;charset=UTF-8',
+  },
+})
+
+// 请求拦截器
+request.interceptors.request.use(
+  (config) => {
+    // 生成并注入签名与 appId
+    try {
+      // const sign = generateSign(APP_ID)
+      config.headers = config.headers || {}
+      // config.headers.appId = APP_ID
+      // config.headers.sign = sign
+    } catch (e) {
+      console.error('签名生成失败:', e)
+      return Promise.reject(e)
+    }
+
+    // 判断是否需要添加token
+    const isLogin = !!config.url && config.url.includes('/historyrical/auth/login')
+    if (!isLogin) {
+      const token = getToken()
+      if (!token) {
+        clearAuth()
+        redirectToLogin()
+        return Promise.reject(new Error('未登录,请先登录'))
+      }
+      // 业务要求:在请求头加上 token 字段
+      config.headers.token = token
+    }
+
+    // 添加时间戳防止缓存
+    if (config.method === 'get') {
+      config.params = {
+        ...config.params,
+        _t: Date.now(),
+      }
+    }
+
+    // 处理不同类型的数据提交
+    if (config.data instanceof FormData) {
+      // FormData:删除Content-Type让浏览器自动设置multipart/form-data
+      delete config.headers['Content-Type']
+      console.log('检测到FormData,已删除Content-Type,让浏览器自动设置')
+    } else if (config.headers['Content-Type'] === 'application/x-www-form-urlencoded') {
+      // 表单数据:保持application/x-www-form-urlencoded
+      console.log('使用application/x-www-form-urlencoded格式')
+    }
+
+    // 允许接口调用时自定义headers
+    if (config.customHeaders) {
+      config.headers = {
+        ...config.headers,
+        ...config.customHeaders,
+      }
+      delete config.customHeaders // 删除自定义属性,避免发送到服务器
+    }
+
+    console.log('请求发送:', config)
+    return config
+  },
+  (error) => {
+    console.error('请求错误:', error)
+    return Promise.reject(error)
+  },
+)
+
+// 响应拦截器
+request.interceptors.response.use(
+  (response) => {
+    console.log('响应接收:', response)
+
+    // 统一处理响应数据
+    const { data } = response
+    console.log('data', data)
+    // 根据后端返回的数据结构调整
+    if (data.code === 0 || data.success === true) {
+      return data.data || data
+    } else {
+      // 处理业务错误
+      const code = data.code
+      if (code === 401 || code === 403) {
+        // 服务端返回未授权/禁止访问,视为登录过期或无效
+        clearAuth()
+        redirectToLogin()
+        return Promise.reject(new Error('登录已过期,请重新登录'))
+      }
+      const errorMsg = data.message || data.msg || '请求失败'
+      console.error('业务错误:', errorMsg)
+      return Promise.reject(new Error(errorMsg))
+    }
+  },
+  (error) => {
+    console.error('响应错误:', error)
+
+    let errorMsg = '网络错误'
+
+    if (error.response) {
+      // 服务器返回错误状态码
+      const { status, data } = error.response
+
+      switch (status) {
+        case 400:
+          errorMsg = '请求参数错误'
+          break
+        case 401:
+          errorMsg = '未授权,请登录'
+          clearAuth()
+          redirectToLogin()
+          break
+        case 403:
+          errorMsg = '拒绝访问'
+          // 可能token过期或无权限
+          clearAuth()
+          redirectToLogin()
+          break
+        case 404:
+          errorMsg = '请求地址不存在'
+          break
+        case 500:
+          errorMsg = '服务器内部错误'
+          break
+        default:
+          errorMsg = data?.message || data?.msg || `请求失败(${status})`
+      }
+    } else if (error.request) {
+      // 请求发送但没有收到响应
+      errorMsg = '网络连接超时'
+    }
+
+    return Promise.reject(new Error(errorMsg))
+  },
+)
+
+export default request

+ 339 - 0
src/views/Home/index.vue

@@ -0,0 +1,339 @@
+<template>
+  <div class="home">
+    <!-- 顶部区域 -->
+    <div class="home-top">
+      <div class="home-top-inner">
+        <div class="quit-login" @click="onLogout"><img src="@/assets/img/quit.svg" alt="logo" />退出登录</div>
+        <div class="home-title">不可移动文物资源数据库</div>
+        <!-- 搜索栏:两侧卷轴 -->
+        <div class="search-bar">
+          <img class="paper paper-left" src="@/assets/img/paper_l.png" alt="left" />
+          <el-input v-model="keyword" placeholder="请输入关键词" clearable class="search-input" @keyup.enter="onSearch"
+            @clear="onSearch">
+          </el-input>
+          <img class="search-icon" @click="onSearch" src="@/assets/img/search.svg" alt="search" />
+          <img class="paper paper-right" src="@/assets/img/paper_l.png" alt="right" />
+        </div>
+      </div>
+    </div>
+
+    <!-- 列表区域 -->
+    <div class="home-list w1100">
+      <ul>
+        <li v-for="(item, idx) in list" :key="idx" class="list-item">
+          <div class="item-left">
+            <div class="item-title limit-line">{{ item.title }}</div>
+            <div class="item-meta">
+              <span class="meta-item"><img src="@/assets/img/icon_time.svg" alt="size" />上传时间:{{ item.uploadTime
+              }}</span>
+              <span class="meta-item"><el-icon :size="16">
+                  <LocationInformation />
+                </el-icon>点位数:{{ item.shootCount }}</span>
+              <span class="meta-item"><img src="@/assets/img/icon_data.svg" alt="size" />数据大小:{{ formatSize(item.space)
+              }}</span>
+            </div>
+          </div>
+          <div class="item-right">
+            <span class="meta-item-scan" @click="onView(item)">查看</span>
+            <span class="meta-item meta-item-danger" @click="onDelete(item)">删除</span>
+          </div>
+        </li>
+      </ul>
+
+      <div class="pagination">
+        <el-pagination background layout="prev, pager, next" v-model:current-page="pageNum" :page-size="pageSize"
+          :total="total" @current-change="onPageChange" />
+      </div>
+    </div>
+  </div>
+
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import api from '@/api'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search } from '@element-plus/icons-vue'
+
+const keyword = ref('')
+const pageNum = ref(1)
+const pageSize = 10
+const total = ref(0)
+const list = ref([])
+
+const router = useRouter()
+
+async function fetchList() {
+  try {
+    const res = await api.getSceneListApi({
+      pageNum: pageNum.value,
+      pageSize,
+      orderBy: '',
+      sortBy: '',
+      title: keyword.value || ''
+    })
+    const records = res?.pageData
+    list.value = Array.isArray(records) ? records : []
+    total.value = res?.total || res?.count || res?.page?.total || list.value.length || 0
+  } catch (e) {
+    console.error('获取场景列表失败:', e)
+    list.value = []
+    total.value = 0
+  }
+}
+
+function onSearch() {
+  pageNum.value = 1
+  fetchList()
+}
+
+function onPageChange(page) {
+  pageNum.value = page
+  fetchList()
+}
+
+onMounted(() => {
+  fetchList()
+})
+
+function formatSize(val) {
+  const bytes = Number(val) || 0
+  const MB = 1024 * 1024
+  const GB = MB * 1024
+  if (bytes >= GB) return (bytes / GB).toFixed(2) + 'GB'
+  return (bytes / MB).toFixed(2) + 'MB'
+}
+
+function onView(item) {
+  let url = `https://wwjc.cchicc.org.cn/offline/meta/www/offline.html?m=${item.num}`
+  if(item.type == 'laser'){
+    url = `https://wwjc.cchicc.org.cn/offline/meta/www/offline.html?m=${item.num}`
+  } else {
+    url = `https://wwjc.cchicc.org.cn/offline/kk/wwwroot/spg.html?m=${item.num}`
+  }
+  window.open(url)
+  // router.push({ name: 'pushSystem' })
+}
+
+async function onDelete(item) {
+  const id = item?.id
+  if (!id) {
+    ElMessage.error('缺少id')
+    return
+  }
+  // 确认删除
+  try {
+    await ElMessageBox.confirm('确认删除该场景吗?', '提示', {
+      type: 'warning',
+      confirmButtonText: '删除',
+      cancelButtonText: '取消',
+    })
+  } catch {
+    return
+  }
+  // 调用删除接口
+  try {
+    await api.deleteSceneApi(id)
+    ElMessage.success('删除成功')
+    fetchList()
+  } catch (e) {
+    ElMessage.error(e?.message || '删除失败')
+  }
+}
+
+function onLogout() {
+  try {
+    // 清除本地 token 信息
+    localStorage.removeItem('historicTOKEN')
+    localStorage.removeItem('TOKEN_EXPIRES_AT')
+  } finally {
+    // 跳转登录页(优先使用路由,其次回退到 hash 跳转)
+    router.push('/login').catch(() => {
+      window.location.hash = '#/login'
+    })
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.home {
+  background: #F5F3F0;
+  min-height: 100vh;
+}
+
+.home-top {
+  height: 240px;
+  background-image: url('@/assets/img/home-bg.png');
+  background-repeat: no-repeat;
+  background-position: right top;
+  background-size: auto 100%;
+
+  .home-top-inner {
+    position: relative;
+    width: 1200px;
+    height: 100%;
+    margin: 0 auto;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    overflow: visible !important;
+
+    .quit-login {
+      position: absolute;
+      display: flex;
+      align-items: center;
+      top: 20px;
+      right: 0;
+      font-family: Microsoft YaHei, Microsoft YaHei;
+      font-size: 14px;
+      color: rgba(147, 121, 93, 1);
+      gap: 4px;
+      cursor: pointer;
+    }
+  }
+
+  .home-title {
+    font-family: Microsoft YaHei, Microsoft YaHei;
+    font-size: 40px;
+    color: #781C0B;
+    letter-spacing: 2px;
+    margin-bottom: 54px;
+    margin-left: -14px;
+    font-weight: bold;
+  }
+
+  .search-bar {
+    position: relative;
+    width: 560px;
+
+    :deep(.el-input__inner::placeholder) {
+      color: rgba(0, 0, 0, 0.25);
+      font-family: Microsoft YaHei, Microsoft YaHei;
+      font-weight: 400;
+    }
+
+    :deep(.el-input__wrapper) {
+      padding-right: 36px;
+    }
+
+    :deep(.el-input__clear) {
+      font-size: 16px;
+      /* 设置图标大小 */
+    }
+  }
+
+  .search-input :deep(.el-input__wrapper) {
+    height: 40px;
+    border-color: #93795D;
+    border-left: none;
+    border-radius: 0;
+  }
+
+  .paper {
+    position: absolute;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 14px;
+    height: 48px;
+    pointer-events: none;
+    z-index: 1;
+  }
+
+  .paper-left {
+    left: -13px;
+  }
+
+  .paper-right {
+    right: -13px;
+  }
+
+  .search-icon {
+    position: absolute;
+    right: 10px;
+    top: 50%;
+    transform: translateY(-50%);
+    cursor: pointer;
+  }
+}
+
+.home-list {
+  padding: 40px 64px;
+  height: calc(100vh - 360px);
+  background: #fff;
+  overflow-y: auto;
+
+  .item-left {
+    line-height: 30px;
+  }
+
+  .list-item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 18px 8px;
+    border-bottom: 1px solid #F5F5F5;
+  }
+
+  .item-title {
+    font-size: 16px;
+    color: #333;
+    margin-bottom: 16px;
+    max-width: 900px;
+  }
+
+  .item-meta {
+    width: 800px;
+    color: #8c8c8c;
+    display: flex;
+    justify-content: space-between;
+    gap: 24px;
+    font-size: 13px;
+
+    .meta-item {
+      display: flex;
+      height: 20px;
+      line-height: 20px;
+      gap: 4px;
+
+      img {
+        width: 16px;
+        height: 16px;
+      }
+    }
+  }
+
+  .item-right {
+    width: 80px;
+    text-align: right;
+  }
+
+  .pagination {
+    display: flex;
+    justify-content: flex-end;
+    padding-top: 44px;
+  }
+}
+
+.meta-item-scan {
+  color: #93795D;
+  cursor: pointer;
+}
+
+.meta-item-danger {
+  cursor: pointer;
+  color: #A62525;
+  margin-left: 24px;
+}
+</style>
+<style>
+.el-button:hover {
+  background-color: rgba(180, 157, 126, 0.8);
+  border-color: #B49D7E;
+  color: #fff;
+}
+
+.el-button:focus-visible {
+  outline: none !important;
+}
+</style>

+ 203 - 0
src/views/Login/index.vue

@@ -0,0 +1,203 @@
+<template>
+  <div class="login-page">
+    <div class="login-top-login">
+      <img src="@/assets/img/top-logo.png" alt="">
+    </div>
+    <div class="camera-content">
+      <div class="title">不可移动文物资源数据库</div>
+      <div class="camera">
+        <img src="@/assets/img/title.png" alt="">
+      </div>
+      <div class="camera">
+        <img src="@/assets/img/pic_camera.png" alt="">
+      </div>
+    </div>
+    <div class="login-panel">
+      <div class="login-title">欢迎登录</div>
+      <el-form :model="form" ref="formRef" label-position="top" class="login-form">
+        <el-form-item>
+          <el-input v-model="form.username" placeholder="请输入账号" clearable />
+        </el-form-item>
+
+        <el-form-item>
+          <el-input v-model="form.password" placeholder="请输入密码" show-password />
+        </el-form-item>
+
+        <el-form-item>
+          <DragVerify v-model="verifyPassed" />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" class="login-btn" :disabled="!verifyPassed || loading" @click="onSubmit">
+            登录
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref } from 'vue'
+import DragVerify from '@/components/DragVerify.vue'
+import api from '@/api'
+import { ElMessage } from 'element-plus'
+
+const formRef = ref()
+const form = reactive({
+  username: '',
+  password: ''
+})
+const verifyPassed = ref(false)
+const loading = ref(false)
+
+function base64EncodeUnicode(str) {
+  // 兼容中文的base64
+  try {
+    return btoa(
+      encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode('0x' + p1))
+    )
+  } catch (e) {
+    return btoa(str)
+  }
+}
+
+async function onSubmit() {
+  if (!verifyPassed.value) return
+  if (!form.username || !form.password) {
+    ElMessage.warning('请输入账号和密码')
+    return
+  }
+  loading.value = true
+  try {
+    const res = await api.loginApi({
+      username: form.username,
+      password: base64EncodeUnicode(form.password)
+    })
+    console.log(res, 777)
+    // 兼容不同返回结构的token与过期时间
+    const token = res?.accessToken
+    const expiresIn = res?.expiresIn || res?.expires_in
+    const expireAt = res?.expireAt || res?.expire_at
+    const expiresAt = expireAt
+      ? (typeof expireAt === 'number' ? expireAt : new Date(expireAt).getTime())
+      : (expiresIn ? Date.now() + Number(expiresIn) * 1000 : 0)
+
+    if (token) {
+      localStorage.setItem('historicTOKEN', token)
+      if (expiresAt) localStorage.setItem('TOKEN_EXPIRES_AT', String(expiresAt))
+    }
+
+    ElMessage.success('登录成功')
+    // 跳转到首页
+    window.location.hash = '#/home'
+  } catch (err) {
+    ElMessage.error(err?.message || '登录失败')
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.login-page {
+  width: 100vw;
+  height: 100vh;
+  background-image: url('@/assets/img/pic_bg.png');
+  background-size: cover;
+  background-position: center;
+  background-repeat: no-repeat;
+  position: relative;
+}
+
+.login-top-login {
+  position: absolute;
+  top: 64px;
+  left: 64px;
+}
+
+.camera-content {
+  position: absolute;
+  bottom: 0;
+  left: 174px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  height: 706px;
+  width: 576px;
+
+  .title {
+    width: 576px;
+    height: 63px;
+    font-family: Microsoft YaHei, Microsoft YaHei;
+    font-weight: bold;
+    font-size: 48px;
+    color: #781C0B;
+    letter-spacing: 4px;
+    text-align: center;
+    font-style: normal;
+    text-transform: none;
+    margin-bottom: 40px;
+  }
+}
+
+.login-panel {
+  position: absolute;
+  right: 160px;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 420px;
+  background: rgba(255, 255, 255, 0.92);
+  
+  :deep(.el-input__wrapper) {
+    box-shadow: 0 0 0 1px var(--el-input-hover-border-color) inset;
+  }
+  :deep(.el-input__wrapper:hover) {
+    box-shadow: 0 0 0 1px #781C0B inset;
+  }
+  :deep(.el-input__wrapper:focus) {
+    box-shadow: 0 0 0 1px #781C0B inset;
+  }
+  :deep(.el-input__wrapper.is-focus) {
+    box-shadow: 0 0 0 1px #781C0B inset;
+  }
+}
+
+.login-title {
+  font-size: 30px;
+  font-weight: bold;
+  margin-bottom: 12px;
+  color: rgba(0, 0, 0, 0.85);
+}
+
+.login-form :deep(.el-form-item__label) {
+  color: #333;
+}
+
+.login-form {
+  :deep(.el-input) {
+    height: 40px;
+    width: 400px;
+  }
+
+  :deep(.el-input__password) {
+    font-size: 16px;
+  }
+}
+
+.login-btn {
+  width: 400px;
+  height: 40px;
+  background: #93795D;
+  border: none;
+
+  &:hover {
+    background: #A9947D;
+    border: none;
+  }
+}
+
+.el-button.is-disabled {
+  background: rgba(147, 121, 93, 0.6);
+}
+</style>

+ 332 - 0
src/views/pushSystem/index.vue

@@ -0,0 +1,332 @@
+<template>
+  <div class="push-system">
+    <!-- 顶部区域:高度 240px -->
+    <div class="ps-top">
+      <div class="ps-top-inner">
+        <div class="ps-title">信息中心不可移动文物资源数据库数据推送系统</div>
+      </div>
+    </div>
+
+    <!-- 内容区域 -->
+    <div class="ps-content w1100">
+      <!-- 上传区域:仅允许 .xls 文件 -->
+      <div class="upload-wrapper">
+        <el-upload
+          v-if="!selectedFileName"
+          class="upload"
+          drag
+          action="#"
+          :auto-upload="false"
+          accept=".xls"
+          :show-file-list="true"
+          :before-upload="beforeUpload"
+          :on-change="onFileChange"
+          ref="uploadRef"
+        >
+          <img class="upload-icon" src="@/assets/img/Inbox.svg" alt="">
+          <div class="el-upload__text">点击或拖拽文件到此处上传</div>
+          <div class="el-upload__tip">支持扩展名:.xls</div>
+        </el-upload>
+        <div v-else class="file-bar">
+          <div class="file-info">
+            <el-icon class="file-icon"><Document /></el-icon>
+            <span class="file-name">{{ selectedFileName }}</span>
+          </div>
+          <div class="file-actions">
+            <div text @click="clearSelectedFile" class="clear-btn">
+              <img src="@/assets/img/DeleteOutlined.svg" alt="">
+            </div>
+            <span class="action-divider"></span>
+            <div link class="push-btn" @click="startPush">
+              <img src="@/assets/img/push.svg" alt="">
+              <span class="push-text">开始推送</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 推送内容:切换推送中 / 已完成 -->
+      <el-tabs v-model="activeTab" class="ps-tabs">
+        <el-tab-pane :label="`推送中(${runningList.length})`" name="running">
+          <ul class="task-list">
+            <li v-for="(item, idx) in runningPageItems" :key="idx" class="task-item">
+              <div class="task-left">
+                <div class="task-title">{{ item.title }} | {{ item.code }}</div>
+                <div class="task-status">{{ item.statusText }}</div>
+              </div>
+              <div class="task-right">
+                <el-button link type="danger" @click="onCancel(item)">取消</el-button>
+              </div>
+            </li>
+          </ul>
+          <div v-if="!runningList.length" class="empty">暂无推送任务</div>
+          <div class="pagination">
+            <el-pagination
+              background
+              layout="prev, pager, next"
+              v-model:current-page="runningPageNum"
+              :page-size="runningPageSize"
+              :total="runningList.length"
+              @current-change="onRunningPageChange"
+            />
+          </div>
+        </el-tab-pane>
+        <el-tab-pane :label="`已完成(${doneList.length})`" name="done">
+          <ul class="task-list">
+            <li v-for="(item, idx) in donePageItems" :key="idx" class="task-item">
+              <div class="task-left">
+                <div class="task-title">{{ item.title }} | {{ item.code }}</div>
+                <div class="task-status">推送时间:{{ item.pushedAt }}</div>
+              </div>
+              <div class="task-right">
+                <el-button link type="primary">查看</el-button>
+              </div>
+            </li>
+          </ul>
+          <div v-if="!doneList.length" class="empty">暂无已完成任务</div>
+          <div class="pagination">
+            <el-pagination
+              background
+              layout="prev, pager, next"
+              v-model:current-page="donePageNum"
+              :page-size="donePageSize"
+              :total="doneList.length"
+              @current-change="onDonePageChange"
+            />
+          </div>
+        </el-tab-pane>
+      </el-tabs>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+
+const activeTab = ref('running')
+const uploadRef = ref()
+const selectedFileName = ref('')
+const runningList = ref([
+  { title: '场景标识', code: 'SG-392HHJ89', statusText: '推送中' },
+  { title: '场景标识', code: 'SG-392HHJ90', statusText: '等待中' }
+])
+const doneList = ref([
+  { title: '场景标识', code: 'SG-JD83NFUD', pushedAt: '2025-08-26 12:12' }
+])
+// 分页:推送中
+const runningPageNum = ref(1)
+const runningPageSize = 10
+const runningPageItems = computed(() => {
+  const start = (runningPageNum.value - 1) * runningPageSize
+  return runningList.value.slice(start, start + runningPageSize)
+})
+function onRunningPageChange(page) {
+  runningPageNum.value = page
+}
+
+// 分页:已完成
+const donePageNum = ref(1)
+const donePageSize = 10
+const donePageItems = computed(() => {
+  const start = (donePageNum.value - 1) * donePageSize
+  return doneList.value.slice(start, start + donePageSize)
+})
+function onDonePageChange(page) {
+  donePageNum.value = page
+}
+
+function beforeUpload(file) {
+  // 仅允许 .xls 文件
+  const isXls = /\.xls$/i.test(file.name)
+  if (!isXls) {
+    ElMessage.error('仅支持 .xls 文件上传')
+  }
+  return isXls
+}
+
+function onFileChange(file, fileList) {
+  // 示例:文件加入后把任务放到“推送中”列表
+  if (file && /\.xls$/i.test(file.name)) {
+    selectedFileName.value = file.name
+  }
+}
+
+function onCancel(item) {
+  const idx = runningList.value.indexOf(item)
+  if (idx > -1) runningList.value.splice(idx, 1)
+}
+
+function clearSelectedFile() {
+  selectedFileName.value = ''
+  if (uploadRef.value) uploadRef.value.clearFiles()
+}
+
+function startPush() {
+  if (!selectedFileName.value) {
+    ElMessage.warning('请先上传 .xls 文件')
+    return
+  }
+  const code = selectedFileName.value.replace(/\.xls$/i, '')
+  runningList.value.unshift({ title: '场景标识', code, statusText: '推送中' })
+  ElMessage.success('已开始推送')
+}
+</script>
+
+<style lang="scss" scoped>
+.push-system {
+  background: #f5f0ea;
+  min-height: 100vh;
+}
+
+.ps-top {
+  height: 240px;
+  background-image: url('@/assets/img/home-bg.png');
+  background-repeat: no-repeat;
+  background-position: right top;
+  background-size: auto 100%;
+
+  .ps-top-inner {
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .ps-title {
+    width: 671px;
+    height: 106px;
+    font-family: Microsoft YaHei, Microsoft YaHei;
+    font-weight: bold;
+    font-size: 40px;
+    color: #781C0B;
+    letter-spacing: 4px;
+    text-align: center;
+    font-style: normal;
+    text-transform: none;
+  }
+}
+
+.ps-content {
+  padding: 0px 0 40px;
+}
+
+.upload-wrapper {
+  width: 1100px;
+  height: 156px;
+  margin: 0 0 24px;
+  :deep(.el-upload-dragger) {
+    width: 1100px;
+    border: none;
+  }
+  :deep(.el-upload){
+    --el-upload-dragger-padding-horizontal: 34px;
+  }
+}
+
+.upload {
+  .upload-icon {
+    width: 32px;
+    height: 32px;
+  }
+  .el-upload__text {
+    font-size: 16px;
+    color: rgba(0,0,0,0.85);
+  }
+  .el-upload__tip {
+    font-size: 14px;
+    color: rgba(0,0,0,0.45);
+  }
+}
+
+.file-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #fff;
+  padding: 0 0 0 64px;
+  border-radius: 6px;
+  height: 158px;
+  .file-actions { 
+    display: flex; 
+    align-items: center;
+  }
+  .push-btn { 
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 264px;
+    color: #a46a4b; 
+    padding: 0 8px; 
+    cursor: pointer;
+    span{
+      display: flex;
+      flex-direction: column;
+    }
+  }
+  .push-btn :deep(.el-icon) { 
+    font-size: 32px; 
+    color: #a46a4b; 
+    margin-right: 8px; 
+  }
+}
+.file-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.file-icon { color: #a46a4b; }
+.file-name { color: #333; }
+.push-text { color: #666; font-size: 16px; vertical-align: middle; }
+.clear-btn { color: #a46a4b; margin-right: 54px;cursor: pointer; }
+.action-divider { width: 2px; height: 158px; background: #FAFAFA; display: inline-block; }
+
+.ps-tabs {
+  background: #fff;
+  padding: 40px 64px;
+  border-radius: 6px;
+  :deep(.el-tabs__nav-wrap:after) {
+    height: 1px;
+    background-color: #f5f5f5;
+  }
+}
+
+.task-list {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+.task-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 8px;
+  border-bottom: 1px solid #F5F5F5;
+}
+.task-title {
+  font-size: 15px;
+  color: #333;
+}
+.task-status {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #8c8c8c;
+}
+.empty {
+  padding: 24px;
+  color: #9c9c9c;
+  text-align: center;
+}
+
+.pagination {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 12px;
+}
+
+.w1100 {
+  width: 1100px;
+  margin: 0 auto;
+}
+</style>

+ 35 - 0
vite.config.js

@@ -0,0 +1,35 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import { defineConfig, loadEnv } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueDevTools from 'vite-plugin-vue-devtools'
+
+// https://vite.dev/config/
+export default defineConfig(({ mode }) => {
+  // eslint-disable-next-line no-undef
+  const env = loadEnv(mode, process.cwd())
+  console.log(env, 7777)
+  return {
+    base: env.VITE_PUBLIC_PATH || '/',
+    plugins: [
+      vue(),
+      vueDevTools(),
+    ],
+    resolve: {
+      alias: {
+        '@': fileURLToPath(new URL('./src', import.meta.url))
+      },
+    },
+    server: {
+      host: '0.0.0.0',
+      port: 9696,
+      open: true,
+      proxy: {
+        '/historyrical': {
+          target: env.VITE_PROXY_TARGET,
+          changeOrigin: true,
+        },
+      },
+    },
+  }
+})

+ 14 - 0
vitest.config.js

@@ -0,0 +1,14 @@
+import { fileURLToPath } from 'node:url'
+import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
+import viteConfig from './vite.config'
+
+export default mergeConfig(
+  viteConfig,
+  defineConfig({
+    test: {
+      environment: 'jsdom',
+      exclude: [...configDefaults.exclude, 'e2e/**'],
+      root: fileURLToPath(new URL('./', import.meta.url)),
+    }
+  }),
+)