wangfumin vor 4 Tagen
Commit
6eb0c3fc3b
63 geänderte Dateien mit 14182 neuen und 0 gelöschten Zeilen
  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. 90 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. BIN
      src/assets/img/search.png
  33. BIN
      src/assets/img/title.png
  34. BIN
      src/assets/img/top-logo.png
  35. 177 0
      src/assets/main.css
  36. 83 0
      src/assets/styles/cut-corner.scss
  37. 31 0
      src/assets/styles/element-variables.scss
  38. 44 0
      src/assets/styles/variable.scss
  39. 11 0
      src/assets/svgs/icon_comment_yellow.svg
  40. 5 0
      src/assets/svgs/icon_copy.svg
  41. 5 0
      src/assets/svgs/icon_delete.svg
  42. 5 0
      src/assets/svgs/icon_eyes.svg
  43. 16 0
      src/assets/svgs/icon_fullscreen_yellow.svg
  44. 6 0
      src/assets/svgs/icon_like_yellow.svg
  45. 8 0
      src/assets/svgs/icon_mark_yellow.svg
  46. 8 0
      src/assets/svgs/icon_menu_yellow.svg
  47. 5 0
      src/assets/svgs/icon_search.svg
  48. 8 0
      src/assets/svgs/icon_search_yellow.svg
  49. 8 0
      src/assets/svgs/icon_setting_yellow.svg
  50. 10 0
      src/assets/svgs/icon_share_yellow.svg
  51. 5 0
      src/assets/svgs/icon_time.svg
  52. 7 0
      src/assets/svgs/icon_upload_yellow.svg
  53. 139 0
      src/components/DragVerify.vue
  54. 26 0
      src/main.js
  55. 23 0
      src/router/index.js
  56. 18 0
      src/store/index.js
  57. 0 0
      src/utils/index.js
  58. 143 0
      src/utils/request.js
  59. 218 0
      src/views/Home/index.vue
  60. 169 0
      src/views/Login/index.vue
  61. 492 0
      src/views/pushSystem/index.vue
  62. 35 0
      vite.config.js
  63. 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:10002'
+VITE_PROXY_TARGET = 'https://ts.4dkankan.com/'
+
+# 图片基础
+# 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"]
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 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"
+  }
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 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>

+ 90 - 0
src/api/index.js

@@ -0,0 +1,90 @@
+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
+      }
+    })
+  },
+  // 首页获取推荐列表
+  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
+      }
+    })
+  },
+
+  // 推送场景文件(multipart/form-data,参数:file)
+  pushSceneApi(formData) {
+    return request({
+      url: '/service/external/xxzx/pushScene',
+      method: 'post',
+      data: formData
+    })
+  },
+
+  // 任务列表(POST:pageSize, pageNo, status)
+  getTaskListApi(data = {}) {
+    const pageNo = data.pageNo || 1
+    return request({
+      url: '/service/external/xxzx/taskList',
+      method: 'post',
+      data: {
+        pageSize: 10,
+        pageNo,
+        pageNum: pageNo,
+        status: 0,
+        ...data
+      }
+    })
+  },
+
+  // 取消任务
+  cancelTaskApi(id) {
+    return request({
+      url: `/service/external/xxzx/cancel/${id}`,
+      method: 'delete'
+    })
+  }
+}
+
+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


Datei-Diff unterdrückt, da er zu groß ist
+ 3 - 0
src/assets/img/DeleteOutlined.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 3 - 0
src/assets/img/Inbox.svg


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


Datei-Diff unterdrückt, da er zu groß ist
+ 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


Datei-Diff unterdrückt, da er zu groß ist
+ 3 - 0
src/assets/img/push.svg


BIN
src/assets/img/search.png


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: 1200px;
+  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;
+    }
+  }
+}

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

@@ -0,0 +1,31 @@
+/* 改变主题色 */
+: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-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;
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 11 - 0
src/assets/svgs/icon_comment_yellow.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 5 - 0
src/assets/svgs/icon_copy.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 5 - 0
src/assets/svgs/icon_delete.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 5 - 0
src/assets/svgs/icon_eyes.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 16 - 0
src/assets/svgs/icon_fullscreen_yellow.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 6 - 0
src/assets/svgs/icon_like_yellow.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 8 - 0
src/assets/svgs/icon_mark_yellow.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 8 - 0
src/assets/svgs/icon_menu_yellow.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 5 - 0
src/assets/svgs/icon_search.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 8 - 0
src/assets/svgs/icon_search_yellow.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 8 - 0
src/assets/svgs/icon_setting_yellow.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 10 - 0
src/assets/svgs/icon_share_yellow.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 5 - 0
src/assets/svgs/icon_time.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 7 - 0
src/assets/svgs/icon_upload_yellow.svg


+ 139 - 0
src/components/DragVerify.vue

@@ -0,0 +1,139 @@
+<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: #00b894;
+}
+
+.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;
+}
+.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')

+ 23 - 0
src/router/index.js

@@ -0,0 +1,23 @@
+import { createRouter, createWebHashHistory } from "vue-router";
+
+const router = createRouter({
+  history: createWebHashHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: "/",
+      redirect: "/pushSystem",
+    },
+    {
+      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


+ 143 - 0
src/utils/request.js

@@ -0,0 +1,143 @@
+import axios from 'axios'
+import JSEncrypt from 'jsencrypt'
+
+// 签名所需常量
+const APP_ID = 'bf6acc5386b1106428fb1096b506661c'
+const PUBLIC_KEY = `MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCt12b9v0dMF7ageC2fcYIheWb4u++WvwxJUhPJ+7BhNi7wf/mKZ47VjxdJyJQgsZX+7n+56UcSGUVDI4dhdWuFWVDQM8CyngiMhmHVXds2BxNFrju42VO5s1rmd8KXrnKJb5t2e4VdmAfQdeOaITMs/ymWt40O6NDDAMQ/udMLmwIDAQAB`
+
+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,直接请求服务
+
+    // 添加时间戳防止缓存
+    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) {
+        // 未授权/禁止访问,不做页面跳转
+        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 = '未授权或拒绝访问'
+          break
+        case 403:
+          errorMsg = '未授权或拒绝访问'
+          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

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

@@ -0,0 +1,218 @@
+<template>
+  <div class="home">
+    <!-- 顶部区域 -->
+    <div class="home-top">
+      <div class="home-top-inner w1100">
+        <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"
+          />
+          <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">
+            <el-button link type="primary" @click="onView(item)">查看</el-button>
+          </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'
+
+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) {
+  router.push({ name: 'pushSystem' })
+}
+</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 {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    overflow: visible !important;
+  }
+
+  .home-title {
+    font-family: 'Source Han Serif CN-Bold';
+    font-size: 28px;
+    color: #781C0B;
+    letter-spacing: 2px;
+    margin-bottom: 18px;
+  }
+
+  .search-bar {
+    position: relative;
+    width: 560px;
+  }
+
+  .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; }
+}
+
+.home-list {
+  padding: 40px 64px;
+  height: calc(100vh - 240px);
+  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;
+  }
+  .item-meta {
+    width: 1000px;
+    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: 24px;
+  }
+}
+</style>

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

@@ -0,0 +1,169 @@
+<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 label="账号">
+          <el-input v-model="form.username" placeholder="请输入账号" clearable />
+        </el-form-item>
+
+        <el-form-item label="密码">
+          <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('TOKEN', 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);
+}
+.login-title {
+  font-size: 20px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+.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;
+}
+</style>

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

@@ -0,0 +1,492 @@
+<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="`推送中(${runningTotal})`" name="running">
+          <div v-loading="runningLoading" class="loading-container">
+            <ul class="task-list" v-if="runningList.length > 0 && !runningLoading">
+              <li v-for="(item, idx) in runningPageItems" :key="idx" class="task-item">
+                <div class="task-left">
+                  <div class="task-title" :title="item.title">{{ item.title }}</div>
+                  <div class="task-status">{{ getStatusText(item.status) }}</div>
+                </div>
+                <div class="cancel-btn" v-if="item.status === 0" @click="cancelPush(item)">取消</div>
+              </li>
+            </ul>
+            <div v-if="!runningList.length" class="empty">暂无推送任务</div>
+          </div>
+          <div class="pagination">
+            <el-pagination background layout="prev, pager, next" v-model:current-page="runningPageNum"
+              :page-size="runningPageSize" :total="runningTotal" @current-change="onRunningPageChange" />
+          </div>
+        </el-tab-pane>
+        <el-tab-pane :label="`已完成(${doneTotal})`" name="done">
+          <div v-loading="doneLoading" class="loading-container">
+            <ul class="task-list" v-if="doneList.length > 0 && !doneLoading">
+              <li v-for="(item, idx) in donePageItems" :key="idx" class="task-item">
+                <div class="task-left">
+                  <div class="task-title" :title="item.title + ' | ' + item.num">{{ item.title }} | {{ item.num }}</div>
+                  <div class="task-status">推送时间:{{ item.updateTime }}</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>
+          <div class="pagination">
+            <el-pagination background layout="prev, pager, next" v-model:current-page="donePageNum"
+              :page-size="donePageSize" :total="doneTotal" @current-change="onDonePageChange" />
+          </div>
+        </el-tab-pane>
+      </el-tabs>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, watch } from 'vue'
+import { ElMessageBox, ElMessage } from 'element-plus'
+import api from '@/api'
+
+const activeTab = ref('running')
+const uploadRef = ref()
+const selectedFileName = ref('')
+const selectedFile = ref(null)
+const runningList = ref([])
+const runningLoading = ref(true)
+const doneList = ref([])
+const doneLoading = ref(true)
+const runningTotal = ref(0)
+const doneTotal = ref(0)
+// 分页:推送中
+const runningPageNum = ref(1)
+const runningPageSize = 10
+const runningPageItems = computed(() => runningList.value)
+function onRunningPageChange(page) {
+  runningPageNum.value = page
+  fetchRunningList()
+}
+
+// 分页:已完成
+const donePageNum = ref(1)
+const donePageSize = 10
+const donePageItems = computed(() => doneList.value)
+function onDonePageChange(page) {
+  donePageNum.value = page
+  fetchDoneList()
+}
+
+function beforeUpload(file) {
+  // 仅允许 .xls 文件
+  const isXls = /\.xls$/i.test(file.name)
+  if (!isXls) {
+    ElMessage.error('仅支持 .xls 文件上传')
+  }
+  return isXls
+}
+
+function onFileChange(file) {
+  const fileName = file?.name || file?.raw?.name || ''
+  const isXls = /\.xls$/i.test(fileName)
+  if (!isXls) {
+    ElMessage.error('仅支持 .xls 文件上传')
+    selectedFileName.value = ''
+    selectedFile.value = null
+    if (uploadRef.value) uploadRef.value.clearFiles()
+    return
+  }
+
+  selectedFile.value = file.raw || file
+  selectedFileName.value = fileName
+}
+
+async function cancelPush(item) {
+  try {
+    await api.cancelTaskApi(item.id)
+    ElMessage.success('取消成功')
+    fetchRunningList()
+  } catch (e) {
+    ElMessage.error(e?.message || '取消失败')
+  }
+}
+
+function clearSelectedFile() {
+  selectedFileName.value = ''
+  selectedFile.value = null
+  if (uploadRef.value) uploadRef.value.clearFiles()
+}
+
+function getStatusText(status) {
+  const s = Number(status)
+  switch (s) {
+    case -1:
+      return '推送失败'
+    case 0:
+      return '等待中'
+    case 1:
+      return '成功'
+    case 2:
+      return '推送中'
+    default:
+      return '未知状态'
+  }
+}
+
+async function startPush() {
+  if (!selectedFile.value) {
+    ElMessage.warning('请先上传 .xls 文件')
+    return
+  }
+  const formData = new FormData()
+  formData.append('file', selectedFile.value)
+  try {
+    await api.pushSceneApi(formData)
+    // ElMessage.success('已开始推送')
+    clearSelectedFile()
+    activeTab.value = 'running'
+    runningPageNum.value = 1
+    await fetchRunningList()
+  } catch (e) {
+
+    ElMessageBox.alert(e?.message || '推送失败', '错误提示', { type: 'error' })
+  }
+}
+
+async function fetchRunningList() {
+  runningLoading.value = true
+  try {
+    const res = await api.getTaskListApi({
+      pageSize: runningPageSize,
+      pageNo: runningPageNum.value,
+      status: 0,
+    })
+    const records = res?.pageData || res?.records || res?.list || res?.rows || []
+    runningList.value = Array.isArray(records) ? records : []
+    runningTotal.value = res?.total || res?.count || res?.page?.total || res?.page?.count || runningList.value.length || 0
+    runningLoading.value = false
+  } catch (e) {
+    console.error('获取推送中任务失败:', e)
+    runningList.value = []
+    runningTotal.value = 0
+  }
+}
+
+async function fetchDoneList() {
+  doneLoading.value = true
+  try {
+    const res = await api.getTaskListApi({
+      pageSize: donePageSize,
+      pageNo: donePageNum.value,
+      status: 1,
+    })
+    doneList.value = []
+    const records = res?.pageData || res?.records || res?.list || res?.rows || []
+    doneList.value = Array.isArray(records) ? records : []
+    doneTotal.value = res?.total || res?.count || res?.page?.total || res?.page?.count || doneList.value.length || 0
+    doneLoading.value = false
+  } catch (e) {
+    console.error('获取已完成任务失败:', e)
+    doneList.value = []
+    doneTotal.value = 0
+  }
+}
+
+onMounted(() => {
+  fetchRunningList()
+  fetchDoneList()
+})
+
+// 切换标签时,根据状态刷新对应列表
+watch(activeTab, (val) => {
+  if (val === 'running') {
+    runningPageNum.value = 1
+    fetchRunningList()
+  } else if (val === 'done') {
+    donePageNum.value = 1
+    fetchDoneList()
+  }
+})
+</script>
+<style>
+.el-popup-parent--hidden {
+  width: 100% !important;
+}
+
+.el-message-box__container {
+  padding: 48px;
+}
+
+.el-message-box__header {
+  border-bottom: 1px solid #f5f5f5;
+}
+
+.el-message-box__btns {
+  border-top: 1px solid #f5f5f5;
+}
+
+.el-button:hover {
+  background-color: #A9947D;
+  border-color: #A9947D;
+}
+
+.el-button:active {
+  background: #76614A;
+  border-color: #76614A;
+}
+</style>
+<style lang="scss" scoped>
+.push-system {
+  background: #f5f0ea;
+  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: 1200px;
+  height: 156px;
+  margin: 0 0 24px;
+
+  :deep(.el-upload-dragger) {
+    width: 1200px;
+    border: none;
+  }
+
+  :deep(.el-upload) {
+    --el-upload-dragger-padding-horizontal: 34px;
+  }
+}
+
+.upload {
+  .upload-icon {
+    width: 48px;
+    height: 48px;
+  }
+
+  .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;
+    gap: 16px;
+
+    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__item) {
+    color: rgba(0, 0, 0, 0.5);
+  }
+
+  :deep(.el-tabs__item.is-active) {
+    color: #93795D;
+  }
+
+  :deep(.el-tabs__nav-wrap:after) {
+    height: 1px;
+    background-color: #f5f5f5;
+  }
+}
+
+.loading-container {
+  height: 35vh;
+}
+
+.task-list {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  height: 35vh;
+  overflow: auto;
+}
+
+.task-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 8px;
+  border-bottom: 1px solid #F5F5F5;
+
+  .cancel-btn {
+    cursor: pointer;
+    color: #A62525;
+  }
+}
+
+.task-title {
+  max-width: 900px;
+  font-size: 15px;
+  color: #333;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.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: 44px;
+}
+
+:deep(.el-button:hover) {
+  background-color: #93795D;
+  color: #fff;
+}
+</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: 7788,
+      open: true,
+      proxy: {
+        '/service': {
+          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)),
+    }
+  }),
+)