Browse Source

🎉 init: 项目初始化

Pchen. 11 months ago
commit
d5f07a6f1a
99 changed files with 6731 additions and 0 deletions
  1. 3 0
      .env
  2. 3 0
      .env.preview
  3. 26 0
      .gitignore
  4. 3 0
      .vscode/extensions.json
  5. 19 0
      README.md
  6. 14 0
      index.html
  7. 45 0
      package.json
  8. 1 0
      public/logo.svg
  9. BIN
      public/mark/begin.png
  10. BIN
      public/mark/daka.png
  11. BIN
      public/mark/end.png
  12. 5 0
      src/App.vue
  13. 103 0
      src/api/ai.js
  14. 31 0
      src/api/goods.js
  15. 65 0
      src/api/lepao.js
  16. 65 0
      src/api/login.js
  17. 31 0
      src/api/order.js
  18. 31 0
      src/api/pathData.js
  19. 24 0
      src/api/upload.js
  20. 49 0
      src/api/user.js
  21. 1 0
      src/assets/VIP.svg
  22. 1 0
      src/assets/ai.svg
  23. 1 0
      src/assets/ai2.svg
  24. 4 0
      src/assets/css/common.css
  25. 122 0
      src/assets/css/index.css
  26. BIN
      src/assets/font/AlibabaSans-MediumItalic.woff2
  27. BIN
      src/assets/font/AlimamaShuHeiTi-Bold.woff2
  28. 32 0
      src/assets/font/iconfont.css
  29. BIN
      src/assets/img/ai.png
  30. BIN
      src/assets/img/avatar/assistant.png
  31. BIN
      src/assets/img/bg.png
  32. BIN
      src/assets/img/bg1.png
  33. 1 0
      src/assets/img/gitDataPanel/code.svg
  34. 1 0
      src/assets/img/gitDataPanel/commit.svg
  35. 1 0
      src/assets/img/gitDataPanel/group.svg
  36. 1 0
      src/assets/img/gitDataPanel/time.svg
  37. BIN
      src/assets/img/homePage/ai.webp
  38. BIN
      src/assets/img/homePage/dataScreen.webp
  39. 0 0
      src/assets/img/homePage/icons/ai.svg
  40. 1 0
      src/assets/img/homePage/icons/quality.svg
  41. 1 0
      src/assets/img/homePage/icons/visualization.svg
  42. BIN
      src/assets/img/homePage/scan.webp
  43. BIN
      src/assets/img/index_bg.png
  44. 1 0
      src/assets/repo.svg
  45. BIN
      src/assets/userinfo-background.jpg
  46. BIN
      src/assets/video/background.webm
  47. 352 0
      src/components/AIChat/index.vue
  48. 39 0
      src/components/Breadcrumb/index.vue
  49. 233 0
      src/components/CanvasBackend/index.vue
  50. 97 0
      src/components/ChangeBranch/index.vue
  51. 40 0
      src/components/Chart/index.vue
  52. 113 0
      src/components/CodeBlock/index.vue
  53. 25 0
      src/components/Footer/index.vue
  54. 107 0
      src/components/Header/index.vue
  55. 122 0
      src/components/Map/MapContainer.vue
  56. 74 0
      src/components/Menu/index.vue
  57. 140 0
      src/components/Navbar/index.vue
  58. 43 0
      src/components/index.js
  59. 10 0
      src/hooks/chart-option.js
  60. 181 0
      src/layout/default-layout.vue
  61. 10 0
      src/layout/page-layout.vue
  62. 13 0
      src/main.js
  63. 38 0
      src/pages/Login/Login.vue
  64. 79 0
      src/pages/Login/components/container.vue
  65. 167 0
      src/pages/Login/components/login.vue
  66. 236 0
      src/pages/Login/components/register.vue
  67. 183 0
      src/pages/Login/uniLogin/uniLogin.vue
  68. 127 0
      src/pages/Main/Main.vue
  69. 106 0
      src/pages/Main/components/center.vue
  70. 145 0
      src/pages/Main/components/section2.vue
  71. 88 0
      src/pages/User/info/components/latest-activity.vue
  72. 112 0
      src/pages/User/info/components/my-project.vue
  73. 72 0
      src/pages/User/info/components/user-info-header.vue
  74. 70 0
      src/pages/User/info/index.vue
  75. 223 0
      src/pages/User/setting/components/basic-information.vue
  76. 103 0
      src/pages/User/setting/components/security-settings.vue
  77. 153 0
      src/pages/User/setting/components/user-panel.vue
  78. 47 0
      src/pages/User/setting/index.vue
  79. 50 0
      src/pages/lepao/accountList/components/userCard.vue
  80. 477 0
      src/pages/lepao/accountList/index.vue
  81. 278 0
      src/pages/lepao/lepaoRecords/index.vue
  82. 89 0
      src/pages/lepao/lepaoRecords/recordDetail.vue
  83. 132 0
      src/pages/path/pathDetail.vue
  84. 209 0
      src/pages/path/pathList.vue
  85. 166 0
      src/pages/store/goodsDetail/index.vue
  86. 115 0
      src/pages/store/goodsList/index.vue
  87. 247 0
      src/pages/store/orders/orderDetail/index.vue
  88. 201 0
      src/pages/store/orders/orderList/index.vue
  89. 186 0
      src/router/index.js
  90. 5 0
      src/store/index.js
  91. 95 0
      src/store/modules/user.js
  92. 48 0
      src/style.css
  93. 35 0
      src/utils/axios.js
  94. 29 0
      src/utils/encrypt.js
  95. 2 0
      src/utils/eventBus.js
  96. 84 0
      src/utils/request.js
  97. 25 0
      src/utils/route-listener.js
  98. 6 0
      src/utils/util.js
  99. 18 0
      vite.config.js

+ 3 - 0
.env

@@ -0,0 +1,3 @@
+NODE_ENV=production
+VITE_APP_API_BASE_URL=https://lepao-api.xxoo365.top
+VITE_RSA_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzgYFc5Q0ejm8xjFlJ7LI\\nAd2Fx/SjS49d9zl2dyZ73C0fKSqnsZIAFdDJVeezmJzOXNXWhaVGhqp3GBQeop0J\\nR1zFwmK5zoQxISL79YQwJlhJ7ZzYa/LMpkFd4CTT8S50Las7QiqKjDMAB1KdJZNr\\n4NGr3TYUx1UiO9TMoXWyAtVQASvkyEIQHopxOehwFn4daTO//1yMtr6vhrQ8zrQ3\\nqPm5abfcIQ3puX5IwS+zDfJB9FoksJZktV4r6+36SQ7Xjv01AB2o+m2z6j73nZ45\\n/yLLy6fGLneMjSiLG08MQaBR5uu3HM4g2Jgjp8yMtFH5NG9g+5utaL3swrFB8qwU\\ntQIDAQAB\\n-----END PUBLIC KEY-----

+ 3 - 0
.env.preview

@@ -0,0 +1,3 @@
+NODE_ENV=production
+VITE_APP_API_BASE_URL=/api
+VITE_RSA_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzgYFc5Q0ejm8xjFlJ7LI\\nAd2Fx/SjS49d9zl2dyZ73C0fKSqnsZIAFdDJVeezmJzOXNXWhaVGhqp3GBQeop0J\\nR1zFwmK5zoQxISL79YQwJlhJ7ZzYa/LMpkFd4CTT8S50Las7QiqKjDMAB1KdJZNr\\n4NGr3TYUx1UiO9TMoXWyAtVQASvkyEIQHopxOehwFn4daTO//1yMtr6vhrQ8zrQ3\\nqPm5abfcIQ3puX5IwS+zDfJB9FoksJZktV4r6+36SQ7Xjv01AB2o+m2z6j73nZ45\\n/yLLy6fGLneMjSiLG08MQaBR5uu3HM4g2Jgjp8yMtFH5NG9g+5utaL3swrFB8qwU\\ntQIDAQAB\\n-----END PUBLIC KEY-----

+ 26 - 0
.gitignore

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

+ 3 - 0
.vscode/extensions.json

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

+ 19 - 0
README.md

@@ -0,0 +1,19 @@
+# GitNexus-frontend
+
+安装依赖:
+
+```
+npm install
+```
+
+开发:
+
+```
+npm run dev
+```
+
+编译打包:
+
+```
+npm run build
+```

+ 14 - 0
index.html

@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/logo.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="viewport" content="width=1280">
+    <title>RunForge - 让跑步的意义由技术重新定义</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 45 - 0
package.json

@@ -0,0 +1,45 @@
+{
+  "name": "gitnexus",
+  "private": true,
+  "version": "1.0.0",
+  "type": "module",
+  "author": "thc <2580797295@qq.com>",
+  "description": "GitNexus 一款Git可视化分析管理工具",
+  "homepage": "https://www.gitnexus.cn",
+  "repository": {
+    "type": "git",
+    "url": "https://git.stackoverflow.vip/gitnexus_team/GitNexus-frontend"
+  },
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@amap/amap-jsapi-loader": "^1.0.1",
+    "@highlightjs/vue-plugin": "^2.1.0",
+    "@kjgl77/datav-vue3": "^1.7.4",
+    "axios": "^1.8.3",
+    "blueimp-md5": "^2.19.0",
+    "crypto-js": "^4.2.0",
+    "echarts": "^5.6.0",
+    "highlight.js": "^11.11.1",
+    "html2pdf.js": "^0.10.3",
+    "jsencrypt": "^3.3.2",
+    "markdown-it": "^14.1.0",
+    "mitt": "^3.0.1",
+    "pinia": "^3.0.1",
+    "store": "^2.0.12",
+    "three": "^0.175.0",
+    "vue": "^3.5.13",
+    "vue-echarts": "^7.0.3",
+    "vue-router": "^4.5.0"
+  },
+  "devDependencies": {
+    "@arco-design/web-vue": "^2.57.0",
+    "@arco-plugins/vite-vue": "^1.4.5",
+    "@vitejs/plugin-vue": "^5.2.1",
+    "less": "^4.2.2",
+    "vite": "^6.2.0"
+  }
+}

+ 1 - 0
public/logo.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1747188290992" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9213" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M550.4 1024c-8.7 0-17.2-2.3-24.8-6.5-24.7-13.7-33.6-44.9-19.9-69.6l106.6-191.8-145.1-130-103.5 117.3c-9.3 15.2-25.9 24.5-43.7 24.6H89.6c-28.3 0-51.2-22.9-51.2-51.2 0-28.3 22.9-51.2 51.2-51.2h201.6l132.3-209-1.2-0.7 110-194.9-102.6-30.6-126.8 95.9c-17.7 11.8-41.5 7-53.3-10.7-11.8-17.7-7-41.5 10.7-53.3l136.5-102.4c6.3-4.2 13.7-6.4 21.3-6.3l163.5 20.1h0.3c7 0 13.8 1.9 19.7 5.5l122.2 69.5c2.9 1.8 5.4 3.9 7.7 6.4 3.3 3.3 6 7.1 7.9 11.4l67.1 168.7h140.8c21.2 0 38.4 17.2 38.4 38.4S968.5 512 947.3 512H781.1c-15.4 0-29.3-9.2-35.3-23.3L681 362.1 576.1 547.8l133.5 153.9c20.2 15.4 25.9 43.3 13.5 65.5l-128 230.4c-9 16.2-26.1 26.4-44.7 26.4z m204.8-819.2c-56.6 0-102.4-45.8-102.4-102.4S698.6 0 755.2 0s102.4 45.8 102.4 102.4-45.8 102.4-102.4 102.4z" fill="#FFBA57" p-id="9214"></path><path d="M423.5 456.6l-132.3 209 72.5 77.7 186.1-210.9-126.3-75.8z" fill="#2D4C5C" p-id="9215"></path><path d="M723.7 248.7l-122.1-69.5c-5.9-3.6-12.8-5.5-19.7-5.5h-0.3L422.4 455.9 576 548.1l160.9-285.4c-3.1-5.7-7.6-10.5-13.2-14z" fill="#00ACC1" p-id="9216"></path><path d="M576 548.2l-153.6-92.3 25.9-45.9-38.7 68.8c-24.7 41.3-12.4 94.7 27.9 120.9l174.7 156.5 97.4-54.3L576 548v0.2z" fill="#546E7A" p-id="9217"></path></svg>

BIN
public/mark/begin.png


BIN
public/mark/daka.png


BIN
public/mark/end.png


+ 5 - 0
src/App.vue

@@ -0,0 +1,5 @@
+<template>
+  <div id="app">
+    <router-view></router-view>
+  </div>
+</template>

+ 103 - 0
src/api/ai.js

@@ -0,0 +1,103 @@
+import request from '@/utils/request'
+
+const api = {
+    ScanRepo: '/AI/ScanRepo',
+    GetScanTaskList: '/AI/GetScanTaskList',
+    GetScanTaskDetail: '/AI/GetScanTaskDetail',
+    GetCommitSummary: '/AI/GetCommitSummary',
+    SummaryCommit: '/AI/SummaryCommit',
+    SummaryFile: '/AI/SummaryFile',
+    GetFileSummary: '/AI/GetFileSummary',
+    GetAIChatMessages: '/AI/GetAIChatMessages',
+    GetAIChatMessage: '/AI/GetAIChatMessage',
+    DeleteAIChatMessages: '/AI/DeleteAIChatMessages',
+    AIChat: '/AI/Chat'
+}
+
+export function ScanRepo(parameter) {
+    return request({
+        url: api.ScanRepo,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function GetScanTaskList(parameter) {
+    return request({
+        url: api.GetScanTaskList,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function GetScanTaskDetail(parameter) {
+    return request({
+        url: api.GetScanTaskDetail,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function GetCommitSummary(parameter) {
+    return request({
+        url: api.GetCommitSummary,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function SummaryCommit(parameter) {
+    return request({
+        url: api.SummaryCommit,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function SummaryFile(parameter) {
+    return request({
+        url: api.SummaryFile,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function GetFileSummary(parameter) {
+    return request({
+        url: api.GetFileSummary,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function GetAIChatMessages(parameter) {
+    return request({
+        url: api.GetAIChatMessages,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function GetAIChatMessage(parameter) {
+    return request({
+        url: api.GetAIChatMessage,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function DeleteAIChatMessages(parameter) {
+    return request({
+        url: api.DeleteAIChatMessages,
+        method: 'get',
+        params: parameter
+    })
+}
+
+export function AIChat(parameter) {
+    return request({
+        url: api.AIChat,
+        method: 'post',
+        data: parameter
+    })
+}

+ 31 - 0
src/api/goods.js

@@ -0,0 +1,31 @@
+import request from '../utils/request'
+
+const api = {
+  Goods: '/Goods',
+  GoodsList: '/Goods/List',
+  GetCount: '/Goods/GetCount'
+}
+
+export function getGoods (parameter) {
+  return request({
+    url: api.Goods,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getGoodsList (parameter) {
+  return request({
+    url: api.GoodsList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getCount (parameter) {
+  return request({
+    url: api.GetCount,
+    method: 'get',
+    params: parameter
+  })
+}

+ 65 - 0
src/api/lepao.js

@@ -0,0 +1,65 @@
+import request from '../utils/request'
+
+const api = {
+  Account: '/Lepao/Account',
+  Records: '/Lepao/Records',
+  ChangeAutoRun: '/Lepao/ChangeAutoRun',
+  SingleRun: '/Lepao/SingleRun',
+  GetRecordDetail: '/Lepao/GetRecordDetail'
+}
+
+export function addAccount (parameter) {
+  return request({
+    url: api.Account,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function accountList (parameter) {
+  return request({
+    url: api.Account,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function deleteAccount (parameter) {
+  return request({
+    url: api.Account,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function lepaoRecords (parameter) {
+  return request({
+    url: api.Records,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function changeAutoRun (parameter) {
+  return request({
+    url: api.ChangeAutoRun,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function singleRun (parameter) {
+  return request({
+    url: api.SingleRun,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function GetRecordDetail (parameter) {
+  return request({
+    url: api.GetRecordDetail,
+    method: 'get',
+    params: parameter
+  })
+}

+ 65 - 0
src/api/login.js

@@ -0,0 +1,65 @@
+import request from '@/utils/request'
+
+const api = {
+  register: '/User/Register',
+  Login: '/User/Login',
+  ImgCaptcha: '/Captcha/ImageCaptcha',
+  GetPermissions: '/User/GetPermissions',
+  sendEmail: '/Captcha/SendEmail',
+  GetLoginUrl: '/UniLogin/GetLoginUrl',
+  UniLogin: '/UniLogin/Login'
+}
+
+export function register(parameter) {
+  return request({
+    url: api.register,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function login (parameter) {
+  return request({
+    url: api.Login,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function sendEmail(parameter) {
+  return request({
+    url: api.sendEmail,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function getImageCaptcha () {
+  return request({
+    url: api.ImgCaptcha,
+    method: 'get'
+  })
+}
+
+export function GetPermissions() {
+  return request({
+    url: api.GetPermissions,
+    method: 'get'
+  })
+}
+
+export function getLoginUrl(parameter) {
+  return request({
+    url: api.GetLoginUrl,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function uniLogin(parameter) {
+  return request({
+    url: api.UniLogin,
+    method: 'post',
+    data: parameter
+  })
+}

+ 31 - 0
src/api/order.js

@@ -0,0 +1,31 @@
+import request from '../utils/request'
+
+const api = {
+  Create: '/Order/CreateOrder',
+  Detail: '/Order/Detail',
+  GetMyOrder: '/Order/GetMyOrders'
+}
+
+export function createOrder(parameter) {
+  return request({
+    url: api.Create,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function orderDeatil(parameter) {
+  return request({
+    url: api.Detail,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getMyOrder(parameter) {
+  return request({
+    url: api.GetMyOrder,
+    method: 'get',
+    params: parameter
+  })
+}

+ 31 - 0
src/api/pathData.js

@@ -0,0 +1,31 @@
+import request from '../utils/request'
+
+const api = {
+  GetPathDetail: '/Lepao/GetPathDetail',
+  GetPathList: '/Lepao/GetPathList',
+  ChangePathState: '/Lepao/ChangePathState'
+}
+
+export function GetPathList(parameter) {
+  return request({
+    url: api.GetPathList,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function GetPathDetail(parameter) {
+  return request({
+    url: api.GetPathDetail,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function ChangePathState(parameter) {
+  return request({
+    url: api.ChangePathState,
+    method: 'post',
+    data: parameter
+  })
+}

+ 24 - 0
src/api/upload.js

@@ -0,0 +1,24 @@
+import request from '@/utils/request'
+
+const api = {
+    uploadImg: '/UploadPicture',
+    uploadAvatar: '/uploadAvatar'
+}
+
+export function uploadImg(parameter) {
+    return request({
+        url: api.uploadImg,
+        headers: { 'Content-Type': 'multipart/form-data' },
+        method: 'post',
+        data: parameter
+    })
+}
+
+export function uploadAvatar(parameter) {
+    return request({
+        url: api.uploadAvatar,
+        headers: { 'Content-Type': 'multipart/form-data' },
+        method: 'post',
+        data: parameter
+    })
+}

+ 49 - 0
src/api/user.js

@@ -0,0 +1,49 @@
+import request from '../utils/request'
+
+const api = {
+  ChangeUsername: '/User/ChangeUsername',
+  ChangePassword: '/User/ChangePassword',
+  UserInfo: '/User/Info',
+  BindEmail: '/User/BindEmail',
+  GetRepos: '/User/GetRepos'
+}
+
+export function ChangeUsername(parameter) {
+  return request({
+    url: api.ChangeUsername,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function BindEmail(parameter) {
+  return request({
+    url: api.BindEmail,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function ChangePassword(parameter) {
+  return request({
+    url: api.ChangePassword,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function GetUserInfo(parameter) {
+  return request({
+    url: api.UserInfo,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function GetRepos(parameter) {
+  return request({
+    url: api.GetRepos,
+    method: 'get',
+    params: parameter
+  })
+}

+ 1 - 0
src/assets/VIP.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1747383322546" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4664" width="32" height="32" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M510.955102 831.738776c-23.510204 0-45.453061-9.926531-61.64898-27.167347L138.971429 468.114286c-28.734694-31.346939-29.779592-79.412245-1.567347-111.804082l117.55102-135.314286c15.673469-18.285714 38.661224-28.734694 63.216327-28.734694H705.306122c24.032653 0 47.020408 10.44898 62.693878 28.734694l118.073469 135.314286c28.212245 32.391837 27.689796 80.457143-1.567347 111.804082L572.081633 804.571429c-15.673469 17.240816-38.138776 27.167347-61.126531 27.167347z" fill="#F2CB51" p-id="4665"></path><path d="M506.77551 642.612245c-5.22449 0-10.971429-2.089796-15.15102-6.269388l-203.755102-208.979592c-7.836735-8.359184-7.836735-21.420408 0.522449-29.779592 8.359184-7.836735 21.420408-7.836735 29.779592 0.522449l189.12653 193.828572 199.053061-194.351021c8.359184-7.836735 21.420408-7.836735 29.779592 0.522449 7.836735 8.359184 7.836735 21.420408-0.522449 29.779592l-214.204081 208.979592c-4.179592 3.657143-9.404082 5.746939-14.628572 5.746939z" fill="#FFF7E1" p-id="4666"></path></svg>

+ 1 - 0
src/assets/ai.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1743221393320" class="icon" viewBox="0 0 1152 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16541" xmlns:xlink="http://www.w3.org/1999/xlink" width="72" height="64"><path d="M640 0l384 1024H0L384 0h256zM512 205.376L329.792 704H576v192H259.648l-46.656 128h597.952L512 205.376z" fill="#3366FF" p-id="16542"></path><path d="M928 384h192v640h-192z" fill="#3366FF" p-id="16543"></path><path d="M1024 128m-128 0a128 128 0 1 0 256 0 128 128 0 1 0-256 0Z" fill="#F5222D" p-id="16544"></path></svg>

+ 1 - 0
src/assets/ai2.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1743250828824" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16701" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M661.3 349.4h65.1v322.2h-65.1zM499.8 349.3h-68.9L305.5 671.6h69l26.6-73.2h128.7l28.3 73.2h70.7l-129-322.3z m-78.7 194.8l43.5-119.5L509 544.1h-87.9zM70.7 763.2L73 635l72.3 1.3-1.5 86.2 365.7 217.9 220.7-123.6 35.4 63.1L508.4 1024z" fill="#1296db" p-id="16702"></path><path d="M809.4 770.6l71.5-42v-429L667.8 170.2l37.6-61.9 247.9 150.5V770L846 833zM74.3 544.5V249.4L520.7 0l105.5 65.6-38.2 61.5L518.7 84 146.6 291.9v252.6z" fill="#1296db" p-id="16703"></path></svg>

+ 4 - 0
src/assets/css/common.css

@@ -0,0 +1,4 @@
+html,body,#app{
+  width: 100%;
+  height: 100%;
+}

+ 122 - 0
src/assets/css/index.css

@@ -0,0 +1,122 @@
+html,
+body,
+#app {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    margin: 0;
+    padding: 0;
+}
+
+.el-header {
+    background-color: #000000;
+    color: #333;
+    text-align: center;
+    line-height: 60px;
+}
+
+.el-aside {
+    background-color: #000000;
+    color: #333;
+    text-align: center;
+    line-height: 200px;
+}
+
+.bg {
+    background: url("../img/index_bg.png");
+    width: 100%;
+    height: 100%;
+    background-size: cover;
+    background-repeat: no-repeat;
+    color: #fffcfc;
+}
+
+.header-box {
+    width: 100%;
+    height: 70px;
+    display: flex;
+    justify-content: space-between;
+    text-align: center;
+    margin-bottom: 10px;
+}
+
+.header-box h2 {
+    margin-top: 3px;
+    letter-spacing: 5px;
+}
+
+/* 整体布局 */
+.main {
+    width: 100%;
+    height: 100%;
+    display: grid;
+    grid-template-columns: 1fr 2fr 1fr; /* 修改列布局,设置三列 */
+    grid-template-rows: 0.3fr 2fr; /* 修改行布局 */
+    /* gap: 10px; */
+}
+
+/* 第一模块 */
+.main div:nth-child(1) {
+    grid-column: 1 / 4; /* 跨三列 */
+    grid-row: 1 / 2;
+}
+
+/* 第二模块 */
+.main div:nth-child(2) {
+    grid-row: 2 / 3;
+    grid-column: 1 / 2; /* 位于第一列 */
+}
+
+/* 第三模块 */
+.main div:nth-child(3) {
+    grid-column: 3 / 4; /* 位于第三列 */
+    grid-row: 2 / 3;
+}
+
+/* 第四模块 */
+.main div:nth-child(4) {
+    grid-column: 2 / 3; /* 位于第二列,即中间 */
+    grid-row: 2 / 3;
+    /* display: flex; */
+    justify-content: center; /* 水平居中 */
+    align-items: center; /* 垂直居中 */
+}
+
+/* 第一模块样式 */
+.modulefirst {
+    height: 100%;
+    font-size: 25px;
+}
+
+/* 边框样式 */
+.bgstyle {
+    height: 100%;
+    background-color: #0D2049;
+    border-top: 2px solid #0B3E6B;
+}
+
+/* 第三个模块  */
+.modulethird {
+    display: flex;
+}
+
+.modulethird-left {
+    flex: 0 1 60%;
+}
+
+.modulethird-right {
+    flex: 0 1 40%;
+}
+
+/* 第四个模块 */
+.modulefourth {
+    /* display: flex; */
+    width: 100%;
+}
+/* 每个小板块标题 */
+.title {
+  font-size: 20px;
+  font-weight: bold;
+  margin-bottom: 10px;
+}
+/*  */

BIN
src/assets/font/AlibabaSans-MediumItalic.woff2


BIN
src/assets/font/AlimamaShuHeiTi-Bold.woff2


+ 32 - 0
src/assets/font/iconfont.css

@@ -0,0 +1,32 @@
+@font-face {
+    font-family: "iconfont"; /* Project id 4320411 */
+    src: url('iconfont.woff2?t=1699374041097') format('woff2'),
+         url('iconfont.woff?t=1699374041097') format('woff'),
+         url('iconfont.ttf?t=1699374041097') format('truetype');
+  }
+  
+  .iconfont {
+    font-family: "iconfont" !important;
+    font-size: 16px;
+    font-style: normal;
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
+  }
+  
+  .icon-tubiao:before {
+    content: "\e670";
+  }
+  
+  .icon-tubiaotongji:before {
+    content: "\e66a";
+  }
+  
+  .icon-a-tubiao2:before {
+    content: "\eb41";
+  }
+  
+  .icon-a-tubiao3:before {
+    content: "\eb42";
+  }
+  
+  

BIN
src/assets/img/ai.png


BIN
src/assets/img/avatar/assistant.png


BIN
src/assets/img/bg.png


BIN
src/assets/img/bg1.png


+ 1 - 0
src/assets/img/gitDataPanel/code.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1741154062294" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="17559" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M0 512a512 512 0 1 0 1024 0A512 512 0 1 0 0 512z" fill="#1296DB" p-id="17560"></path><path d="M402.002 384a20.48 20.48 0 0 1-7.496 27.955l-154.132 112.64a20.48 20.48 0 1 1-20.48-35.43l154.132-112.64A20.48 20.48 0 0 1 402.002 384z" fill="#FFFFFF" p-id="17561"></path><path d="M406.815 634.88a20.48 20.48 0 0 0-7.475-27.955l-153.6-112.64a20.48 20.48 0 0 0-20.48 35.43l153.6 112.64a20.378 20.378 0 0 0 27.955-7.475z" fill="#FFFFFF" p-id="17562"></path><path d="M622.182 379.085a20.726 20.726 0 0 0 7.68 28.467l157.799 114.79a21.053 21.053 0 0 0 28.672-7.68 20.726 20.726 0 0 0-7.68-28.467l-157.799-114.79a21.053 21.053 0 0 0-28.672 7.68z" fill="#FFFFFF" p-id="17563"></path><path d="M617.267 634.675a20.726 20.726 0 0 1 7.68-28.467l157.287-114.79a20.88 20.88 0 1 1 20.89 36.147l-157.287 114.79a20.91 20.91 0 0 1-28.57-7.68z" fill="#FFFFFF" p-id="17564"></path><path d="M551.731 363.622a20.552 20.552 0 0 1 14.541 25.088l-68.946 257.127a20.48 20.48 0 1 1-39.557-10.547l68.874-257.127a20.593 20.593 0 0 1 25.088-14.54z" fill="#FFFFFF" p-id="17565"></path></svg>

+ 1 - 0
src/assets/img/gitDataPanel/commit.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1741153899968" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8822" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#57C686" p-id="8823"></path><path d="M582.22592 245.76v140.43136c0 19.39456 15.70816 35.10272 35.10272 35.10272H757.76V737.28c0 19.39456-15.72864 35.10272-35.10272 35.10272H301.34272A35.10272 35.10272 0 0 1 266.24 737.28V280.86272c0-19.37408 15.72864-35.10272 35.10272-35.10272H582.2464z m35.10272 0L757.76 386.19136h-122.88a17.55136 17.55136 0 0 1-17.55136-17.55136v-122.88z m-147.80416 375.33696L419.84 571.45344a17.55136 17.55136 0 0 0-24.80128 24.82176l62.0544 62.0544a17.55136 17.55136 0 0 0 24.82176 0l136.54016-136.51968a17.55136 17.55136 0 0 0-24.82176-24.84224l-124.1088 124.12928z" fill="#FFFFFF" p-id="8824"></path></svg>

+ 1 - 0
src/assets/img/gitDataPanel/group.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1741153966074" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9831" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M511.867961 1023.998953A512.007562 512.007562 0 0 1 312.623357 40.491199a511.995928 511.995928 0 0 1 398.489208 943.279586A508.64552 508.64552 0 0 1 511.867961 1023.998953zM324.745321 581.465834a80.979838 80.979838 0 0 0-80.886771 80.875138 40.495736 40.495736 0 0 0 40.449202 40.449202h300.443227a40.484102 40.484102 0 0 0 40.437569-40.449202 80.968205 80.968205 0 0 0-80.875138-80.875138z m255.68968-10.62126a80.456337 80.456337 0 0 1 68.020272 79.863035 41.717239 41.717239 0 0 1-0.372267 5.537481h103.315895a28.501739 28.501739 0 0 0 28.466838-28.466839 57.003478 57.003478 0 0 0-56.933677-57.003478zM645.651633 387.816877a73.208752 73.208752 0 1 0 73.220385 73.208752v-73.208752z m-211.122267-66.310167a104.002263 104.002263 0 1 0 104.002263 104.002263V321.460176z" fill="#0084FF" p-id="9832"></path></svg>

+ 1 - 0
src/assets/img/gitDataPanel/time.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1741154758137" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19781" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M511.926857 0.804571A511.707429 511.707429 0 0 0 0.731429 512 511.707429 511.707429 0 0 0 512 1023.049143 511.707429 511.707429 0 0 0 1023.049143 512 511.707429 511.707429 0 0 0 511.926857 0.804571z" fill="#09B590" p-id="19782"></path><path d="M709.851429 637.220571L533.211429 535.04v-278.674286a42.642286 42.642286 0 0 0-85.211429 0v298.203429c0 15.798857 19.602286 29.037714 32.402286 36.352 3.510857 5.339429 13.238857 10.166857 19.163428 13.531428L675.254857 710.948571c20.406857 11.702857 42.422857 4.754286 54.198857-15.579428 11.702857-20.333714 0.804571-46.372571-19.602285-58.148572z" fill="#FFFFFF" p-id="19783"></path></svg>

BIN
src/assets/img/homePage/ai.webp


BIN
src/assets/img/homePage/dataScreen.webp


File diff suppressed because it is too large
+ 0 - 0
src/assets/img/homePage/icons/ai.svg


+ 1 - 0
src/assets/img/homePage/icons/quality.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1745378763867" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13193" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M512 62c-200.323 0-362.903 162.58-362.903 362.903 0 104.516 45 200.323 116.129 265.645V962L512 876.355 758.774 962V690.548c71.13-66.774 116.13-161.129 116.13-265.645C874.903 224.581 712.322 62 512 62z m188.71 682.258V880.71L512 815.387 323.29 880.71V744.258h15.968c50.807 27.58 110.323 43.548 172.742 43.548s120.484-15.967 172.742-43.548h15.968z m-31.936-58.064c-46.451 27.58-98.71 43.548-156.774 43.548s-110.323-15.968-156.774-43.548c-88.549-53.71-148.065-149.517-148.065-261.29 0-168.388 136.452-304.84 304.839-304.84s304.839 136.452 304.839 304.84c0 111.773-59.516 207.58-148.065 261.29z" fill="#1890FF" p-id="13194"></path><path d="M512 642.645c120.484 0 217.742-97.258 217.742-217.742S632.484 207.161 512 207.161 294.258 304.42 294.258 424.903 391.516 642.645 512 642.645z m0-58.064c-88.548 0-159.677-71.13-159.677-159.678S423.452 265.226 512 265.226s159.677 71.129 159.677 159.677S600.548 584.581 512 584.581z" fill="#1890FF" p-id="13195"></path><path d="M352.323 424.903a159.677 159.677 0 1 0 319.354 0 159.677 159.677 0 1 0-319.354 0z" fill="#D3EAFF" p-id="13196"></path><path d="M599.097 404.58l-58.065-11.612L512 337.806l-29.032 55.162-58.065 11.613 40.645 45L458.29 512 512 485.871 565.71 512l-7.258-62.42z" fill="#FFC13D" p-id="13197"></path></svg>

+ 1 - 0
src/assets/img/homePage/icons/visualization.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1745378736005" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11949" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M391.12704 587.776m-273.24416 0a273.24416 273.24416 0 1 0 546.48832 0 273.24416 273.24416 0 1 0-546.48832 0Z" fill="#346BAA" p-id="11950"></path><path d="M486.35904 141.55776h383.1808v383.1808h-383.1808z" fill="#64B064" p-id="11951"></path><path d="M422.66624 882.44224l241.70496-418.69312 241.74592 418.69312z" fill="#43A3D4" p-id="11952"></path></svg>

BIN
src/assets/img/homePage/scan.webp


BIN
src/assets/img/index_bg.png


+ 1 - 0
src/assets/repo.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1739203237720" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7478" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M554.666667 896v106.666667l-128-85.333334-128 85.333334V896h-21.333334A149.333333 149.333333 0 0 1 128 746.666667V213.333333a128 128 0 0 1 128-128h597.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v725.333333a42.666667 42.666667 0 0 1-42.666667 42.666667h-298.666666z m-256-85.333333v-85.333334h256v85.333334h256v-128H277.333333a64 64 0 0 0 0 128H298.666667zM298.666667 213.333333v85.333334h85.333333V213.333333H298.666667z m0 128v85.333334h85.333333V341.333333H298.666667z m0 128v85.333334h85.333333v-85.333334H298.666667z" p-id="7479" fill="#707070"></path></svg>

BIN
src/assets/userinfo-background.jpg


BIN
src/assets/video/background.webm


+ 352 - 0
src/components/AIChat/index.vue

@@ -0,0 +1,352 @@
+<template>
+    <a-drawer :visible="props.visible" class="drawer" width="570px" @cancel="closeAI" placement="left" :footer="false">
+        <template #title>
+            <div class="aiTitle">
+                <img alt="AI" src="@/assets/ai.svg" height="25">
+                <span>AI助手</span>
+                <a-button size="small" class="button" @click="deleteMessages">清空记录</a-button>
+            </div>
+        </template>
+        <div class="container">
+            <div class="messages" ref="messagesContainer">
+                <a-spin :loading="messagesLoading">
+                    <div v-for="(item, index) in messages">
+                        <div class="time"
+                            v-if="!messages[index - 1] || (messages[index].time - messages[index - 1].time) > 300000">{{
+                                stramptoTime(item.time) }}</div>
+                        <div :class="['message', item.type === 'user' ? 'right' : 'left']">
+                            <a-avatar class="avatar">
+                                <img alt="avatar" src="@/assets/img/avatar/assistant.png" v-if="item.type !== 'user'" />
+                                <img alt="avatar" :src="user.avatar" v-else-if="item.type === 'user'" />
+                            </a-avatar>
+                            <a-dropdown trigger="contextMenu" alignPoint :style="{ display: 'block', zIndex: 99 }">
+                                <div :class="['content', item.type === 'user' ? 'user' : 'ai']"
+                                    v-html="renderMarkdown(item.content)">
+                                </div>
+                                <template #content>
+                                    <a-doption>
+                                        <icon-play-arrow /> 打开仓库
+                                    </a-doption>
+                                </template>
+                            </a-dropdown>
+                        </div>
+                    </div>
+                </a-spin>
+            </div>
+
+            <div class="inputBox">
+                <a-textarea placeholder="您的专属AI助手~" :max-length="300" :auto-size="{
+                    minRows: 1,
+                    maxRows: 6
+                }" allow-clear v-model="input" />
+                <a-button type="primary" :loading="send" @click="sendMessage">发送</a-button>
+            </div>
+        </div>
+    </a-drawer>
+</template>
+
+<script setup>
+import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
+import { Message, Modal } from '@arco-design/web-vue'
+import { eventBus } from '@/utils/eventBus'
+import { AIChat, GetAIChatMessages, GetAIChatMessage, DeleteAIChatMessages } from '@/api/ai'
+import { useUserStore } from '@/store/modules/user'
+import MarkdownIt from 'markdown-it'
+
+const md = new MarkdownIt()
+
+const renderMarkdown = (text) => {
+    return md.render(text || '')
+}
+
+const props = defineProps({
+    visible: {
+        type: Boolean,
+        default: false
+    }
+})
+
+const user = ref('')
+const input = ref('')
+const messagesLoading = ref(false)
+const send = ref(false)
+const messages = ref([])
+const msgid = ref('')
+const newIndex = ref()
+
+const messagesContainer = ref(null)
+
+const scrollToBottom = () => {
+    if (messagesContainer.value) {
+        // 等 DOM 渲染完成再滚动
+        nextTick(() => {
+            messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
+        })
+    }
+}
+
+const getAIChatMessages = async () => {
+    try {
+        messagesLoading.value = true
+        const res = await GetAIChatMessages()
+        if (!res || res.code !== 0)
+            return Message.error(`获取历史对话消息失败!${res?.msg || ''}`)
+        messages.value = res.data
+        messages.value.push({
+            type: 'system', time: new Date().getTime(), content: '你好!我是你的专属AI智能助手“小吉”,你可以问我任何问题哦~~\n试着问问:\n- 我拥有哪些Git仓库?\n- 仓库GitNexus最近一次提交的信息是什么?\n- 请对比electron仓库的最后两次提交。'
+        })
+    } catch (error) {
+        Message.error(`获取历史对话消息失败!`)
+    } finally {
+        scrollToBottom()
+        messagesLoading.value = false
+    }
+}
+
+const getAIChatMessage = async () => {
+    try {
+        const res = await GetAIChatMessage({ id: msgid.value })
+        if (!res || res.code !== 0) {
+            send.value = false
+            return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
+        }
+
+        if (!res.data)
+            return
+
+        stopPolling()
+        messages.value[newIndex.value].content = ''
+        for (let i = 0; i < res.data.length; i++) {
+            await new Promise(resolve => setTimeout(resolve, 30))
+            messages.value[newIndex.value].content += res.data[i]
+            scrollToBottom()
+        }
+        send.value = false
+    } catch (error) {
+        Message.error(`获取对话消息失败!`)
+    } finally {
+        scrollToBottom()
+    }
+}
+
+let timer = null
+
+// 轮询
+const startPolling = () => {
+    if (!timer) {
+        timer = setInterval(async () => {
+            await getAIChatMessage()
+        }, 1000)
+    }
+}
+
+// 停止轮询
+const stopPolling = () => {
+    if (timer) {
+        clearInterval(timer)
+        timer = null
+    }
+    send.value = false
+}
+
+const sendMessage = async () => {
+    if (input.value === '') return
+    send.value = true
+    let content = input.value
+    input.value = ''
+    try {
+        messages.value.push({ type: 'user', time: new Date().getTime(), content })
+        newIndex.value = messages.value.push({ type: 'ai', time: new Date().getTime(), content: '小吉正在思考哦~请稍候...' }) - 1
+        scrollToBottom()
+        const res = await AIChat({ message: content })
+        if (!res || res.code !== 0) {
+            send.value = false
+            return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
+        }
+
+        msgid.value = res.id
+        startPolling()
+    } catch (error) {
+        console.log(error)
+        Message.error('获取对话消息失败!请稍后再试')
+    }
+}
+
+const closeAI = () => {
+    eventBus.emit('closeAI')
+}
+
+const getuser = async () => {
+    const userStore = useUserStore()
+    let userInfo = await userStore.getInfo()
+    user.value = userInfo
+}
+
+onMounted(async () => {
+    getuser()
+    await getAIChatMessages()
+})
+
+// 组件销毁时停止轮询
+onUnmounted(() => {
+    stopPolling()
+})
+
+watch(
+    () => props.visible,
+    (val) => {
+        if (val) {
+            nextTick(() => {
+                scrollToBottom()
+            })
+        }
+    }
+)
+
+const stramptoTime = (time) => {
+    return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+}
+
+const deleteMessages = () => {
+    Modal.confirm({
+        title: '清空消息记录',
+        content: '您即将清空所有消息记录,是否继续?',
+        onOk: async () => {
+            const res = await DeleteAIChatMessages()
+            if (!res || res.code !== 0)
+                return Message.error('清空消息记录失败!')
+            getAIChatMessages()
+            Message.success('清空消息记录成功!')
+        },
+        onCancel: () => {
+
+        }
+    })
+}
+
+</script>
+
+<style lang="less" scoped>
+.container {
+    display: flex;
+    flex-direction: column;
+    height: calc(100% + 20px);
+    gap: 10px;
+    margin-top: -20px;
+
+    /* 对于 Webkit 浏览器(Chrome、Safari) */
+    .messages::-webkit-scrollbar {
+        width: 8px;
+    }
+
+    .messages::-webkit-scrollbar-track {
+        background: transparent;
+    }
+
+    .messages::-webkit-scrollbar-thumb {
+        background-color: rgba(0, 0, 0, 0.2);
+        /* 滚动条滑块背景 */
+        border-radius: 10px;
+        /* 滚动条滑块圆角 */
+    }
+
+    .messages::-webkit-scrollbar-thumb:hover {
+        background-color: rgba(0, 0, 0, 0.3);
+        /* 滚动条滑块悬停时的背景 */
+    }
+
+    .messages {
+        flex: 1;
+        overflow-y: auto;
+        padding: 10px;
+        display: flex;
+        flex-direction: column;
+
+        .time {
+            color: #888;
+            font-size: 0.9em;
+            text-align: center;
+            margin: 10px 0 0;
+        }
+
+        .message {
+            display: flex;
+            margin-top: 10px;
+
+            &.right {
+                flex-direction: row-reverse;
+                justify-content: end;
+            }
+
+            .avatar {
+                user-select: none;
+                width: 36px;
+                height: 36px;
+                background-color: #fff;
+            }
+
+
+            /* 对于 Webkit 浏览器(Chrome、Safari) */
+            .content::-webkit-scrollbar {
+                height: 6px;
+            }
+
+            .content::-webkit-scrollbar-track {
+                background: transparent;
+            }
+
+            .content::-webkit-scrollbar-thumb {
+                background-color: rgba(0, 0, 0, 0.2);
+                /* 滚动条滑块背景 */
+                border-radius: 10px;
+                /* 滚动条滑块圆角 */
+            }
+
+            .content::-webkit-scrollbar-thumb:hover {
+                background-color: rgba(0, 0, 0, 0.3);
+                /* 滚动条滑块悬停时的背景 */
+            }
+
+            .content {
+                max-width: 70%;
+                padding: 0 14px;
+                border-radius: 10px;
+                margin: 0 10px;
+                font-size: 14px;
+                line-height: 1.5;
+                word-break: break-word;
+                overflow-x: auto;
+
+                &.user {
+                    background-color: #4e88ff;
+                    color: white;
+                }
+
+                &.ai {
+                    background-color: #eee;
+                    color: #333;
+                }
+            }
+        }
+
+    }
+
+    .inputBox {
+        display: flex;
+        gap: 10px;
+        min-height: 15px;
+    }
+}
+
+.aiTitle {
+    display: flex;
+    font-size: 1.1em;
+    gap: 10px;
+    width: 100%;
+
+    .button {
+        position: absolute;
+        left: 75%;
+    }
+}
+</style>

+ 39 - 0
src/components/Breadcrumb/index.vue

@@ -0,0 +1,39 @@
+<template>
+ 
+    <a-breadcrumb class="container-breadcrumb">
+      <a-breadcrumb-item>
+        <icon-apps />
+      </a-breadcrumb-item>
+      <a-breadcrumb-item v-for="item in items" :key="item">
+        <span class="item">{{ item }}</span>
+      </a-breadcrumb-item>
+    </a-breadcrumb>
+
+</template>
+
+<script setup>
+defineProps({
+  items: {
+    type: [],
+    default() {
+      return [];
+    },
+  },
+});
+</script>
+
+<style scoped lang="less">
+.container-breadcrumb {
+  margin: 16px 0;
+  :deep(.arco-breadcrumb-item) {
+    color: rgb(var(--gray-6));
+    &:last-child {
+      color: rgb(var(--gray-8));
+    }
+  }
+}
+
+.item {
+  cursor: pointer;
+}
+</style>

+ 233 - 0
src/components/CanvasBackend/index.vue

@@ -0,0 +1,233 @@
+<template>
+  <div>
+    <canvas ref="webgl" class="webgl"></canvas>
+  </div>
+</template>
+
+<script>
+import * as THREE from 'three'
+
+export default {
+  name: "WebGLComponent",
+  data() {
+    return {
+      mouseX: 0,  // 鼠标的X坐标,用于追踪鼠标移动
+      mouseY: 0   // 鼠标的Y坐标
+    };
+  },
+  mounted() {
+    this.initWebGL();  // 组件加载完成后,初始化WebGL
+  },
+  methods: {
+    // 初始化WebGL的方法
+    initWebGL() {
+      const canvas = this.$refs.webgl;  // 获取canvas元素
+      canvas.width = window.innerWidth;  // 设置canvas的宽度为窗口宽度
+      canvas.height = window.innerHeight;  // 设置canvas的高度为窗口高度
+
+      const gl = canvas.getContext("webgl");  // 获取WebGL上下文
+
+      // 顶点着色器源代码
+      const vertexShaderSource = `
+        attribute vec4 position;  // 顶点位置
+        attribute float scale;  // 顶点大小
+        uniform mat4 modelViewMatrix;  // 视图矩阵
+        uniform mat4 projectionMatrix;  // 投影矩阵
+        void main() {
+          vec4 mvPosition = modelViewMatrix * position;  // 计算模型视图变换后的顶点位置
+          gl_PointSize = scale * 1.0 * (200.0 / - mvPosition.z);  // 根据深度调整点的大小
+          gl_Position = projectionMatrix * mvPosition;  // 最终的顶点位置
+        }
+      `;
+
+      // 片段着色器源代码
+      const fragShaderSource = `
+        void main() {
+          if (length(gl_PointCoord - vec2(0.5, 0.5)) > 0.49) discard;  // 如果超出圆形范围,则丢弃
+          gl_FragColor = vec4(0.5, 0.7, 1.0, 1.0);  // 设置点的颜色
+        }
+      `;
+
+      // 初始化着色器程序
+      const program = this.initShader(gl, vertexShaderSource, fragShaderSource);
+
+      // 获取着色器中属性和uniform的位置
+      const aposLocation = gl.getAttribLocation(program, 'position');
+      const scale = gl.getAttribLocation(program, 'scale');
+      const modelViewMatrixLoc = gl.getUniformLocation(program, 'modelViewMatrix');
+      const projectionMatrixLoc = gl.getUniformLocation(program, 'projectionMatrix');
+
+      // 粒子系统的参数
+      const SEPARATION = 100, AMOUNTX = 50, AMOUNTY = 50;
+      const numParticles = AMOUNTX * AMOUNTY;
+
+      // 存储粒子的位置和大小
+      const positions = new Float32Array(numParticles * 3);
+      const scales = new Float32Array(numParticles);
+
+      let i = 0, j = 0;
+      // 初始化粒子的位置和大小
+      for (let ix = 0; ix < AMOUNTX; ix++) {
+        for (let iy = 0; iy < AMOUNTY; iy++) {
+          positions[i] = ix * SEPARATION - ((AMOUNTX * SEPARATION) / 2); // x
+          positions[i + 1] = 0; // y
+          positions[i + 2] = iy * SEPARATION - ((AMOUNTY * SEPARATION) / 2); // z
+          scales[j] = 1;  // 默认大小
+          i += 3;
+          j++;
+        }
+      }
+
+      // 创建颜色缓冲区
+      const colorBuffer = gl.createBuffer();
+      gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
+      gl.bufferData(gl.ARRAY_BUFFER, scales, gl.STATIC_DRAW);
+      gl.vertexAttribPointer(scale, 1, gl.FLOAT, false, 0, 0);
+      gl.enableVertexAttribArray(scale);
+
+      // 创建位置缓冲区
+      const buffer = gl.createBuffer();
+      gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+      gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
+      gl.vertexAttribPointer(aposLocation, 3, gl.FLOAT, false, 0, 0);
+      gl.enableVertexAttribArray(aposLocation);
+
+      // 开启深度测试
+      gl.enable(gl.DEPTH_TEST);
+
+      // 初始化相机
+      const width = window.innerWidth;
+      const height = window.innerHeight;
+      const camera = new THREE.PerspectiveCamera(60, width / height, 1, 10000);
+      camera.position.set(944, 206, -262);  // 设置相机位置
+      camera.lookAt(new THREE.Vector3(0, 0, 0));  // 设置相机朝向
+      camera.updateProjectionMatrix();  // 更新相机的投影矩阵
+      camera.updateMatrixWorld(true);  // 更新相机的世界矩阵
+
+      // 将相机的投影矩阵传递给WebGL
+      const mat4 = new THREE.Matrix4();
+      mat4.copy(camera.projectionMatrix);
+      const mxArr = new Float32Array(mat4.elements);
+      gl.uniformMatrix4fv(projectionMatrixLoc, false, mxArr);
+
+      // 将相机的反向矩阵传递给WebGL
+      const mat4y = new THREE.Matrix4();
+      mat4y.copy(camera.matrixWorldInverse);
+      const myArr = new Float32Array(mat4y.elements);
+      gl.uniformMatrix4fv(modelViewMatrixLoc, false, myArr);
+
+      let count = 0;
+
+      // 动画绘制函数
+      const draw = () => {
+        // 根据鼠标位置更新相机的位置
+        camera.position.x += (this.mouseX - camera.position.x) * 0.01;
+        camera.updateMatrixWorld(true);  // 更新相机世界矩阵
+        mat4y.copy(camera.matrixWorldInverse);  // 获取更新后的相机反向矩阵
+        const myArr = new Float32Array(mat4y.elements);
+        gl.uniformMatrix4fv(modelViewMatrixLoc, false, myArr);  // 传递新的矩阵给WebGL
+
+        // 更新粒子的位置和大小
+        let i = 0, j = 0;
+        for (let ix = 0; ix < AMOUNTX; ix++) {
+          for (let iy = 0; iy < AMOUNTY; iy++) {
+            positions[i + 1] = (Math.sin((ix + count) * 0.3) * 50) + (Math.sin((iy + count) * 0.5) * 50);
+            scales[j] = (Math.sin((ix + count) * 0.3) + 1.3) * 8 + (Math.sin((iy + count) * 0.5) + 1.3) * 8;
+            i += 3;
+            j++;
+          }
+        }
+        count += 0.1;
+
+        // 更新缓冲区的数据
+        gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
+        gl.bufferData(gl.ARRAY_BUFFER, scales, gl.STATIC_DRAW);
+        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+        gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
+
+        requestAnimationFrame(draw);  // 请求下一帧绘制
+        gl.drawArrays(gl.POINTS, 0, 2500);  // 绘制粒子
+      };
+
+      draw();  // 启动绘制
+
+      // 监听鼠标和触摸事件
+      document.addEventListener('mousemove', this.onDocumentMouseMove, false);
+      document.addEventListener('touchstart', this.onDocumentTouchStart, false);
+      document.addEventListener('touchmove', this.onDocumentTouchMove, false);
+
+      // 监听窗口大小变化,更新画布和相机的投影矩阵
+      window.onresize = () => {
+        canvas.width = window.innerWidth;
+        canvas.height = window.innerHeight;
+        gl.viewport(0, 0, window.innerWidth, window.innerHeight);
+        camera.aspect = window.innerWidth / window.innerHeight;
+        camera.updateProjectionMatrix();
+        mat4.copy(camera.projectionMatrix);
+        const mxArr = new Float32Array(mat4.elements);
+        gl.uniformMatrix4fv(projectionMatrixLoc, false, mxArr);
+      };
+    },
+
+    // 初始化着色器的方法
+    initShader(gl, vertexShaderSource, fragmentShaderSource) {
+      const vertexShader = gl.createShader(gl.VERTEX_SHADER);
+      const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
+      gl.shaderSource(vertexShader, vertexShaderSource);
+      gl.shaderSource(fragmentShader, fragmentShaderSource);
+      gl.compileShader(vertexShader);
+      gl.compileShader(fragmentShader);
+      const program = gl.createProgram();
+      gl.attachShader(program, vertexShader);
+      gl.attachShader(program, fragmentShader);
+      gl.linkProgram(program);
+      gl.useProgram(program);
+      return program;
+    },
+
+    // 鼠标移动事件处理
+    onDocumentMouseMove(event) {
+      this.mouseX = event.clientX - window.innerWidth / 2;
+      this.mouseY = event.clientY - window.innerHeight / 2;
+      
+    },
+
+    // 触摸开始事件处理
+    onDocumentTouchStart(event) {
+      if (event.touches.length === 1) {
+        event.preventDefault();
+        this.mouseX = event.touches[0].pageX - window.innerWidth / 2;
+        this.mouseY = event.touches[0].pageY - window.innerHeight / 2;
+      }
+    },
+
+    // 触摸移动事件处理
+    onDocumentTouchMove(event) {
+      if (event.touches.length === 1) {
+        event.preventDefault();
+        this.mouseX = event.touches[0].pageX - window.innerWidth / 2;
+        this.mouseY = event.touches[0].pageY - window.innerHeight / 2;
+      }
+    }
+  }
+};
+</script>
+
+<style scoped>
+body {
+  padding: 0;
+  margin: 0;
+  background-color: transparent;
+  font-family: "微软雅黑";
+  overflow: hidden;
+}
+
+.webgl {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  /* height: 100%; */
+  background-color: transparent;
+  width: 100%;
+}
+</style>

+ 97 - 0
src/components/ChangeBranch/index.vue

@@ -0,0 +1,97 @@
+<template>
+    <a-drawer :visible="props.visible" class="drawer" width="520px" v-if="status" @cancel="closeChangeBranch"
+    placement="left" :footer="false">
+    <template #title><icon-swap /> 切换分支</template>
+    <a-list hoverable>
+        <a-list-item v-for="(item, index) in props.status.branches" :key="index">
+            <div class="branch-list">
+                <div class="icon">
+                    <icon-branch :size="25" />
+                </div>
+                <div class="info">
+                    <span class="title">
+                        {{ item.name }}
+                    </span>
+                    <span class="label">
+                        <a-tag size="small">{{ item.commit }}</a-tag>
+                        {{ item.label }}
+                    </span>
+                </div>
+                <div class="button">
+                    <a-button type="primary" size="small" v-if="item.name !== status.status.current"
+                        @click="ChangeBranch(item.name)">切换</a-button>
+                    <a-button type="text" size="small" v-else>当前</a-button>
+                </div>
+            </div>
+        </a-list-item>
+    </a-list>
+</a-drawer>
+</template>
+
+<script setup>
+import { Notification, Message } from '@arco-design/web-vue'
+import { useRepoStore } from '@/store'
+import { eventBus } from '@/utils/eventBus'
+
+const repoStore = useRepoStore()
+const props = defineProps({
+    id: {
+        type: Number
+    },
+    visible: {
+        type: Boolean,
+        default: false
+    },
+    status: {
+        type: Object
+    }
+})
+
+const emit = defineEmits(['changeBegin', 'changeFail', 'close'])
+
+const ChangeBranch = async (branch) => {
+    try {
+        emit('changeBegin')
+        emit('close')
+        await repoStore.ChangeBranch(props.id, branch )
+        Message.success(`已切换至分支 ${branch}`)
+        eventBus.emit('changeOK')
+    } catch (error) {
+        console.error(error)
+        Notification.error({
+            title: '切换仓库分支失败!',
+            content: error.message || '请稍后再试'
+        })
+        emit('changeFail')
+    } 
+}
+
+const closeChangeBranch = () => {
+    emit('close')
+}
+
+</script>
+
+<style lang="less" scoped>
+.branch-list {
+    display: flex;
+    min-height: 60px;
+    align-items: center;
+
+    .info {
+        display: flex;
+        flex-direction: column;
+        margin-left: 15px;
+        margin-right: 15px;
+
+
+        .title {
+            font-size: 1.3em;
+        }
+    }
+
+    .button {
+        margin-left: auto;
+    }
+}
+</style>

+ 40 - 0
src/components/Chart/index.vue

@@ -0,0 +1,40 @@
+<template>
+  <VCharts
+    v-if="renderChart"
+    :option="options"
+    :autoresize="autoResize"
+    :style="{ width, height }"
+  />
+</template>
+
+<script setup>
+  import { ref, nextTick } from 'vue'
+  import VCharts from 'vue-echarts'
+
+  defineProps({
+    options: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+    autoResize: {
+      type: Boolean,
+      default: true,
+    },
+    width: {
+      type: String,
+      default: '100%',
+    },
+    height: {
+      type: String,
+      default: '100%',
+    },
+  })
+  const renderChart = ref(false)
+  nextTick(() => {
+    renderChart.value = true
+  })
+</script>
+
+<style scoped lang="less"></style>

+ 113 - 0
src/components/CodeBlock/index.vue

@@ -0,0 +1,113 @@
+<template>
+    <div class="code-container">
+        <div class="copy" v-if="!copied" @click="copyCode">复制</div>
+        <div class="copy" v-else><icon-check-circle /> 复制成功!</div>
+        <div class="line">
+            <span class="line-number" v-for="num in line">{{ num + props.startLine - 1 }}</span>
+        </div>
+        <highlightjs :code="code" class="code" />
+    </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import hljsVuePlugin from '@highlightjs/vue-plugin'
+// import { useThemeStore } from '@/store'
+import 'highlight.js/lib/common'
+import 'highlight.js/styles/atom-one-light.css'
+
+// const themeStore = useThemeStore()
+const props = defineProps({
+    code: {
+        type: String,
+        required: true
+    },
+    startLine: {
+        type: Number,
+        default: 1
+    }
+})
+const copied = ref(false)
+
+const highlightjs = hljsVuePlugin.component
+const line = computed(() => {
+    const lines = props.code.split('\n')
+    if (lines[lines.length - 1] == '')
+        return lines.length - 1
+    return lines.length
+})
+
+function copyCode() {
+  navigator.clipboard.writeText(props.code).then(() => {
+    copied.value = true
+    setTimeout(() => {
+      copied.value = false
+    }, 2000)
+  })
+}
+
+// function loadThemeCSS(theme) {
+//     import(`highlight.js/styles/${theme}.css`) 
+// }
+
+// onMounted(() => {
+//   loadThemeCSS(themeStore.getTheme())
+// })
+
+</script>
+
+<style scoped lang="less">
+.code-container {
+    position: relative;
+    display: flex;
+    padding: 0;
+    white-space: pre;
+    overflow-x: auto;
+
+    .copy {
+        position: absolute;
+        right: 6px;
+        top: 19px;
+        background: #eee;
+        padding: 4px 10px;
+        font-size: 12px;
+        border-radius: 4px;
+        cursor: pointer;
+        display: none;
+        user-select: none;
+    }
+
+    .code {
+        line-height: 1.5em;
+        margin-bottom: 10px;
+        width: 100%;
+        overflow-x: auto;
+    }
+
+    .line {
+        display: flex;
+        flex-direction: column;
+        line-height: 1.5em;
+        background-color: #f0f0f0;
+        margin-bottom: 10px;
+        margin-top: 15px;
+        padding-top: 15px;
+
+        .line-number {
+            user-select: none;
+            display: inline-block;
+            min-width: 35px;
+            color: #888;
+            text-align: center;
+            font-family: 'Fira Code', 'JetBrains Mono', Menlo, Consolas, 'Courier New', monospace;
+        }
+    }
+}
+
+.code-container:hover {
+    .copy {
+        display: block;
+    }
+}
+
+</style>

+ 25 - 0
src/components/Footer/index.vue

@@ -0,0 +1,25 @@
+<template>
+    <div class="footer">
+        <span>© {{ new Date().getFullYear() }} CTBU_RunForge</span>
+    </div>
+</template>
+
+<style scoped>
+.footer {
+    cursor: pointer;
+    position: absolute;
+    bottom: 15px;
+    left: 50%;
+    transform: translateX(-50%);
+    color: #777;
+    font-size: 12px;
+    display: flex;
+    min-width: 300px;
+    justify-content: center;
+    gap: 10px;
+}
+
+a {
+    color: #777;
+}
+</style>

+ 107 - 0
src/components/Header/index.vue

@@ -0,0 +1,107 @@
+<template>
+    <div class="root">
+        <a-menu mode="horizontal" class="menu" :selected-keys="['99']">
+            <a-menu-item key="0"
+                :style="{ cursor: 'pointer', padding: 0, marginRight: '650px', background: 'transparent', color: props.color }"
+                disabled>
+                <div class="logo" @click="$router.push('/')">
+                    <img alt="RunForge" src="/logo.svg" height="40">
+                    <span class="title">RunForge</span>
+                </div>
+            </a-menu-item>
+
+            <a-menu-item key="4" :style="{ background: 'transparent', color: props.color }" v-if="!user"
+                @click="$router.push('/login')">
+                用户登录
+            </a-menu-item>
+            <a-sub-menu key="5" :style="{ background: 'transparent', color: props.color }" v-else>
+                <template #expand-icon-down></template>
+                <template #title>
+                    <div class="userinfo">
+                        <a-avatar :size="30"><img alt="avatar" :src="user.avatar" /></a-avatar>
+                        <span>{{ user.username }}</span>
+                    </div>
+                </template>
+                <a-menu-item key="5_0" @click="$router.push('/user')"><icon-user /> 个人中心</a-menu-item>
+                <a-menu-item key="5_1" @click="logout"><icon-export /> 退出登录</a-menu-item>
+            </a-sub-menu>
+        </a-menu>
+    </div>
+
+</template>
+
+<script setup>
+import { useUserStore } from '@/store/modules/user'
+import { ref } from 'vue'
+import { Message, Modal } from '@arco-design/web-vue'
+
+const props = defineProps({
+    color: {
+        type: String,
+        default: 'black'
+    }
+})
+
+const user = ref('')
+const getuser = async () => {
+    const userStore = useUserStore()
+    let userInfo = await userStore.getInfo()
+    if (userInfo?.avatar && userInfo?.username && userInfo?.uuid && userInfo?.session)
+        user.value = userInfo
+}
+
+const logout = () => {
+    Modal.confirm({
+        title: '退出登录',
+        content: '您即将退出登录,是否继续?',
+        onOk: () => {
+            const userStore = useUserStore()
+            userStore.logout()
+            Message.success('退出成功!')
+            setTimeout(() => { window.location.reload() }, 1000)
+        },
+        onCancel: () => {
+
+        }
+    });
+}
+
+getuser()
+
+</script>
+
+<style scoped>
+.root {
+    position: absolute;
+    top: 0;
+    left: 50%;
+    transform: translateX(-50%);
+    width: 80%;
+    min-width: 1280px;
+    z-index: 100;
+    user-select: none;
+}
+
+.menu {
+    width: 100%;
+    background: transparent;
+}
+
+.logo {
+    display: flex;
+}
+
+.logo span {
+    font-size: 1.5em;
+    margin: 5px 8px;
+}
+
+.userinfo {
+    margin-top: -15px;
+    transform: translateY(23px);
+}
+
+.userinfo span {
+    margin-left: 7px;
+}
+</style>

+ 122 - 0
src/components/Map/MapContainer.vue

@@ -0,0 +1,122 @@
+<template>
+  <div id="container"></div>
+</template>
+
+<script setup>
+import { onMounted, onUnmounted } from "vue"
+import AMapLoader from "@amap/amap-jsapi-loader"
+
+let props = defineProps({
+  pathData: {
+    type: Array,
+    default: () => ([])
+  },
+  point_list: {
+    type: Array,
+    default: () => ([])
+  },
+  threeD: {
+    type: Boolean,
+    default: false
+  }
+})
+
+let map = null
+
+onMounted(() => {
+  AMapLoader.load({
+    key: "d1f123693def8a412c976184daa4b60e",
+    version: "2.0",
+  })
+    .then((AMap) => {
+      map = new AMap.Map("container", {
+        pitch: 50, //地图俯仰角度,有效范围 0 度- 83 度
+        viewMode: props.threeD ? '3D' : '2D', //地图模式
+        rotateEnable: true, //是否开启地图旋转交互 鼠标右键 + 鼠标画圈移动 或 键盘Ctrl + 鼠标左键画圈移动
+        pitchEnable: true, //是否开启地图倾斜交互 鼠标右键 + 鼠标上下移动或键盘Ctrl + 鼠标左键上下移动
+        zoom: 17.5, //初始化地图层级
+        rotation: -15, //初始地图顺时针旋转的角度
+        zooms: [3, 20], //地图显示的缩放级别范围
+        center: props.pathData?.length > 0 ? props.pathData[0] : [106.5799475868821, 29.504864472181577],
+      })
+
+      // 添加折线
+      if (props.pathData?.length > 0) {
+        let polyline = new AMap.Polyline({
+          path: props.pathData,
+          strokeWeight: 2, //线条宽度
+          strokeColor: "red", //线条颜色
+          lineJoin: "round", //折线拐点连接处样式
+        })
+        map.add(polyline)
+
+        const beginMarker = new AMap.Marker({
+          position: props.pathData[0],
+          offset: new AMap.Pixel(-24, -48),
+          icon: "/mark/begin.png",
+          title: "起始点"
+        })
+        const endMarker = new AMap.Marker({
+          position: props.pathData[props.pathData.length - 1],
+          offset: new AMap.Pixel(-24, -48),
+          icon: "/mark/end.png",
+          title: "终点"
+        })
+        map.add([beginMarker, endMarker])
+      }
+
+      // 添加打卡点
+      console.log(props.point_list)
+      if (props.point_list?.length > 0) {
+        props.point_list.forEach((point, index) => {
+          console.log(point)
+          const marker = new AMap.Marker({
+            position: [point.longtitude, point.latitude],
+            offset: new AMap.Pixel(-24, -48),
+            icon: "/mark/daka.png",
+            title: `打卡点${index + 1}`
+          })
+          console.log('添加点')
+          map.add(marker)
+        })
+      }
+
+      AMap.plugin(['AMap.ControlBar', "AMap.ToolBar", "AMap.Scale"], function () {
+        let controlBar = new AMap.ControlBar({ //控制地图旋转插件
+          position: {
+            right: '10px',
+            top: '10px'
+          }
+        })
+        map.addControl(controlBar)
+        let toolbar = new AMap.ToolBar()
+        map.addControl(toolbar)
+        let scale = new AMap.Scale()
+        map.addControl(scale)
+      })
+
+
+    })
+    .catch((e) => {
+      console.log(e)
+    })
+
+})
+
+onUnmounted(() => {
+  map?.destroy()
+})
+</script>
+
+<style scoped>
+#container {
+  width: 100%;
+  height: 550px
+}
+
+@media only screen and (max-width: 768px) {
+  #container {
+    height: 450px
+  }
+}
+</style>

+ 74 - 0
src/components/Menu/index.vue

@@ -0,0 +1,74 @@
+<template>
+    <div class="menu">
+        <a-menu :style="{ width: '200px', height: '100%' }" v-model:selected-keys="selectedKey" auto-open-selected>
+            <template v-for="menu in menuData" :key="menu.key">
+                <a-sub-menu v-if="menu.children" :key="menu.key">
+                    <template #icon>
+                        <component :is="menu.icon"></component>
+                    </template>
+                    <template #title>{{ menu.label }}</template>
+                    <a-menu-item v-for="child in menu.children" :key="child.key" :disabled="child.disabled"
+                        @click="$router.push(child.key)">
+                        {{ child.label }}
+                    </a-menu-item>
+                </a-sub-menu>
+
+                <a-menu-item v-else :key="menu.key" :disabled="menu.disabled" @click="$router.push(menu.key)">
+                    <template #icon>
+                        <component :is="menu.icon"></component>
+                    </template>
+                    {{ menu.label }}
+                </a-menu-item>
+            </template>
+        </a-menu>
+    </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { routes } from '@/router'
+import { useUserStore } from '@/store/modules/user'
+import { useRouteListener } from '@/utils/route-listener'
+
+const userStore = useUserStore()
+const user = userStore.getInfo()
+
+const { selectedKey } = useRouteListener()
+
+// 判断用户是否有权限访问该路由
+const hasPermission = (route) => {
+    if (!route.meta || !route.meta.permission || !user.roles) return true
+    return route.meta.permission.some((perm) => user.roles.includes(perm))
+}
+
+// 递归构建菜单数据
+const generateMenu = (routes, parentPath = '') => {
+    return routes
+        .filter((route) => hasPermission(route) && !(route.meta && route.meta.hideInMenu))
+        .map((route) => {
+            const fullPath = parentPath + route.path; // 确保子路由路径完整
+            const menu = {
+                key: fullPath,
+                label: route.meta?.title || route.name,
+                icon: route.meta?.icon || '',
+                disabled: route.meta?.disabled || false
+            }
+
+            if (route.children && route.children.length > 0) {
+                menu.children = generateMenu(route.children, fullPath + '/')
+            }
+
+            return menu
+        })
+}
+
+
+const useMenuData = () => {
+    const menuData = computed(() => {
+        return generateMenu(routes)
+    })
+    return { menuData }
+}
+
+const { menuData } = useMenuData()
+</script>

+ 140 - 0
src/components/Navbar/index.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="navbar">
+    <div class="left-side">
+      <a-space style="cursor: pointer;" @click="$router.push('/')">
+        <img alt="RunForge" src="/logo.svg" height="35">
+        <a-typography-title :style="{ margin: 0, fontSize: '18px' }" :heading="5">
+          RunForge
+        </a-typography-title>
+      </a-space>
+    </div>
+
+    <ul class="right-side">
+      <li>
+        <a-tooltip content="首页">
+          <a-button class="nav-btn" type="outline" :shape="'circle'" @click="$router.push('/')">
+            <template #icon>
+              <icon-home />
+            </template>
+          </a-button>
+        </a-tooltip>
+      </li>
+
+
+      <li>
+        <a-dropdown trigger="hover">
+          <a-avatar :size="32" :style="{ marginRight: '8px', cursor: 'pointer' }">
+            <img alt="avatar" :src="avatar" />
+          </a-avatar>
+          <template #content>
+            <a-doption>
+              <a-space @click="$router.push('/user')">
+                <icon-user />
+                <span>
+                  个人中心
+                </span>
+              </a-space>
+            </a-doption>
+            <a-doption>
+              <a-space @click="handleLogout">
+                <icon-export />
+                <span>
+                  退出登录
+                </span>
+              </a-space>
+            </a-doption>
+          </template>
+        </a-dropdown>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { Message, Modal } from '@arco-design/web-vue'
+import { useUserStore } from '@/store'
+
+const userStore = useUserStore();
+
+const avatar = computed(() => {
+  return userStore.avatar;
+});
+
+const refBtn = ref()
+const triggerBtn = ref()
+
+const handleLogout = () => {
+  Modal.confirm({
+    title: '退出登录',
+    content: '您即将退出登录,是否继续?',
+    onOk: () => {
+      const userStore = useUserStore()
+      userStore.logout()
+      Message.success('退出成功!')
+      setTimeout(() => { window.location.reload() }, 1000)
+    },
+    onCancel: () => {
+
+    }
+  });
+}
+</script>
+
+<style scoped lang="less">
+.navbar {
+  display: flex;
+  justify-content: space-between;
+  height: 100%;
+  background-color: var(--color-bg-2);
+  border-bottom: 1px solid var(--color-border);
+  user-select: none;
+}
+
+.left-side {
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+}
+
+.center-side {
+  flex: 1;
+}
+
+.right-side {
+  display: flex;
+  padding-right: 30px;
+  list-style: none;
+
+  :deep(.locale-select) {
+    border-radius: 20px;
+  }
+
+  li {
+    display: flex;
+    align-items: center;
+    padding: 0 10px;
+  }
+
+  a {
+    color: var(--color-text-1);
+    text-decoration: none;
+  }
+
+  .nav-btn {
+    border-color: rgb(var(--gray-2));
+    color: rgb(var(--gray-8));
+    font-size: 16px;
+  }
+
+  .trigger-btn,
+  .ref-btn {
+    position: absolute;
+    bottom: 14px;
+  }
+
+  .trigger-btn {
+    margin-left: 14px;
+  }
+}
+</style>

+ 43 - 0
src/components/index.js

@@ -0,0 +1,43 @@
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { BarChart, LineChart, PieChart, RadarChart, HeatmapChart, GraphChart } from 'echarts/charts'
+import {
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent,
+  TitleComponent,
+  CalendarComponent,
+  VisualMapComponent,
+  ToolboxComponent
+} from 'echarts/components'
+import Chart from './Chart/index.vue'
+
+import Breadcrumb from './Breadcrumb/index.vue'
+
+use([
+  CanvasRenderer,
+  BarChart,
+  LineChart,
+  PieChart,
+  RadarChart,
+  HeatmapChart,
+  GraphChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  DataZoomComponent,
+  GraphicComponent,
+  CalendarComponent,
+  VisualMapComponent ,
+  TitleComponent,
+  ToolboxComponent
+])
+
+export default {
+  install(Vue) {
+    Vue.component('Chart', Chart)
+    Vue.component('Breadcrumb', Breadcrumb)
+  },
+}

+ 10 - 0
src/hooks/chart-option.js

@@ -0,0 +1,10 @@
+import { computed } from 'vue'
+
+export default function useChartOption(sourceOption) {
+  const chartOption = computed(() => {
+    return sourceOption()
+  })
+  return {
+    chartOption
+  }
+}

+ 181 - 0
src/layout/default-layout.vue

@@ -0,0 +1,181 @@
+<template>
+  <a-layout class="layout">
+    <a-layout-header class="layout-navbar">
+      <Navbar />
+    </a-layout-header>
+    <a-layout>
+       <a-layout-sider class="layout-sider">
+        <Menu class="menu-wrapper" />
+      </a-layout-sider>
+      <a-layout-content class="layout-content">
+        <PageLayout style="margin-bottom: 30px;"/>
+        <Footer />
+      </a-layout-content>
+    </a-layout>
+
+    <!-- <a-avatar class="aiButton" @click="chat = !chat">
+      <img alt="avatar" src="@/assets/img/ai.png" />
+    </a-avatar>
+
+    <AIChat :visible="chat"/> -->
+  </a-layout>
+</template>
+
+<script setup>
+import { onMounted, onBeforeUnmount, ref,onUnmounted } from 'vue'
+import { eventBus } from '@/utils/eventBus'
+
+import PageLayout from './page-layout.vue'
+import Menu from '@/components/Menu/index.vue'
+import Navbar from '@/components/Navbar/index.vue'
+import Footer from '@/components/Footer/index.vue'
+import AIChat from '@/components/AIChat/index.vue'
+
+const chat = ref(false)
+
+// 按钮拖动
+let isDragging = false
+let offsetX = 0
+let offsetY = 0
+let button = null
+
+function handleMouseDown(e) {
+  isDragging = true
+  const rect = button.getBoundingClientRect()
+  offsetX = e.clientX - rect.left
+  offsetY = e.clientY - rect.top
+
+  document.addEventListener('mousemove', handleMouseMove)
+  document.addEventListener('mouseup', handleMouseUp)
+}
+
+function handleMouseMove(e) {
+  if (!isDragging) return
+  const x = e.clientX - offsetX
+  const y = e.clientY - offsetY
+
+  button.style.left = x + 'px'
+  button.style.top = y + 'px'
+  button.style.right = 'auto'
+  button.style.bottom = 'auto'
+}
+
+function handleMouseUp() {
+  isDragging = false
+  document.removeEventListener('mousemove', handleMouseMove)
+  document.removeEventListener('mouseup', handleMouseUp)
+}
+
+onMounted(() => {
+  button = document.querySelector('.aiButton')
+  if (button) {
+    button.addEventListener('mousedown', handleMouseDown)
+  }
+  eventBus.on('closeAI',  ()=>{chat.value = false})
+})
+
+onBeforeUnmount(() => {
+  if (button) {
+    button.removeEventListener('mousedown', handleMouseDown)
+  }
+})
+
+onUnmounted(() => {
+    eventBus.off('closeAI', ()=>{chat.value = false})
+})
+
+</script>
+
+<style scoped lang="less">
+@nav-size-height: 60px;
+
+.layout {
+  width: 100%;
+  height: 100%;
+}
+
+.layout-navbar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 100;
+  width: 100%;
+  height: @nav-size-height;
+}
+
+.layout-sider {
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 99;
+  height: 100%;
+  transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
+
+  &::after {
+    position: absolute;
+    top: 0;
+    right: -1px;
+    display: block;
+    width: 1px;
+    height: 100%;
+    background-color: var(--color-border);
+    content: '';
+  }
+
+  > :deep(.arco-layout-sider-children) {
+    overflow-y: hidden;
+  }
+}
+
+.menu-wrapper {
+  user-select: none; 
+  margin-top: 60px;
+  overflow: auto;
+  overflow-x: hidden;
+
+  :deep(.arco-menu) {
+    ::-webkit-scrollbar {
+      width: 12px;
+      height: 4px;
+    }
+
+    ::-webkit-scrollbar-thumb {
+      border: 4px solid transparent;
+      background-clip: padding-box;
+      border-radius: 7px;
+      background-color: var(--color-text-4);
+    }
+
+    ::-webkit-scrollbar-thumb:hover {
+      background-color: var(--color-text-3);
+    }
+  }
+}
+
+.layout-content {
+  position: absolute;
+  top: @nav-size-height;
+  left: 200px;
+  width: calc(100% - 200px);
+  min-height: calc(100% - @nav-size-height);
+  overflow-y: auto;
+  background-color: var(--color-fill-2);
+  transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
+}
+
+.aiButton {
+  position: fixed;
+  bottom: 100px;
+  right: 60px;
+  z-index: 200;
+  width: 55px;
+  height: 55px;
+  background-color: #fff;
+  cursor: pointer;
+  transition: transform 0.3s ease;
+
+  &:hover {
+    transform: scale(1.15);
+  }
+}
+</style>

+ 10 - 0
src/layout/page-layout.vue

@@ -0,0 +1,10 @@
+<template>
+  <div class="root">
+    <router-view />
+  </div>
+  
+</template>
+
+<style scoped>
+
+</style>

+ 13 - 0
src/main.js

@@ -0,0 +1,13 @@
+import { createApp } from 'vue'
+import './style.css'
+
+import ArcoVueIcon from '@arco-design/web-vue/es/icon'
+
+import App from './App.vue'
+import { router } from './router/'
+import { createPinia } from 'pinia'
+import globalComponents from '@/components'
+import DataVVue3 from '@kjgl77/datav-vue3'
+
+const app = createApp(App)
+app.use(ArcoVueIcon).use(router).use(createPinia()).use(globalComponents).use(DataVVue3).mount('#app')

+ 38 - 0
src/pages/Login/Login.vue

@@ -0,0 +1,38 @@
+<template>
+    <div class="root">
+        <Header />
+        <Container />
+        <CanvasBackend />
+        <Footer />
+    </div>
+</template>
+
+<script setup>
+import CanvasBackend from '@/components/CanvasBackend/index.vue'
+import Header from '@/components/Header/index.vue'
+import Container from './components/container.vue'
+import Footer from '@/components/Footer/index.vue'
+</script>
+
+<style scoped>
+.root {
+    text-align: center;
+}
+
+.center {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: 800px;
+    height: 300px;
+    z-index: 2;
+    transform: translate(-50%, -50%);
+    pointer-events: auto; /* center 可以接收鼠标事件 */
+}
+
+.button {
+    position: absolute;
+    z-index: 101;
+}
+
+</style>

+ 79 - 0
src/pages/Login/components/container.vue

@@ -0,0 +1,79 @@
+<template>
+    <div class='center'>
+        <login v-if="current === 'login'" @changeMode="changeMode" />
+        <register v-else-if="current === 'register'" @changeMode="changeMode" />
+        <uni-login v-else-if="current === 'uniLogin'" @changeMode="changeMode"></uni-login>
+    </div>
+</template>
+
+<script setup>
+import login from './login.vue'
+import register from './register.vue'
+import uniLogin from '../uniLogin/uniLogin.vue'
+import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+
+let current = ref('uniLogin')
+
+const changeMode = (mode) => {
+    document.querySelector('.center').className = `center ${mode}`
+    current.value = mode
+}
+
+onMounted(() => {
+    if(route.query.mode) {
+        changeMode(route.query.mode)
+    }
+})
+
+</script>
+
+<style scoped>
+.center {
+    background-image: linear-gradient(to bottom,
+            rgba(235, 242, 255, 0.7),
+            rgba(210, 230, 255, 0.5),
+            rgba(200, 225, 255, 0.3),
+            rgba(245, 255, 255, 0.1));
+
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    /* background-color: rgba(190, 218, 255, 0.2); */
+    display: flex;
+    justify-content: center;
+    border-radius: 10px;
+    padding: 110px 10px;
+}
+
+@keyframes registAnimation {
+    0% {
+        padding: 140px 10px;
+    }
+
+    100% {
+        padding: 180px 10px;
+    }
+}
+
+@keyframes loginAnimation {
+    0% {
+        padding: 180px 10px;
+    }
+
+    100% {
+        padding: 140px 10px;
+    }
+}
+
+.register {
+    animation: registAnimation 0.3s ease-in-out forwards;
+}
+
+.login {
+    animation: loginAnimation 0.5s ease-in-out forwards;
+}
+</style>

+ 167 - 0
src/pages/Login/components/login.vue

@@ -0,0 +1,167 @@
+<template>
+    <div class="root">
+        <div class="logo">
+            <img alt="RunForge" src="/logo.svg" height="40">
+            <span class="title">RunForge | 用户登录</span>
+        </div>
+
+        <a-form size="large" :model="form" :rules="[]" class="form" @submit="handleSubmit">
+            <a-form-item field="username" hide-label>
+                <a-input placeholder="请输入用户名" allow-clear v-model="form.username">
+                    <template #prefix>
+                        <icon-user />
+                    </template>
+                </a-input>
+            </a-form-item>
+
+            <a-form-item field="password" hide-label>
+                <a-input-password placeholder="请输入密码" allow-clear v-model="form.password">
+                    <template #prefix>
+                        <icon-lock />
+                    </template>
+                </a-input-password>
+            </a-form-item>
+
+            <a-form-item field="captcha" hide-label>
+                <a-input placeholder="请输入验证码" allow-clear v-model="form.captcha">
+                    <template #prefix>
+                        <icon-check-circle />
+                    </template>
+                    <template #append>
+                        <img alt="!点我重试" :src="ImageCaptcha" class="captcha" @click="getCaptcha()">
+                    </template>
+                </a-input>
+            </a-form-item>
+
+            <div class="forgetpass">
+                <a-button type="text" @click="emit('changeMode', 'uniLogin')">快捷登录</a-button>
+                <a-button type="text" @click="emit('changeMode', 'register')">注册账号</a-button>
+            </div>
+
+            <a-button :style="{ marginTop: '15px' }" class="formitem" type="primary" html-type="submit"
+                :loading="loading">登录</a-button>
+        </a-form>
+    </div>
+
+</template>
+
+<script setup>
+import { getImageCaptcha } from '@/api/login'
+import { Notification } from '@arco-design/web-vue'
+import { ref, reactive } from 'vue'
+import { useUserStore } from '@/store/modules/user'
+import { timeFix } from '@/utils/util'
+import { useRoute, useRouter } from 'vue-router'
+
+const route = useRoute()
+const router = useRouter()
+const emit = defineEmits(['changeMode'])
+
+const from = route.query.from
+
+const userStore = useUserStore()
+
+let CaptchaId = ref('')
+let ImageCaptcha = ref('')
+let loading = ref(false)
+let form = reactive({
+    username: '',
+    password: '',
+    captcha: ''
+})
+
+const getCaptcha = async () => {
+    try {
+        const res = await getImageCaptcha()
+        if (!res || res.code != 0)
+            return requestFailed('获取图片验证码失败!' + res?.msg || '')
+        ImageCaptcha.value = res.data.img
+        CaptchaId.value = res.data.id
+    } catch (error) {
+        requestFailed('获取图片验证码失败!')
+    }
+}
+
+getCaptcha()
+
+const handleSubmit = async ({ values, errors }) => {
+    if (!values.username || !values.password || !values.captcha)
+        return requestFailed('请填写所有信息')
+
+    loading.value = true
+    let data = { ...values, id: CaptchaId.value }
+    data.password = btoa(data.password)
+
+    userStore.login(data)
+        .then((res) => { loginSuccess(res) })
+        .catch((err) => {
+            getCaptcha()
+            requestFailed(err.message)
+        })
+        .finally(() => {
+            form.captcha = ''
+            loading.value = false
+        })
+}
+
+const loginSuccess = (res) => {
+    Notification.success({
+        title: `${timeFix()},${res.username}`,
+        content: '欢迎回来!',
+        duration: 2000
+    })
+    // setTimeout(() => { router.push(from || '/')}, 2000)
+    router.push(from || '/')
+}
+
+const requestFailed = (msg) => {
+    Notification.error({
+        title: '错误',
+        content: msg || '请求出现错误,请稍后再试',
+    })
+}
+</script>
+
+<style scoped>
+.root {
+    display: flex;
+    gap: 40px;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+}
+
+.form {
+    width: 100%;
+}
+
+.formitem {
+    width: 350px;
+    min-height: 35px
+}
+
+.logo {
+    display: flex
+}
+
+.logo .title {
+    color: #3370FF;
+    font-size: 1.8em;
+    font-weight: bold;
+    margin-left: 15px;
+    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
+}
+
+.forgetpass {
+    width: 100%;
+    margin-top: -10px;
+    display: flex;
+    justify-content: space-between;
+}
+
+.captcha {
+    max-height: 35px;
+    margin-right: -10px;
+
+}
+</style>

+ 236 - 0
src/pages/Login/components/register.vue

@@ -0,0 +1,236 @@
+<template>
+    <div class="root">
+        <div class="logo">
+            <img alt="RunForge" src="/logo.svg" height="40">
+            <span class="title">RunForge | 用户注册</span>
+        </div>
+
+        <a-form size="large" ref="formRef" :model="form" :rules="[]" class="form" @submit="handleSubmit">
+            <a-form-item field="username" hide-label
+                :rules="[{ required: true, message: '用户名不能为空' }, { minLength: 4, maxLength: 12, message: '用户名长度需在4~12位间' }]"
+                :validate-trigger="['change']">
+                <a-input placeholder="请输入用户名" allow-clear v-model="form.username">
+                    <template #prefix>
+                        <icon-user />
+                    </template>
+                </a-input>
+            </a-form-item>
+
+            <a-form-item field="password" hide-label
+                :rules="[{ required: true, message: '密码不能为空' }, { maxLength: 16, minLength: 8, message: '密码长度需在8~16位之间' }]"
+                :validate-trigger="['change']">
+                <a-input-password placeholder="请输入密码" allow-clear v-model="form.password">
+                    <template #prefix>
+                        <icon-lock />
+                    </template>
+                </a-input-password>
+            </a-form-item>
+
+            <a-form-item field="password2" hide-label
+                :rules="[{ required: true, message: '请再次输入密码' }, { maxLength: 16, minLength: 8, message: '密码长度需在8~16位之间' }]"
+                :validate-trigger="['change']">
+                <a-input-password placeholder="请再次输入密码" allow-clear v-model="form.password2">
+                    <template #prefix>
+                        <icon-lock />
+                    </template>
+                </a-input-password>
+            </a-form-item>
+
+            <a-form-item field="email" hide-label :rules="[{ type: 'email', required: true, message: '请填写正确的邮箱地址' }]"
+                :validate-trigger="['change']">
+                <a-input placeholder="请输入邮箱" allow-clear v-model="form.email">
+                    <template #prefix>
+                        <icon-email />
+                    </template>
+                </a-input>
+            </a-form-item>
+
+
+            <a-form-item field="captcha" hide-label :rules="[{ length: 4, required: true, message: '请正确填写图片验证码' }]">
+                <a-input placeholder="请输入图片验证码" allow-clear v-model="form.captcha">
+                    <template #prefix>
+                        <icon-check-circle />
+                    </template>
+                    <template #append>
+                        <img alt="!点我重试" :src="ImageCaptcha" class="captcha" @click="getCaptcha()">
+                    </template>
+                </a-input>
+            </a-form-item>
+
+            <a-form-item field="code" hide-label :rules="[{ length: 6, required: true, message: '请正确填写邮箱验证码' }]">
+                <a-input placeholder="请输入邮箱验证码" allow-clear v-model="form.code">
+                    <template #prefix>
+                        <icon-code-square />
+                    </template>
+                    <template #append>
+                        <a-button type="text" style="width: 80px" @click="SendEmail" :disabled="state.smsSendBtn">
+                            <span v-if="!state.smsSendBtn">获取验证码</span>
+                            <span v-else>{{ state.time }} s</span>
+                        </a-button>
+                    </template>
+                </a-input>
+            </a-form-item>
+
+            <a-button type="text" class="forgetpass" @click="emit('changeMode', 'login')">已有账号,去登录</a-button>
+
+            <a-button :style="{ marginTop: '15px' }" class="formitem" type="primary" html-type="submit"
+                :loading="state.okButton">立即注册</a-button>
+        </a-form>
+    </div>
+
+</template>
+
+<script setup>
+import { getImageCaptcha, sendEmail, register } from '@/api/login'
+import { Notification, Message } from '@arco-design/web-vue';
+import { ref, reactive, defineEmits } from 'vue'
+
+const emit = defineEmits(['changeMode'])
+
+const formRef = ref(null)
+let CaptchaId = ref('')
+let ImageCaptcha = ref('')
+let form = reactive({
+    username: '',
+    password: '',
+    password2: '',
+    email: '',
+    code: '',
+    captcha: ''
+})
+let state = reactive({
+    smsSendBtn: false,
+    time: 60,
+    okButton: false
+})
+
+const SendEmail = async () => {
+    try {
+        const v = await formRef.value.validateField(['email', 'captcha'])
+        if (v) return
+
+        let email = form.email
+        let text = form.captcha
+
+        state.smsSendBtn = true
+        const interval = window.setInterval(() => {
+            if (state.time-- <= 0) {
+                state.time = 60
+                state.smsSendBtn = false
+                window.clearInterval(interval)
+            }
+        }, 1000)
+
+        Message.loading('验证码发送中..')
+
+        const res = await sendEmail({ email, text, id: CaptchaId.value, type: 'register' })
+
+        if (!res || res.code != 0) {
+            state.time = 60
+            state.smsSendBtn = false
+            window.clearInterval(interval)
+            getImageCaptcha()
+            form.captcha = ''
+
+            if (res.code === -10501) {
+                return Notification.error({
+                    title: '验证码输入错误',
+                    content: res?.msg || '请求出现错误,请稍后再试'
+                })
+            }
+
+            return Notification.error({
+                title: '获取验证码失败',
+                content: res?.msg || '请求出现错误,请稍后再试'
+            })
+        }
+    } catch (error) {
+        Message.error('验证码发送失败')
+    }
+}
+
+const getCaptcha = async () => {
+    try {
+        const res = await getImageCaptcha()
+        if (!res || res.code != 0)
+            return requestFailed('获取图片验证码失败!' + res?.msg || '')
+        ImageCaptcha.value = res.data.img
+        CaptchaId.value = res.data.id
+    } catch (error) {
+        requestFailed('获取图片验证码失败!')
+    }
+}
+
+getCaptcha()
+
+const handleSubmit = async ({ values, errors }) => {
+    try {
+        state.okButton = true
+        const v = await formRef.value.validate()
+        if (v) return
+        let data = { ...values }
+        if (data.password !== data.password2) return Message.error('请确保两次输入的密码一致!')
+
+        data.password = btoa(data.password)
+        const res = await register(data)
+        if(!res || res.code !== 0)
+            return requestFailed(res?.msg)
+        Message.success('注册成功!请登录')
+        emit('changeMode', 'login')
+    } catch (error) {
+        requestFailed(error.message)
+    } finally {
+        state.okButton = false
+    }
+}
+
+const requestFailed = (msg) => {
+    Notification.error({
+        title: '错误',
+        content: msg || '请求出现错误,请稍后再试',
+    })
+}
+</script>
+
+<style scoped>
+.root {
+    display: flex;
+    gap: 40px;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    margin: 20px
+}
+
+.form {
+    width: 100%;
+}
+
+.formitem {
+    width: 350px;
+    min-height: 35px
+}
+
+.logo {
+    display: flex
+}
+
+.logo .title {
+    color: #3370FF;
+    font-size: 1.8em;
+    font-weight: bold;
+    margin-left: 15px;
+    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
+}
+
+.forgetpass {
+    width: 130px;
+    margin-top: -10px;
+}
+
+.captcha {
+    max-height: 35px;
+    margin-right: -10px;
+
+}
+</style>

+ 183 - 0
src/pages/Login/uniLogin/uniLogin.vue

@@ -0,0 +1,183 @@
+<template>
+    <div class="root">
+        <div class="logo">
+            <img alt="RunForge" src="/logo.svg" height="40">
+            <span class="title">RunForge | 快捷登录</span>
+        </div>
+
+        <a-form size="large" :model="form" :rules="[]" class="form" @submit="handleSubmit">
+            <div class="uniLoginButton">
+                <div class="button">
+                    <a-tooltip content="QQ登录" mini>
+                        <a-avatar :size="64" :style="{ backgroundColor: '#3370ff' }"
+                            @click="GetLoginUrl('qq')"><icon-qq /></a-avatar>
+                    </a-tooltip>
+                </div>
+                <div class="button">
+                    <a-tooltip content="微信登录" mini>
+                        <a-avatar :size="64" :style="{ backgroundColor: '#3370ff' }"
+                            @click="GetLoginUrl('wx')"><icon-wechat /></a-avatar>
+                    </a-tooltip>
+                </div>
+            </div>
+            <div class="tip">未注册的账号登录后将自动注册<br>不同登录方式的账号资产和权限互不共享</div>
+            <a-button type="text" class="forgetpass" @click="emit('changeMode', 'login')">账号密码登录</a-button>
+        </a-form>
+    </div>
+
+    <div class="loading" v-if="loading">
+        <div class="loadingTip">
+            <icon-loading :size="32" />
+            <div class="text">努力加载中</div>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { getLoginUrl } from '@/api/login'
+import { timeFix } from '@/utils/util'
+import { useRoute, useRouter } from 'vue-router'
+import { ref, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { useUserStore } from '@/store/modules/user'
+
+const userStore = useUserStore()
+
+const loading = ref(false)
+const route = useRoute()
+const router = useRouter()
+const emit = defineEmits(['changeMode', 'changeLoading'])
+
+const from = route.query.from
+
+const GetLoginUrl = async (type) => {
+    try {
+        changeLoading(true)
+        const res = await getLoginUrl({ type })
+        if (!res || res.code != 0)
+            return requestFailed('获取登录链接失败!' + res?.msg || '')
+        window.location.href = res.data
+    } catch (error) {
+        requestFailed('获取登录链接失败!')
+    } finally {
+        changeLoading(false)
+    }
+}
+
+const UniLogin = async (type, code) => {
+    try {
+        changeLoading(true)
+        userStore.uniLogin(type, code)
+            .then((res) => { loginSuccess(res) })
+            .catch((err) => {
+                requestFailed(err.message)
+            })
+            .finally(() => {
+                changeLoading(false)
+            })
+    } catch (error) {
+        requestFailed('登录失败!请稍后再试')
+    }
+}
+
+const changeLoading = (value) => {
+    loading.value = value
+}
+
+const loginSuccess = (res) => {
+    Notification.success({
+        title: `${timeFix()},${res.username}`,
+        content: '欢迎回来!',
+        duration: 2000
+    })
+    router.push(from || '/')
+}
+
+const requestFailed = (msg) => {
+    Notification.error({
+        title: '错误',
+        content: msg || '请求出现错误,请稍后再试',
+    })
+}
+
+onMounted(() => {
+    if (route.query.type && route.query.code) {
+        UniLogin(route.query.type, route.query.code)
+    }
+})
+
+</script>
+
+<style lang="less" scoped>
+.loading {
+    position: fixed;
+    top: 0;
+    left: 0;
+    height: 100%;
+    width: 100%;
+    background-color: rgba(0, 0, 0, 0.2);
+    z-index: 9999;
+    border-radius: 10px;
+
+    .loadingTip {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        color: #3370FF;
+
+        .text {
+            font-family: 'Alimama ShuHeiTi';
+            font-size: 1.2em;
+        }
+    }
+}
+
+.root {
+    display: flex;
+    gap: 40px;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+}
+
+.form {
+    width: 100%;
+}
+
+.logo {
+    display: flex;
+
+    .title {
+        color: #3370FF;
+        font-size: 1.8em;
+        font-weight: bold;
+        margin-left: 15px;
+        font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
+    }
+}
+
+.tip {
+    color: #777;
+    font-size: 0.9em;
+    text-align: left;
+    margin-bottom: 20px;
+    margin-left: -8px;
+}
+
+.uniLoginButton {
+    display: flex;
+    gap: 40px;
+    justify-content: center;
+    height: 100px;
+
+    .button {
+        cursor: pointer;
+    }
+}
+
+.forgetpass {
+    width: 70px;
+    margin-top: -10px;
+}
+</style>

+ 127 - 0
src/pages/Main/Main.vue

@@ -0,0 +1,127 @@
+<template>
+    <div id="section1" class="section root">
+        <div class="videocontainer">
+            <video class="fullscreenvideo" playsinline="" autoplay="" muted="" loop=""
+                src="https://www.gitnexus.cn/assets/background-CL3lUlc-.webm" type="video/mp4" />
+            <div class="overlay" />
+        </div>
+        <Header color="white" />
+        <Center />
+
+        <a-button shape="circle" class="floating-button" size="large" @click="scrollToSection('section2')">
+            <IconDown />
+        </a-button>
+    </div>
+
+    <div id="section2" class="section">
+        <Section2 />
+    </div>
+    <div class="footer">
+        <span>© {{ new Date().getFullYear() }} CTBU_RunForge</span>
+    </div>
+
+</template>
+
+<script setup>
+import Header from '@/components/Header/index.vue'
+import Center from './components/center.vue'
+import Section2 from './components/section2.vue'
+
+const scrollToSection = (sectionId) => {
+    const section = document.getElementById(sectionId);
+    if (section) {
+        section.scrollIntoView({ behavior: 'smooth' });
+    }
+};
+
+</script>
+
+<style lang="less" scoped>
+.section {
+    padding: 0;
+    margin: 0;
+    width: 100%;
+    min-height: 100vh;
+    scroll-snap-align: start;
+    position: relative;
+}
+
+.root {
+    text-align: center;
+}
+
+.fullscreenvideo {
+    position: absolute;
+    min-width: 100%;
+    min-height: 100%;
+    width: auto;
+    height: auto;
+    z-index: -100;
+    left: 50%;
+    transform: translateX(-50%);
+}
+
+.videocontainer {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    z-index: -100;
+}
+
+.overlay {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    background-color: rgba(0, 0, 0, 0.4);
+    z-index: -99;
+}
+
+.center {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: 800px;
+    height: 300px;
+    z-index: 2;
+    transform: translate(-50%, -50%);
+    pointer-events: auto;
+    /* center 可以接收鼠标事件 */
+}
+
+.button {
+    position: absolute;
+    z-index: 101;
+}
+
+.floating-button {
+    background-color: rgba(255, 255, 255, 0.5);
+    position: absolute;
+    bottom: 50px;
+    left: 50%;
+    transform: translateX(-50%);
+    border-radius: 50%;
+    border: none;
+    padding: 10px;
+    transition: background-color 0.3s;
+}
+
+.footer {
+    cursor: pointer;
+    position: relative;
+    bottom: 15px;
+    left: 50%;
+    transform: translateX(-50%);
+    color: #777;
+    font-size: 12px;
+    display: flex;
+    min-width: 300px;
+    justify-content: center;
+    gap: 10px;
+
+
+    a {
+        color: #777;
+    }
+}
+</style>

+ 106 - 0
src/pages/Main/components/center.vue

@@ -0,0 +1,106 @@
+<template>
+    <div class="center">
+        <h1>RunForge</h1>
+        <h2>—— 让跑步的意义由技术重新定义</h2>
+        <div class="animated-button" @click="$router.push('/lepao')">
+            <span>立即体验</span>
+        </div>
+    </div>
+</template>
+
+<style scoped>
+.center {
+    margin-top: -200px;
+}
+
+.center h1,
+.center h2 {
+    background: linear-gradient(270deg,
+        #a8cfff,
+        #d0e7ff,
+        #ffffff,
+        #a8cfff);
+    background-size: 500% 500%;
+    animation: gradientMove 6s ease infinite;
+    -webkit-background-clip: text;
+    background-clip: text;
+    -webkit-text-fill-color: transparent;
+}
+
+/* h1 特别设置字号和字体 */
+.center h1 {
+    font-size: 10em;
+    line-height: 1.3em;
+    font-family: AlibabaSans, -apple-system, BlinkMacSystemFont;
+}
+
+/* h2 特别设置字号和字体 */
+.center h2 {
+    font-size: 2.5em;
+    margin-top: -50px;
+    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
+}
+
+/* 动态渐变动画 */
+@keyframes gradientMove {
+    0% {
+        background-position: 0% 50%;
+    }
+
+    50% {
+        background-position: 100% 50%;
+    }
+
+    100% {
+        background-position: 0% 50%;
+    }
+}
+
+
+.animated-button {
+    user-select: none;
+    cursor: pointer;
+    position: absolute;
+    left: 50%;
+    margin-top: -50px;
+    transform: translateX(-50%);
+    background-color: rgba(22, 93, 255, 0.8);
+    color: #fff;
+    border: none;
+    border-radius: 5px;
+    padding: 10px 30px;
+    overflow: hidden;
+    font-size: 20px;
+    position: relative;
+    transition: color 0.4s;
+    margin-top: 160px;
+    width: 100px;
+    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
+}
+
+.animated-button::before {
+    content: "";
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(255, 255, 255, 0.8);
+    color: rgba(22, 93, 255, 0.8);
+    transform: translateX(-100%);
+    transition: transform 0.4s;
+}
+
+.animated-button:hover::before {
+    transform: translateX(0);
+}
+
+.animated-button:hover {
+    color: rgb(22, 93, 255);
+}
+
+.animated-button span {
+    position: relative;
+    transition: color 0.1s;
+}
+</style>

+ 145 - 0
src/pages/Main/components/section2.vue

@@ -0,0 +1,145 @@
+<template>
+    <div id="section2" class="features-section">
+        <CanvasBackend />
+        <div v-for="(card, index) in cards" :key="index" :ref="el => cardRefs[index] = el" class="card"
+            :class="[{ 'in-view': inView[index] }, { 'reverse': card.reverse }]">
+            <div class="content">
+                <div class="title">
+                    <img :src="card.icon" />
+                    {{ card.title }}
+                </div>
+                <div class="dec">{{ card.dec }}</div>
+            </div>
+            <div class="img">
+                <img :src="card.img" />
+            </div>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+import CanvasBackend from '@/components/CanvasBackend/index.vue'
+
+const cards = [
+    {
+        title: '全自动乐跑体验',
+        icon: new URL('@/assets/img/homePage/icons/visualization.svg', import.meta.url).href,
+        dec: '自动记录跑步数据,无需手动操作,轻松完成校园任务',
+        img: new URL('https://cloud.vite.net.cn/view.php/6cf4d3d8f5d9e6b41c525541c8949329.png').href,
+        reverse: false
+    },
+    {
+        title: '智能成绩上传系统',
+        icon: new URL('@/assets/img/homePage/icons/ai.svg', import.meta.url).href,
+        dec: '支持夜间上传与异常修复,忘跑也能及时补救,成绩无忧',
+        img: new URL('https://cloud.vite.net.cn/view.php/6cf4d3d8f5d9e6b41c525541c8949329.png').href,
+        reverse: true
+    },
+    {
+        title: '自由跑区与贴心服务',
+        icon: new URL('@/assets/img/homePage/icons/quality.svg', import.meta.url).href,
+        dec: '支持自定义跑步区域,注册即送免费乐跑,体验便捷又人性化',
+        img: new URL('https://cloud.vite.net.cn/view.php/6cf4d3d8f5d9e6b41c525541c8949329.png').href,
+        reverse: false
+    }
+]
+
+
+const cardRefs = ref([])
+const inView = ref(cards.map(() => false)) // 初始化每个卡片的状态
+
+let observers = []
+
+onMounted(() => {
+    cardRefs.value.forEach((el, index) => {
+        const observer = new IntersectionObserver(
+            ([entry]) => {
+                if (entry.isIntersecting) {
+                    inView.value[index] = true
+                    observer.disconnect()
+                }
+            },
+            { threshold: 0.4 }
+        )
+        if (el) observer.observe(el)
+        observers.push(observer)
+    })
+})
+
+onUnmounted(() => {
+    observers.forEach(observer => observer.disconnect())
+})
+
+</script>
+
+<style lang="less" scoped>
+.features-section {
+    position: relative;
+    background-image: linear-gradient(to bottom, #ebf2ff, hsla(0, 0%, 100%, 0));
+    padding: 80px 20px;
+    display: flex;
+    gap: 50px;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+
+    .reverse {
+        flex-direction: row-reverse;
+        background: linear-gradient(346deg, #ebf2ff, #d6e0ff) !important;
+    }
+
+    .card {
+        width: 1200px;
+        display: flex;
+        gap: 50px;
+        align-items: center;
+        justify-content: center;
+        background: linear-gradient(135deg, #ebf2ff, #d6e0ff);
+        border-radius: 10px;
+        height: 400px;
+        opacity: 0;
+        transform: translateY(100px);
+        transition: all 0.8s ease-out;
+
+        &.in-view {
+            opacity: 1;
+            transform: translateY(0);
+        }
+
+        .content {
+            width: 500px;
+
+            .title {
+                font-weight: bold;
+                font-size: 2.5em;
+                font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
+                display: flex;
+                align-items: center;
+                gap: 10px;
+            }
+
+            .dec {
+                margin-top: 15px;
+                font-size: 1.4em;
+                line-height: 35px;
+                color: rgb(102, 102, 102);
+            }
+        }
+
+        img {
+            max-width: 500px;
+            user-select: none;
+        }
+    }
+
+    // 动画定义
+    @keyframes slideFadeIn {
+        to {
+            opacity: 1;
+            transform: translateY(0);
+        }
+    }
+
+}
+</style>

+ 88 - 0
src/pages/User/info/components/latest-activity.vue

@@ -0,0 +1,88 @@
+<template>
+  <a-card class="general-card" :title="$t('userInfo.title.latestActivity')">
+    <template #extra>
+      <a-link>{{ $t('userInfo.viewAll') }}</a-link>
+    </template>
+    <a-list :bordered="false">
+      <a-list-item
+        v-for="activity in activityList"
+        :key="activity.id"
+        action-layout="horizontal"
+      >
+        <a-skeleton
+          v-if="loading"
+          :loading="loading"
+          :animation="true"
+          class="skeleton-item"
+        >
+          <a-row :gutter="6">
+            <a-col :span="2">
+              <a-skeleton-shape shape="circle" />
+            </a-col>
+            <a-col :span="22">
+              <a-skeleton-line :widths="['40%', '100%']" :rows="2" />
+            </a-col>
+          </a-row>
+        </a-skeleton>
+        <a-list-item-meta
+          v-else
+          :title="activity.title"
+          :description="activity.description"
+        >
+          <template #avatar>
+            <a-avatar>
+              <img :src="activity.avatar" />
+            </a-avatar>
+          </template>
+        </a-list-item-meta>
+      </a-list-item>
+    </a-list>
+  </a-card>
+</template>
+
+<script setup>
+  import { ref } from 'vue';
+  import { queryLatestActivity } from '@/api/user-center';
+  import useLoading from '@/hooks/loading';
+
+  const { loading, setLoading } = useLoading(true);
+  const activityList = ref([])
+  const fetchData = async () => {
+    try {
+      const { data } = await queryLatestActivity();
+      activityList.value = data;
+    } catch (err) {
+      // you can report use errorHandler or other
+    } finally {
+      setLoading(false);
+    }
+  };
+  fetchData();
+</script>
+
+<style scoped lang="less">
+  .latest-activity {
+    &-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+  }
+  .general-card :deep(.arco-list-item) {
+    padding-left: 0;
+    border-bottom: none;
+    .arco-list-item-meta-content {
+      flex: 1;
+      padding-bottom: 27px;
+      border-bottom: 1px solid var(--color-neutral-3);
+    }
+    .arco-list-item-meta-avatar {
+      padding-bottom: 27px;
+    }
+    .skeleton-item {
+      margin-top: 10px;
+      padding-bottom: 20px;
+      border-bottom: 1px solid var(--color-neutral-3);
+    }
+  }
+</style>

+ 112 - 0
src/pages/User/info/components/my-project.vue

@@ -0,0 +1,112 @@
+<template>
+  <a-card class="general-card" title="我的仓库">
+    <template #extra>
+      <a-link @click="$router.push('/repos/list')">查看更多</a-link>
+    </template>
+    <a-row :gutter="16">
+      <a-col v-for="(item, index) in repos" :key="index" :xs="12" :sm="12" :md="12" :lg="12" :xl="8" :xxl="8">
+        <a-card class="card" hoverable  @click="$router.push(`/repos/detail/${item.id}`)">
+          <a-skeleton v-if="loading" :loading="loading" :animation="true">
+            <a-skeleton-line :rows="3" />
+          </a-skeleton>
+          <div v-else>
+            <a-avatar shape="square" :size="45" class="logo">
+              <img alt="" src="@/assets/repo.svg" v-if="!item.logo" />
+              <img alt="" :src="item.logo" v-else />
+            </a-avatar>
+            <div class="right">
+              <div class="name">{{ item.name }}</div>
+              <div class="info">
+                <div>
+                  创建时间:{{ stramptoTime(item.create_time) }}
+                </div>
+                <div>
+                  更新时间:{{ stramptoTime(item.update_time) }}
+                </div>
+              </div>
+            </div>
+          </div>
+        </a-card>
+      </a-col>
+    </a-row>
+  </a-card>
+</template>
+
+<script setup>
+import { Notification } from '@arco-design/web-vue'
+import { ref } from 'vue'
+import { GetRepos } from '@/api/user'
+
+const loading = ref(false)
+const repos = ref([])
+
+const GetRepo = async () => {
+  try {
+    loading.value = true
+    const res = await GetRepos()
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取仓库列表失败!',
+        content: res?.msg || '请稍后再试'
+      })
+    repos.value = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取仓库列表失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+GetRepo()
+
+const stramptoTime = (time) => {
+  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+
+</script>
+
+<style scoped lang="less">
+.general-card {
+  border-radius: 5px;
+}
+
+:deep(.arco-card-body) {
+  min-height: 128px;
+  padding-bottom: 0;
+}
+
+.card {
+  margin-bottom: 15px;
+  height: 130px;
+  display: flex;
+  border-radius: 3px;
+  cursor: pointer;
+
+  .logo {
+    user-select: none;
+    background-color: rgba(0, 0, 0, 0);
+    position: absolute;
+    top: 50%;
+    transform: translateY(-50%);
+  }
+
+  .right {
+    margin-left: 60px;
+    margin-top: 10px;
+
+    .name {
+      font-size: 1.2em;
+      font-weight: 550;
+    }
+
+    .info {
+      color: #666;
+      margin-top: 10px;
+      font-size: 0.9em;
+    }
+  }
+
+}
+</style>

+ 72 - 0
src/pages/User/info/components/user-info-header.vue

@@ -0,0 +1,72 @@
+<template>
+  <div class="header">
+    <a-space :size="12" direction="vertical" align="center" class="center">
+      <a-avatar :size="80" :imgUrl="user.avatar">
+        <img :src="user.avatar" />
+      </a-avatar>
+      <a-typography-title :heading="5" style="margin: 0">
+        {{ user.username }}
+      </a-typography-title>
+      <div class="user-msg">
+        <a-space :size="18">
+          <div>
+            <icon-email />
+            <a-typography-text>{{ user.email }}</a-typography-text>
+          </div>
+        </a-space>
+      </div>
+    </a-space>
+  </div>
+</template>
+
+<script setup>
+import { useUserStore } from '@/store/modules/user'
+import { ref } from 'vue'
+
+const user = ref('')
+const getuser = async () => {
+    const userStore = useUserStore()
+    let userInfo = await userStore.getInfo()
+    user.value = userInfo
+}
+
+getuser()
+</script>
+
+<style scoped lang="less">
+  .header {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    height: 204px;
+    color: var(--gray-10);
+    background: url('@/assets/userinfo-background.jpg')
+      no-repeat;
+    background-size: cover;
+    background-position: center;
+    border-radius: 4px;
+
+    .user-msg {
+      .arco-icon {
+        color: rgb(var(--gray-10));
+      }
+      .arco-typography {
+        margin-left: 6px;
+      }
+    }
+  }
+
+  .header::after {
+    content: '';
+    height: 204px;
+    width: 100%;
+    background-color: rgba(255,255,255,0.5);
+  }
+
+  .center {
+    position: absolute;
+  }
+
+  
+</style>

+ 70 - 0
src/pages/User/info/index.vue

@@ -0,0 +1,70 @@
+<template>
+  
+  <div class="container">
+    <Breadcrumb :items="['个人中心', '用户信息']" />
+    <UserInfoHeader />
+    <div class="content">
+        <a-grid :cols="24" :col-gap="16" :row-gap="16">
+          <a-grid-item :span="24">
+            <MyProject />
+          </a-grid-item>
+          <a-grid-item :span="24">
+            <!-- <LatestActivity /> -->
+          </a-grid-item>
+        </a-grid>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import UserInfoHeader from './components/user-info-header.vue'
+import MyProject from './components/my-project.vue'
+// import LatestActivity from './components/latest-activity.vue';
+</script>
+
+<style scoped lang="less">
+.container {
+  padding: 0 20px 20px 20px;
+}
+
+.content {
+  display: flex;
+  margin-top: 12px;
+
+  &-left {
+    flex: 1;
+    margin-right: 16px;
+    overflow: hidden;
+    // background-color: var(--color-bg-2);
+
+    :deep(.arco-tabs-nav-tab) {
+      margin-left: 16px;
+    }
+  }
+
+  &-right {
+    width: 332px;
+  }
+
+  .tab-pane-wrapper {
+    padding: 0 16px 16px 16px;
+  }
+}
+</style>
+
+<style lang="less" scoped>
+.mobile {
+  .content {
+    display: block;
+
+    &-left {
+      margin-right: 0;
+      margin-bottom: 16px;
+    }
+
+    &-right {
+      width: 100%;
+    }
+  }
+}
+</style>

+ 223 - 0
src/pages/User/setting/components/basic-information.vue

@@ -0,0 +1,223 @@
+<template>
+  <div class="form">
+    <div class="username">
+      <a-form size="large" ref="usernameFormRef" :model="usernameForm" class="form" @submit="changeName">
+        <a-form-item field="username" label="用户名"
+          :rules="[{ required: true, message: '用户名不能为空' }, { minLength: 4, maxLength: 12, message: '用户名长度需在4~12位间' }]"
+          :validate-trigger="['change']">
+          <a-input placeholder="请输入用户名" allow-clear v-model="usernameForm.username">
+            <template #prefix>
+              <icon-user />
+            </template>
+          </a-input>
+        </a-form-item>
+        <a-button class="button" type="primary" html-type="submit" :loading="state.usernameButton"
+          :disabled="user?.username === usernameForm.username">更新用户名</a-button>
+      </a-form>
+    </div>
+    <a-divider />
+
+    <div class="email">
+      <a-form size="large" ref="emailFormRef" :model="emailForm" class="form" @submit="changeEmail">
+        <a-form-item field="email" label="邮箱" :rules="[{ type: 'email', required: true, message: '请填写正确的邮箱地址' }]"
+          :validate-trigger="['change']">
+          <a-input placeholder="请输入邮箱" allow-clear v-model="emailForm.email">
+            <template #prefix>
+              <icon-email />
+            </template>
+          </a-input>
+        </a-form-item>
+
+        <a-form-item field="captcha" label="图片验证码" :rules="[{ length: 4, required: true, message: '请正确填写图片验证码' }]">
+          <a-input placeholder="请输入图片验证码" allow-clear v-model="emailForm.captcha">
+            <template #prefix>
+              <icon-check-circle />
+            </template>
+            <template #append>
+              <img alt="!点我重试" height="34" :src="ImageCaptcha" class="captcha" @click="getCaptcha()">
+            </template>
+          </a-input>
+        </a-form-item>
+
+        <a-form-item field="code" label="邮箱验证码" :rules="[{ length: 6, required: true, message: '请正确填写邮箱验证码' }]">
+          <a-input placeholder="请输入邮箱验证码" allow-clear v-model="emailForm.code">
+            <template #prefix>
+              <icon-code-square />
+            </template>
+            <template #append>
+              <a-button type="text" style="width: 80px" @click="SendEmail" :disabled="state.smsSendBtn || user?.email === emailForm.email">
+                <span v-if="!state.smsSendBtn">获取验证码</span>
+                <span v-else>{{ state.time }} s</span>
+              </a-button>
+            </template>
+          </a-input>
+        </a-form-item>
+        <a-button class="button" type="primary" html-type="submit" :loading="state.emailButton"
+        :disabled="user?.email === emailForm.email">更新邮箱</a-button>
+      </a-form>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onBeforeMount } from 'vue'
+import { ChangeUsername, BindEmail } from '@/api/user'
+import { getImageCaptcha, sendEmail } from '@/api/login'
+import { useUserStore } from '@/store/modules/user'
+import { Notification, Message } from '@arco-design/web-vue'
+
+const userStore = useUserStore()
+let user = ref(null)
+
+let usernameFormRef = ref(null)
+let emailFormRef = ref(null)
+
+let state = reactive({
+  usernameButton: false,
+  emailButton: false,
+  smsSendBtn: false,
+  time: 60
+})
+
+let usernameForm = reactive({
+  username: ''
+})
+
+let emailForm = reactive({
+  email: '',
+  captcha: '',
+  code: ''
+})
+
+const getUser = async () => {
+  const userInfo = await userStore.getInfo()
+  user.value = userInfo
+  usernameForm.username = userInfo.username
+  emailForm.email = userInfo.email
+}
+
+onBeforeMount(getUser)
+
+
+let CaptchaId = ref('')
+let ImageCaptcha = ref('')
+
+const getCaptcha = async () => {
+    try {
+        const res = await getImageCaptcha()
+        if (!res || res.code != 0)
+            return Message.error('获取图片验证码失败!' + res?.msg || '')
+        ImageCaptcha.value = res.data.img
+        CaptchaId.value = res.data.id
+    } catch (error) {
+      Message.error('获取图片验证码失败!')
+    }
+}
+
+getCaptcha()
+
+const changeName = async () => {
+  try {
+    state.usernameButton = true
+    const v = await usernameFormRef.value.validate()
+    if (v || user.value.username === usernameForm.username) return
+
+    const res = await ChangeUsername({ username: usernameForm.username })
+    if (!res || res.code !== 0) throw new Error(res?.msg || '更新失败!请稍后再试')
+
+    user.value = await userStore.getInfoFromServer()
+    Message.success('更新用户名成功!')
+  } catch (error) {
+    Notification.error({
+      title: '更新用户名失败!',
+      content: error.message || ''
+    })
+  } finally {
+    state.usernameButton = false
+  }
+}
+
+const SendEmail = async () => {
+    try {
+        const v = await emailFormRef.value.validateField(['email', 'captcha'])
+        if (v) return
+
+        let email = emailForm.email
+        let text = emailForm.captcha
+
+        state.smsSendBtn = true
+        const interval = window.setInterval(() => {
+            if (state.time-- <= 0) {
+                state.time = 60
+                state.smsSendBtn = false
+                window.clearInterval(interval)
+            }
+        }, 1000)
+
+        Message.loading('验证码发送中..')
+
+        const res = await sendEmail({ email, text, id: CaptchaId.value, type: 'bind' })
+
+        if (!res || res.code != 0) {
+            state.time = 60
+            state.smsSendBtn = false
+            window.clearInterval(interval)
+            getImageCaptcha()
+            emailForm.captcha = ''
+
+            if (res.code === -10501) {
+                return Notification.error({
+                    title: '验证码输入错误',
+                    content: res?.msg || '请求出现错误,请稍后再试'
+                })
+            }
+
+            return Notification.error({
+                title: '获取验证码失败',
+                content: res?.msg || '请求出现错误,请稍后再试'
+            })
+        }
+    } catch (error) {
+        Message.error('验证码发送失败')
+    }
+}
+
+const changeEmail = async () => {
+  try {
+    state.emailButton = true
+    const v = await emailFormRef.value.validate()
+    if (v || user.value.email === emailForm.email) return
+
+    const res = await BindEmail({ email: emailForm.email, code:  emailForm.code})
+    if (!res || res.code !== 0) throw new Error(res?.msg || '更新失败!请稍后再试')
+
+    user.value = await userStore.getInfoFromServer()
+    Message.success('更新邮箱成功!')
+  } catch (error) {
+    Notification.error({
+      title: '更新邮箱失败!',
+      content: error.message || ''
+    })
+    getCaptcha()
+    emailForm.captcha = ''
+  } finally {
+    state.emailButton = false
+  }
+}
+
+</script>
+
+<style scoped lang="less">
+.form {
+  width: 540px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.button {
+  width: 150px;
+}
+</style>

+ 103 - 0
src/pages/User/setting/components/security-settings.vue

@@ -0,0 +1,103 @@
+<template>
+  <div class="form">
+    <a-form size="large" ref="formRef" :model="form" class="form" @submit="changePassword">
+      <a-form-item field="oldpassword" label="输入旧密码"
+        :rules="[ { maxLength: 16, minLength: 8, message: '密码长度需在8~16位之间' }]"
+        :validate-trigger="['change']">
+        <a-input-password placeholder="请输入旧密码(未设置请留空)" allow-clear v-model="form.oldpassword">
+          <template #prefix>
+            <icon-lock />
+          </template>
+        </a-input-password>
+      </a-form-item>
+
+      <a-form-item field="password" label="新密码"
+        :rules="[{ required: true, message: '密码不能为空' }, { maxLength: 16, minLength: 8, message: '密码长度需在8~16位之间' }]"
+        :validate-trigger="['change']">
+        <a-input-password placeholder="请设置新密码" allow-clear v-model="form.password">
+          <template #prefix>
+            <icon-lock />
+          </template>
+        </a-input-password>
+      </a-form-item>
+
+      <a-form-item field="password2" label="重复新密码"
+        :rules="[{ required: true, message: '请再次输入密码' }, { maxLength: 16, minLength: 8, message: '密码长度需在8~16位之间' }]"
+        :validate-trigger="['change']">
+        <a-input-password placeholder="请再次输入密码" allow-clear v-model="form.password2">
+          <template #prefix>
+            <icon-lock />
+          </template>
+        </a-input-password>
+      </a-form-item>
+      
+      <a-button class="button" type="primary" html-type="submit" :loading="state.button">更改密码</a-button>
+    </a-form>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import { ChangePassword } from '@/api/user'
+import { useUserStore } from '@/store/modules/user'
+import { Notification, Message } from '@arco-design/web-vue'
+import { useRouter } from 'vue-router'
+
+const userStore = useUserStore()
+const router = useRouter()
+
+let formRef = ref(null)
+
+let state = reactive({
+  button: false,
+})
+
+let form = reactive({
+  oldpassword: '',
+  password: '',
+  password2: ''
+})
+
+const changePassword = async () => {
+  try {
+    state.button = true
+    const v = await formRef.value.validate()
+    if (v) return
+    if(form.password !== form.password2)
+      return Message.error('请确保两次输入的密码一致!')
+
+      let { oldpassword, password } = form
+      oldpassword = btoa(oldpassword)
+      password = btoa(password)
+
+    const res = await ChangePassword({oldpassword, password })
+    if (!res || res.code !== 0) throw new Error(res?.msg || '更新密码失败!请稍后再试')
+
+    Message.success('更新密码成功!请重新登录')
+    userStore.logout()
+    router.push('/login')
+  } catch (error) {
+    Notification.error({
+      title: '更新密码失败!',
+      content: error.message || ''
+    })
+  } finally {
+    state.button = false
+  }
+}
+</script>
+
+<style scoped lang="less">
+.form {
+  width: 560px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.button {
+  width: 150px;
+}
+</style>

+ 153 - 0
src/pages/User/setting/components/user-panel.vue

@@ -0,0 +1,153 @@
+<template>
+  <a-card :bordered="false">
+    <a-space :size="54">
+      <a-upload :custom-request="customRequest" :file-list="fileList" :show-upload-button="true" :show-file-list="false"
+        :on-before-upload="beforeUpload" @change="uploadChange" accept="image/png, image/jpeg, image/jpg">
+        <template #upload-button>
+          <a-avatar :size="100" class="info-avatar" v-if="fileList.length" :imageUrl="fileList[0].url">
+            <template #trigger-icon>
+              <icon-camera />
+            </template>
+          </a-avatar>
+        </template>
+      </a-upload>
+      <a-descriptions :data="renderData" :column="2" align="right" layout="inline-horizontal" :label-style="{
+        width: '140px',
+        fontWeight: 'normal',
+        color: 'rgb(var(--gray-8))',
+      }" :value-style="{
+        width: '250px',
+        paddingLeft: '8px',
+        textAlign: 'left',
+      }">
+        <template #label="{ label }">{{ label }} :</template>
+        <template #value="{ value, data }">
+          <span>{{ value }}</span>
+          <a-tag v-if="data.label === '邮箱' && value !== '未设置'" color="green" size="small" style="margin-left: 5px">
+            已验证
+          </a-tag>
+        </template>
+      </a-descriptions>
+    </a-space>
+  </a-card>
+</template>
+
+<script setup>
+import { uploadAvatar } from '@/api/upload'
+import { Notification } from '@arco-design/web-vue'
+import { ref, computed, onBeforeMount } from 'vue'
+import { useUserStore } from '@/store/modules/user'
+import { Message } from '@arco-design/web-vue'
+
+const user = ref(null)
+const fileList = ref([])
+
+const userStore = useUserStore()
+
+const getUser = async () => {
+  const userInfo = await userStore.getInfoFromServer()
+  if (userInfo?.avatar) {
+    user.value = userInfo
+    fileList.value = [{ uid: '-1', name: 'avatar', url: userInfo.avatar }]
+  }
+}
+
+onBeforeMount(getUser)
+
+const renderData = computed(() => {
+  if (!user.value) return []
+  return [
+    { label: '用户名', value: user.value.username },
+    { label: '邮箱', value: user.value.email },
+    { label: '注册时间', value: stramptoTime(user.value.registTime) }
+  ]
+})
+
+const stramptoTime = (time) => {
+  if (time < 10)
+    return '未知时间'
+  return new Date(+time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+
+const uploadChange = (fileItemList) => {
+  if (fileItemList.length > 0) {
+    fileList.value = [{ uid: '-1', name: 'avatar', url: fileItemList[0].url }]
+  }
+}
+
+const beforeUpload = (file) => {
+  return new Promise((resolve, reject) => {
+    const isJpgOrPng =
+      file.type === 'image/jpeg' || file.type === 'image/png' || file.type === 'image/jpg'
+    if (!isJpgOrPng) {
+      Notification.error({
+        title: '图片上传失败!',
+        content: '请上传jpg/png格式的图片文件!'
+      })
+      return reject('图片格式错误')
+    }
+
+    const isLt3M = file.size / 1024 / 1024 < 3
+    if (!isLt3M) {
+      Notification.error({
+        title: '图片上传失败!',
+        content: '图片大小不得超过3MB!',
+      })
+      return reject('图片大小超过限制')
+    }
+
+    resolve(file)
+  })
+}
+
+const customRequest = async ({ onSuccess, onError, fileItem }) => {
+  try {
+    const formData = new FormData()
+    formData.append('upload', fileItem.file)
+    formData.append('uuid', user.value?.uuid)
+    formData.append('session', user.value?.session)
+
+    const res = await uploadAvatar(formData)
+    if (!res || res.code !== 0) {
+      onError(new Error('上传失败'), res)
+      return Notification.error({
+        title: '头像上传失败',
+        content: res?.msg || '请求出现错误,请稍后再试'
+      })
+    }
+
+    fileList.value = [{ uid: '-1', name: 'avatar', url: res.data.picturePath }]
+
+    await userStore.getInfoFromServer()
+    Message.success('上传头像成功!')
+    onSuccess(res.data)
+    // setTimeout(() => { router.go(0) }, 2000)
+  } catch (error) {
+    Notification.error({
+      title: '头像上传失败',
+      content: '请求出现错误,请稍后再试',
+    })
+    onError()
+  }
+}
+</script>
+
+<style scoped lang="less">
+.arco-card {
+  padding: 14px 0 4px 4px;
+  border-radius: 4px;
+}
+
+:deep(.arco-avatar-trigger-icon-button) {
+  width: 32px;
+  height: 32px;
+  line-height: 32px;
+  background-color: #e8f3ff;
+
+  .arco-icon-camera {
+    margin-top: 8px;
+    color: rgb(var(--arcoblue-6));
+    font-size: 14px;
+  }
+}
+</style>

+ 47 - 0
src/pages/User/setting/index.vue

@@ -0,0 +1,47 @@
+<template>
+  <div class="container">
+    <Breadcrumb :items="['个人中心', '用户设置']" />
+    <a-row style="margin-bottom: 16px">
+      <a-col :span="24">
+        <UserPanel :user="user" />
+      </a-col>
+    </a-row>
+    <a-row class="wrapper">
+      <a-col :span="24">
+        <a-tabs default-active-key="1" type="rounded">
+          <a-tab-pane key="1" title="基础信息">
+            <BasicInformation />
+          </a-tab-pane>
+          <a-tab-pane key="2" title="安全设置">
+            <SecuritySettings />
+          </a-tab-pane>
+        </a-tabs>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script setup>
+import UserPanel from './components/user-panel.vue'
+import BasicInformation from './components/basic-information.vue'
+import SecuritySettings from './components/security-settings.vue'
+</script>
+
+<style scoped lang="less">
+.container {
+  padding: 0 20px 20px 20px;
+}
+
+.wrapper {
+  padding: 20px 0 0 20px;
+  min-height: 580px;
+  background-color: var(--color-bg-2);
+  border-radius: 4px;
+}
+
+:deep(.section-title) {
+  margin-top: 0;
+  margin-bottom: 16px;
+  font-size: 14px;
+}
+</style>

+ 50 - 0
src/pages/lepao/accountList/components/userCard.vue

@@ -0,0 +1,50 @@
+<template>
+  <a-card class="card">
+    <a-space size="large">
+      <a-statistic title="剩余乐跑次数" :value="userCount?.lepao_count" show-group-separator />
+      <a-button type="primary" size="large" @click="$router.push('/store/goodsList')" style="margin-left: 20px;"><icon-gift /> 去购买</a-button>
+    </a-space>
+  </a-card>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useUserStore } from '@/store/modules/user'
+import { getCount } from '@/api/goods'
+import { Notification } from '@arco-design/web-vue'
+
+const userCount = ref({})
+const loading = ref(false)
+
+const user = ref('')
+const getuser = async () => {
+  const userStore = useUserStore()
+  let userInfo = await userStore.getInfo()
+  user.value = userInfo
+}
+
+const GetCount = async () => {
+  try {
+    loading.value = true
+    const res = await getCount()
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取用户数据失败!',
+        content: res?.msg || '请稍后再试'
+      })
+    userCount.value = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取用户数据失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+GetCount()
+getuser()
+</script>
+
+<style scoped lang="less"></style>

+ 477 - 0
src/pages/lepao/accountList/index.vue

@@ -0,0 +1,477 @@
+<template>
+  <div class="container">
+    <Breadcrumb :items="['校园乐跑', '账号管理']" />
+
+    <userCard />
+
+    <a-card title="账号列表" style="margin-top: 15px;">
+      <a-button type="primary" size="large" @click="editAccount">
+        <template #icon>
+          <icon-plus />
+        </template>
+        添加账号
+      </a-button>
+
+      <a-button size="large" @click="download" style="margin-left: 10px;">
+        <template #icon>
+          <IconInfoCircle />
+        </template>
+        操作说明
+      </a-button>
+
+      <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading" :scroll="{
+        x: 1600
+      }" :pagination="{ showPageSize: true, showJumper: true, defaultPageSize: 15 }">
+
+        <template #name-filter="{ filterValue, setFilterValue, handleFilterConfirm, handleFilterReset }">
+          <div class="custom-filter">
+            <a-space direction="vertical">
+              <a-input :model-value="filterValue[0]" @input="(value) => setFilterValue([value])" />
+              <div class="custom-filter-footer">
+                <a-button @click="handleFilterReset">重置</a-button>
+                <a-button @click="handleFilterConfirm">确定</a-button>
+              </div>
+            </a-space>
+          </div>
+        </template>
+
+        <template #columns>
+          <a-table-column title="学号" :width="120" data-index="student_num" ellipsis tooltip :filterable="{
+            filter: (value, record) => (record.student_num ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }"></a-table-column>
+          <a-table-column title="用户名" :width="130" :filterable="{
+            filter: (value, record) => (record.name ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }">
+            <template #cell="{ record }">
+              {{ record.name }}
+            </template>
+          </a-table-column>
+          <a-table-column title="性别" :width="80" ellipsis tooltip :filterable="{
+            filters: [
+              { text: '男', value: 1 },
+              { text: '女', value: 2 }
+            ],
+            filter: (value, record) => record.sex == value
+          }">
+            <template #cell="{ record }">
+              <icon-man v-if="record.sex === 1" />
+              <icon-woman v-else />
+              {{ record.sex === 1 ? '男' : '女' }}
+            </template>
+          </a-table-column>
+          <a-table-column title="学院" :width="220" data-index="academy_name" ellipsis tooltip :filterable="{
+            filter: (value, record) => (record.academy_name ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }"></a-table-column>
+          <a-table-column title="跑区" :width="130" :filterable="{
+            filter: (value, record) => (record.name ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }">
+            <template #cell="{ record }">
+              <div class="vipcontent">
+                <span>{{ record.area || '随机分配' }} </span>
+                <!-- <img src="@/assets/vip.svg" alt="vip" height="20" v-if="record.area"> -->
+              </div>
+            </template>
+          </a-table-column>
+          <a-table-column title="通知邮箱" :width="180" data-index="email" ellipsis tooltip :filterable="{
+            filter: (value, record) => (record.email ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }"></a-table-column>
+          <a-table-column title="帐号状态" :width="100" ellipsis tooltip :filterable="{
+            filters: [
+              { text: '正常', value: 1 },
+              { text: '需登录', value: 0 },
+              { text: '状态异常', value: 2 },
+            ],
+            filter: (value, record) => record.state == value
+          }">
+            <template #cell="{ record }">
+              <div v-if="record.state === 0" class="state">
+                <div class="circle zero"></div>需登录
+              </div>
+              <div v-else-if="record.state === 1" class="state">
+                <div class="circle one"></div>正常
+              </div>
+              <div v-else class="state">
+                <div class="circle else"></div>状态异常
+              </div>
+            </template>
+          </a-table-column>
+          <a-table-column title="自动乐跑" :width="100" ellipsis tooltip :filterable="{
+            filters: [
+              { text: '开启', value: 1 },
+              { text: '关闭', value: 0 }
+            ],
+            filter: (value, record) => record.auto_run == value
+          }">
+            <template #cell="{ record }">
+              <a-tag color="green" v-if="record.auto_run">开启</a-tag>
+              <a-tag color="red" v-else>关闭</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="自动乐跑时段" :width="100" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ autoTimeLabel(record.auto_time) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="累计次数" data-index="total_num" :width="110" ellipsis tooltip :sortable="{
+            sortDirections: ['ascend', 'descend']
+          }"></a-table-column>
+          <a-table-column title="剩余次数" :width="110" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ record.term_num - record.total_num > 0 ? (record.term_num - record.total_num) : '已完成' }}
+            </template>
+          </a-table-column>
+          <a-table-column title="添加时间" :width="170" ellipsis tooltip :sortable="{
+            sortDirections: ['ascend', 'descend']
+          }">
+            <template #cell="{ record }">
+              {{ stramptoTime(record.create_time) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="更新时间" :width="170" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ stramptoTime(record.update_time || record.create_time) }}
+            </template>
+          </a-table-column>
+
+          <a-table-column title="" fixed="right" :width="100">
+            <template #cell="{ record }">
+              <a-dropdown :popup-max-height="false" trigger="hover">
+                <a-button>操作 <icon-down /></a-button>
+                <template #content>
+                  <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
+                  <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
+                  <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
+                    '开启' }}自动乐跑</a-doption>
+                  <a-doption @click="DeleteAccount(record)"><icon-delete /> 删除账号</a-doption>
+                </template>
+              </a-dropdown>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+
+  <!-- 账号编辑对话框 -->
+  <a-modal v-model:visible="visible" title="编辑账号" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
+    :ok-loading="ok_loading" esc-to-close closable>
+    <a-form :model="form">
+      <a-form-item field="student_num" label="学号">
+        <a-input v-model="form.student_num" placeholder="账号所有者学号,填写错误将无法登录" />
+      </a-form-item>
+      <a-form-item field="email" label="通知邮箱">
+        <a-input v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知" />
+      </a-form-item>
+      <a-form-item field="area" label="乐跑跑区">
+        <a-select v-model="form.area" placeholder="请选择乐跑跑区" default-value="">
+          <a-option value="">随机分配</a-option>
+          <a-option v-for="(item, index) in area" :key="index" :value="item">
+            <span class="vipcontent">
+              <span>{{ item }} </span>
+              <!-- <img src="@/assets/vip.svg" alt="vip" height="20"> -->
+            </span>
+          </a-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item field="area" label="自动乐跑时段">
+        <a-select v-model="form.auto_time" placeholder="请选择每天自动乐跑的时段" :options="auto_time" />
+      </a-form-item>
+      <!-- <a-form-item field="distance" label="距离区间">
+        <a-select v-model="form.distance" placeholder="请选择距离区间">
+          <a-option :value="0">默认(2~4Km)</a-option>
+          <a-option v-for="(item, index) in distance" :key="index" :value="item.value">
+            <div class="vipcontent">
+              <div>{{ item.label }} </div>
+              <img src="@/assets/vip.svg" alt="vip" height="20">
+            </div>
+          </a-option>
+        </a-select>
+      </a-form-item> -->
+    </a-form>
+  </a-modal>
+</template>
+
+<script setup>
+import { ref, reactive, h } from 'vue'
+import { useUserStore } from '@/store/modules/user'
+import { accountList, deleteAccount, addAccount, changeAutoRun, singleRun } from '@/api/lepao'
+import { getCount } from '@/api/goods'
+import { Modal, Notification, Message } from '@arco-design/web-vue'
+import { IconSearch } from '@arco-design/web-vue/es/icon'
+import userCard from './components/userCard.vue'
+
+const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "江北校区跑区"]
+const distance = [
+  { label: '女生(1.6~2Km)', value: [1.60, 2.00] },
+  { label: '男生(2.0~2.4Km)', value: [2.00, 2.40] }
+]
+const auto_time = Array.from({ length: 17 }, (_, i) => {
+  const hour = i + 7
+  return {
+    label: `${hour} ~ ${hour + 1}时`,
+    value: hour
+  }
+})
+const autoTimeLabel = (val) => {
+  const match = auto_time.find(item => item.value === val)
+  return match ? match.label : '-'
+}
+
+const data = ref([])
+const loading = ref(false)
+
+const email = ref('')
+const userCount = ref({})
+const getuser = async () => {
+  const userStore = useUserStore()
+  let userInfo = await userStore.getInfo()
+  email.value = userInfo.email
+}
+
+const GetCount = async () => {
+  try {
+    loading.value = true
+    const res = await getCount()
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取用户数据失败!',
+        content: res?.msg || '请稍后再试'
+      })
+    userCount.value = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取用户数据失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const visible = ref(false)
+const ok_loading = ref(false)
+const form = reactive({
+  id: null,
+  student_num: '',
+  email: '',
+  distance: [2.00, 4.00],
+  area: '',
+  auto_time: 8,
+})
+
+const download = () => {
+  const a = document.createElement('a');
+  a.href = 'https://cloud.vite.net.cn/down.php/945623b0bfe7c3acc521abe11f5b3afc.pdf';
+  a.download = 'RunForge操作说明.pdf'
+  document.body.appendChild(a);
+  a.click();
+  document.body.removeChild(a);
+}
+
+const editAccount = (item) => {
+  if (item) {
+    form.id = item.id
+    form.student_num = item.student_num
+    form.email = item.email
+    form.distance[0] = item.min_distance
+    form.distance[1] = item.max_distance
+    form.area = item.area
+    form.auto_time = item.auto_time
+  } else {
+    form.id = null
+    form.student_num = ''
+    form.email = email.value
+  }
+  visible.value = true
+}
+
+const handleBeforeOk = async (done) => {
+  try {
+    ok_loading.value = true
+    const { student_num, email } = form
+    if (!student_num || !email) {
+      Message.error('请填写完整的账号信息')
+      return false
+    }
+
+    let data = {
+      ...form,
+      min_distance: form.distance[0],
+      max_distance: form.distance[1]
+    }
+
+    const res = await addAccount(data)
+    if (!res || res.code !== 0) {
+      Notification.error({
+        title: '保存乐跑账号失败!',
+        content: res?.msg || '请稍后再试'
+      })
+      return false
+    }
+
+    Message.success('保存成功!')
+    done()
+    getAccounts()
+  } catch (error) {
+    Notification.error({
+      title: '保存乐跑账号失败!',
+      content: error.message || '请稍后再试'
+    })
+    return false
+  } finally {
+    ok_loading.value = false
+  }
+}
+
+const handleCancel = () => {
+  visible.value = false;
+}
+
+const getAccounts = async () => {
+  try {
+    loading.value = true
+    const res = await accountList()
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取账号列表失败!',
+        content: res?.msg || '请稍后再试'
+      })
+    data.value = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取账号列表失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const SingleRun = async (item) => {
+  Modal.confirm({
+    title: '开始乐跑',
+    content: () => h('div', [
+      h('p', `您是否要为${item.name}乐跑?若乐跑成功将扣减乐跑次数`)
+    ]),
+    onOk: async () => {
+      const res = await singleRun({ student_num: item.student_num })
+      if (!res || res.code !== 0)
+        return Notification.error({
+          title: '提交乐跑任务失败',
+          content: res?.msg || '请稍后再试'
+        })
+      Message.success('提交乐跑任务成功!')
+    }
+  })
+}
+
+const DeleteAccount = async (item) => {
+  Modal.confirm({
+    title: '删除账号',
+    content: () => h('div', [
+      h('p', '您是否要删除该账号?该操作不可逆')
+    ]),
+    onOk: async () => {
+      const res = await deleteAccount({ id: item.id })
+      if (!res || res.code !== 0)
+        return Notification.error({
+          title: '删除失败',
+          content: res?.msg || '请稍后再试'
+        })
+      Message.success('删除成功!')
+      getAccounts()
+    }
+  })
+}
+
+const ChangeAutoRun = async (record) => {
+  const oldValue = record.auto_run;
+  record.auto_run = oldValue === 1 ? 0 : 1;
+  try {
+    const res = await changeAutoRun({ id: record.id });
+    if (!res || res.code !== 0) {
+      record.auto_run = oldValue;
+      Message.error('切换自动乐跑状态失败!');
+    } else {
+      Message.success(`自动乐跑状态已${record.auto_run === 1 ? '开启' : '关闭'}`)
+      getAccounts()
+    }
+  } catch (error) {
+    record.auto_run = oldValue;
+    Message.error('切换自动乐跑状态失败!');
+  }
+};
+
+
+const stramptoTime = (time) => {
+  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+
+getAccounts()
+getuser()
+GetCount()
+</script>
+
+<style scoped lang="less">
+.container {
+  padding: 0 20px 20px 20px;
+}
+
+.table {
+  margin-top: 15px;
+
+  .state {
+    display: flex;
+    align-items: center;
+
+    .circle {
+      border-radius: 50%;
+      height: 8px;
+      min-height: 8px;
+      width: 8px;
+      min-width: 8px;
+      margin-right: 5px;
+    }
+
+    .zero {
+      background-color: rgb(var(--orange-6));
+    }
+
+    .one {
+      background-color: rgb(var(--green-6));
+    }
+
+    .else {
+      background-color: rgb(var(--red-6));
+    }
+  }
+}
+
+.custom-filter {
+  padding: 20px;
+  background: var(--color-bg-5);
+  border: 1px solid var(--color-neutral-3);
+  border-radius: var(--border-radius-medium);
+  box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
+}
+
+.custom-filter-footer {
+  display: flex;
+  justify-content: space-between;
+}
+
+.vipcontent {
+  display: flex;
+  align-items: center;
+}
+</style>

+ 278 - 0
src/pages/lepao/lepaoRecords/index.vue

@@ -0,0 +1,278 @@
+<template>
+
+  <div class="container">
+    <Breadcrumb :items="['校园乐跑', '乐跑记录']" />
+    <a-card title="乐跑记录">
+      <a-row>
+        <a-col :flex="'1000px'">
+          <a-form :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
+            label-align="left">
+            <a-row :gutter="16">
+              <a-col :span="12">
+                <a-form-item field="name" label="账号名称">
+                  <a-input v-model="queryData.name" placeholder="请输入账号名称" />
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="lepao_account" label="学号">
+                  <a-input-number v-model="queryData.lepao_account" placeholder="请输入学号" :step="1" :precision="0" />
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="email" label="通知邮箱">
+                  <a-input v-model="queryData.email" placeholder="请输入通知邮箱" />
+                </a-form-item>
+              </a-col>
+            </a-row>
+          </a-form>
+        </a-col>
+        <a-divider style="height: 84px" direction="vertical" />
+        <a-col :flex="1" >
+          <a-space direction="vertical" :size="18">
+            <a-button type="primary" @click="search">
+              <template #icon>
+                <icon-search />
+              </template>
+              搜索
+            </a-button>
+            <a-button @click="reset">
+              <template #icon>
+                <icon-refresh />
+              </template>
+              重置
+            </a-button>
+          </a-space>
+        </a-col>
+      </a-row>
+
+      <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading" :pagination="{
+        showPageSize: true,
+        showJumper: true,
+        showTotal: true,
+        pageSize: pagination.pagesize,
+        current: pagination.current,
+        total: pagination.total
+      }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
+
+        <template #name-filter="{ filterValue, setFilterValue, handleFilterConfirm, handleFilterReset }">
+          <div class="custom-filter">
+            <a-space direction="vertical">
+              <a-input :model-value="filterValue[0]" @input="(value) => setFilterValue([value])" />
+              <div class="custom-filter-footer">
+                <a-button @click="handleFilterReset">重置</a-button>
+                <a-button @click="handleFilterConfirm">确定</a-button>
+              </div>
+            </a-space>
+          </div>
+        </template>
+
+        <template #columns>
+          <a-table-column title="学号" :width="120" data-index="lepao_account" ellipsis tooltip :filterable="{
+            filter: (value, record) => (record.lepao_account ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }"></a-table-column>
+          <a-table-column title="账号名称" :filterable="{
+            filter: (value, record) => (record.name ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }">
+            <template #cell="{ record }">
+              {{ record.name }}
+            </template>
+          </a-table-column>
+          <a-table-column title="状态" ellipsis tooltip>
+            <template #cell="{ record }">
+              <div v-if="record.result.record_failed_reason === ''" class="state">
+                <div class="circle one"></div>正常
+              </div>
+              <div v-else class="state">
+                <div class="circle else"></div>{{ record.result.record_failed_reason }}
+              </div>
+            </template>
+          </a-table-column>
+          <a-table-column title="跑区" :filterable="{
+            filter: (value, record) => (record.result.pass_tit ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }">
+            <template #cell="{ record }">
+              {{ record.result.pass_tit }}
+            </template>
+          </a-table-column>
+          <a-table-column title="乐跑距离" :width="120" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ record.result.distance }} Km
+            </template>
+          </a-table-column>
+          <a-table-column title="跑步时长" :width="120" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ formatSecondsToMinSec(record.result.time) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="平均配速" :width="120" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ calculatePace(record.result.time, record.result.distance) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="乐跑时间" :width="170" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ stramptoTime(record.time) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="170" ellipsis tooltip>
+            <template #cell="{ record }">
+              <a-button @click="$router.push(`/lepao/recordDetail/${record.id}`)">查看详情</a-button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+
+</template>
+
+<script setup>
+import { ref, reactive, h } from 'vue'
+import { lepaoRecords } from '@/api/lepao'
+import { Notification } from '@arco-design/web-vue'
+import { IconSearch } from '@arco-design/web-vue/es/icon'
+
+const data = ref([])
+const loading = ref(false)
+
+const queryData = reactive({
+  name: '',
+  lepao_account: '',
+  email: ''
+})
+
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+
+const search = () => {
+  pagination.current = 1
+  getRecords()
+}
+
+const reset = () => {
+  pagination.current = 1
+  queryData.name = ''
+  queryData.lepao_account = ''
+  queryData.email = ''
+  queryData.area = ''
+  getRecords()
+}
+
+const getRecords = async () => {
+  try {
+    loading.value = true
+    const reqData = {
+      ...queryData,
+      pagesize: pagination.pagesize,
+      current: pagination.current
+    }
+    const res = await lepaoRecords(reqData)
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取乐跑记录失败!',
+        content: res?.msg || '请稍后再试'
+      })
+    data.value = res.data
+    pagination.total = res.pagination.total
+  } catch (error) {
+    Notification.error({
+      title: '获取乐跑记录失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+// 分页 - 页码变化
+const handlePageChange = (page) => {
+  pagination.current = page
+  getPathList()
+}
+
+// 分页 - 每页条数变化
+const handlePageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1 // 页大小变化后回到第一页
+  getPathList()
+}
+
+const stramptoTime = (time) => {
+  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+}
+
+function calculatePace(seconds, kilometers) {
+  const paceInSeconds = seconds / kilometers;
+  const minutes = Math.floor(paceInSeconds / 60);
+  const remainingSeconds = Math.round(paceInSeconds % 60);
+
+  return `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`;
+}
+
+function formatSecondsToMinSec(totalSeconds) {
+  const minutes = Math.floor(totalSeconds / 60);
+  const seconds = totalSeconds % 60;
+
+  return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
+}
+
+getRecords()
+</script>
+
+<style scoped lang="less">
+.container {
+  padding: 0 20px 20px 20px;
+}
+
+.table {
+  margin-top: 15px;
+
+  .state {
+    display: flex;
+    align-items: center;
+
+    .circle {
+      border-radius: 50%;
+      height: 8px;
+      min-height: 8px;
+      width: 8px;
+      min-width: 8px;
+      margin-right: 5px;
+    }
+
+    .zero {
+      background-color: rgb(var(--orange-6));
+    }
+
+    .one {
+      background-color: rgb(var(--green-6));
+    }
+
+    .else {
+      background-color: rgb(var(--red-6));
+    }
+  }
+}
+
+.custom-filter {
+  padding: 20px;
+  background: var(--color-bg-5);
+  border: 1px solid var(--color-neutral-3);
+  border-radius: var(--border-radius-medium);
+  box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
+}
+
+.custom-filter-footer {
+  display: flex;
+  justify-content: space-between;
+}
+</style>

+ 89 - 0
src/pages/lepao/lepaoRecords/recordDetail.vue

@@ -0,0 +1,89 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['乐跑记录', '记录详情']" />
+        <a-card title="记录详情">
+            <a-descriptions :data="info" :column="2" />
+            <MapContainer v-if="showMap" :point_list="data.result.point_list" :pathData="data.data" threeD style="margin-top: 10px;"/>
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { GetRecordDetail } from '@/api/lepao'
+import { Notification } from '@arco-design/web-vue'
+import { useRoute } from 'vue-router'
+import MapContainer from '@/components/Map/MapContainer.vue'
+
+const route = useRoute()
+
+const loading = ref(false)
+const showMap = ref(false)
+const data = ref({})
+const info = ref([])
+
+const getRecordDetail = async (id) => {
+    try {
+        loading.value = true
+        showMap.value = false
+        const res = await GetRecordDetail({ id })
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取路径数据失败!',
+                content: res?.msg || '请稍后再试'
+            })
+
+        data.value = res.data
+        showMap.value = true
+
+        info.value = [
+            { label: '账号名称', value: res.data.name },
+            { label: '乐跑账号', value: res.data.lepao_account },
+            { label: '跑区名称', value: res.data.result.pass_tit },
+            { label: '记录时间', value: stramptoTime(res.data.time) },
+            { label: '开始时间', value: stramptoTime(res.data.result.start_time * 1000) },
+            { label: '打卡点数量', value: res.data.result.point_list.length },
+            { label: '跑步距离', value: res.data.result.distance + ' Km' },
+            { label: '跑步时长', value: formatSecondsToMinSec(res.data.result.time ) },
+            { label: '平均配速', value: calculatePace(res.data.result.time, res.data.result.distance) }
+        ]
+    } catch (error) {
+        Notification.error({
+            title: '获取路径数据失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+onMounted(() => {
+    getRecordDetail(route.params.id)
+})
+
+const stramptoTime = (time) => {
+  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+}
+
+function calculatePace(seconds, kilometers) {
+  const paceInSeconds = seconds / kilometers;
+  const minutes = Math.floor(paceInSeconds / 60);
+  const remainingSeconds = Math.round(paceInSeconds % 60);
+
+  return `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`;
+}
+
+function formatSecondsToMinSec(totalSeconds) {
+    const minutes = Math.floor(totalSeconds / 60);
+    const seconds = totalSeconds % 60;
+
+    return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
+}
+
+</script>
+
+<style scoped lang="less">
+.container {
+    padding: 0 20px 20px 20px;
+}
+</style>

+ 132 - 0
src/pages/path/pathDetail.vue

@@ -0,0 +1,132 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['路径数据', '路径详情']" />
+        <a-card title="路径详情">
+            <a-descriptions :data="info" :column="2" />
+            <div class="buttonGroup">
+                <a-button type="primary" status="danger" size="large" :loading="buttonLoading"
+                    @click="changePathState(2)" :disabled="data.state === 2">{{ data.state === 2 ? '已拒绝' :
+                    '拒绝'}}</a-button>
+                <a-button type="primary" status="success" size="large" :loading="buttonLoading"
+                    @click="changePathState(1)" :disabled="data.state === 1">{{ data.state === 1 ? '已通过' :
+                    '通过'}}</a-button>
+            </div>
+            <MapContainer v-if="showMap" :point_list="data.point_list" :pathData="data.data" />
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref, watch, onMounted } from 'vue'
+import { GetPathDetail, ChangePathState } from '@/api/pathData'
+import { Notification } from '@arco-design/web-vue'
+import { useRoute, useRouter } from 'vue-router'
+import MapContainer from '@/components/Map/MapContainer.vue'
+
+const route = useRoute()
+const router = useRouter()
+
+const loading = ref(false)
+const buttonLoading = ref(false)
+const showMap = ref(false)
+const data = ref({})
+const nextId = ref()
+const info = ref([])
+
+function getState(state) {
+    switch (state) {
+        case 0:
+            return '待审核'
+        case 1:
+            return '审核通过'
+        case 2:
+            return '审核失败'
+    }
+    return '未知'
+}
+
+const changePathState = async (state) => {
+    try {
+        buttonLoading.value = true
+        const res = await ChangePathState({ id: data.value.id, state })
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '改变路径状态失败!',
+                content: res?.msg || '请稍后再试'
+            })
+        Notification.success({
+            title: '改变成功!',
+            content: res.msg
+        })
+        router.push(`/path/detail/${res.data || Number(data.value.id) + 1}`)
+    } catch (error) {
+        Notification.error({
+            title: '改变路径状态失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        buttonLoading.value = false
+    }
+}
+
+const getPathDetail = async (id) => {
+    try {
+        loading.value = true
+        showMap.value = false
+        const res = await GetPathDetail({ id })
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取路径数据失败!',
+                content: res?.msg || '请稍后再试'
+            })
+
+        data.value = res.data
+        showMap.value = true
+
+        info.value = [
+            { label: '路径ID', value: res.data.id },
+            { label: '跑区名称', value: res.data.run_zone_name },
+            { label: '跑步距离', value: res.data.distance + ' Km' },
+            { label: '跑步时长', value: formatSecondsToMinSec(res.data.time) },
+            { label: '平均配速', value: res.data.speed },
+            { label: '审核状态', value: getState(res.data.state) }
+        ]
+    } catch (error) {
+        Notification.error({
+            title: '获取路径数据失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+onMounted(() => {
+    getPathDetail(route.params.id)
+})
+
+watch(() => route.params.id, (newId) => {
+    getPathDetail(newId)
+})
+
+function formatSecondsToMinSec(totalSeconds) {
+    const minutes = Math.floor(totalSeconds / 60);
+    const seconds = totalSeconds % 60;
+
+    return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
+}
+
+</script>
+
+<style scoped lang="less">
+.container {
+    padding: 0 20px 20px 20px;
+
+    .buttonGroup {
+        display: flex;
+        justify-content: center;
+        gap: 20px;
+        margin: 15px;
+    }
+}
+</style>

+ 209 - 0
src/pages/path/pathList.vue

@@ -0,0 +1,209 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['路径数据', '路径列表']" />
+
+        <a-card title="路径列表">
+            <a-row>
+                <a-col :flex="1">
+                    <a-form :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
+                        label-align="left">
+                        <a-row :gutter="16">
+                            <a-col :span="8">
+                                <a-form-item field="area" label="跑区">
+                                    <a-select v-model="queryData.area" placeholder="请选择乐跑跑区" default-value="">
+                                        <a-option value="">所有</a-option>
+                                        <a-option v-for="(item, index) in areas" :key="index" :value="item">
+                                            {{ item }}
+                                        </a-option>
+                                    </a-select>
+                                </a-form-item>
+                            </a-col>
+                            <a-col :span="8">
+                                <a-form-item field="min_distance" label="最短距离">
+                                    <a-input-number v-model="queryData.min_distance" placeholder="请输入最短距离" :step="0.01"
+                                        :precision="2" />
+                                </a-form-item>
+                            </a-col>
+                            <a-col :span="8">
+                                <a-form-item field="max_distance" label="最长距离">
+                                    <a-input-number v-model="queryData.max_distance" placeholder="请输入最长距离" :step="0.01"
+                                        :precision="2" />
+                                </a-form-item>
+                            </a-col>
+                            <a-col :span="8">
+                                <a-form-item field="area" label="状态">
+                                    <a-select v-model="queryData.state" :options="state" placeholder="请选择路径状态"
+                                        :default-value="-1" />
+                                </a-form-item>
+                            </a-col>
+                        </a-row>
+                    </a-form>
+                </a-col>
+                <a-divider style="height: 84px" direction="vertical" />
+                <a-col :flex="'86px'" style="text-align: right">
+                    <a-space direction="vertical" :size="18">
+                        <a-button type="primary" @click="search">
+                            <template #icon>
+                                <icon-search />
+                            </template>
+                            搜索
+                        </a-button>
+                        <a-button @click="reset">
+                            <template #icon>
+                                <icon-refresh />
+                            </template>
+                            重置
+                        </a-button>
+                    </a-space>
+                </a-col>
+            </a-row>
+
+            <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
+                :pagination="{
+                    showPageSize: true,
+                    showJumper: true,
+                    showTotal: true,
+                    pageSize: pagination.pagesize,
+                    current: pagination.current,
+                    total: pagination.total
+                }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
+                <template #time="{ record }">
+                    {{ formatSecondsToMinSec(record.time) }}
+                </template>
+                <template #state="{ record }">
+                    <a-tag color="blue" v-if="!record.state">待审核</a-tag>
+                    <a-tag color="green" v-else-if="record.state === 1">审核通过</a-tag>
+                    <a-tag color="red" v-else-if="record.state === 2">审核失败</a-tag>
+                </template>
+                <template #optional="{ record }">
+                    <a-button @click="$router.push(`/path/detail/${record.id}`)">查看详情</a-button>
+                </template>
+            </a-table>
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { GetPathList } from '@/api/pathData'
+import { Notification } from '@arco-design/web-vue'
+
+const queryData = reactive({
+    area: '',
+    min_distance: 1.60,
+    max_distance: 10.00,
+    state: -1
+})
+
+const pagination = reactive({
+    total: 0,
+    current: 1, // 默认从第1页开始
+    pagesize: 20
+})
+
+const loading = ref(false)
+const data = ref([])
+
+const state = [
+    { label: '全部', value: -1 }, { label: '待审核', value: 0 }, { label: '审核通过', value: 1 }, { label: '审核失败', value: 2 }
+]
+const areas = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "江北校区跑区"]
+
+const columns = [{
+    title: 'ID',
+    dataIndex: 'id',
+}, {
+    title: '跑区',
+    dataIndex: 'run_zone_name',
+}, {
+    title: '跑步距离(Km)',
+    dataIndex: 'distance',
+}, {
+    title: '用时',
+    slotName: 'time'
+}, {
+    title: '配速',
+    dataIndex: 'speed',
+}, {
+    title: '状态',
+    slotName: 'state'
+}, {
+    title: '操作',
+    slotName: 'optional'
+}]
+
+const search = () => {
+    pagination.current = 1
+    getPathList()
+}
+
+const reset = () => {
+    pagination.current = 1
+    queryData.area = ''
+    queryData.state = -1
+    queryData.min_distance = 1.60
+    queryData.max_distance = 10.00
+    getPathList()
+}
+
+const getPathList = async () => {
+    try {
+        loading.value = true
+        const reqData = {
+            ...queryData,
+            pagesize: pagination.pagesize,
+            current: pagination.current
+        }
+        const res = await GetPathList(reqData)
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取路径数据失败!',
+                content: res?.msg || '请稍后再试'
+            })
+
+        data.value = res.data
+        pagination.total = res.pagination.total
+    } catch (error) {
+        Notification.error({
+            title: '获取路径数据失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+// 分页 - 页码变化
+const handlePageChange = (page) => {
+    pagination.current = page
+    getPathList()
+}
+
+// 分页 - 每页条数变化
+const handlePageSizeChange = (size) => {
+    pagination.pagesize = size
+    pagination.current = 1 // 页大小变化后回到第一页
+    getPathList()
+}
+
+onMounted(() => {
+    getPathList()
+})
+
+function formatSecondsToMinSec(totalSeconds) {
+    const minutes = Math.floor(totalSeconds / 60);
+    const seconds = totalSeconds % 60;
+
+    return `${minutes}分${seconds.toString().padStart(2, '0')}秒`;
+}
+</script>
+
+<style scoped>
+.container {
+    padding: 0 20px 20px 20px;
+}
+
+.table {
+    margin-top: 15px;
+}
+</style>

+ 166 - 0
src/pages/store/goodsDetail/index.vue

@@ -0,0 +1,166 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['Forge商城', '商品详情', data?.name]" />
+        <a-card class="goodsdetail">
+            <div class="title">
+                {{ data?.name }} <a-tag v-if="data?.limit > 0" color="orange" size="large">限购{{ data?.limit }}件</a-tag>
+            </div>
+            <div class="price">
+                ¥ {{ data?.price }}
+            </div>
+            <div class="num">
+                剩余库存:{{ data?.num > 99 ? '充足' : data?.num }}
+            </div>
+
+            <a-button class="buy-button" type="primary" size="large" @click="buy">立即购买</a-button>
+
+            <a-divider orientation="center" style="margin-top: 30px;"><span
+                    style="font-size: 1.2em;">商品详情</span></a-divider>
+            <a-skeleton animation :loading="loading">
+                <a-space direction="vertical" :style="{ width: '100%' }" size="large">
+                    <a-skeleton-shape />
+                    <a-skeleton-line :rows="5" />
+                </a-space>
+            </a-skeleton>
+            <div class="content">
+                <div v-html="content"></div>
+            </div>
+        </a-card>
+    </div>
+
+    <!--下单对话框 -->
+    <a-modal v-model:visible="visible" title="确认订单" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
+        :ok-loading="ok_loading" esc-to-close closable>
+        <a-form :model="form">
+            <a-form-item field="name" label="商品名称">
+                <b>{{ form.name }}</b>
+            </a-form-item>
+            <a-form-item field="price" label="支付价格">
+                <b>¥ {{ form.price }}</b>
+            </a-form-item>
+            <a-form-item field="pay_type" label="支付方式">
+                <a-select v-model="form.pay_type" placeholder="请选择支付方式">
+                    <a-option value="alipay"><icon-alipay-circle /> 支付宝</a-option>
+                    <!-- <a-option value="qqpay"><icon-qq /> QQ支付</a-option> -->
+                    <a-option value="wxpay"><icon-wechatpay /> 微信支付</a-option>
+                </a-select>
+            </a-form-item>
+        </a-form>
+    </a-modal>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import { getGoods } from '@/api/goods'
+import { createOrder } from '@/api/order'
+import { useRoute, useRouter } from 'vue-router'
+import { Notification, Message } from '@arco-design/web-vue'
+
+const route = useRoute()
+const router = useRouter()
+const { id } = route.params
+
+const loading = ref(true)
+const data = ref({})
+const content = ref('')
+
+const visible = ref(false)
+const form = reactive({
+    name: "",
+    price: 0,
+    pay_type: ''
+})
+
+const handleBeforeOk = async (done) => {
+    try {
+        if (!form.pay_type) {
+            Message.error('请选择支付方式!')
+            return false
+        }
+
+        const res = await createOrder({ goods_id: id, pay_type: form.pay_type })
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '创建订单失败!',
+                content: res?.msg || '请稍后再试'
+            })
+            return false
+        }
+
+        router.push(`/store/orderDetail/${res.id}`)
+    } catch (error) {
+        Notification.error({
+            title: '创建订单失败!',
+            content: error.message || '请稍后再试'
+        })
+        return false
+    }
+}
+
+const handleCancel = () => {
+    visible.value = false;
+}
+
+const getGoodsDetail = async () => {
+    try {
+        loading.value = true
+        const res = await getGoods({ id })
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取商品列表失败!',
+                content: res?.msg || '请稍后再试'
+            })
+        data.value = res.data
+        content.value = decodeURI(atob(res.data.content || ''))
+        form.name = data.value.name
+        form.price = data.value.price
+    } catch (error) {
+        Notification.error({
+            title: '获取商品列表失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+const buy = () => {
+    visible.value = true
+}
+
+getGoodsDetail()
+</script>
+
+<style lang="less" scoped>
+.container {
+    padding: 0 20px 20px 20px;
+}
+
+.goodsdetail {
+    border-radius: 5px;
+
+    .title {
+        font-size: 1.8em;
+    }
+
+    .price {
+        font-size: 2em;
+        color: rgb(22, 93, 255);
+    }
+
+    .num {
+        font-size: 1.1em;
+    }
+
+    .buy-button {
+        position: relative;
+        left: 50%;
+        translate: (-50%);
+        margin-top: 20px;
+    }
+
+    .content {
+        padding: 0 20px;
+    }
+}
+</style>

+ 115 - 0
src/pages/store/goodsList/index.vue

@@ -0,0 +1,115 @@
+<template>
+    <div class="container">
+        <Breadcrumb :items="['Forge商城', '商品列表']" />
+        <a-card>
+            <a-space size="large">
+                <a-statistic title="剩余乐跑次数" :value="userCount?.lepao_count" show-group-separator />
+            </a-space>
+        </a-card>
+        <a-card title="商品列表" style="margin-top: 20px;">
+            <a-list hoverable :loading="loading">
+                <a-list-item v-for="(item, index) in data" :key="index">
+                    <div class="list">
+                        <div class="icon">
+                            <icon-gift :size="25" />
+                        </div>
+                        <div class="info">
+                            <span class="title">
+                                {{ item.name }}
+                            </span>
+                            <span class="label">
+                                <a-tag size="small">库存:{{ item.num > 99 ? '充足' : item.num }}</a-tag>
+                                ¥ {{ item.price }}
+                            </span>
+                        </div>
+                        <div class="button">
+                            <a-button type="primary" size="small"
+                                @click="$router.push(`/store/goodsDetail/${item.id}`)">查看详情</a-button>
+                        </div>
+                    </div>
+                </a-list-item>
+            </a-list>
+        </a-card>
+    </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { getGoodsList, getCount } from '@/api/goods'
+import { Notification } from '@arco-design/web-vue'
+
+const data = ref([])
+const userCount = ref({})
+const loading = ref(false)
+
+const getGoods = async () => {
+    try {
+        loading.value = true
+        const res = await getGoodsList()
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取商品列表失败!',
+                content: res?.msg || '请稍后再试'
+            })
+        data.value = res.data
+    } catch (error) {
+        Notification.error({
+            title: '获取商品列表失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+const GetCount = async () => {
+    try {
+        loading.value = true
+        const res = await getCount()
+        if (!res || res.code !== 0)
+            return Notification.error({
+                title: '获取用户数据失败!',
+                content: res?.msg || '请稍后再试'
+            })
+        userCount.value = res.data
+    } catch (error) {
+        Notification.error({
+            title: '获取用户数据失败!',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        loading.value = false
+    }
+}
+
+getGoods()
+GetCount()
+</script>
+
+<style lang="less" scoped>
+.container {
+    padding: 0 20px 20px 20px;
+}
+
+.list {
+    display: flex;
+    min-height: 60px;
+    align-items: center;
+
+    .info {
+        display: flex;
+        flex-direction: column;
+        margin-left: 15px;
+        margin-right: 15px;
+
+
+        .title {
+            font-size: 1.3em;
+        }
+    }
+
+    .button {
+        margin-left: auto;
+    }
+}
+</style>

+ 247 - 0
src/pages/store/orders/orderDetail/index.vue

@@ -0,0 +1,247 @@
+<template>
+  <div class="container">
+    <Breadcrumb :items="['Forge商城', '订单详情']" />
+    <a-space direction="vertical" :size="16" fill>
+      <a-card class="general-card" title="订单状态">
+        <div class="step">
+          <a-steps :current="current" :status="status">
+            <a-step :description="oneDes">{{ oneText }}</a-step>
+            <a-step :description="twoDes">{{ twoText }}</a-step>
+            <a-step :description="threeDes">{{ threeText }}</a-step>
+          </a-steps>
+        </div>
+        <a-button type="primary" size="large" class="paybutton" @click="pay"
+          v-if="data?.state === 0 && payData != {}">立即支付</a-button>
+
+      </a-card>
+      <a-card class="general-card">
+        <a-descriptions style="margin-top: 20px" :data="orderData" size="large" title="订单详情" :column="1"/>
+      </a-card>
+      <a-card class="general-card" title="商品详情">
+        <div v-html="content"></div>
+      </a-card>
+
+    </a-space>
+  </div>
+</template>
+
+<script setup>
+import { ref, onUnmounted, onMounted, h } from 'vue'
+import { orderDeatil } from '@/api/order'
+import { useRoute } from 'vue-router'
+import { Notification } from '@arco-design/web-vue'
+import { Message } from '@arco-design/web-vue'
+import { IconQq,IconWechatpay, IconAlipayCircle } from '@arco-design/web-vue/es/icon'
+const route = useRoute()
+const { id } = route.params
+
+const loading = ref(true)
+const data = ref({})
+const payData = ref({})
+
+const current = ref(1)
+const status = ref('process')
+const oneText = ref('待支付')
+const twoText = ref('待处理')
+const threeText = ref('已完成')
+const oneDes = ref('')
+const twoDes = ref('')
+const threeDes = ref('')
+
+const orderData = ref([])
+const content = ref('')
+
+const stramptoTime = (time) => {
+  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+
+function getPayType(type) {
+  switch(type) {
+    case 'alipay':
+      return [h(IconAlipayCircle), ' 支付宝']
+    case 'qqpay':
+      return [h(IconQq), ' QQ支付']
+    case 'wxpay':
+      return [h(IconWechatpay), ' 微信支付']
+  }
+}
+
+const getOrderDeatil = async () => {
+  try {
+    const res = await orderDeatil({ orderId: id })
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取订单详情失败!',
+        content: res?.msg || '请稍后再试'
+      })
+    data.value = res.data
+    content.value = decodeURI(atob(res.data.content || ''))
+
+    let detail = []
+    if(res.data.name)
+      detail.push({label: '商品名称', value: res.data.name})
+    if(res.data.price)
+      detail.push({label: '支付价格', value: res.data.price})
+    if(res.data.pay_type)
+      detail.push({label: '支付方式', value: getPayType(res.data.pay_type)})
+    if(res.data.orderId)
+      detail.push({label: '平台订单号', value: res.data.orderId})
+    if(res.data.pay_id)
+      detail.push({label: '支付平台订单号', value: res.data.pay_id})
+    if(res.data.create_time)
+      detail.push({label: '下单时间', value: stramptoTime(res.data.create_time)})
+    if(res.data.pay_time)
+      detail.push({label: '支付时间', value: stramptoTime(res.data.pay_time)})
+
+    orderData.value = detail
+
+    if (res.payData)
+      payData.value = res.payData
+    else
+      payData.value = {}
+  } catch (error) {
+    Notification.error({
+      title: '获取订单详情失败!',
+      content: error.message || '请稍后再试'
+    })
+  }
+}
+
+let timer = null
+
+// 轮询
+const startPolling = () => {
+  if (!timer) {
+    timer = setInterval(async () => {
+      await getOrderDeatil()
+      getOrderState()
+    }, 1000)
+  }
+}
+
+// 停止轮询
+const stopPolling = () => {
+  if (timer) {
+    clearInterval(timer)
+    timer = null
+  }
+}
+
+onMounted(async () => {
+  loading.value = true
+  await getOrderDeatil()
+  loading.value = false
+  startPolling()
+})
+
+// 组件销毁时停止轮询
+onUnmounted(() => {
+  stopPolling()
+})
+
+function getOrderState() {
+  if (data.value) {
+    
+    switch (data.value.state) {
+      case 0:
+        current.value = 1
+        getPaymentStatus(data.value.create_time)
+        break
+
+      case 1:
+        current.value = 2
+        twoDes.value = '支付完成,等待系统处理'
+        oneDes.value = ''
+        break
+
+      case 2:
+        current.value = 3
+        threeDes.value = '订单处理完毕'
+        status.value = 'finish'
+        oneDes.value = ''
+        twoDes.value = ''
+        stopPolling()
+        break
+
+      case 3:
+        current.value = 1
+        status.value = 'error'
+        oneText.value = '支付超时'
+        oneDes.value = '订单已关闭'
+        stopPolling()
+        break
+
+    }
+  }
+}
+
+function getPaymentStatus(timestamp) {
+  const now = Date.now();
+  const diffMs = now - timestamp; // 毫秒差值
+  const diffSeconds = Math.floor(diffMs / 1000); // 秒差值
+
+  const timeout = 300; 
+
+  if (diffSeconds > timeout) {
+    oneDes.value = "支付超时";
+  } else {
+    const remaining = timeout - diffSeconds;
+    const minutes = Math.floor(remaining / 60);
+    const seconds = remaining % 60;
+    oneDes.value = `请在${minutes}分${seconds.toString().padStart(2, '0')}秒内完成支付`;
+  }
+}
+
+const pay = () => {
+  Message.success('正在跳转支付页面...请在新页面内完成支付')
+  if (payData.value != {}) {
+    openPaymentWindow(payData.value.payUrl, payData.value.payData)
+  }
+}
+
+function openPaymentWindow(payUrl, payData) {
+  const form = document.createElement('form');
+  form.method = 'POST';
+  form.action = payUrl;
+  // form.target = '_blank'
+  form.style.display = 'none';
+
+  // 遍历 payData,将每一项添加为隐藏的 input
+  for (const key in payData) {
+    if (payData.hasOwnProperty(key)) {
+      const input = document.createElement('input');
+      input.type = 'hidden';
+      input.name = key;
+      input.value = payData[key];
+      form.appendChild(input);
+    }
+  }
+
+  document.body.appendChild(form);
+  form.submit();
+  document.body.removeChild(form);
+}
+
+</script>
+
+<style lang="less" scoped>
+.container {
+  padding: 0 20px 20px 20px;
+}
+
+.general-card {
+  border-radius: 5px;
+
+  .step {
+    width: 900px;
+    margin: 30px auto;
+  }
+
+  .paybutton {
+    position: relative;
+    left: 50%;
+    translate: (-50%);
+    margin-top: 10px;
+  }
+}
+</style>

+ 201 - 0
src/pages/store/orders/orderList/index.vue

@@ -0,0 +1,201 @@
+<template>
+
+  <div class="container">
+    <Breadcrumb :items="['Forge商城', '我的订单']" />
+    <a-card title="我的订单">
+      <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading"
+        :pagination="{ showPageSize: true, showJumper: true, defaultPageSize: 15 }">
+
+        <template #name-filter="{ filterValue, setFilterValue, handleFilterConfirm, handleFilterReset }">
+          <div class="custom-filter">
+            <a-space direction="vertical">
+              <a-input :model-value="filterValue[0]" @input="(value) => setFilterValue([value])" />
+              <div class="custom-filter-footer">
+                <a-button @click="handleFilterReset">重置</a-button>
+                <a-button @click="handleFilterConfirm">确定</a-button>
+              </div>
+            </a-space>
+          </div>
+        </template>
+
+        <template #columns>
+          <a-table-column title="订单号" :width="180" data-index="orderId" ellipsis tooltip :filterable="{
+            filter: (value, record) => (record.orderId ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }"></a-table-column>
+          <a-table-column title="商品名称" :filterable="{
+            filter: (value, record) => (record.name ?? '').includes(value),
+            slotName: 'name-filter',
+            icon: () => h(IconSearch)
+          }">
+            <template #cell="{ record }">
+              {{ record.name }}
+            </template>
+          </a-table-column>
+          <a-table-column title="订单金额" ellipsis tooltip :sortable="{
+            sortDirections: ['ascend', 'descend']
+          }">
+            <template #cell="{ record }">
+              ¥ {{ record.price }}
+            </template>
+          </a-table-column>
+          <a-table-column title="支付方式" ellipsis tooltip>
+            <template #cell="{ record }">
+              <span>
+                <icon-wechatpay v-if="record.pay_type === 'wxpay'"/>
+                <icon-alipay-circle v-else-if="record.pay_type === 'alipay'"/>
+                <icon-qq v-else/>
+              </span>
+              {{ getPayType(record.pay_type) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="订单状态" ellipsis tooltip>
+            <template #cell="{ record }">
+              <div v-if="record.state === 0" class="state">
+                <div class="circle zero"></div>待支付
+              </div>
+              <div v-else-if="record.state === 1" class="state">
+                <div class="circle one"></div>待处理
+              </div>
+              <div v-else-if="record.state === 2" class="state">
+                <div class="circle one"></div>已完成
+              </div>
+              <div v-else class="state">
+                <div class="circle else"></div>已关闭
+              </div>
+              <!-- {{ getOrderState(record.state) }} -->
+            </template>
+          </a-table-column>
+          <a-table-column title="创建时间" ellipsis tooltip :sortable="{
+            sortDirections: ['ascend', 'descend']
+          }">
+            <template #cell="{ record }">
+              {{ stramptoTime(record.create_time) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="" fixed="right" :width="100">
+            <template #cell="{ record }">
+              <a-button @click="$router.push(`/store/orderDetail/${record.orderId}`)">详情</a-button>
+            </template>
+
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+
+</template>
+
+<script setup>
+import { ref, h } from 'vue'
+import { getMyOrder } from '@/api/order'
+import { Notification } from '@arco-design/web-vue'
+import { IconSearch } from '@arco-design/web-vue/es/icon'
+
+const data = ref([])
+const loading = ref(false)
+
+const GetMyOrder = async () => {
+  try {
+    loading.value = true
+    const res = await getMyOrder()
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取订单列表失败!',
+        content: res?.msg || '请稍后再试'
+      })
+    data.value = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取订单列表失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const stramptoTime = (time) => {
+  return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+}
+
+function getPayType(type) {
+  switch (type) {
+    case 'alipay':
+      return '支付宝'
+    case 'qqpay':
+      return 'QQ支付'
+    case 'wxpay':
+      return '微信支付'
+  }
+}
+
+function getOrderState(state) {
+  switch (state) {
+    case 0:
+      return '待支付'
+
+    case 1:
+      return '待处理'
+
+    case 2:
+      return '已完成'
+
+    case 3:
+      return '状态异常'
+  }
+
+}
+
+GetMyOrder()
+</script>
+
+<style scoped lang="less">
+.container {
+  padding: 0 20px 20px 20px;
+}
+
+.table {
+  margin-top: 15px;
+
+  .state {
+    display: flex;
+    align-items: center;
+
+    .circle {
+      border-radius: 50%;
+      height: 8px;
+      min-height: 8px;
+      width: 8px;
+      min-width: 8px;
+      margin-right: 5px;
+    }
+
+    .zero {
+      background-color: rgb(var(--orange-6));
+    }
+
+    .one {
+      background-color: rgb(var(--green-6));
+    }
+
+    .else {
+      background-color: rgb(var(--red-6));
+    }
+  }
+}
+
+.custom-filter {
+  padding: 20px;
+  background: var(--color-bg-5);
+  border: 1px solid var(--color-neutral-3);
+  border-radius: var(--border-radius-medium);
+  box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
+}
+
+.custom-filter-footer {
+  display: flex;
+  justify-content: space-between;
+}
+</style>

+ 186 - 0
src/router/index.js

@@ -0,0 +1,186 @@
+import * as VueRouter from 'vue-router'
+import { useUserStore } from '@/store'
+const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue')
+import { Message } from '@arco-design/web-vue'
+
+const routes = [
+    {
+        path: "/",
+        name: "main",
+        component: () => import('../pages/Main/Main.vue'),
+        meta: {
+            hideInMenu: true
+        }
+    },
+    {
+        path: "/login",
+        name: "login",
+        component: () => import('../pages/login/login.vue'),
+        meta: {
+            title: '用户登录',
+            hideInMenu: true
+        }
+    },
+    {
+        path: "/store",
+        name: 'store',
+        redirect: '/store/goodsList',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: 'Forge商城',
+            icon: 'icon-gift'
+        },
+        children: [
+            {
+                path: 'goodsList',
+                name: 'store.goodsList',
+                component: () => import('../pages/store/goodsList/index.vue'),
+                meta: {
+                    title: '商品列表'
+                }
+            },
+            {
+                path: 'goodsDetail/:id',
+                name: 'store.goodsDetail',
+                component: () => import('../pages/store/goodsDetail/index.vue'),
+                meta: {
+                    title: '商品详情',
+                    hideInMenu: true
+                }
+            },
+            {
+                path: 'orderDetail/:id',
+                name: 'store.orderDetail',
+                component: () => import('../pages/store/orders/orderDetail/index.vue'),
+                meta: {
+                    title: '订单详情',
+                    hideInMenu: true
+                }
+            },
+            {
+                path: 'myOrder',
+                name: 'store.myOrder',
+                component: () => import('../pages/store/orders/orderList/index.vue'),
+                meta: {
+                    title: '我的订单'
+                }
+            },
+        ]
+    },
+    {
+        path: "/lepao",
+        name: 'lepao',
+        redirect: '/lepao/accountList',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: '校园乐跑',
+            icon: 'icon-bug'
+        },
+        children: [
+            {
+                path: 'accountList',
+                name: 'lepao.accountList',
+                component: () => import('../pages/lepao/accountList/index.vue'),
+                meta: {
+                    title: '账号管理'
+                }
+            },
+            {
+                path: 'lepaoRecords',
+                name: 'lepao.lepaoRecords',
+                component: () => import('../pages/lepao/lepaoRecords/index.vue'),
+                meta: {
+                    title: '乐跑记录'
+                }
+            },
+            {
+                path: 'recordDetail/:id',
+                name: 'lepao.recordDetail',
+                component: () => import('../pages/lepao/lepaoRecords/recordDetail.vue'),
+                meta: {
+                    title: '路线详情',
+                    hideInMenu: true
+                }
+            }
+        ]
+    },
+    {
+        path: "/user",
+        name: "user",
+        redirect: '/user/setting',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: '个人中心',
+            icon: 'icon-user'
+        },
+        children: [
+            {
+                path: 'setting',
+                name: 'user.setings',
+                component: () => import('../pages/user/setting/index.vue'),
+                meta: {
+                    title: '用户设置'
+                },
+            }
+        ]
+    },
+    {
+        path: "/path",
+        name: "path",
+        redirect: '/path/list',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: '路径数据',
+            icon: 'icon-location',
+            permission: ['admin', 'path']
+        },
+        children: [
+            {
+                path: 'list',
+                name: 'path.list',
+                component: () => import('../pages/path/pathList.vue'),
+                meta: {
+                    title: '路径列表'
+                }
+            },
+            {
+                path: 'detail/:id',
+                name: 'path.detail',
+                component: () => import('../pages/path/pathDetail.vue'),
+                meta: {
+                    title: '路径详情',
+                    hideInMenu: true
+                }
+            }
+        ]
+    },
+]
+
+const router = VueRouter.createRouter({
+    history: VueRouter.createWebHashHistory(),
+    routes: routes
+})
+
+const allow = ['/', '/login']
+
+router.beforeEach(async (to, from, next) => {
+    if (!allow.includes(to.path)) {
+        const userStore = useUserStore()
+        let user = await userStore.getInfo()
+
+        if (!user || !user.uuid || !user.session) {
+            // Message.error('请先登录')
+            return router.push(`/login?from=${to.path}`)
+        }
+    }
+
+    if (!to.meta.title) {
+        document.title = 'RunForge - 让跑步的意义由技术重新定义'
+    } else {
+        document.title = to.meta.title + ' - RunForge'
+    }
+
+    next()
+})
+
+export { routes, router };

+ 5 - 0
src/store/index.js

@@ -0,0 +1,5 @@
+import { createPinia } from 'pinia'
+import { useUserStore } from './modules/user'
+const pinia = createPinia()
+
+export { pinia, useUserStore }

+ 95 - 0
src/store/modules/user.js

@@ -0,0 +1,95 @@
+import { defineStore } from 'pinia'
+import storage from 'store'
+import expirePlugin from 'store/plugins/expire'
+import { login, uniLogin } from '@/api/login'
+import { ChangeUsername, GetUserInfo } from '@/api/user'
+
+storage.addPlugin(expirePlugin)
+
+export const useUserStore = defineStore('user', {
+  state: () => ({
+    session: '',
+    uuid: '',
+    username: '',
+    avatar: '',
+    email: '',
+    roles: []
+  }),
+
+  actions: { 
+    // 登录  
+    async login(userInfo) {
+      try {
+        const res = await login(userInfo)
+        if (!res || res.code !== 0) throw new Error(res?.msg || '登录失败!请稍后再试')
+        storage.set('user', res.data, new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
+        this.setUser(res.data)
+        return res.data
+      } catch (error) {
+        throw error
+      }
+    },
+
+    async uniLogin(type, code) {
+      try {
+        const res = await uniLogin({type, code})
+        if (!res || res.code !== 0) throw new Error(res?.msg || '登录失败!请稍后再试')
+        storage.set('user', res.data, new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
+        this.setUser(res.data)
+        return res.data
+      } catch (error) {
+        throw error
+      }
+    },
+
+    // 更新用户名
+    async changeName(username) {
+      try {
+        const res = await ChangeUsername({ username })
+        if (!res || res.code !== 0) throw new Error(res?.msg || '更新失败!请稍后再试')
+        this.username = username
+        storage.set('user', this.$state, new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
+      } catch (error) {
+        throw error
+      }
+    },
+
+    // 获取用户信息
+    async getInfo() {
+      const user = storage.get('user')
+      if (user) {
+        this.setUser(user)
+        return user
+      }
+      return this.$state
+    },
+
+    async getInfoFromServer() {
+      try {
+        const res = await GetUserInfo()
+        if (!res || res.code !== 0) throw new Error(res?.msg || '获取用户信息失败!请稍后再试')
+        storage.set('user', res.data, new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
+        this.setUser(res.data)
+        return res.data
+      } catch (error) {
+        throw error
+      }
+    },
+
+    // 登出
+    logout() {
+      this.setUser({})
+      storage.remove('user')
+    },
+
+    // 设置用户信息
+    setUser(user = {}) {
+      this.session = user.session || ''
+      this.uuid = user.uuid || ''
+      this.avatar = user.avatar || ''
+      this.username = user.username || ''
+      this.email = user.email || ''
+      this.roles = user.roles || []
+    }
+  }
+})

+ 48 - 0
src/style.css

@@ -0,0 +1,48 @@
+:root {
+  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+  line-height: 1.5;
+  font-weight: 400;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+@font-face {
+  font-family: 'Alimama ShuHeiTi';
+  src: url('@/assets/font/AlimamaShuHeiTi-Bold.woff2') format('woff2')
+}
+
+@font-face {
+  font-family: 'AlibabaSans';
+  src: url('@/assets/font/AlibabaSans-MediumItalic.woff2')
+}
+
+a {
+  font-weight: 500;
+  color: #646cff;
+  text-decoration: inherit;
+}
+
+a:hover {
+  color: #535bf2;
+}
+
+body {
+  margin: 0;
+  display: flex;
+  place-items: center;
+  min-width: 1280px;
+  min-height: 100%;
+}
+
+h1 {
+  font-size: 3.2em;
+  line-height: 1.1;
+}
+
+#app {
+  width: 100%;
+  padding: 0;
+  margin: 0;
+}

+ 35 - 0
src/utils/axios.js

@@ -0,0 +1,35 @@
+const VueAxios = {
+  vm: {},
+  // eslint-disable-next-line no-unused-vars
+  install (Vue, instance) {
+    if (this.installed) {
+      return
+    }
+    this.installed = true
+
+    if (!instance) {
+      // eslint-disable-next-line no-console
+      console.error('You have to install axios')
+      return
+    }
+
+    Vue.axios = instance
+
+    Object.defineProperties(Vue.prototype, {
+      axios: {
+        get: function get () {
+          return instance
+        }
+      },
+      $http: {
+        get: function get () {
+          return instance
+        }
+      }
+    })
+  }
+}
+
+export {
+  VueAxios
+}

+ 29 - 0
src/utils/encrypt.js

@@ -0,0 +1,29 @@
+import CryptoJS from 'crypto-js'
+import JSEncrypt from 'jsencrypt'
+
+const publicKey = import.meta.env.VITE_RSA_PUBLIC_KEY.replace(/\\n/g, '\n')
+
+// 生成随机 AES 密钥
+export function generateAesKey(length = 16) {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+  let key = ''
+  for (let i = 0; i < length; i++) {
+    key += chars.charAt(Math.floor(Math.random() * chars.length))
+  }
+  return key
+}
+
+// AES 加密
+export function aesEncrypt(data, key) {
+  return CryptoJS.AES.encrypt(JSON.stringify(data), CryptoJS.enc.Utf8.parse(key), {
+    mode: CryptoJS.mode.ECB,
+    padding: CryptoJS.pad.Pkcs7
+  }).toString()
+}
+
+// RSA 加密 AES 密钥
+export function rsaEncryptKey(aesKey) {
+  const encryptor = new JSEncrypt()
+  encryptor.setPublicKey(publicKey)
+  return encryptor.encrypt(aesKey)
+}

+ 2 - 0
src/utils/eventBus.js

@@ -0,0 +1,2 @@
+import mitt from 'mitt'
+export const eventBus = mitt()

+ 84 - 0
src/utils/request.js

@@ -0,0 +1,84 @@
+import axios from 'axios'
+import { useUserStore } from '@/store'
+import storage from 'store'
+import { Notification } from '@arco-design/web-vue';
+import { VueAxios } from './axios'
+import { generateAesKey, aesEncrypt, rsaEncryptKey } from './encrypt'
+
+// 创建 axios 实例
+const request = axios.create({
+  // API 请求的默认前缀
+  baseURL: import.meta.env.VITE_APP_API_BASE_URL,
+  timeout: 30000 // 请求超时时间
+})
+
+// 异常拦截处理器
+const errorHandler = (error) => {
+  const userStore = useUserStore()
+  const user = storage.get('user')
+  console.log(error)
+
+  if (error.response) {
+    if (error.response.status === 401) {
+      Notification.error({
+        title: '登录失效',
+        content: '请重新登录'
+      })
+      if (user) {
+        userStore.logout()  // 调用 Pinia store 中的 logout 方法
+        setTimeout(() => {
+          window.location.reload()
+        }, 1500)
+      }
+    }
+  } else {
+    console.error('无法连接到服务器,请检查网络')
+
+    
+    Notification.error({
+      title: '服务器维护中',
+      content: '服务器停机维护中,请耐心等待'
+    })
+  }
+}
+
+request.interceptors.request.use(config => {
+  const userStore = useUserStore()  // 使用 Pinia store
+
+  const user = userStore.$state
+  if (user && config.headers['Content-Type'] !== 'multipart/form-data') {
+    if (config.method === 'get') {
+      config.params = {
+        ...config.params,
+        uuid: user.uuid,
+        session: user.session
+      }
+    } else {
+      config.data = {
+        ...config.data,
+        uuid: user.uuid,
+        session: user.session
+      }
+    }
+  }
+  return config
+}, errorHandler)
+
+// response interceptor
+request.interceptors.response.use((response) => {
+  return response.data
+}, errorHandler)
+
+const installer = {
+  vm: {},
+  install(Vue) {
+    Vue.use(VueAxios, request)
+  }
+}
+
+export default request
+
+export {
+  installer as VueAxios,
+  request as axios
+}

+ 25 - 0
src/utils/route-listener.js

@@ -0,0 +1,25 @@
+import { ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+
+/**
+ * 路由监听器
+ * @returns {object} selectedKey - 当前选中的菜单项及其父级
+ */
+export function useRouteListener() {
+    const route = useRoute()
+    
+    // 计算当前路径及其所有父级路径
+    const getRouteHierarchy = (path) => {
+        const segments = path.split('/').filter(Boolean)
+        return segments.map((_, index) => '/' + segments.slice(0, index + 1).join('/'))
+    }
+    
+    const selectedKey = ref(getRouteHierarchy(route.path))
+
+    // 监听路径变化并更新选中项
+    watch(() => route.path, (newPath) => {
+        selectedKey.value = getRouteHierarchy(newPath)
+    })
+
+    return { selectedKey }
+}

+ 6 - 0
src/utils/util.js

@@ -0,0 +1,6 @@
+export function timeFix () {
+    const time = new Date()
+    const hour = time.getHours()
+    return hour < 9 ? '早上好' : hour <= 11 ? '上午好' : hour <= 13 ? '中午好' : hour < 20 ? '下午好' : '晚上好'
+  }
+  

+ 18 - 0
vite.config.js

@@ -0,0 +1,18 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import path from 'path'
+import { vitePluginForArco } from '@arco-plugins/vite-vue'
+
+export default defineConfig({
+  plugins: [
+    vue(),
+    vitePluginForArco({
+      style: 'css'
+    })
+  ],
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, 'src')
+    }
+  }
+})

Some files were not shown because too many files changed in this diff