Browse Source

merge: 将 main 分支 UI 重构与响应式样式移植到 jkes

合并 main 的全站设计系统、表格/商城/首页样式优化及移动端适配,保留 jkes 的 API 地址、哪吒乐跑品牌与简化后的路由结构。

Co-authored-by: Cursor <cursoragent@cursor.com>
Pchen. 1 month ago
parent
commit
e10f36180b
100 changed files with 16081 additions and 1714 deletions
  1. 1 2
      .env
  2. 1 1
      index.html
  3. 3212 0
      package-lock.json
  4. 1 0
      public/download.html
  5. 56 0
      src/api/article.js
  6. 40 0
      src/api/coupon.js
  7. 23 0
      src/api/download.js
  8. 37 1
      src/api/goods.js
  9. 77 0
      src/api/ic.js
  10. 22 0
      src/api/jwAccount.js
  11. 66 2
      src/api/lepao.js
  12. 22 3
      src/api/login.js
  13. 48 1
      src/api/order.js
  14. 48 0
      src/api/permission.js
  15. 19 1
      src/api/power.js
  16. 191 0
      src/api/qk.js
  17. 19 1
      src/api/qxs.js
  18. 18 0
      src/api/user.js
  19. BIN
      src/assets/img/homePage/figma/hero-illustration.png
  20. 33 4
      src/components/AIChat/index.vue
  21. 23 0
      src/components/BindBot/bindBot.vue
  22. 79 9
      src/components/Breadcrumb/index.vue
  23. 143 173
      src/components/CanvasBackend/index.vue
  24. 31 0
      src/components/EmailAutoComplete/index.vue
  25. 7 8
      src/components/Footer/index.vue
  26. 266 49
      src/components/Header/index.vue
  27. 639 0
      src/components/LepaoAccountCard/accountDetailCard.vue
  28. 10 1
      src/components/Map/MapContainer.vue
  29. 143 15
      src/components/Menu/index.vue
  30. 71 9
      src/components/Navbar/index.vue
  31. 42 34
      src/components/index.js
  32. 71 0
      src/components/layout/AppPageShell.vue
  33. 48 0
      src/components/layout/AppQueryFilter.vue
  34. 60 0
      src/components/layout/AppTableEllipsis.vue
  35. 14 0
      src/components/service/WorkOrderStateTag.vue
  36. 106 0
      src/components/service/WorkOrderTemplatePicker.vue
  37. 121 0
      src/components/skeleton/StoreDetailSkeleton.vue
  38. 60 0
      src/components/skeleton/StoreGridSkeleton.vue
  39. 15 0
      src/components/skeleton/StoreRecordSkeleton.vue
  40. 74 0
      src/components/skeleton/StoreStackSkeleton.vue
  41. 49 0
      src/components/store/GoodsEmoji.vue
  42. 168 0
      src/components/store/OrderProgressSteps.vue
  43. 219 0
      src/components/store/OrderRefundPanel.vue
  44. 39 0
      src/components/store/OrderStateTag.vue
  45. 74 0
      src/components/store/PayMethodPicker.vue
  46. 154 94
      src/components/userCard/userCard.vue
  47. 51 0
      src/hooks/useResponsiveTable.js
  48. 158 17
      src/layout/default-layout.vue
  49. 4 7
      src/layout/html-view.vue
  50. 14 2
      src/main.js
  51. 24 29
      src/pages/Login/Login.vue
  52. 5 48
      src/pages/Login/components/container.vue
  53. 8 48
      src/pages/Login/components/login.vue
  54. 5 44
      src/pages/Login/components/register.vue
  55. 44 37
      src/pages/Login/uniLogin/uniLogin.vue
  56. 343 67
      src/pages/Main/Main.vue
  57. 99 78
      src/pages/Main/components/center.vue
  58. 115 60
      src/pages/Main/components/section2.vue
  59. 23 0
      src/pages/Main/theme.less
  60. 24 1
      src/pages/User/info/components/user-info-header.vue
  61. 10 16
      src/pages/User/info/index.vue
  62. 38 3
      src/pages/User/setting/components/basic-information.vue
  63. 30 1
      src/pages/User/setting/components/security-settings.vue
  64. 194 0
      src/pages/User/setting/components/social-bindings.vue
  65. 52 5
      src/pages/User/setting/components/user-panel.vue
  66. 32 10
      src/pages/User/setting/index.vue
  67. 419 0
      src/pages/admin/article/index.vue
  68. 144 0
      src/pages/admin/articleCategory/index.vue
  69. 204 0
      src/pages/admin/download/index.vue
  70. 365 134
      src/pages/admin/goods/addGoods.vue
  71. 261 0
      src/pages/admin/goods/couponEdit.vue
  72. 191 0
      src/pages/admin/goods/couponList.vue
  73. 162 176
      src/pages/admin/goods/goodsList.vue
  74. 268 0
      src/pages/admin/goods/orderDetail.vue
  75. 268 0
      src/pages/admin/goods/orderList.vue
  76. 280 0
      src/pages/admin/goods/sendCountRequestList.vue
  77. 331 207
      src/pages/admin/lepaoAccount/accountList.vue
  78. 296 0
      src/pages/admin/lepaoBindAudit/index.vue
  79. 279 0
      src/pages/admin/lepaoCountLedger/index.vue
  80. 96 62
      src/pages/admin/lepaoRecords/lepaoRecords.vue
  81. 84 47
      src/pages/admin/lepaoRecords/recordDetail.vue
  82. 6 8
      src/pages/admin/mqQueue/index.vue
  83. 4 7
      src/pages/admin/notice/index.vue
  84. 5 8
      src/pages/admin/popup/index.vue
  85. 325 0
      src/pages/admin/powerTask/index.vue
  86. 175 0
      src/pages/admin/qkBatch/index.vue
  87. 493 0
      src/pages/admin/qkClient/index.vue
  88. 328 0
      src/pages/admin/qkReport/index.vue
  89. 937 0
      src/pages/admin/qkTask/index.vue
  90. 312 0
      src/pages/admin/qxsAccount/index.vue
  91. 7 9
      src/pages/admin/reqLog/index.vue
  92. 577 43
      src/pages/admin/user/userList.vue
  93. 99 40
      src/pages/admin/workOrder/orderDetail.vue
  94. 72 18
      src/pages/admin/workOrder/orderList.vue
  95. 150 0
      src/pages/doc/detail.vue
  96. 220 0
      src/pages/doc/index.vue
  97. 50 74
      src/pages/download/index.vue
  98. 255 0
      src/pages/ic/freeSeats.vue
  99. 183 0
      src/pages/ic/myOrder.vue
  100. 302 0
      src/pages/ic/rule.vue

+ 1 - 2
.env

@@ -1,4 +1,3 @@
-NODE_ENV=production
 VITE_APP_API_BASE_URL=https://jkes-api.xxoo365.top
 VITE_RSA_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6Z1lGYzVRMGVqbTh4akZsSjdMSQpBZDJGeC9TalM0OWQ5emwyZHlaNzNDMGZLU3Fuc1pJQUZkREpWZWV6bUp6T1hOWFdoYVZHaHFwM0dCUWVvcDBKClIxekZ3bUs1em9ReElTTDc5WVF3SmxoSjdaellhL0xNcGtGZDRDVFQ4UzUwTGFzN1FpcUtqRE1BQjFLZEpaTnIKNE5HcjNUWVV4MVVpTzlUTW9YV3lBdFZRQVN2a3lFSVFIb3B4T2Vod0ZuNGRhVE8vLzF5TXRyNnZoclE4enJRMwpxUG01YWJmY0lRM3B1WDVJd1MrekRmSkI5Rm9rc0paa3RWNHI2KzM2U1E3WGp2MDFBQjJvK20yejZqNzNuWjQ1Ci95TEx5NmZHTG5lTWpTaUxHMDhNUWFCUjV1dTNITTRnMkpnanA4eU10Rkg1Tkc5Zys1dXRhTDNzd3JGQjhxd1UKdFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==
-VITE_APP_VERSION=2.0
+VITE_APP_VERSION=3.2

+ 1 - 1
index.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/logo.svg" />
-    <meta name="viewport" content="width=1280">
+    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
     <title>哪吒乐跑 - 智能校园乐跑平台</title>
   </head>
   <body>

+ 3212 - 0
package-lock.json

@@ -0,0 +1,3212 @@
+{
+  "name": "runforge",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "runforge",
+      "version": "1.0.0",
+      "dependencies": {
+        "@amap/amap-jsapi-loader": "^1.0.1",
+        "@arco-themes/vue-mengtu": "^0.1.1",
+        "@chenfengyuan/vue-qrcode": "^2.0.0",
+        "@vladmandic/face-api": "^1.7.15",
+        "@wangeditor/editor": "^5.1.23",
+        "@wangeditor/editor-for-vue": "next",
+        "axios": "^1.8.3",
+        "crypto-js": "^4.2.0",
+        "echarts": "^5.6.0",
+        "jsencrypt": "^3.3.2",
+        "lru-cache": "^11.1.0",
+        "markdown-it": "^14.1.0",
+        "mitt": "^3.0.1",
+        "pinia": "^3.0.1",
+        "qrcode": "^1.5.4",
+        "store": "^2.0.12",
+        "three": "^0.175.0",
+        "uuid": "^11.1.0",
+        "vue": "^3.5.13",
+        "vue-echarts": "^7.0.3",
+        "vue-router": "^4.5.0"
+      },
+      "devDependencies": {
+        "@arco-design/web-vue": "^2.57.0",
+        "@vitejs/plugin-vue": "^5.2.1",
+        "less": "^4.2.2",
+        "vite": "^6.2.0"
+      }
+    },
+    "node_modules/@amap/amap-jsapi-loader": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
+      "integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
+      "license": "MIT"
+    },
+    "node_modules/@arco-design/color": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@arco-design/color/-/color-0.4.0.tgz",
+      "integrity": "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g==",
+      "license": "MIT",
+      "dependencies": {
+        "color": "^3.1.3"
+      }
+    },
+    "node_modules/@arco-design/web-vue": {
+      "version": "2.57.0",
+      "resolved": "https://registry.npmjs.org/@arco-design/web-vue/-/web-vue-2.57.0.tgz",
+      "integrity": "sha512-R5YReC3C2sG3Jv0+YuR3B7kzkq2KdhhQNCGXD8T11xAoa0zMt6SWTP1xJQOdZcM9du+q3z6tk5mRvh4qkieRJw==",
+      "license": "MIT",
+      "dependencies": {
+        "@arco-design/color": "^0.4.0",
+        "b-tween": "^0.3.3",
+        "b-validate": "^1.5.3",
+        "compute-scroll-into-view": "^1.0.20",
+        "dayjs": "^1.11.13",
+        "number-precision": "^1.6.0",
+        "resize-observer-polyfill": "^1.5.1",
+        "scroll-into-view-if-needed": "^2.2.31",
+        "vue": "^3.1.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.1.0"
+      }
+    },
+    "node_modules/@arco-themes/vue-mengtu": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/@arco-themes/vue-mengtu/-/vue-mengtu-0.1.1.tgz",
+      "integrity": "sha512-7WqM8Vvzb3HszdRU6pdhSjwP6OxT2EchRHohAZD1W7uKdlbML5Y3PdEdpZojfCYKXjPf7zXiFNpgD6NjWg0WfQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@arco-design/web-vue": "^2.54.3"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+      "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@chenfengyuan/vue-qrcode": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@chenfengyuan/vue-qrcode/-/vue-qrcode-2.0.0.tgz",
+      "integrity": "sha512-33Cfr0zjbc3Dd8d5b1IgzXRAgXH0c2Gv19VI4snS25V/x9Z41eg769tC+Us1x+vqgQQhgD5YUjLnkpkrQfeMSw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "qrcode": "^1.5.0",
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+      "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+      "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+      "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+      "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+      "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+      "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+      "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+      "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+      "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+      "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+      "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+      "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+      "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+      "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+      "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+      "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+      "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+      "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+      "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+      "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+      "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+      "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+      "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+      "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+      "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@transloadit/prettier-bytes": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
+      "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/@types/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
+      "license": "MIT"
+    },
+    "node_modules/@uppy/companion-client": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-2.2.2.tgz",
+      "integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/utils": "^4.1.2",
+        "namespace-emitter": "^2.0.1"
+      }
+    },
+    "node_modules/@uppy/core": {
+      "version": "2.3.4",
+      "resolved": "https://registry.npmjs.org/@uppy/core/-/core-2.3.4.tgz",
+      "integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@transloadit/prettier-bytes": "0.0.7",
+        "@uppy/store-default": "^2.1.1",
+        "@uppy/utils": "^4.1.3",
+        "lodash.throttle": "^4.1.1",
+        "mime-match": "^1.0.2",
+        "namespace-emitter": "^2.0.1",
+        "nanoid": "^3.1.25",
+        "preact": "^10.5.13"
+      }
+    },
+    "node_modules/@uppy/store-default": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-2.1.1.tgz",
+      "integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==",
+      "license": "MIT"
+    },
+    "node_modules/@uppy/utils": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-4.1.3.tgz",
+      "integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash.throttle": "^4.1.1"
+      }
+    },
+    "node_modules/@uppy/xhr-upload": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
+      "integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/companion-client": "^2.2.2",
+        "@uppy/utils": "^4.1.2",
+        "nanoid": "^3.1.25"
+      },
+      "peerDependencies": {
+        "@uppy/core": "^2.3.3"
+      }
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vladmandic/face-api": {
+      "version": "1.7.15",
+      "resolved": "https://registry.npmjs.org/@vladmandic/face-api/-/face-api-1.7.15.tgz",
+      "integrity": "sha512-WDMmK3CfNLo8jylWqMoQgf4nIst3M0fzx1dnac96wv/dvMTN4DxC/Pq1DGtduDk1lktCamQ3MIDXFnvrdHTXDw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
+      "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.2",
+        "@vue/shared": "3.5.32",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-core/node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
+      "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.32",
+        "@vue/shared": "3.5.32"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
+      "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.2",
+        "@vue/compiler-core": "3.5.32",
+        "@vue/compiler-dom": "3.5.32",
+        "@vue/compiler-ssr": "3.5.32",
+        "@vue/shared": "3.5.32",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.8",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
+      "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.32",
+        "@vue/shared": "3.5.32"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "7.7.9",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
+      "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-kit": "^7.7.9"
+      }
+    },
+    "node_modules/@vue/devtools-kit": {
+      "version": "7.7.9",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
+      "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-shared": "^7.7.9",
+        "birpc": "^2.3.0",
+        "hookable": "^5.5.3",
+        "mitt": "^3.0.1",
+        "perfect-debounce": "^1.0.0",
+        "speakingurl": "^14.0.1",
+        "superjson": "^2.2.2"
+      }
+    },
+    "node_modules/@vue/devtools-shared": {
+      "version": "7.7.9",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
+      "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
+      "license": "MIT",
+      "dependencies": {
+        "rfdc": "^1.4.1"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
+      "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.32"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
+      "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.32",
+        "@vue/shared": "3.5.32"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
+      "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.32",
+        "@vue/runtime-core": "3.5.32",
+        "@vue/shared": "3.5.32",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
+      "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.32",
+        "@vue/shared": "3.5.32"
+      },
+      "peerDependencies": {
+        "vue": "3.5.32"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
+      "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==",
+      "license": "MIT"
+    },
+    "node_modules/@wangeditor/basic-modules": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
+      "integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
+      "license": "MIT",
+      "dependencies": {
+        "is-url": "^1.2.4"
+      },
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.throttle": "^4.1.1",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/code-highlight": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
+      "integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
+      "license": "MIT",
+      "dependencies": {
+        "prismjs": "^1.23.0"
+      },
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/core": {
+      "version": "1.1.19",
+      "resolved": "https://registry.npmjs.org/@wangeditor/core/-/core-1.1.19.tgz",
+      "integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/event-emitter": "^0.3.3",
+        "event-emitter": "^0.3.5",
+        "html-void-elements": "^2.0.0",
+        "i18next": "^20.4.0",
+        "scroll-into-view-if-needed": "^2.2.28",
+        "slate-history": "^0.66.0"
+      },
+      "peerDependencies": {
+        "@uppy/core": "^2.1.1",
+        "@uppy/xhr-upload": "^2.0.3",
+        "dom7": "^3.0.0",
+        "is-hotkey": "^0.2.0",
+        "lodash.camelcase": "^4.3.0",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.debounce": "^4.0.8",
+        "lodash.foreach": "^4.5.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "lodash.toarray": "^4.4.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/editor": {
+      "version": "5.1.23",
+      "resolved": "https://registry.npmjs.org/@wangeditor/editor/-/editor-5.1.23.tgz",
+      "integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@uppy/core": "^2.1.1",
+        "@uppy/xhr-upload": "^2.0.3",
+        "@wangeditor/basic-modules": "^1.1.7",
+        "@wangeditor/code-highlight": "^1.0.3",
+        "@wangeditor/core": "^1.1.19",
+        "@wangeditor/list-module": "^1.0.5",
+        "@wangeditor/table-module": "^1.1.4",
+        "@wangeditor/upload-image-module": "^1.0.2",
+        "@wangeditor/video-module": "^1.1.4",
+        "dom7": "^3.0.0",
+        "is-hotkey": "^0.2.0",
+        "lodash.camelcase": "^4.3.0",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.debounce": "^4.0.8",
+        "lodash.foreach": "^4.5.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "lodash.toarray": "^4.4.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/editor-for-vue": {
+      "version": "5.1.12",
+      "resolved": "https://registry.npmjs.org/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz",
+      "integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/editor": ">=5.1.0",
+        "vue": "^3.0.5"
+      }
+    },
+    "node_modules/@wangeditor/list-module": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@wangeditor/list-module/-/list-module-1.0.5.tgz",
+      "integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/table-module": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@wangeditor/table-module/-/table-module-1.1.4.tgz",
+      "integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.isequal": "^4.5.0",
+        "lodash.throttle": "^4.1.1",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/upload-image-module": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
+      "integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@uppy/core": "^2.0.3",
+        "@uppy/xhr-upload": "^2.0.3",
+        "@wangeditor/basic-modules": "1.x",
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "lodash.foreach": "^4.5.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/@wangeditor/video-module": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@wangeditor/video-module/-/video-module-1.1.4.tgz",
+      "integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@uppy/core": "^2.1.4",
+        "@uppy/xhr-upload": "^2.0.7",
+        "@wangeditor/core": "1.x",
+        "dom7": "^3.0.0",
+        "nanoid": "^3.2.0",
+        "slate": "^0.72.0",
+        "snabbdom": "^3.1.0"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/ansi-styles/node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/ansi-styles/node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "license": "MIT"
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "license": "Python-2.0"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
+      "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.11",
+        "form-data": "^4.0.5",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/b-tween": {
+      "version": "0.3.3",
+      "resolved": "https://registry.npmjs.org/b-tween/-/b-tween-0.3.3.tgz",
+      "integrity": "sha512-oEHegcRpA7fAuc9KC4nktucuZn2aS8htymCPcP3qkEGPqiBH+GfqtqoG2l7LxHngg6O0HFM7hOeOYExl1Oz4ZA==",
+      "license": "MIT"
+    },
+    "node_modules/b-validate": {
+      "version": "1.5.3",
+      "resolved": "https://registry.npmjs.org/b-validate/-/b-validate-1.5.3.tgz",
+      "integrity": "sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==",
+      "license": "MIT"
+    },
+    "node_modules/birpc": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
+      "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/color": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
+      "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^1.9.3",
+        "color-string": "^1.6.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+      "license": "MIT"
+    },
+    "node_modules/color-string": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+      "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "^1.0.0",
+        "simple-swizzle": "^0.2.2"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+      "license": "MIT"
+    },
+    "node_modules/copy-anything": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
+      "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-what": "^4.1.8"
+      },
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/crypto-js": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/d": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
+      "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
+      "license": "ISC",
+      "dependencies": {
+        "es5-ext": "^0.10.64",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.20",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
+      "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
+      "license": "MIT"
+    },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dijkstrajs": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+      "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+      "license": "MIT"
+    },
+    "node_modules/dom7": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/dom7/-/dom7-3.0.0.tgz",
+      "integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
+      "license": "MIT",
+      "dependencies": {
+        "ssr-window": "^3.0.0-alpha.1"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "license": "MIT"
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/errno": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
+      "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "prr": "~1.0.1"
+      },
+      "bin": {
+        "errno": "cli.js"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es5-ext": {
+      "version": "0.10.64",
+      "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
+      "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
+      "hasInstallScript": true,
+      "license": "ISC",
+      "dependencies": {
+        "es6-iterator": "^2.0.3",
+        "es6-symbol": "^3.1.3",
+        "esniff": "^2.0.1",
+        "next-tick": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/es6-iterator": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+      "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
+      "license": "MIT",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "^0.10.35",
+        "es6-symbol": "^3.1.1"
+      }
+    },
+    "node_modules/es6-symbol": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
+      "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
+      "license": "ISC",
+      "dependencies": {
+        "d": "^1.0.2",
+        "ext": "^1.7.0"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.12",
+        "@esbuild/android-arm": "0.25.12",
+        "@esbuild/android-arm64": "0.25.12",
+        "@esbuild/android-x64": "0.25.12",
+        "@esbuild/darwin-arm64": "0.25.12",
+        "@esbuild/darwin-x64": "0.25.12",
+        "@esbuild/freebsd-arm64": "0.25.12",
+        "@esbuild/freebsd-x64": "0.25.12",
+        "@esbuild/linux-arm": "0.25.12",
+        "@esbuild/linux-arm64": "0.25.12",
+        "@esbuild/linux-ia32": "0.25.12",
+        "@esbuild/linux-loong64": "0.25.12",
+        "@esbuild/linux-mips64el": "0.25.12",
+        "@esbuild/linux-ppc64": "0.25.12",
+        "@esbuild/linux-riscv64": "0.25.12",
+        "@esbuild/linux-s390x": "0.25.12",
+        "@esbuild/linux-x64": "0.25.12",
+        "@esbuild/netbsd-arm64": "0.25.12",
+        "@esbuild/netbsd-x64": "0.25.12",
+        "@esbuild/openbsd-arm64": "0.25.12",
+        "@esbuild/openbsd-x64": "0.25.12",
+        "@esbuild/openharmony-arm64": "0.25.12",
+        "@esbuild/sunos-x64": "0.25.12",
+        "@esbuild/win32-arm64": "0.25.12",
+        "@esbuild/win32-ia32": "0.25.12",
+        "@esbuild/win32-x64": "0.25.12"
+      }
+    },
+    "node_modules/esniff": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
+      "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
+      "license": "ISC",
+      "dependencies": {
+        "d": "^1.0.1",
+        "es5-ext": "^0.10.62",
+        "event-emitter": "^0.3.5",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
+      "license": "MIT",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "~0.10.14"
+      }
+    },
+    "node_modules/ext": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
+      "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
+      "license": "ISC",
+      "dependencies": {
+        "type": "^2.7.2"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "license": "ISC",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true,
+      "license": "ISC",
+      "optional": true
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/hookable": {
+      "version": "5.5.3",
+      "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
+      "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+      "license": "MIT"
+    },
+    "node_modules/html-void-elements": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
+      "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/i18next": {
+      "version": "20.6.1",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-20.6.1.tgz",
+      "integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.12.0"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/image-size": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
+      "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "bin": {
+        "image-size": "bin/image-size.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/immer": {
+      "version": "9.0.21",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
+      "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
+    "node_modules/is-arrayish": {
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+      "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+      "license": "MIT"
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-hotkey": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
+      "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
+      "license": "MIT"
+    },
+    "node_modules/is-plain-object": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+      "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-url": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
+      "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
+      "license": "MIT"
+    },
+    "node_modules/is-what": {
+      "version": "4.1.16",
+      "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
+      "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/jsencrypt": {
+      "version": "3.5.4",
+      "resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
+      "integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
+      "license": "MIT"
+    },
+    "node_modules/less": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmjs.org/less/-/less-4.6.4.tgz",
+      "integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "copy-anything": "^3.0.5",
+        "parse-node-version": "^1.0.1"
+      },
+      "bin": {
+        "lessc": "bin/lessc"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "errno": "^0.1.1",
+        "graceful-fs": "^4.1.2",
+        "image-size": "~0.5.0",
+        "make-dir": "^2.1.0",
+        "mime": "^1.4.1",
+        "needle": "^3.1.0",
+        "source-map": "~0.6.0"
+      }
+    },
+    "node_modules/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+      "license": "MIT",
+      "dependencies": {
+        "uc.micro": "^2.0.0"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/lodash.camelcase": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+      "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.debounce": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+      "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.foreach": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
+      "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+      "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+      "license": "MIT"
+    },
+    "node_modules/lodash.throttle": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+      "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.toarray": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
+      "integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "11.3.2",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.2.tgz",
+      "integrity": "sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==",
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+      "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "pify": "^4.0.1",
+        "semver": "^5.6.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/markdown-it": {
+      "version": "14.1.1",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
+      "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "^4.4.0",
+        "linkify-it": "^5.0.0",
+        "mdurl": "^2.0.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^2.1.0"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.mjs"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+      "license": "MIT"
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
+      "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
+      "license": "ISC",
+      "dependencies": {
+        "wildcard": "^1.1.0"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mitt": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+      "license": "MIT"
+    },
+    "node_modules/namespace-emitter": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
+      "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/needle": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz",
+      "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "iconv-lite": "^0.6.3",
+        "sax": "^1.2.4"
+      },
+      "bin": {
+        "needle": "bin/needle"
+      },
+      "engines": {
+        "node": ">= 4.4.x"
+      }
+    },
+    "node_modules/next-tick": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
+      "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
+      "license": "ISC"
+    },
+    "node_modules/number-precision": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/number-precision/-/number-precision-1.6.0.tgz",
+      "integrity": "sha512-05OLPgbgmnixJw+VvEh18yNPUo3iyp4BEWJcrLu4X9W05KmMifN7Mu5exYvQXqxxeNWhvIF+j3Rij+HmddM/hQ==",
+      "license": "MIT"
+    },
+    "node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "license": "MIT",
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parse-node-version": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
+      "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/perfect-debounce": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pify": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+      "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/pinia": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
+      "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^7.7.7"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.5.0",
+        "vue": "^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pngjs": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.8",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+      "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/preact": {
+      "version": "10.29.1",
+      "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
+      "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/preact"
+      }
+    },
+    "node_modules/prismjs": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/prr": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
+      "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qrcode": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+      "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "dijkstrajs": "^1.0.1",
+        "pngjs": "^5.0.0",
+        "yargs": "^15.3.1"
+      },
+      "bin": {
+        "qrcode": "bin/qrcode"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "license": "ISC"
+    },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+      "license": "MIT"
+    },
+    "node_modules/rfdc": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+      "license": "MIT"
+    },
+    "node_modules/rollup": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+      "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.1",
+        "@rollup/rollup-android-arm64": "4.60.1",
+        "@rollup/rollup-darwin-arm64": "4.60.1",
+        "@rollup/rollup-darwin-x64": "4.60.1",
+        "@rollup/rollup-freebsd-arm64": "4.60.1",
+        "@rollup/rollup-freebsd-x64": "4.60.1",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+        "@rollup/rollup-linux-arm64-musl": "4.60.1",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+        "@rollup/rollup-linux-loong64-musl": "4.60.1",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+        "@rollup/rollup-linux-x64-gnu": "4.60.1",
+        "@rollup/rollup-linux-x64-musl": "4.60.1",
+        "@rollup/rollup-openbsd-x64": "4.60.1",
+        "@rollup/rollup-openharmony-arm64": "4.60.1",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+        "@rollup/rollup-win32-x64-gnu": "4.60.1",
+        "@rollup/rollup-win32-x64-msvc": "4.60.1",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/sax": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+      "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "optional": true,
+      "engines": {
+        "node": ">=11.0.0"
+      }
+    },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+      "license": "MIT",
+      "dependencies": {
+        "compute-scroll-into-view": "^1.0.20"
+      }
+    },
+    "node_modules/semver": {
+      "version": "5.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+      "dev": true,
+      "license": "ISC",
+      "optional": true,
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "license": "ISC"
+    },
+    "node_modules/simple-swizzle": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+      "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+      "license": "MIT",
+      "dependencies": {
+        "is-arrayish": "^0.3.1"
+      }
+    },
+    "node_modules/slate": {
+      "version": "0.72.8",
+      "resolved": "https://registry.npmjs.org/slate/-/slate-0.72.8.tgz",
+      "integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
+      "license": "MIT",
+      "dependencies": {
+        "immer": "^9.0.6",
+        "is-plain-object": "^5.0.0",
+        "tiny-warning": "^1.0.3"
+      }
+    },
+    "node_modules/slate-history": {
+      "version": "0.66.0",
+      "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.66.0.tgz",
+      "integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
+      "license": "MIT",
+      "dependencies": {
+        "is-plain-object": "^5.0.0"
+      },
+      "peerDependencies": {
+        "slate": ">=0.65.3"
+      }
+    },
+    "node_modules/snabbdom": {
+      "version": "3.6.3",
+      "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz",
+      "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.17.0"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/speakingurl": {
+      "version": "14.0.1",
+      "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
+      "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ssr-window": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-3.0.0.tgz",
+      "integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==",
+      "license": "MIT"
+    },
+    "node_modules/store": {
+      "version": "2.0.12",
+      "resolved": "https://registry.npmjs.org/store/-/store-2.0.12.tgz",
+      "integrity": "sha512-eO9xlzDpXLiMr9W1nQ3Nfp9EzZieIQc10zPPMP5jsVV7bLOziSFFBP0XoDXACEIFtdI+rIz0NwWVA/QVJ8zJtw==",
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/superjson": {
+      "version": "2.2.6",
+      "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
+      "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
+      "license": "MIT",
+      "dependencies": {
+        "copy-anything": "^4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/superjson/node_modules/copy-anything": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
+      "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
+      "license": "MIT",
+      "dependencies": {
+        "is-what": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/superjson/node_modules/is-what": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
+      "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/three": {
+      "version": "0.175.0",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.175.0.tgz",
+      "integrity": "sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==",
+      "license": "MIT"
+    },
+    "node_modules/tiny-warning": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+      "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
+    "node_modules/type": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
+      "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
+      "license": "ISC"
+    },
+    "node_modules/uc.micro": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+      "license": "MIT"
+    },
+    "node_modules/uuid": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+      "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/esm/bin/uuid"
+      }
+    },
+    "node_modules/vite": {
+      "version": "6.4.2",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+      "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.4.4",
+        "picomatch": "^4.0.2",
+        "postcss": "^8.5.3",
+        "rollup": "^4.34.9",
+        "tinyglobby": "^0.2.13"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.32",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
+      "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.32",
+        "@vue/compiler-sfc": "3.5.32",
+        "@vue/runtime-dom": "3.5.32",
+        "@vue/server-renderer": "3.5.32",
+        "@vue/shared": "3.5.32"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-demi": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
+      "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-echarts": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-7.0.3.tgz",
+      "integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==",
+      "license": "MIT",
+      "dependencies": {
+        "vue-demi": "^0.13.11"
+      },
+      "peerDependencies": {
+        "@vue/runtime-core": "^3.0.0",
+        "echarts": "^5.5.1",
+        "vue": "^2.7.0 || ^3.1.1"
+      },
+      "peerDependenciesMeta": {
+        "@vue/runtime-core": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/vue-router/node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/which-module": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+      "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+      "license": "ISC"
+    },
+    "node_modules/wildcard": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
+      "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
+      "license": "MIT"
+    },
+    "node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "license": "ISC"
+    },
+    "node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "license": "ISC",
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    }
+  }
+}

+ 1 - 0
public/download.html

@@ -3,6 +3,7 @@
 
 <head>
   <meta charset="UTF-8">
+  <meta http-equiv="refresh" content="0;url=/#/download">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>哪吒乐跑应用下载中心</title>
   <link rel="icon" type="image/svg+xml" href="//xxoo365.top/logo.svg" />

+ 56 - 0
src/api/article.js

@@ -0,0 +1,56 @@
+import request from '../utils/request'
+
+const api = {
+  ArticleCategories: '/Article/Categories',
+  ArticleList: '/Article/List',
+  Article: '/Article',
+  AdminArticleList: '/Admin/Article/List',
+  AdminArticle: '/Admin/Article',
+  AdminArticlePublic: '/Admin/Article/Public',
+  AdminCategoryList: '/Admin/Article/Category/List',
+  AdminCategory: '/Admin/Article/Category'
+}
+
+export function getArticleCategories() {
+  return request({ url: api.ArticleCategories, method: 'get' })
+}
+
+export function getArticleList(params) {
+  return request({ url: api.ArticleList, method: 'get', params })
+}
+
+export function getArticle(params) {
+  return request({ url: api.Article, method: 'get', params })
+}
+
+export function getAdminArticleList(params) {
+  return request({ url: api.AdminArticleList, method: 'get', params })
+}
+
+export function getAdminArticle(params) {
+  return request({ url: api.AdminArticle, method: 'get', params })
+}
+
+export function saveAdminArticle(data) {
+  return request({ url: api.AdminArticle, method: 'post', data })
+}
+
+export function deleteAdminArticle(data) {
+  return request({ url: api.AdminArticle, method: 'delete', data })
+}
+
+export function toggleAdminArticlePublic(data) {
+  return request({ url: api.AdminArticlePublic, method: 'post', data })
+}
+
+export function getAdminCategoryList(params) {
+  return request({ url: api.AdminCategoryList, method: 'get', params })
+}
+
+export function saveAdminCategory(data) {
+  return request({ url: api.AdminCategory, method: 'post', data })
+}
+
+export function deleteAdminCategory(data) {
+  return request({ url: api.AdminCategory, method: 'delete', data })
+}

+ 40 - 0
src/api/coupon.js

@@ -0,0 +1,40 @@
+import request from '../utils/request'
+
+const api = {
+  Validate: '/Coupon/Validate',
+  AdminList: '/Admin/Coupon/List',
+  AdminDetail: '/Admin/Coupon/Detail',
+  AdminSave: '/Admin/Coupon/Save'
+}
+
+export function validateCoupon(data) {
+  return request({
+    url: api.Validate,
+    method: 'post',
+    data
+  })
+}
+
+export function adminCouponList(params) {
+  return request({
+    url: api.AdminList,
+    method: 'get',
+    params
+  })
+}
+
+export function adminCouponDetail(params) {
+  return request({
+    url: api.AdminDetail,
+    method: 'get',
+    params
+  })
+}
+
+export function adminSaveCoupon(data) {
+  return request({
+    url: api.AdminSave,
+    method: 'post',
+    data
+  })
+}

+ 23 - 0
src/api/download.js

@@ -0,0 +1,23 @@
+import request from '../utils/request'
+
+const api = {
+  DownloadCenter: '/Public/GetDownloadCenter',
+  AdminDownloadList: '/Admin/Download/List',
+  AdminDownload: '/Admin/Download'
+}
+
+export function getDownloadCenter() {
+  return request({ url: api.DownloadCenter, method: 'get' })
+}
+
+export function getAdminDownloadList(params) {
+  return request({ url: api.AdminDownloadList, method: 'get', params })
+}
+
+export function saveAdminDownload(data) {
+  return request({ url: api.AdminDownload, method: 'post', data })
+}
+
+export function deleteAdminDownload(data) {
+  return request({ url: api.AdminDownload, method: 'delete', data })
+}

+ 37 - 1
src/api/goods.js

@@ -5,8 +5,12 @@ const api = {
   GoodsList: '/Goods/List',
   GetCount: '/Goods/GetCount',
   SendCount: '/Goods/SendCount',
+  MySendCountRequestList: '/Goods/SendCountRequest/MyList',
   AdminGoods: '/Admin/Goods',
-  AdminGoodsList: '/Admin/Goods/List'
+  AdminGoodsList: '/Admin/Goods/List',
+  AdminSendCountRequestList: '/Admin/Goods/SendCountRequest/List',
+  AdminApproveSendCountRequest: '/Admin/Goods/SendCountRequest/Approve',
+  AdminRejectSendCountRequest: '/Admin/Goods/SendCountRequest/Reject'
 }
 
 export function getGoods (parameter) {
@@ -33,6 +37,14 @@ export function sendCount (parameter) {
   })
 }
 
+export function getMySendCountRequestList (parameter) {
+  return request({
+    url: api.MySendCountRequestList,
+    method: 'get',
+    params: parameter
+  })
+}
+
 export function adminGetGoods (parameter) {
   return request({
     url: api.AdminGoods,
@@ -64,3 +76,27 @@ export function getCount (parameter) {
     params: parameter
   })
 }
+
+export function adminSendCountRequestList (parameter) {
+  return request({
+    url: api.AdminSendCountRequestList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminApproveSendCountRequest (parameter) {
+  return request({
+    url: api.AdminApproveSendCountRequest,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminRejectSendCountRequest (parameter) {
+  return request({
+    url: api.AdminRejectSendCountRequest,
+    method: 'post',
+    data: parameter
+  })
+}

+ 77 - 0
src/api/ic.js

@@ -0,0 +1,77 @@
+import request from '../utils/request'
+
+const api = {
+  getSeatMenu: '/IC/GetSeatMenu',
+  getSeatInfo: '/IC/GetSeatInfo',
+  seat: '/IC/Seat',
+  endAhead: '/IC/Seat/EndAhead',
+  star: '/IC/Star',
+  starGroup: '/IC/StarGroup',
+  rule: '/IC/Rule',
+  myOrder: '/IC/Seat/MyOrder',
+  orderHistory: '/IC/OrderHistory'
+}
+
+export function getSeatMenu(parameter) {
+  return request({ url: api.getSeatMenu, method: 'get', params: parameter })
+}
+
+export function getSeatInfo(parameter) {
+  return request({ url: api.getSeatInfo, method: 'get', params: parameter })
+}
+
+export function orderSeat(parameter) {
+  return request({ url: api.seat, method: 'post', data: parameter })
+}
+
+export function myOrder(parameter) {
+  return request({ url: api.myOrder, method: 'post', data: parameter })
+}
+
+export function deleteSeat(parameter) {
+  return request({ url: api.seat, method: 'delete', data: parameter })
+}
+
+export function endAhead(parameter) {
+  return request({ url: api.endAhead, method: 'post', data: parameter })
+}
+
+export function getStar() {
+  return request({ url: api.star, method: 'get' })
+}
+
+export function saveStar(parameter) {
+  return request({ url: api.star, method: 'post', data: parameter })
+}
+
+export function deleteStar(parameter) {
+  return request({ url: api.star, method: 'delete', data: parameter })
+}
+
+export function getStarGroup() {
+  return request({ url: api.starGroup, method: 'get' })
+}
+
+export function saveStarGroup(parameter) {
+  return request({ url: api.starGroup, method: 'post', data: parameter })
+}
+
+export function deleteStarGroup(parameter) {
+  return request({ url: api.starGroup, method: 'delete', data: parameter })
+}
+
+export function getRule() {
+  return request({ url: api.rule, method: 'get' })
+}
+
+export function saveRule(parameter) {
+  return request({ url: api.rule, method: 'post', data: parameter })
+}
+
+export function deleteRule(parameter) {
+  return request({ url: api.rule, method: 'delete', data: parameter })
+}
+
+export function getOrderHistory() {
+  return request({ url: api.orderHistory, method: 'get' })
+}

+ 22 - 0
src/api/jwAccount.js

@@ -0,0 +1,22 @@
+import request from '../utils/request'
+
+const api = {
+  account: '/JW/Account',
+  active: '/JW/ActiveAccount'
+}
+
+export function getJwAccount() {
+  return request({ url: api.account, method: 'get' })
+}
+
+export function saveJwAccount(parameter) {
+  return request({ url: api.account, method: 'post', data: parameter })
+}
+
+export function deleteJwAccount(parameter) {
+  return request({ url: api.account, method: 'delete', data: parameter })
+}
+
+export function activeJwAccount(parameter) {
+  return request({ url: api.active, method: 'post', data: parameter })
+}

+ 66 - 2
src/api/lepao.js

@@ -3,15 +3,23 @@ import request from '../utils/request'
 const api = {
   Account: '/Lepao/Account',
   UpdateSelfAccount: '/Lepao/Account/UpdateSelfAccount',
+  AdminUpdateAccountInfo: '/Admin/Lepao/Account/UpdateAccountInfo',
   AdminAccount: '/Admin/Lepao/Account',
+  CountLedgerMyList: '/Lepao/Count/Ledger/MyList',
+  CountLedgerAdminList: '/Admin/Lepao/Count/Ledger/List',
   Records: '/Lepao/Records',
+  OfficialTermRecords: '/Lepao/OfficialTermRecords',
   ChangeAutoRun: '/Lepao/ChangeAutoRun',
   SingleRun: '/Lepao/SingleRun',
   JkesRunOverview: '/Lepao/JkesRunOverview',
   GetRecordDetail: '/Lepao/GetRecordDetail',
   AdminRecords: '/Admin/Lepao/Records',
+  AdminOfficialTermRecords: '/Admin/Lepao/OfficialTermRecords',
   AdminGetRecordDetail: '/Admin/Lepao/GetRecordDetail',
-  BeginFaceReco: '/Face/BeginFaceReco'
+  BeginFaceReco: '/Face/BeginFaceReco',
+  BindAuditList: '/Lepao/BindAudit/List',
+  AdminBindAuditByAccount: '/Admin/Lepao/BindAudit/ByAccount',
+  AdminBindAuditList: '/Admin/Lepao/BindAudit/List'
 }
 
 export function addAccount (parameter) {
@@ -46,6 +54,14 @@ export function adminAccountList (parameter) {
   })
 }
 
+export function adminUpdateAccountInfo (parameter) {
+  return request({
+    url: api.AdminUpdateAccountInfo,
+    method: 'post',
+    data: parameter
+  })
+}
+
 export function deleteAccount (parameter) {
   return request({
     url: api.Account,
@@ -70,6 +86,22 @@ export function adminLepaoRecords (parameter) {
   })
 }
 
+export function officialTermRecords (parameter) {
+  return request({
+    url: api.OfficialTermRecords,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminOfficialTermRecords (parameter) {
+  return request({
+    url: api.AdminOfficialTermRecords,
+    method: 'get',
+    params: parameter
+  })
+}
+
 export function changeAutoRun (parameter) {
   return request({
     url: api.ChangeAutoRun,
@@ -117,4 +149,36 @@ export function BeginFaceReco (parameter) {
     method: 'post',
     data: parameter
   })
-}
+}
+
+export function getMyCountLedger (parameter) {
+  return request({
+    url: api.CountLedgerMyList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getAdminCountLedger (parameter) {
+  return request({
+    url: api.CountLedgerAdminList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getAdminBindAuditByAccount (parameter) {
+  return request({
+    url: api.AdminBindAuditByAccount,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getAdminBindAuditList (parameter) {
+  return request({
+    url: api.AdminBindAuditList,
+    method: 'get',
+    params: parameter
+  })
+}

+ 22 - 3
src/api/login.js

@@ -7,7 +7,9 @@ const api = {
   GetPermissions: '/User/GetPermissions',
   sendEmail: '/Captcha/SendEmail',
   GetLoginUrl: '/UniLogin/GetLoginUrl',
-  UniLogin: '/UniLogin/Login'
+  UniLogin: '/UniLogin/Login',
+  BindSocial: '/UniLogin/BindSocial',
+  UnbindSocial: '/UniLogin/UnbindSocial'
 }
 
 export function register(parameter) {
@@ -41,10 +43,11 @@ export function getImageCaptcha () {
   })
 }
 
-export function GetPermissions() {
+export function GetPermissions(parameter) {
   return request({
     url: api.GetPermissions,
-    method: 'get'
+    method: 'get',
+    params: parameter
   })
 }
 
@@ -62,4 +65,20 @@ export function uniLogin(parameter) {
     method: 'post',
     data: parameter
   })
+}
+
+export function bindSocial(parameter) {
+  return request({
+    url: api.BindSocial,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function unbindSocial(parameter) {
+  return request({
+    url: api.UnbindSocial,
+    method: 'post',
+    data: parameter
+  })
 }

+ 48 - 1
src/api/order.js

@@ -2,8 +2,13 @@ import request from '../utils/request'
 
 const api = {
   Create: '/Order/CreateOrder',
+  Cancel: '/Order/CancelOrder',
+  Refund: '/Order/RefundOrder',
   Detail: '/Order/Detail',
-  GetMyOrder: '/Order/GetMyOrders'
+  GetMyOrder: '/Order/GetMyOrders',
+  AdminOrderList: '/Admin/Order/List',
+  AdminOrderDetail: '/Admin/Order/Detail',
+  AdminRefund: '/Admin/Order/RefundOrder'
 }
 
 export function createOrder(parameter) {
@@ -22,6 +27,23 @@ export function orderDeatil(parameter) {
   })
 }
 
+export function cancelOrder(parameter) {
+  return request({
+    url: api.Cancel,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function refundOrder(parameter) {
+  return request({
+    url: api.Refund,
+    method: 'post',
+    data: parameter,
+    timeout: 60000
+  })
+}
+
 export function getMyOrder(parameter) {
   return request({
     url: api.GetMyOrder,
@@ -29,3 +51,28 @@ export function getMyOrder(parameter) {
     params: parameter
   })
 }
+
+export function adminOrderList(parameter) {
+  return request({
+    url: api.AdminOrderList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminOrderDetail(parameter) {
+  return request({
+    url: api.AdminOrderDetail,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminRefundOrder(parameter) {
+  return request({
+    url: api.AdminRefund,
+    method: 'post',
+    data: parameter,
+    timeout: 60000
+  })
+}

+ 48 - 0
src/api/permission.js

@@ -0,0 +1,48 @@
+import request from '@/utils/request'
+
+const api = {
+  Points: '/Admin/Permission/Points',
+  User: '/Admin/Permission/User',
+  Resources: '/Admin/Permission/Resources',
+  Resource: '/Admin/Permission/Resource'
+}
+
+export function getPermissionPoints(parameter) {
+  return request({
+    url: api.Points,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getUserPermissions(parameter) {
+  return request({
+    url: api.User,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function setUserPermissions(parameter) {
+  return request({
+    url: api.User,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function getPermissionResources(parameter) {
+  return request({
+    url: api.Resources,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function updatePermissionResource(parameter) {
+  return request({
+    url: api.Resource,
+    method: 'post',
+    data: parameter
+  })
+}

+ 19 - 1
src/api/power.js

@@ -3,7 +3,9 @@ import request from '../utils/request'
 const api = {
   Account: '/Power/Account',
   GetChangeRecord: '/Power/GetChangeRecord',
-  GetPowerData: '/Power/GetPowerData'
+  GetPowerData: '/Power/GetPowerData',
+  AdminTaskList: '/Admin/Power/Task/List',
+  AdminGetChangeRecord: '/Admin/Power/Task/GetChangeRecord'
 }
 
 export function getPowerData(parameter) {
@@ -45,3 +47,19 @@ export function getAccount(parameter) {
     params: parameter
   })
 }
+
+export function adminPowerTaskList(parameter) {
+  return request({
+    url: api.AdminTaskList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminPowerGetChangeRecord(parameter) {
+  return request({
+    url: api.AdminGetChangeRecord,
+    method: 'get',
+    params: parameter
+  })
+}

+ 191 - 0
src/api/qk.js

@@ -0,0 +1,191 @@
+import request from '../utils/request'
+
+const api = {
+  Task: '/QK/Task',
+  TaskDetail: '/QK/Task/Detail',
+  AdminClientList: '/Admin/QK/Client/List',
+  AdminClientDelete: '/Admin/QK/Client/Delete',
+  AdminClientEnable: '/Admin/QK/Client/Enable',
+  AdminTaskList: '/Admin/QK/Task/List',
+  AdminTaskDetail: '/Admin/QK/Task/Detail',
+  AdminTaskUpdate: '/Admin/QK/Task/Update',
+  AdminTaskCancel: '/Admin/QK/Task/Cancel',
+  AdminTaskRetry: '/Admin/QK/Task/Retry',
+  AdminTaskStart: '/Admin/QK/Task/Start',
+  AdminTaskPause: '/Admin/QK/Task/Pause',
+  AdminReportList: '/Admin/QK/Report/List',
+  TaskStart: '/QK/Task/Start',
+  TaskPause: '/QK/Task/Pause',
+  Batch: '/QK/Batch',
+  AdminBatchList: '/Admin/QK/Batch/List',
+  AdminBatchSave: '/Admin/QK/Batch/Save',
+  AdminJwAccount: '/Admin/JW/Account'
+}
+
+export function getQkTask(parameter) {
+  return request({
+    url: api.Task,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getQkTaskDetail(parameter) {
+  return request({
+    url: api.TaskDetail,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function saveQkTask(parameter) {
+  return request({
+    url: api.Task,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function deleteQkTask(parameter) {
+  return request({
+    url: api.Task,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function startQkTask(parameter) {
+  return request({
+    url: api.TaskStart,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function pauseQkTask(parameter) {
+  return request({
+    url: api.TaskPause,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function getQkBatchList(parameter) {
+  return request({
+    url: api.Batch,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkBatchList(parameter) {
+  return request({
+    url: api.AdminBatchList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkBatchSave(parameter) {
+  return request({
+    url: api.AdminBatchSave,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkClientList(parameter) {
+  return request({
+    url: api.AdminClientList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkClientDelete(parameter) {
+  return request({
+    url: api.AdminClientDelete,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function adminQkClientEnable(parameter) {
+  return request({
+    url: api.AdminClientEnable,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkTaskList(parameter) {
+  return request({
+    url: api.AdminTaskList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkTaskDetail(parameter) {
+  return request({
+    url: api.AdminTaskDetail,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQkTaskUpdate(parameter) {
+  return request({
+    url: api.AdminTaskUpdate,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkTaskCancel(parameter) {
+  return request({
+    url: api.AdminTaskCancel,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function adminQkTaskRetry(parameter) {
+  return request({
+    url: api.AdminTaskRetry,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkTaskStart(parameter) {
+  return request({
+    url: api.AdminTaskStart,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkTaskPause(parameter) {
+  return request({
+    url: api.AdminTaskPause,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminQkReportList(parameter) {
+  return request({
+    url: api.AdminReportList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminGetJwAccount(parameter) {
+  return request({
+    url: api.AdminJwAccount,
+    method: 'get',
+    params: parameter
+  })
+}

+ 19 - 1
src/api/qxs.js

@@ -2,7 +2,9 @@ import request from '../utils/request'
 
 const api = {
   getBookList: '/QXS/GetBookList',
-  getBookImg: '/QXS/GetBookImg'
+  getBookImg: '/QXS/GetBookImg',
+  AdminAccountList: '/Admin/QXS/Account/List',
+  AdminAccountDetail: '/Admin/QXS/Account/Detail'
 }
 
 export function GetBookList(parameter) {
@@ -20,3 +22,19 @@ export function GetBookImg(parameter) {
     params: parameter
   })
 }
+
+export function adminQxsAccountList(parameter) {
+  return request({
+    url: api.AdminAccountList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminQxsAccountDetail(parameter) {
+  return request({
+    url: api.AdminAccountDetail,
+    method: 'get',
+    params: parameter
+  })
+}

+ 18 - 0
src/api/user.js

@@ -7,6 +7,8 @@ const api = {
   BindEmail: '/User/BindEmail',
   GetRepos: '/User/GetRepos',
   AdminUserList: '/Admin/User/GetUserList',
+  AdminSetSendCountAutoApprove: '/Admin/User/SetSendCountAutoApprove',
+  AdminSetUserBan: '/Admin/User/SetUserBan',
   AdminChangeLepaoCount: '/Admin/User/ChangeLepaoCount',
   AdminGetReqLog: '/Admin/User/GetReqLog',
   AdminGetReqLogDetail: '/Admin/User/GetReqLogDetail'
@@ -44,6 +46,22 @@ export function adminChangeLepaoCount(parameter) {
   })
 }
 
+export function adminSetSendCountAutoApprove(parameter) {
+  return request({
+    url: api.AdminSetSendCountAutoApprove,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminSetUserBan(parameter) {
+  return request({
+    url: api.AdminSetUserBan,
+    method: 'post',
+    data: parameter
+  })
+}
+
 export function BindEmail(parameter) {
   return request({
     url: api.BindEmail,

BIN
src/assets/img/homePage/figma/hero-illustration.png


+ 33 - 4
src/components/AIChat/index.vue

@@ -9,8 +9,15 @@
         </template>
         <div class="container">
             <div class="messages" ref="messagesContainer">
-                <a-spin :loading="messagesLoading">
-                    <div v-for="(item, index) in messages">
+                <a-skeleton animation :loading="messagesLoading" class="messages-skeleton">
+                    <div class="messages-skeleton__list">
+                        <div v-for="n in 3" :key="n" class="messages-skeleton__item" :class="{ 'messages-skeleton__item--right': n === 2 }">
+                            <a-skeleton-shape shape="circle" />
+                            <a-skeleton-line :widths="['180px', '220px']" :rows="2" />
+                        </div>
+                    </div>
+                    <template #content>
+                        <div v-for="(item, index) in messages" :key="index">
                         <div class="time"
                             v-if="!messages[index - 1] || (messages[index].time - messages[index - 1].time) > 300000">{{
                                 stramptoTime(item.time) }}</div>
@@ -30,8 +37,9 @@
                                 </template>
                             </a-dropdown>
                         </div>
-                    </div>
-                </a-spin>
+                        </div>
+                    </template>
+                </a-skeleton>
             </div>
 
             <div class="inputBox">
@@ -262,6 +270,27 @@ const deleteMessages = () => {
         display: flex;
         flex-direction: column;
 
+        .messages-skeleton {
+            width: 100%;
+
+            &__list {
+                display: flex;
+                flex-direction: column;
+                gap: 16px;
+                padding: 8px 0;
+            }
+
+            &__item {
+                display: flex;
+                align-items: flex-start;
+                gap: 10px;
+
+                &--right {
+                    flex-direction: row-reverse;
+                }
+            }
+        }
+
         .time {
             color: #888;
             font-size: 0.9em;

+ 23 - 0
src/components/BindBot/bindBot.vue

@@ -89,4 +89,27 @@ defineExpose({ openModal })
         }
     }
 }
+
+@media (max-width: 768px) {
+    .container {
+        flex-direction: column;
+        align-items: stretch;
+        gap: 12px;
+    }
+
+    .container .left {
+        display: flex;
+        justify-content: center;
+    }
+
+    .container .line {
+        width: 100%;
+        height: 1px;
+    }
+
+    .container .right .faceCode .value {
+        font-size: 1.75em;
+        word-break: break-all;
+    }
+}
 </style>

+ 79 - 9
src/components/Breadcrumb/index.vue

@@ -4,22 +4,88 @@
       <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
+        v-for="(item, index) in breadcrumbItems"
+        :key="item.key"
+      >
+        <span
+          class="item"
+          :class="{ clickable: item.path && index !== breadcrumbItems.length - 1 }"
+          @click="handleClick(item, index)"
+        >
+          {{ item.title }}
+        </span>
       </a-breadcrumb-item>
     </a-breadcrumb>
 
 </template>
 
 <script setup>
-defineProps({
-  items: {
-    type: [],
-    default() {
-      return [];
-    },
-  },
+import { computed } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const route = useRoute();
+const router = useRouter();
+
+const buildPathByRecord = (recordPath, params) => {
+  return recordPath.replace(/:([^/]+)/g, (_, paramKey) => {
+    const value = params[paramKey];
+    return value == null ? '' : String(value);
+  });
+};
+
+const recordToItem = (record, params = {}) => {
+  const path =
+    typeof record.redirect === 'string'
+      ? record.redirect
+      : buildPathByRecord(record.path, params);
+
+  return {
+    key: record.name || record.path,
+    title: record.meta.title,
+    path,
+  };
+};
+
+const resolveParentChain = (parentName) => {
+  const parents = [];
+  let name = parentName;
+
+  while (name) {
+    const resolved = router.resolve({ name });
+    const record = resolved.matched.find((m) => m.name === name);
+    if (!record?.meta?.title) break;
+
+    parents.unshift({
+      ...recordToItem(record, resolved.params),
+      path: resolved.path,
+    });
+    name = record.meta.parent;
+  }
+
+  return parents;
+};
+
+const breadcrumbItems = computed(() => {
+  const matchedWithTitle = route.matched.filter((record) => record.meta?.title);
+  if (!matchedWithTitle.length) return [];
+
+  const ancestors = matchedWithTitle.slice(0, -1).map((record) =>
+    recordToItem(record, route.params),
+  );
+  const leaf = matchedWithTitle[matchedWithTitle.length - 1];
+  const parentChain = leaf.meta?.parent
+    ? resolveParentChain(leaf.meta.parent)
+    : [];
+
+  return [...ancestors, ...parentChain, recordToItem(leaf, route.params)];
 });
+
+const handleClick = (item, index) => {
+  if (!item.path || index === breadcrumbItems.value.length - 1) return;
+  if (item.path === route.path) return;
+  router.push(item.path);
+};
 </script>
 
 <style scoped lang="less">
@@ -34,6 +100,10 @@ defineProps({
 }
 
 .item {
+  cursor: default;
+}
+
+.clickable {
   cursor: pointer;
 }
 </style>

+ 143 - 173
src/components/CanvasBackend/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div class="canvas-backend" aria-hidden="true">
     <canvas ref="webgl" class="webgl"></canvas>
   </div>
 </template>
@@ -8,226 +8,196 @@
 import * as THREE from 'three'
 
 export default {
-  name: "WebGLComponent",
+  name: 'WebGLComponent',
   data() {
     return {
-      mouseX: 0,  // 鼠标的X坐标,用于追踪鼠标移动
-      mouseY: 0   // 鼠标的Y坐标
-    };
+      mouseX: 0,
+      mouseY: 0
+    }
   },
   mounted() {
-    this.initWebGL();  // 组件加载完成后,初始化WebGL
+    this.initWebGL()
+  },
+  beforeUnmount() {
+    document.removeEventListener('mousemove', this.onDocumentMouseMove, false)
+    document.removeEventListener('touchstart', this.onDocumentTouchStart, false)
+    document.removeEventListener('touchmove', this.onDocumentTouchMove, false)
   },
   methods: {
-    // 初始化WebGL的方法
     initWebGL() {
-      const canvas = this.$refs.webgl;  // 获取canvas元素
-      canvas.width = window.innerWidth;  // 设置canvas的宽度为窗口宽度
-      canvas.height = window.innerHeight;  // 设置canvas的高度为窗口高度
+      const canvas = this.$refs.webgl
+      if (!canvas) return
+
+      canvas.width = window.innerWidth
+      canvas.height = window.innerHeight
 
-      const gl = canvas.getContext("webgl");  // 获取WebGL上下文
+      const gl = canvas.getContext('webgl')
+      if (!gl) return
 
-      // 顶点着色器源代码
       const vertexShaderSource = `
-        attribute vec4 position;  // 顶点位置
-        attribute float scale;  // 顶点大小
-        uniform mat4 modelViewMatrix;  // 视图矩阵
-        uniform mat4 projectionMatrix;  // 投影矩阵
+        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;  // 最终的顶点位置
+          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.98, 0.54, 0.51, 1.0);  // 设置点的颜色
+          if (length(gl_PointCoord - vec2(0.5, 0.5)) > 0.49) discard;
+          gl_FragColor = vec4(0.32, 0.72, 0.47, 0.35);
         }
-      `;
+      `
 
-      // 初始化着色器程序
-      const program = this.initShader(gl, vertexShaderSource, fragShaderSource);
+      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 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 SEPARATION = 100
+      const AMOUNTX = 50
+      const AMOUNTY = 50
 
-      // 存储粒子的位置和大小
-      const positions = new Float32Array(numParticles * 3);
-      const scales = new Float32Array(numParticles);
+      const positions = new Float32Array(AMOUNTX * AMOUNTY * 3)
+      const scales = new Float32Array(AMOUNTX * AMOUNTY)
 
-      let i = 0, j = 0;
-      // 初始化粒子的位置和大小
+      let i = 0
+      let 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++;
+          positions[i] = ix * SEPARATION - (AMOUNTX * SEPARATION) / 2
+          positions[i + 1] = 0
+          positions[i + 2] = iy * SEPARATION - (AMOUNTY * SEPARATION) / 2
+          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 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)
+
+      const mat4 = new THREE.Matrix4()
+      mat4.copy(camera.projectionMatrix)
+      gl.uniformMatrix4fv(projectionMatrixLoc, false, new Float32Array(mat4.elements))
+
+      const mat4y = new THREE.Matrix4()
+      mat4y.copy(camera.matrixWorldInverse)
+      gl.uniformMatrix4fv(modelViewMatrixLoc, false, new Float32Array(mat4y.elements))
+
+      let count = 0
+      let rafId = null
+
       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;
+        camera.position.x += (this.mouseX - camera.position.x) * 0.01
+        camera.updateMatrixWorld(true)
+        mat4y.copy(camera.matrixWorldInverse)
+        gl.uniformMatrix4fv(modelViewMatrixLoc, false, new Float32Array(mat4y.elements))
+
+        let pi = 0
+        let pj = 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++;
+            positions[pi + 1] =
+              Math.sin((ix + count) * 0.3) * 50 + Math.sin((iy + count) * 0.5) * 50
+            scales[pj] =
+              (Math.sin((ix + count) * 0.3) + 1.3) * 8 + (Math.sin((iy + count) * 0.5) + 1.3) * 8
+            pi += 3
+            pj++
           }
         }
-        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);
-      };
+        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)
+
+        rafId = requestAnimationFrame(draw)
+        gl.drawArrays(gl.POINTS, 0, AMOUNTX * AMOUNTY)
+      }
+
+      draw()
+
+      this._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)
+        gl.uniformMatrix4fv(projectionMatrixLoc, false, new Float32Array(mat4.elements))
+      }
+
+      window.addEventListener('resize', this._onResize)
+      document.addEventListener('mousemove', this.onDocumentMouseMove, false)
     },
 
-    // 初始化着色器的方法
     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;
+      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;
-      
+      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;
-      }
-    },
+    onDocumentTouchStart() {},
 
-    // 触摸移动事件处理
-    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;
-      }
-    }
+    onDocumentTouchMove() {}
   }
-};
+}
 </script>
 
 <style scoped>
-body {
-  padding: 0;
-  margin: 0;
-  background-color: transparent;
-  font-family: "微软雅黑";
+.canvas-backend {
+  position: fixed;
+  inset: 0;
+  z-index: 0;
+  pointer-events: none;
   overflow: hidden;
 }
 
 .webgl {
-  position: absolute;
-  bottom: 0;
-  left: 0;
-  /* height: 100%; */
-  background-color: transparent;
+  display: block;
   width: 100%;
+  height: 100%;
+  pointer-events: none;
 }
 </style>

+ 31 - 0
src/components/EmailAutoComplete/index.vue

@@ -0,0 +1,31 @@
+<script setup>
+import { useEmailAutocomplete } from '@/utils/emailAutocomplete'
+
+defineOptions({ inheritAttrs: false })
+
+defineProps({
+  modelValue: {
+    type: String,
+    default: '',
+  },
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+const { emailOptions, handleEmailSearch } = useEmailAutocomplete()
+</script>
+
+<template>
+  <a-auto-complete
+    :data="emailOptions"
+    :model-value="modelValue"
+    allow-clear
+    v-bind="$attrs"
+    @search="handleEmailSearch"
+    @update:model-value="emit('update:modelValue', $event)"
+  >
+    <template v-for="(_, name) in $slots" #[name]="slotData">
+      <slot :name="name" v-bind="slotData || {}" />
+    </template>
+  </a-auto-complete>
+</template>

+ 7 - 8
src/components/Footer/index.vue

@@ -6,20 +6,19 @@
 
 <style scoped>
 .footer {
-    cursor: pointer;
-    position: absolute;
-    bottom: 15px;
-    left: 50%;
-    transform: translateX(-50%);
-    color: #777;
+    flex-shrink: 0;
+    width: 100%;
+    padding: 20px 16px;
+    box-sizing: border-box;
+    text-align: center;
+    color: rgba(27, 48, 34, 0.62);
     font-size: 12px;
     display: flex;
-    min-width: 300px;
     justify-content: center;
     gap: 10px;
 }
 
 a {
-    color: #777;
+    color: rgba(27, 48, 34, 0.62);
 }
 </style>

+ 266 - 49
src/components/Header/index.vue

@@ -1,33 +1,62 @@
 <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="哪吒乐跑" src="/logo.svg" height="40">
-                    <span class="title">哪吒乐跑</span>
+    <header class="site-header">
+        <div class="header-inner">
+            <div class="logo" @click="$router.push('/')">
+                <img alt="哪吒乐跑" src="/logo.svg" height="40">
+                <span class="title">哪吒乐跑</span>
+            </div>
+            <div class="nav-actions" :style="{ color: props.color }">
+                <div class="nav-links">
+                    <a-button
+                        type="text"
+                        class="nav-btn nav-link"
+                        aria-label="文档中心"
+                        @click="$router.push('/doc')"
+                    >
+                        <icon-book class="nav-btn-icon" />
+                        <span class="nav-btn-label">文档中心</span>
+                    </a-button>
+                    <a-button
+                        type="text"
+                        class="nav-btn nav-link"
+                        aria-label="下载中心"
+                        @click="$router.push('/download')"
+                    >
+                        <icon-download class="nav-btn-icon" />
+                        <span class="nav-btn-label">下载中心</span>
+                    </a-button>
                 </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>
-
+                <a-button
+                    v-if="!user"
+                    type="text"
+                    class="nav-btn nav-auth"
+                    aria-label="用户登录"
+                    @click="$router.push('/login')"
+                >
+                    <icon-user class="nav-btn-icon" />
+                    <span class="nav-btn-label nav-btn-label--full">用户登录</span>
+                    <span class="nav-btn-label nav-btn-label--short">登录</span>
+                </a-button>
+                <a-dropdown v-else trigger="click" position="br">
+                    <button type="button" class="user-trigger" :style="{ color: props.color }">
+                        <a-avatar class="user-avatar" :size="30">
+                            <img alt="avatar" :src="user.avatar" />
+                        </a-avatar>
+                        <span class="username">{{ user.username }}</span>
+                        <icon-down class="user-trigger-caret" />
+                    </button>
+                    <template #content>
+                        <a-doption @click="$router.push('/user')">
+                            <icon-user /> 个人中心
+                        </a-doption>
+                        <a-doption @click="logout">
+                            <icon-export /> 退出登录
+                        </a-doption>
+                    </template>
+                </a-dropdown>
+            </div>
+        </div>
+    </header>
 </template>
 
 <script setup>
@@ -45,7 +74,7 @@ const props = defineProps({
 const user = ref('')
 const getuser = async () => {
     const userStore = useUserStore()
-    let userInfo = await userStore.getInfo()
+    const userInfo = await userStore.getInfo()
     if (userInfo?.avatar && userInfo?.username && userInfo?.uuid && userInfo?.session)
         user.value = userInfo
 }
@@ -59,49 +88,237 @@ const logout = () => {
             userStore.logout()
             Message.success('退出成功!')
             setTimeout(() => { window.location.reload() }, 1000)
-        },
-        onCancel: () => {
-
         }
-    });
+    })
 }
 
 getuser()
-
 </script>
 
-<style scoped>
-.root {
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+@header-mobile-nav-size: 36px;
+@header-mobile-icon-size: 20px;
+
+.site-header {
     position: absolute;
     top: 0;
-    left: 50%;
-    transform: translateX(-50%);
-    width: 80%;
-    min-width: 1280px;
+    left: 0;
+    right: 0;
     z-index: 100;
+    padding: clamp(12px, 2vh, 16px) 0;
+    box-sizing: border-box;
     user-select: none;
 }
 
-.menu {
+.header-inner {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: clamp(12px, 2vw, 24px);
     width: 100%;
-    background: transparent;
+    max-width: 100%;
+    margin: 0 auto;
+    padding-inline: @store-page-padding-x;
+    box-sizing: border-box;
 }
 
 .logo {
     display: flex;
+    align-items: center;
+    cursor: pointer;
+    flex-shrink: 0;
+    min-width: 0;
 }
 
-.logo span {
+.logo .title {
     font-size: 1.5em;
     margin: 5px 8px;
+    white-space: nowrap;
+    line-height: 1;
+}
+
+.nav-actions {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 1;
+    min-width: 0;
+    margin-left: auto;
+
+    :deep(.arco-btn.nav-btn) {
+        height: 38px;
+        padding: 0 12px;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        gap: 6px;
+        line-height: 1;
+        color: inherit;
+    }
+
+    :deep(.arco-dropdown) {
+        display: inline-flex;
+        align-items: center;
+        flex-shrink: 0;
+    }
+}
+
+.nav-links {
+    display: flex;
+    align-items: center;
+    gap: 4px 12px;
+}
+
+.nav-btn-icon {
+    display: none;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    font-size: 16px;
+    line-height: 1;
+}
+
+.nav-btn-label {
+    white-space: nowrap;
+}
+
+.nav-btn {
+    font-size: 15px;
+}
+
+.mobile-nav-icon-btn() {
+    width: @header-mobile-nav-size;
+    min-width: @header-mobile-nav-size;
+    height: @header-mobile-nav-size;
+    padding: 0;
+    border-radius: 50%;
+    background: fade(#fff, 58%);
+    border: 1px solid fade(#1b3022, 12%);
+    backdrop-filter: blur(10px);
+    box-shadow: 0 2px 8px fade(#1b3022, 5%);
+}
+
+.mobile-nav-icon-normalize() {
+    width: @header-mobile-icon-size;
+    height: @header-mobile-icon-size;
+    font-size: @header-mobile-icon-size;
+    line-height: 1;
+    margin: 0;
+
+    svg {
+        width: @header-mobile-icon-size;
+        height: @header-mobile-icon-size;
+    }
+}
+
+.nav-auth .nav-btn-label--short {
+    display: none;
+}
+
+.user-trigger {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    max-width: min(42vw, 200px);
+    height: 38px;
+    padding: 0 8px;
+    border: none;
+    background: transparent;
+    cursor: pointer;
+    font-size: 15px;
+    line-height: 1;
+    border-radius: 8px;
+    transition: background-color 0.2s;
+    box-sizing: border-box;
+    flex-shrink: 0;
+}
+
+.user-trigger:hover {
+    background: rgba(27, 48, 34, 0.06);
 }
 
-.userinfo {
-    margin-top: -15px;
-    transform: translateY(23px);
+.username {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+@media (max-width: @app-breakpoint-lg) {
+    .logo .title {
+        font-size: 1.25em;
+        margin: 4px 6px;
+    }
+
+    .nav-actions {
+        gap: 8px;
+        flex-shrink: 0;
+    }
+
+    .nav-links {
+        gap: 8px;
+        padding: 0;
+        background: none;
+        border: none;
+        box-shadow: none;
+        backdrop-filter: none;
+    }
+
+    .nav-btn-label {
+        display: none !important;
+    }
+
+    .nav-btn-icon {
+        display: inline-flex;
+        .mobile-nav-icon-normalize();
+    }
+
+    :deep(.arco-btn.nav-btn.nav-link),
+    :deep(.arco-btn.nav-btn.nav-auth) {
+        .mobile-nav-icon-btn();
+    }
+
+    :deep(.arco-btn.nav-btn .arco-icon) {
+        .mobile-nav-icon-normalize();
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+    }
+
+    :deep(.arco-btn.nav-btn:hover) {
+        background: fade(#fff, 72%);
+    }
+
+    .username {
+        display: none;
+    }
+
+    .user-trigger-caret {
+        display: none;
+    }
+
+    .user-trigger {
+        .mobile-nav-icon-btn();
+        max-width: none;
+        justify-content: center;
+    }
+
+    .user-trigger:hover {
+        background: fade(#fff, 72%);
+    }
+
+    :deep(.user-avatar) {
+        width: @header-mobile-icon-size !important;
+        height: @header-mobile-icon-size !important;
+        line-height: @header-mobile-icon-size !important;
+    }
 }
 
-.userinfo span {
-    margin-left: 7px;
+@media (max-width: @app-breakpoint-sm) {
+    .logo .title {
+        display: none;
+    }
 }
-</style>
+</style>

+ 639 - 0
src/components/LepaoAccountCard/accountDetailCard.vue

@@ -0,0 +1,639 @@
+<template>
+  <a-modal v-model:visible="visible" :title="modalTitle" :width="980" :footer="false" unmount-on-close esc-to-close
+    draggable class="account-detail-modal">
+    <a-spin :loading="loading" style="width: 100%">
+      <template v-if="account">
+        <div class="account-header">
+          <a-avatar :size="64">
+            <img :alt="account.name ?? ''" :src="account.user_avatar || defaultAvatar" />
+          </a-avatar>
+          <div class="account-header-main">
+            <div class="account-name">{{ account.name || '未同步姓名' }}</div>
+            <div class="account-meta">
+              <span>学号 {{ account.student_num }}</span>
+              <a-divider direction="vertical" />
+              <span>{{ sexLabel }}</span>
+              <a-divider direction="vertical" />
+              <span>{{ account.grade_id || '-' }} 级</span>
+            </div>
+          </div>
+          <a-tag :color="stateTag.color">{{ stateTag.text }}</a-tag>
+        </div>
+
+        <a-descriptions :column="isMobile ? 1 : 3" bordered size="small" class="account-desc">
+          <a-descriptions-item v-if="admin" label="创建用户">{{ account.create_user || '-' }}</a-descriptions-item>
+          <a-descriptions-item v-if="admin" label="人脸状态">{{ faceStateLabel }}</a-descriptions-item>
+          <a-descriptions-item v-if="admin" label="手机型号">{{ account.deviceModel || '-' }}</a-descriptions-item>
+          <a-descriptions-item label="学院">{{ account.academy_name || '-' }}</a-descriptions-item>
+          <a-descriptions-item label="跑区">{{ account.area || '随机分配' }}</a-descriptions-item>
+          <a-descriptions-item label="通知方式">{{ noticeLabel }}</a-descriptions-item>
+          <a-descriptions-item label="自动乐跑">
+            <a-tag v-if="account.auto_run" color="green">{{ autoRunLabel }}</a-tag>
+            <a-tag v-else color="red">关闭</a-tag>
+          </a-descriptions-item>
+          <a-descriptions-item label="自动星期">{{ autoDayLabel }}</a-descriptions-item>
+          <a-descriptions-item label="自动时段">{{ autoTimeLabel }}</a-descriptions-item>
+          <a-descriptions-item label="学期目标">{{ termProgressLabel }}</a-descriptions-item>
+          <a-descriptions-item label="添加时间">{{ formatTime(account.create_time) }}</a-descriptions-item>
+          <a-descriptions-item label="上次更新">{{ account.update_time ? formatTime(account.update_time) : '待登录'
+            }}</a-descriptions-item>
+          <a-descriptions-item v-if="admin" label="UA" :span="3">{{ account.userAgent || '-' }}</a-descriptions-item>
+          <a-descriptions-item label="备注" :span="3">{{ account.notes || '-' }}</a-descriptions-item>
+        </a-descriptions>
+
+        <div class="section-title">
+          <span>乐跑日历</span>
+          <a-select v-model="calendarYear" :options="yearOptions" size="small" style="width: 110px" />
+        </div>
+        <Chart :options="calendarOption" height="180px" width="100%" />
+
+        <div class="section-title">
+          <span>乐跑记录</span>
+          <span class="record-count">{{ activeRecordCountLabel }}</span>
+        </div>
+        <a-tabs v-model:active-key="recordTab" @change="handleRecordTabChange">
+          <a-tab-pane key="platform" title="平台记录">
+            <a-table :data="records" :pagination="{ pageSize: 8, showTotal: true }" :scroll="{ x: 900, y: 280 }"
+              size="small" row-key="id" :bordered="false">
+              <template #columns>
+                <a-table-column v-if="admin" title="所属用户" :width="120">
+                  <template #cell="{ record }">{{ record.username || '-' }}</template>
+                </a-table-column>
+                <a-table-column title="状态" :width="200">
+                  <template #cell="{ record }">
+                    <div class="state">
+                      <div class="circle" :class="record.result?.record_failed_reason === '自动确认有效' ? 'one' : 'else'" />
+                      {{ record.result?.record_failed_reason }}
+                    </div>
+                  </template>
+                </a-table-column>
+                <a-table-column title="跑区" :width="180">
+                  <template #cell="{ record }">{{ record.result?.pass_tit }}</template>
+                </a-table-column>
+                <a-table-column title="距离" :width="90">
+                  <template #cell="{ record }">{{ record.result?.distance }} Km</template>
+                </a-table-column>
+                <a-table-column title="时长" :width="100">
+                  <template #cell="{ record }">{{ formatSecondsToMinSec(record.result?.time) }}</template>
+                </a-table-column>
+                <a-table-column title="配速" :width="80">
+                  <template #cell="{ record }">
+                    {{ calculatePace(record.result?.time, record.result?.distance) }}
+                  </template>
+                </a-table-column>
+                <a-table-column title="乐跑时间" :width="160">
+                  <template #cell="{ record }">{{ formatTime(record.time) }}</template>
+                </a-table-column>
+                <a-table-column title="" :width="90" :fixed="tableFixed('right')">
+                  <template #cell="{ record }">
+                    <a-button type="text" size="small" @click="openPlatformRecordDetail(record)">详情</a-button>
+                  </template>
+                </a-table-column>
+              </template>
+            </a-table>
+          </a-tab-pane>
+          <a-tab-pane key="official" title="官方记录" :disabled="!canViewOfficialRecords">
+            <a-alert v-if="!canViewOfficialRecords" type="warning" class="official-record-alert"
+              content="仅状态为正常的账号可查看官方记录,请使用登录器更新账号后再试。" />
+            <a-table :data="officialRecords" :loading="officialLoading" :pagination="officialPaginationConfig"
+              :scroll="{ x: 900, y: 280 }" size="small" row-key="id" :bordered="false"
+              @page-change="handleOfficialPageChange">
+              <template #columns>
+                <a-table-column title="状态" :width="70">
+                  <template #cell="{ record }">
+                    <a-tag :color="String(record.record_status) === '1' ? 'green' : 'orangered'">
+                      {{ officialRecordStatus(record) }}
+                    </a-tag>
+                  </template>
+                </a-table-column> 
+                <a-table-column title="跑区" :width="160">
+                  <template #cell="{ record }">{{ record.title || '-' }}</template>
+                </a-table-column>
+                <a-table-column title="类型" :width="100">
+                  <template #cell="{ record }">{{ record.tag || record.point_type_str || '-' }}</template>
+                </a-table-column>
+                <a-table-column title="配速" :width="80">
+                  <template #cell="{ record }">
+                    {{ calculatePace(Number(record.end_time) - Number(record.start_time), record.distance) }}
+                  </template>
+                </a-table-column>
+                <a-table-column title="距离" :width="90">
+                  <template #cell="{ record }">{{ record.distance || '-' }} Km</template>
+                </a-table-column>
+                <a-table-column title="时长" :width="100">
+                  <template #cell="{ record }">{{ formatOfficialDuration(record) }}</template>
+                </a-table-column>
+                <a-table-column title="乐跑时间" :width="160">
+                  <template #cell="{ record }">{{ formatUnixTime(record.start_time) }}</template>
+                </a-table-column>
+              </template>
+            </a-table>
+          </a-tab-pane>
+        </a-tabs>
+      </template>
+    </a-spin>
+  </a-modal>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import {
+  lepaoRecords,
+  adminLepaoRecords,
+  officialTermRecords,
+  adminOfficialTermRecords
+} from '@/api/lepao'
+import { Notification, Message } from '@arco-design/web-vue'
+import useChartOption from '@/hooks/chart-option'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const props = defineProps({
+  admin: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const defaultAvatar = 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'
+
+const auto_day = [
+  { label: '周一', value: 1 },
+  { label: '周二', value: 2 },
+  { label: '周三', value: 3 },
+  { label: '周四', value: 4 },
+  { label: '周五', value: 5 },
+  { label: '周六', value: 6 },
+  { label: '周日', value: 0 }
+]
+
+const auto_time_options = [
+  { label: '随机分配', value: -1 },
+  ...Array.from({ length: 17 }, (_, i) => {
+    const hour = i + 7
+    return { label: `${hour} ~ ${hour + 1}时`, value: hour }
+  })
+]
+
+const visible = ref(false)
+const loading = ref(false)
+const account = ref(null)
+const records = ref([])
+const officialRecords = ref([])
+const officialLoading = ref(false)
+const officialSummary = ref({})
+const officialPagination = ref({ current: 1, pageSize: 10, total: 0 })
+const recordTab = ref('platform')
+const calendarYear = ref(new Date().getFullYear())
+const { isMobile, tableFixed } = useResponsiveTable()
+const router = useRouter()
+
+const modalTitle = computed(() => {
+  if (!account.value) return '账号详情'
+  const name = account.value.name || account.value.student_num
+  return `${name} · 账号详情`
+})
+
+const sexLabel = computed(() => {
+  if (!account.value) return '-'
+  if (account.value.sex === 1) return '男'
+  if (account.value.sex === 2) return '女'
+  return '-'
+})
+
+const stateTag = computed(() => {
+  const state = account.value?.state
+  if (state === 0) return { text: '需登录', color: 'orangered' }
+  if (state === 1) return { text: '正常', color: 'green' }
+  return { text: '状态异常', color: 'red' }
+})
+
+const noticeLabel = computed(() => {
+  const item = account.value
+  if (!item) return '-'
+  if (item.notice_type === 'email') return `📧 ${item.email || '未绑定'}`
+  if (item.notice_type === 'bot') return `🤖 ${item.bot_account || '未绑定'}`
+  return '无通知'
+})
+
+const autoRunLabel = computed(() => {
+  const item = account.value
+  if (!item?.auto_run) return '关闭'
+  return item.target_count === 0 ? '开启 · 不限次' : `开启 · ${item.target_count} 次`
+})
+
+const autoDayLabel = computed(() => {
+  const item = account.value
+  if (!item?.auto_run || !item.auto_day?.length) return '-'
+  return item.auto_day
+    .slice()
+    .sort((a, b) => {
+      if (a === 0) return 1
+      if (b === 0) return -1
+      return a - b
+    })
+    .map((day) => auto_day.find((d) => d.value === day)?.label)
+    .join('、')
+})
+
+const autoTimeLabel = computed(() => {
+  const item = account.value
+  if (!item?.auto_run) return '-'
+  if (item.auto_time === -1) {
+    if (item.today_auto_time) return `随机 · 今日 ${item.today_auto_time} 时`
+    return '随机 · 待分配'
+  }
+  return auto_time_options.find((t) => t.value === item.auto_time)?.label || '-'
+})
+
+const termProgressLabel = computed(() => {
+  const item = account.value
+  if (!item) return '-'
+  if (item.term_num != null && item.total_num != null && item.term_num !== item.total_num) {
+    return `${item.total_num} / ${item.term_num}`
+  }
+  return '已完成'
+})
+
+const faceStateLabel = computed(() => {
+  const faceState = account.value?.face_state
+  if (faceState === 0) return '未采集'
+  if (faceState === 1) return '已通过'
+  if (faceState != null) return '不通过'
+  return '-'
+})
+
+const canViewOfficialRecords = computed(() => account.value?.state === 1)
+
+const activeRecordCountLabel = computed(() => {
+  if (recordTab.value === 'official') {
+    const total = officialPagination.value.total || officialRecords.value.length
+    return `官方记录共 ${total} 条,有效次数 ${officialSummary.value?.total_score_num ?? 0} 次(${officialSummary.value?.term_name ?? '本学期'})`
+  }
+  return `平台记录共 ${records.value.length} 条`
+})
+
+const officialPaginationConfig = computed(() => ({
+  current: officialPagination.value.current,
+  pageSize: officialPagination.value.pageSize,
+  total: officialPagination.value.total || officialRecords.value.length,
+  showTotal: true,
+  showPageSize: false
+}))
+
+const yearOptions = computed(() => {
+  const years = new Set([calendarYear.value, new Date().getFullYear()])
+  records.value.forEach((record) => {
+    years.add(new Date(record.time).getFullYear())
+  })
+  return Array.from(years)
+    .sort((a, b) => b - a)
+    .map((year) => ({ label: `${year} 年`, value: year }))
+})
+
+const formatDateKey = (timestamp) => {
+  const d = new Date(timestamp)
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${day}`
+}
+
+const buildCalendarSeriesData = () => {
+  const counter = new Map()
+  records.value.forEach((record) => {
+    const date = new Date(record.time)
+    if (date.getFullYear() !== calendarYear.value) return
+    const key = formatDateKey(record.time)
+    counter.set(key, (counter.get(key) || 0) + 1)
+  })
+  return Array.from(counter.entries()).map(([date, count]) => [date, count])
+}
+
+const { chartOption: calendarOption } = useChartOption(() => {
+  const seriesData = buildCalendarSeriesData()
+  const maxCount = Math.max(...seriesData.map((item) => item[1]), 1)
+
+  return {
+    tooltip: {
+      position: 'top',
+      formatter(params) {
+        if (!params?.data) return ''
+        return `${params.data[0]}<br/>乐跑 ${params.data[1]} 次`
+      }
+    },
+    visualMap: {
+      min: 0,
+      max: maxCount,
+      show: false,
+      inRange: {
+        color: ['#fff0f5', '#FE82A5']
+      }
+    },
+    calendar: {
+      top: 36,
+      left: 40,
+      right: 20,
+      cellSize: ['auto', 18],
+      range: String(calendarYear.value),
+      itemStyle: {
+        borderWidth: 0.5,
+        borderColor: 'var(--color-border-2)'
+      },
+      yearLabel: { show: true, fontSize: 12 },
+      monthLabel: { fontSize: 11 },
+      dayLabel: { fontSize: 10 }
+    },
+    series: [
+      {
+        type: 'heatmap',
+        coordinateSystem: 'calendar',
+        data: seriesData
+      }
+    ]
+  }
+})
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(time).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const formatUnixTime = (time) => {
+  const timestamp = Number(time)
+  if (!Number.isFinite(timestamp) || timestamp <= 0) return '-'
+  return formatTime(timestamp * 1000)
+}
+
+function calculatePace(seconds, kilometers) {
+  if (!seconds || !kilometers) return '-'
+  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) {
+  if (!totalSeconds && totalSeconds !== 0) return '-'
+  const minutes = Math.floor(totalSeconds / 60)
+  const seconds = totalSeconds % 60
+  return `${minutes}分${seconds.toString().padStart(2, '0')}秒`
+}
+
+const formatOfficialDuration = (record) => {
+  const start = Number(record?.start_time)
+  const end = Number(record?.end_time)
+  if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return '-'
+  return formatSecondsToMinSec(end - start)
+}
+
+const officialRecordStatus = (record) => {
+  const status = String(record?.record_status ?? '')
+  if (status === '1') return '有效'
+  return '无效'
+}
+
+const fetchAllRecords = async (studentNum) => {
+  const all = []
+  let current = 1
+  const pagesize = 100
+  let total = 0
+
+  const fetchRecords = props.admin ? adminLepaoRecords : lepaoRecords
+
+  do {
+    const res = await fetchRecords({
+      lepao_account: studentNum,
+      pagesize,
+      current
+    })
+    if (!res || res.code !== 0) {
+      throw new Error(res?.msg || '获取乐跑记录失败')
+    }
+    all.push(...(res.data || []))
+    total = res.pagination?.total ?? all.length
+    current += 1
+  } while (all.length < total)
+
+  return all.sort((a, b) => b.time - a.time)
+}
+
+const fetchOfficialRecords = async (page = 1) => {
+  if (!account.value?.student_num) return
+  if (!canViewOfficialRecords.value) {
+    Message.warning('请使用登录器更新账号后查看官方乐跑记录')
+    return
+  }
+
+  officialLoading.value = true
+  try {
+    const fetchRecords = props.admin ? adminOfficialTermRecords : officialTermRecords
+    const res = await fetchRecords({
+      student_num: account.value.student_num,
+      page
+    })
+    if (!res || res.code !== 0) {
+      if (account.value && String(res?.msg || '').includes('登录')) {
+        account.value.state = 0
+      }
+      throw new Error(res?.msg || '获取乐跑记录失败')
+    }
+
+    const data = res.data || {}
+    officialSummary.value = data
+    officialRecords.value = data.list || []
+    officialPagination.value = {
+      ...officialPagination.value,
+      current: data.page || page,
+      total: Number(data.total_num || officialRecords.value.length)
+    }
+  } catch (error) {
+    Notification.error({
+      title: '获取官方乐跑记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    officialLoading.value = false
+  }
+}
+
+const handleRecordTabChange = (key) => {
+  if (key !== 'official') return
+  if (!canViewOfficialRecords.value) {
+    recordTab.value = 'platform'
+    Message.warning('请使用登录器更新账号后查看官方记录')
+    return
+  }
+  if (officialRecords.value.length === 0) {
+    fetchOfficialRecords(1)
+  }
+}
+
+const handleOfficialPageChange = (page) => {
+  fetchOfficialRecords(page)
+}
+
+const openPlatformRecordDetail = (record) => {
+  const key = record.public_id || record.id
+  if (!key) {
+    Message.warning('该记录缺少可用标识,无法打开详情')
+    return
+  }
+  visible.value = false
+  const path = props.admin
+    ? `/admin/lepaoRecords/${encodeURIComponent(String(key))}`
+    : `/lepao/recordDetail/${encodeURIComponent(String(key))}`
+  router.push(path)
+}
+
+const openModal = async (record) => {
+  if (!record?.student_num) return
+  account.value = record
+  visible.value = true
+  loading.value = true
+  records.value = []
+  officialRecords.value = []
+  officialSummary.value = {}
+  officialPagination.value = { current: 1, pageSize: 10, total: 0 }
+  recordTab.value = 'platform'
+
+  try {
+    const list = await fetchAllRecords(record.student_num)
+    records.value = list
+    if (list.length > 0) {
+      calendarYear.value = new Date(list[0].time).getFullYear()
+    } else {
+      calendarYear.value = new Date().getFullYear()
+    }
+  } catch (error) {
+    Notification.error({
+      title: '获取乐跑记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+defineExpose({ openModal })
+</script>
+
+<style scoped lang="less">
+.account-detail-modal {
+  :deep(.arco-modal-body) {
+    max-height: 78vh;
+    overflow-y: auto;
+  }
+}
+
+.account-header {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.account-header-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.account-name {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 1.4;
+}
+
+.account-meta {
+  margin-top: 4px;
+  color: var(--color-text-3);
+  font-size: 13px;
+}
+
+.account-desc {
+  margin-bottom: 20px;
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin: 16px 0 8px;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--color-text-1);
+}
+
+.record-count {
+  font-size: 12px;
+  font-weight: 400;
+  color: var(--color-text-3);
+}
+
+.official-record-alert {
+  margin-bottom: 12px;
+}
+
+.state {
+  display: flex;
+  align-items: center;
+
+  .circle {
+    border-radius: 50%;
+    height: 8px;
+    width: 8px;
+    margin-right: 6px;
+    flex-shrink: 0;
+  }
+
+  .one {
+    background-color: rgb(var(--green-6));
+  }
+
+  .else {
+    background-color: rgb(var(--red-6));
+  }
+}
+
+@media (max-width: 768px) {
+  .account-header {
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 10px;
+  }
+
+  .section-title {
+    flex-wrap: wrap;
+    gap: 8px;
+  }
+
+  .record-count {
+    width: 100%;
+  }
+
+  .account-detail-modal {
+    :deep(.arco-modal) {
+      width: calc(100vw - 16px) !important;
+      max-width: calc(100vw - 16px) !important;
+      margin: 8px auto !important;
+    }
+
+    :deep(.arco-descriptions-bordered .arco-descriptions-item) {
+      width: 100%;
+    }
+
+    :deep(.arco-descriptions-bordered .arco-descriptions-item-label),
+    :deep(.arco-descriptions-bordered .arco-descriptions-item-value) {
+      padding: 8px 10px;
+      font-size: 12px;
+      line-height: 1.45;
+    }
+  }
+}
+</style>

+ 10 - 1
src/components/Map/MapContainer.vue

@@ -27,6 +27,15 @@ let props = defineProps({
 
 let map = null
 
+const getInitialCenter = () => {
+  if (props.pathData?.length > 0) return props.pathData[Math.floor(props.pathData.length / 2)]
+  if (props.point_list?.length > 0) {
+    const point = props.point_list[0]
+    return [point.longtitude, point.latitude]
+  }
+  return [106.5799475868821, 29.504864472181577]
+}
+
 onMounted(() => {
   AMapLoader.load({
     key: "d1f123693def8a412c976184daa4b60e",
@@ -41,7 +50,7 @@ onMounted(() => {
         zoom: 18, //初始化地图层级
         rotation: -15, //初始地图顺时针旋转的角度
         zooms: [3, 20], //地图显示的缩放级别范围
-        center: props.pathData?.length > 0 ? props.pathData[Math.floor(props.pathData.length / 2)] : [106.5799475868821, 29.504864472181577],
+        center: getInitialCenter(),
       })
 
       // 添加折线

+ 143 - 15
src/components/Menu/index.vue

@@ -1,78 +1,206 @@
 <template>
+
     <a-menu v-model:selected-keys="selectedKey"
+
         :default-open-keys="selectedKey">
+
         <template v-for="menu in menuData" :key="menu.key">
-            <a-sub-menu v-if="menu.children" :key="menu.key">
+
+            <a-sub-menu v-if="menu.children && menu.children.length" :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>
+
 </template>
 
+
+
 <script setup>
-import { onMounted, ref } from 'vue'
+
+import { onMounted, ref, watch } from 'vue'
+
 import { routes } from '@/router'
+
 import { useUserStore } from '@/store/modules/user'
+
 import { useRouteListener } from '@/utils/route-listener'
+
 import { isElectron } from '../../utils/electron'
 
+import { canShowInMenu } from '@/utils/permission'
+
+
+
 const userStore = useUserStore()
+
 const user = ref({})
+
 const electron = ref(false)
+
 const menuData = ref([])
 
+
+
 const { selectedKey } = useRouteListener()
 
-const hasPermission = (route) => {
-    if (!route.meta || !route.meta.permission) return true
-    if (!user.value.roles || user.value.roles.length === 0) return false
-    return route.meta.permission.some((perm) => user.value.roles.includes(perm))
-}
+
 
 const checkEnv = (route) => {
+
     if (!route.meta) return true
+
     if (route.meta.onlyWeb) return !electron.value
+
     if (route.meta.onlyElectron) return !!electron.value
+
     return true
+
 }
 
-const generateMenu = (routes, parentPath = '') => {
-    return routes
-        .filter((route) => hasPermission(route) && !(route.meta && route.meta.hideInMenu) && checkEnv(route))
+
+
+const joinRoutePath = (parentPath, routePath) => {
+
+    const base = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath
+
+    const segment = routePath.startsWith('/') ? routePath : `/${routePath}`
+
+    return `${base}${segment}`.replace(/\/+/g, '/')
+
+}
+
+
+
+const generateMenu = (routeList, parentPath = '') => {
+
+    return routeList
+
+        .filter((route) => canShowInMenu(route, user.value) && checkEnv(route))
+
         .map((route) => {
-            const fullPath = parentPath + route.path; // 确保子路由路径完整
+
+            const fullPath = joinRoutePath(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 + '/')
+
+                const childPrefix = fullPath.endsWith('/') ? fullPath : `${fullPath}/`
+
+                menu.children = generateMenu(route.children, childPrefix)
+
+                if (menu.children.length === 0) return null
+
+                if (route.meta?.flatMenu && menu.children.length === 1) {
+
+                    const only = menu.children[0]
+
+                    return {
+
+                        ...only,
+
+                        icon: menu.icon || only.icon,
+
+                        label: menu.label || only.label
+
+                    }
+
+                }
+
             }
 
+
+
             return menu
+
         })
+
+        .filter(Boolean)
+
 }
 
-onMounted(async () => {
+
+
+const refreshMenu = async () => {
+
     user.value = await userStore.getInfo()
+
+    if ((!user.value.permissionCodes || user.value.permissionCodes.length === 0) && userStore.refreshPermissions) {
+
+        await userStore.refreshPermissions()
+
+        user.value = userStore.$state
+
+    }
+
     electron.value = isElectron()
+
     menuData.value = generateMenu(routes)
-})
-</script>
+
+}
+
+
+
+onMounted(refreshMenu)
+
+
+
+watch(
+
+    () => [userStore.uuid, userStore.session],
+
+    () => {
+
+        refreshMenu()
+
+    }
+
+)
+
+</script>
+

+ 71 - 9
src/components/Navbar/index.vue

@@ -3,14 +3,14 @@
     <div class="left-side">
       <a-space style="cursor: pointer; -webkit-app-region: no-drag;" @click="$router.push('/')">
         <img alt="哪吒乐跑" src="/logo.svg" height="35">
-        <a-typography-title :style="{ margin: 0, fontSize: '18px' }" :heading="5">
+        <a-typography-title class="brand-title" :style="{ margin: 0, fontSize: '18px' }" :heading="5">
           哪吒乐跑
         </a-typography-title>
       </a-space>
     </div>
 
     <ul class="right-side">
-      <li v-if="!isElectron()">
+      <li v-if="!isElectron()" class="desktop-shortcut">
         <a-tooltip content="首页">
           <a-button class="nav-btn" type="outline" :shape="'circle'" @click="$router.push('/')">
             <template #icon>
@@ -20,7 +20,7 @@
         </a-tooltip>
       </li>
 
-      <li v-if="!isElectron()">
+      <li v-if="!isElectron()" class="desktop-shortcut">
         <a-tooltip content="售后服务">
           <a-button class="nav-btn" type="outline" :shape="'circle'" @click="$router.push('/service/createOrder')">
             <template #icon>
@@ -30,9 +30,9 @@
         </a-tooltip>
       </li>
 
-      <li v-if="!isElectron()">
+      <li v-if="!isElectron()" class="desktop-shortcut">
         <a-tooltip content="下载专区">
-          <a-button class="nav-btn" type="outline" :shape="'circle'" @click="goDownload()">
+          <a-button class="nav-btn" type="outline" :shape="'circle'" @click="$router.push('/download')">
             <template #icon>
               <icon-download />
             </template>
@@ -40,6 +40,40 @@
         </a-tooltip>
       </li>
 
+      <li v-if="!isElectron()" class="desktop-shortcut">
+        <a-tooltip content="文档中心">
+          <a-button class="nav-btn" type="outline" :shape="'circle'" @click="$router.push('/doc')">
+            <template #icon>
+              <icon-book />
+            </template>
+          </a-button>
+        </a-tooltip>
+      </li>
+
+      <li v-if="!isElectron()" class="mobile-shortcut">
+        <a-dropdown trigger="click" position="br">
+          <a-button class="nav-btn" type="outline" :shape="'circle'">
+            <template #icon>
+              <icon-more />
+            </template>
+          </a-button>
+          <template #content>
+            <a-doption @click="$router.push('/')">
+              <icon-home /> 首页
+            </a-doption>
+            <a-doption @click="$router.push('/service/createOrder')">
+              <icon-customer-service /> 售后服务
+            </a-doption>
+            <a-doption @click="$router.push('/download')">
+              <icon-download /> 下载专区
+            </a-doption>
+            <a-doption @click="$router.push('/doc')">
+              <icon-book /> 文档中心
+            </a-doption>
+          </template>
+        </a-dropdown>
+      </li>
+
       <li v-if="avatar">
         <a-dropdown trigger="hover">
           <a-avatar class="nav-btn" :size="32" :style="{ marginRight: '8px', cursor: 'pointer' }">
@@ -101,10 +135,6 @@ const avatar = computed(() => {
   return userStore.avatar;
 })
 
-const goDownload = () => {
-  window.open('https://download.xxoo365.top')
-}
-
 const handleLogout = () => {
   Modal.confirm({
     title: '退出登录',
@@ -147,6 +177,14 @@ function maximizeWindow() {
   display: flex;
   align-items: center;
   padding-left: 20px;
+  min-width: 0;
+}
+
+.brand-title {
+  margin: 0;
+  white-space: nowrap;
+  line-height: 1;
+  flex-shrink: 0;
 }
 
 .center-side {
@@ -200,4 +238,28 @@ function maximizeWindow() {
   background-color: rgb(var(--red-5));
   color: white;
 }
+
+@media (max-width: 992px) {
+  .brand-title {
+    font-size: 16px !important;
+  }
+
+  .right-side {
+    padding-right: 12px;
+  }
+
+  .right-side li {
+    padding: 0 4px;
+  }
+
+  .desktop-shortcut {
+    display: none !important;
+  }
+}
+
+@media (min-width: 993px) {
+  .mobile-shortcut {
+    display: none !important;
+  }
+}
 </style>

+ 42 - 34
src/components/index.js

@@ -1,43 +1,51 @@
-// 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 { 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'
+import EmailAutoComplete from './EmailAutoComplete/index.vue'
+import AppPageShell from './layout/AppPageShell.vue'
+import AppQueryFilter from './layout/AppQueryFilter.vue'
+import AppTableEllipsis from './layout/AppTableEllipsis.vue'
 
-// use([
-//   CanvasRenderer,
-//   BarChart,
-//   LineChart,
-//   PieChart,
-//   RadarChart,
-//   HeatmapChart,
-//   GraphChart,
-//   GridComponent,
-//   TooltipComponent,
-//   LegendComponent,
-//   DataZoomComponent,
-//   GraphicComponent,
-//   CalendarComponent,
-//   VisualMapComponent ,
-//   TitleComponent,
-//   ToolboxComponent
-// ])
+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('Chart', Chart)
     Vue.component('Breadcrumb', Breadcrumb)
+    Vue.component('EmailAutoComplete', EmailAutoComplete)
+    Vue.component('AppPageShell', AppPageShell)
+    Vue.component('AppQueryFilter', AppQueryFilter)
+    Vue.component('AppTableEllipsis', AppTableEllipsis)
   },
 }

+ 71 - 0
src/components/layout/AppPageShell.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="store-page app-page" :class="widthClass">
+    <div v-if="useInner" class="app-page__inner" :class="innerWidthClass">
+      <Breadcrumb v-if="showBreadcrumb" />
+      <header v-if="title || $slots.header" class="app-page-header">
+        <slot name="header">
+          <div class="app-page-header__text">
+            <h1 v-if="title" class="store-section-title">{{ title }}</h1>
+            <p v-if="description" class="store-section-desc">{{ description }}</p>
+          </div>
+          <div v-if="$slots.actions" class="app-page-header__actions">
+            <slot name="actions" />
+          </div>
+        </slot>
+      </header>
+      <a-alert v-if="notice" type="info" closable class="app-notice">{{ notice }}</a-alert>
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <a-skeleton-line :rows="4" />
+        <template #content>
+          <slot />
+        </template>
+      </a-skeleton>
+    </div>
+    <template v-else>
+      <Breadcrumb v-if="showBreadcrumb" />
+      <header v-if="title || $slots.header" class="app-page-header">
+        <slot name="header">
+          <div class="app-page-header__text">
+            <h1 v-if="title" class="store-section-title">{{ title }}</h1>
+            <p v-if="description" class="store-section-desc">{{ description }}</p>
+          </div>
+          <div v-if="$slots.actions" class="app-page-header__actions">
+            <slot name="actions" />
+          </div>
+        </slot>
+      </header>
+      <a-alert v-if="notice" type="info" closable class="app-notice">{{ notice }}</a-alert>
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <a-skeleton-line :rows="4" />
+        <template #content>
+          <slot />
+        </template>
+      </a-skeleton>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  title: { type: String, default: '' },
+  description: { type: String, default: '' },
+  notice: { type: String, default: '' },
+  loading: { type: Boolean, default: false },
+  showBreadcrumb: { type: Boolean, default: true },
+  /** full | wide | narrow — 对应 1200 / 1200 inner / 720 inner */
+  width: { type: String, default: 'wide' },
+  useInner: { type: Boolean, default: true }
+})
+
+const widthClass = computed(() => {
+  if (props.width === 'full') return 'app-page--full'
+  return 'app-page--wide'
+})
+
+const innerWidthClass = computed(() => {
+  if (props.width === 'narrow') return 'app-page__inner--narrow'
+  return 'app-page__inner--wide'
+})
+</script>

+ 48 - 0
src/components/layout/AppQueryFilter.vue

@@ -0,0 +1,48 @@
+<template>
+  <div class="app-query-filter" :class="{ 'is-expanded': expanded }">
+    <a-button
+      v-show="isMobile"
+      type="outline"
+      long
+      class="app-query-filter__toggle"
+      @click="expanded = !expanded"
+    >
+      <icon-filter />
+      <span>{{ label }}</span>
+      <icon-down class="app-query-filter__chevron" />
+    </a-button>
+    <div v-if="showPanel" class="app-query-filter__panel">
+      <slot />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+
+defineProps({
+  label: { type: String, default: '筛选条件' }
+})
+
+const expanded = ref(false)
+const isMobile = ref(false)
+
+let mediaQuery = null
+
+const syncMobile = () => {
+  isMobile.value = mediaQuery?.matches ?? window.innerWidth <= 768
+  if (!isMobile.value) expanded.value = false
+}
+
+const showPanel = computed(() => !isMobile.value || expanded.value)
+
+onMounted(() => {
+  mediaQuery = window.matchMedia('(max-width: 768px)')
+  syncMobile()
+  mediaQuery.addEventListener('change', syncMobile)
+})
+
+onUnmounted(() => {
+  mediaQuery?.removeEventListener('change', syncMobile)
+})
+</script>

+ 60 - 0
src/components/layout/AppTableEllipsis.vue

@@ -0,0 +1,60 @@
+<template>
+  <a-typography-text
+    :key="instanceKey"
+    class="app-table-ellipsis"
+    :ellipsis="ellipsisConfig"
+  >
+    {{ displayText }}
+  </a-typography-text>
+</template>
+
+<script setup>
+import { computed, useSlots } from 'vue'
+
+const props = defineProps({
+  /** 单元格展示文本,优先于默认插槽 */
+  content: { type: [String, Number], default: '' },
+  /** 行/列唯一标识,避免表格复用单元格时省略组件状态残留 */
+  rowKey: { type: [String, Number], default: '' },
+  rows: { type: Number, default: 3 }
+})
+
+const slots = useSlots()
+
+const displayText = computed(() => {
+  if (props.content !== '' && props.content !== null && props.content !== undefined) {
+    return String(props.content)
+  }
+  const slot = slots.default?.()
+  if (!slot || slot.length === 0) return '-'
+  const text = slot
+    .map((node) => {
+      if (typeof node.children === 'string') return node.children
+      if (Array.isArray(node.children)) {
+        return node.children.filter((item) => typeof item === 'string').join('')
+      }
+      return ''
+    })
+    .join('')
+    .trim()
+  return text || '-'
+})
+
+const instanceKey = computed(() => `${props.rowKey}::${displayText.value}`)
+
+const ellipsisConfig = computed(() => ({
+  rows: props.rows,
+  showTooltip: true,
+  css: true
+}))
+</script>
+
+<style scoped lang="less">
+.app-table-ellipsis {
+  display: block;
+  margin: 0;
+  max-width: 100%;
+  word-break: break-word;
+  overflow-wrap: anywhere;
+}
+</style>

+ 14 - 0
src/components/service/WorkOrderStateTag.vue

@@ -0,0 +1,14 @@
+<template>
+  <a-tag :color="meta.color" size="small">{{ meta.label }}</a-tag>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { getWorkOrderStateMeta } from '@/utils/workOrderTemplates'
+
+const props = defineProps({
+  state: { type: Number, required: true }
+})
+
+const meta = computed(() => getWorkOrderStateMeta(props.state))
+</script>

+ 106 - 0
src/components/service/WorkOrderTemplatePicker.vue

@@ -0,0 +1,106 @@
+<template>
+  <div class="template-picker">
+    <div class="template-picker__head">
+      <h3 class="template-picker__title">选择问题类型</h3>
+      <p class="template-picker__desc">点击模板将自动填充标题与内容,您可继续修改后提交</p>
+    </div>
+    <div class="template-grid">
+      <button
+        v-for="item in WORK_ORDER_TEMPLATES"
+        :key="item.id"
+        type="button"
+        class="template-card"
+        :class="{ 'template-card--active': modelValue === item.id }"
+        @click="select(item)"
+      >
+        <span class="template-card__icon">{{ item.icon }}</span>
+        <span class="template-card__label">{{ item.label }}</span>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { WORK_ORDER_TEMPLATES } from '@/utils/workOrderTemplates'
+
+defineProps({
+  modelValue: { type: String, default: '' }
+})
+
+const emit = defineEmits(['update:modelValue', 'select'])
+
+const select = (item) => {
+  emit('update:modelValue', item.id)
+  emit('select', item)
+}
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.template-picker {
+  margin-bottom: 24px;
+
+  &__head {
+    margin-bottom: 16px;
+  }
+
+  &__title {
+    margin: 0 0 6px;
+    font-size: 1rem;
+    font-weight: 600;
+    color: @store-primary;
+  }
+
+  &__desc {
+    margin: 0;
+    font-size: 0.85rem;
+    color: @store-text-muted;
+  }
+}
+
+.template-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
+  gap: 12px;
+}
+
+.template-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 16px 10px;
+  min-height: 96px;
+  border: 2px solid @store-card-border;
+  border-radius: @store-radius-sm;
+  background: @store-card-bg;
+  cursor: pointer;
+  transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
+
+  &:hover {
+    border-color: fade(@store-accent, 50%);
+    transform: translateY(-2px);
+    box-shadow: @store-shadow;
+  }
+
+  &--active {
+    border-color: @store-accent;
+    background: fade(@store-accent, 8%);
+    box-shadow: 0 0 0 2px fade(@store-accent, 15%);
+  }
+
+  &__icon {
+    font-size: 1.75rem;
+    line-height: 1;
+  }
+
+  &__label {
+    font-size: 0.85rem;
+    font-weight: 500;
+    color: @store-primary;
+    text-align: center;
+  }
+}
+</style>

+ 121 - 0
src/components/skeleton/StoreDetailSkeleton.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="store-detail-skeleton" :class="`store-detail-skeleton--${variant}`">
+    <template v-if="variant === 'goods'">
+      <div class="store-detail-skeleton__layout">
+        <aside class="store-detail-skeleton__aside">
+          <a-skeleton-shape size="large" />
+          <a-skeleton-line :widths="['85%']" :rows="1" />
+          <a-skeleton-line :widths="['100%', '90%']" :rows="2" />
+          <a-skeleton-line :widths="['50%']" :rows="1" />
+          <a-skeleton-line :widths="['100%']" :rows="1" />
+        </aside>
+        <main class="store-detail-skeleton__main">
+          <a-skeleton-line :widths="['120px']" :rows="1" />
+          <a-skeleton-line :rows="8" />
+        </main>
+      </div>
+    </template>
+
+    <template v-else-if="variant === 'order'">
+      <div class="store-detail-skeleton__card">
+        <a-skeleton-line :widths="['100%']" :rows="1" />
+        <a-skeleton-line :widths="['60%']" :rows="1" />
+      </div>
+      <div class="store-detail-skeleton__card">
+        <a-skeleton-line :widths="['100px']" :rows="1" />
+        <a-skeleton-line :rows="6" />
+      </div>
+      <div class="store-detail-skeleton__card">
+        <a-skeleton-line :widths="['100px']" :rows="1" />
+        <a-skeleton-line :rows="4" />
+      </div>
+    </template>
+
+    <template v-else>
+      <a-space direction="vertical" :style="{ width: '100%' }" size="large">
+        <a-space align="center" :size="16">
+          <a-skeleton-shape size="large" />
+          <a-skeleton-line :widths="['min(320px, 70vw)']" :rows="1" />
+        </a-space>
+        <a-skeleton-line :widths="['72px', '120px']" :rows="1" />
+        <a-skeleton-line :rows="10" />
+      </a-space>
+    </template>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  variant: {
+    type: String,
+    default: 'default',
+    validator: (v) => ['default', 'goods', 'order'].includes(v)
+  }
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.store-detail-skeleton {
+  width: 100%;
+
+  &--goods,
+  &--order,
+  &--default {
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+    padding: clamp(16px, 4vw, 32px);
+  }
+
+  &--order {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    background: transparent;
+    border: none;
+    padding: 0;
+  }
+
+  &__layout {
+    display: grid;
+    grid-template-columns: minmax(260px, 340px) 1fr;
+    gap: 24px;
+
+    @media (max-width: 900px) {
+      grid-template-columns: 1fr;
+    }
+  }
+
+  &__aside {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    padding: 20px;
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+  }
+
+  &__main {
+    padding: 20px;
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+  }
+
+  &__card {
+    padding: 20px;
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+}
+</style>

+ 60 - 0
src/components/skeleton/StoreGridSkeleton.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="store-grid-skeleton" :style="gridStyle">
+    <article v-for="n in count" :key="n" class="store-grid-skeleton__card">
+      <div class="store-grid-skeleton__visual">
+        <a-skeleton-shape size="large" />
+      </div>
+      <div class="store-grid-skeleton__body">
+        <a-skeleton-line :widths="['75%']" :rows="1" />
+        <a-skeleton-line :widths="['100%', '90%']" :rows="2" />
+        <a-skeleton-line :widths="['40%', '35%']" :rows="1" />
+      </div>
+    </article>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  count: { type: Number, default: 6 },
+  minWidth: { type: String, default: '280px' }
+})
+
+const gridStyle = computed(() => ({
+  gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${props.minWidth}), 1fr))`
+}))
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.store-grid-skeleton {
+  display: grid;
+  gap: 16px;
+  width: 100%;
+
+  &__card {
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+    overflow: hidden;
+  }
+
+  &__visual {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 120px;
+    background: @store-cover-bg;
+    border-bottom: 1px solid @store-card-border;
+  }
+
+  &__body {
+    padding: 16px;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+  }
+}
+</style>

+ 15 - 0
src/components/skeleton/StoreRecordSkeleton.vue

@@ -0,0 +1,15 @@
+<template>
+  <a-space direction="vertical" :style="{ width: '100%' }" size="large">
+    <a-skeleton-line :widths="['100%']" :rows="1" />
+    <a-skeleton-line :rows="5" />
+    <a-skeleton-shape size="large" class="store-record-skeleton__map" />
+  </a-space>
+</template>
+
+<style scoped lang="less">
+.store-record-skeleton__map {
+  width: 100%;
+  height: 280px;
+  border-radius: 8px;
+}
+</style>

+ 74 - 0
src/components/skeleton/StoreStackSkeleton.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="store-stack-skeleton">
+    <div v-for="n in count" :key="n" class="store-stack-skeleton__item">
+      <div class="store-stack-skeleton__icon">
+        <a-skeleton-shape size="small" />
+      </div>
+      <div class="store-stack-skeleton__content">
+        <a-skeleton-line :widths="headerWidths" :rows="1" />
+        <a-skeleton-line :widths="['100%', '70%']" :rows="2" />
+        <a-skeleton-line v-if="showMeta" :widths="['28%', '32%', '24%']" :rows="1" />
+      </div>
+      <a-skeleton-line v-if="showAction" :widths="['72px']" :rows="1" class="store-stack-skeleton__action" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  count: { type: Number, default: 4 },
+  showMeta: { type: Boolean, default: true },
+  showAction: { type: Boolean, default: true },
+  headerWidths: {
+    type: Array,
+    default: () => ['35%', '55%']
+  }
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.store-stack-skeleton {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  width: 100%;
+
+  &__item {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 16px;
+    padding: 16px 20px;
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+  }
+
+  &__icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    width: 72px;
+    height: 72px;
+    background: @store-cover-bg;
+    border-radius: @store-radius-sm;
+    border: 1px solid @store-card-border;
+  }
+
+  &__content {
+    flex: 1 1 200px;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  &__action {
+    flex-shrink: 0;
+    align-self: center;
+  }
+}
+</style>

+ 49 - 0
src/components/store/GoodsEmoji.vue

@@ -0,0 +1,49 @@
+<template>
+  <span class="goods-emoji" :class="`goods-emoji--${size}`" role="img" :aria-label="ariaLabel">
+    {{ emoji }}
+  </span>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { getGoodsEmoji, DEFAULT_GOODS_EMOJI } from '@/utils/storeFormat'
+
+const props = defineProps({
+  icon: { type: String, default: '' },
+  size: {
+    type: String,
+    default: 'md',
+    validator: (v) => ['sm', 'md', 'lg', 'xl'].includes(v)
+  },
+  ariaLabel: { type: String, default: '商品图标' },
+  fallback: { type: String, default: '' }
+})
+
+const emoji = computed(() => getGoodsEmoji(props.icon, props.fallback || DEFAULT_GOODS_EMOJI))
+</script>
+
+<style scoped lang="less">
+.goods-emoji {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+  user-select: none;
+
+  &--sm {
+    font-size: 2rem;
+  }
+
+  &--md {
+    font-size: 2.75rem;
+  }
+
+  &--lg {
+    font-size: 3.5rem;
+  }
+
+  &--xl {
+    font-size: 4.5rem;
+  }
+}
+</style>

+ 168 - 0
src/components/store/OrderProgressSteps.vue

@@ -0,0 +1,168 @@
+<template>
+  <div v-if="mobile" class="mobile-order-steps">
+    <div
+      v-for="(label, index) in labels"
+      :key="index"
+      class="mobile-order-steps__col"
+      :class="{ 'mobile-order-steps__col--connector-active': isConnectorActive(index) }"
+    >
+      <div
+        class="mobile-order-steps__node"
+        :class="`mobile-order-steps__node--${stepState(index)}`"
+      >
+        <icon-close v-if="stepState(index) === 'error'" />
+        <icon-check v-else-if="stepState(index) === 'finish'" />
+        <span v-else class="mobile-order-steps__num">{{ index + 1 }}</span>
+      </div>
+      <div class="mobile-order-steps__label">{{ label }}</div>
+    </div>
+  </div>
+  <a-steps
+    v-else
+    :current="current"
+    :status="status"
+    direction="horizontal"
+    label-placement="vertical"
+    class="app-order-steps"
+  >
+    <a-step
+      v-for="(label, index) in labels"
+      :key="index"
+      :description="descriptions[index] || ''"
+    >
+      {{ label }}
+    </a-step>
+  </a-steps>
+</template>
+
+<script setup>
+const props = defineProps({
+  mobile: { type: Boolean, default: false },
+  current: { type: Number, default: 1 },
+  status: { type: String, default: 'process' },
+  labels: { type: Array, default: () => ['', '', ''] },
+  descriptions: { type: Array, default: () => ['', '', ''] }
+})
+
+const stepState = (index) => {
+  const num = index + 1
+  if (num < props.current) return 'finish'
+  if (num > props.current) return 'wait'
+  return props.status
+}
+
+const isConnectorActive = (index) => {
+  if (props.status === 'error' && props.current === 1) return false
+  return props.current > index + 1
+}
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.mobile-order-steps {
+  display: flex;
+  align-items: flex-start;
+  width: 100%;
+  padding: 8px 0 4px;
+  box-sizing: border-box;
+}
+
+.mobile-order-steps__col {
+  position: relative;
+  flex: 1 1 0;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.mobile-order-steps__col:not(:last-child)::after {
+  content: '';
+  position: absolute;
+  top: 13px;
+  left: calc(50% + 13px);
+  right: 0;
+  height: 1px;
+  background: var(--color-neutral-3);
+  pointer-events: none;
+}
+
+.mobile-order-steps__col--connector-active:not(:last-child)::after {
+  background: rgb(var(--primary-6));
+}
+
+.mobile-order-steps__node {
+  position: relative;
+  z-index: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  font-size: 13px;
+  font-weight: 500;
+  box-sizing: border-box;
+  border: 1px solid transparent;
+}
+
+.mobile-order-steps__node--wait {
+  color: var(--color-text-3);
+  background: var(--color-fill-2);
+  border-color: var(--color-fill-2);
+}
+
+.mobile-order-steps__node--process {
+  color: #fff;
+  background: rgb(var(--primary-6));
+  border-color: rgb(var(--primary-6));
+}
+
+.mobile-order-steps__node--finish {
+  color: rgb(var(--primary-6));
+  background: var(--color-primary-light-1);
+  border-color: rgb(var(--primary-6));
+}
+
+.mobile-order-steps__node--error {
+  color: #fff;
+  background: rgb(var(--danger-6));
+  border-color: rgb(var(--danger-6));
+}
+
+.mobile-order-steps__num {
+  line-height: 1;
+}
+
+.mobile-order-steps__label {
+  margin-top: 6px;
+  max-width: 100%;
+  padding: 0 2px;
+  font-size: 11px;
+  line-height: 1.3;
+  text-align: center;
+  color: var(--color-text-2);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.app-order-steps {
+  max-width: 100%;
+  margin: 8px 0 16px;
+
+  :deep(.arco-steps-item-node) {
+    line-height: 1;
+    vertical-align: top;
+  }
+
+  :deep(.arco-steps-icon) {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    box-sizing: border-box;
+    line-height: 1;
+  }
+}
+</style>

+ 219 - 0
src/components/store/OrderRefundPanel.vue

@@ -0,0 +1,219 @@
+<template>
+  <div class="refund-panel" :class="{ 'refund-panel--disabled': !canRefund }">
+    <div class="refund-panel__main">
+      <div class="refund-panel__icon-wrap" aria-hidden="true">
+        <icon-refresh />
+      </div>
+      <div class="refund-panel__text">
+        <div class="refund-panel__title">
+          {{ canRefund ? titleText : '暂无法退款' }}
+        </div>
+        <p class="refund-panel__desc">
+          {{ canRefund ? activeDesc : refundDisabledReason || '当前订单不满足退款条件' }}
+        </p>
+        <div v-if="canRefund && showSummary" class="refund-panel__summary">
+          <span class="refund-panel__chip">
+            退款 ¥{{ price }}
+          </span>
+          <span v-if="lepaoCount > 0" class="refund-panel__chip refund-panel__chip--muted">
+            <icon-thunderbolt /> 扣回 {{ lepaoCount }} 次
+          </span>
+          <span v-if="userLepaoCount != null" class="refund-panel__chip refund-panel__chip--muted">
+            剩余 {{ userLepaoCount }} 次
+          </span>
+        </div>
+      </div>
+    </div>
+    <div class="refund-panel__actions">
+      <a-button
+        v-if="canRefund"
+        type="primary"
+        status="warning"
+        size="large"
+        class="refund-panel__btn"
+        :loading="loading"
+        @click="$emit('refund')"
+      >
+        <template #icon><icon-refresh /></template>
+        {{ buttonText }}
+      </a-button>
+      <a-tooltip v-else :content="refundDisabledReason || '当前无法退款'">
+        <a-button size="large" class="refund-panel__btn" disabled>
+          <template #icon><icon-refresh /></template>
+          {{ buttonText }}
+        </a-button>
+      </a-tooltip>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  canRefund: { type: Boolean, default: false },
+  refundDisabledReason: { type: String, default: '' },
+  loading: { type: Boolean, default: false },
+  price: { type: [Number, String], default: 0 },
+  lepaoCount: { type: Number, default: 0 },
+  userLepaoCount: { type: Number, default: null },
+  variant: {
+    type: String,
+    default: 'user',
+    validator: (v) => ['user', 'admin'].includes(v)
+  },
+  showSummary: { type: Boolean, default: true }
+})
+
+defineEmits(['refund'])
+
+const titleText = computed(() =>
+  props.variant === 'admin' ? '该订单支持发起退款' : '订单已完成,支持申请退款'
+)
+
+const buttonText = computed(() =>
+  props.variant === 'admin' ? '发起退款' : '申请退款'
+)
+
+const activeDesc = computed(() => {
+  if (props.variant === 'admin') {
+    return '确认后将原路退回款项,并扣回用户已发放的乐跑次数。'
+  }
+  if (props.lepaoCount > 0) {
+    return '退款成功后款项将原路退回,对应乐跑次数将从账户中扣回。'
+  }
+  return '退款成功后,款项将按原支付方式原路退回。'
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.refund-panel {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20px;
+  padding: 20px 24px;
+  background: linear-gradient(135deg, #fff8f0 0%, #ffe8d6 55%, #ffd8bf 100%);
+  border: 1px solid #ffbb96;
+  border-radius: @store-radius;
+  box-shadow: 0 8px 24px rgba(232, 93, 4, 0.08);
+
+  &--disabled {
+    background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
+    border-color: var(--color-border-2);
+    box-shadow: none;
+
+    .refund-panel__icon-wrap {
+      background: var(--color-fill-2);
+      color: var(--color-text-3);
+    }
+
+    .refund-panel__title {
+      color: var(--color-text-2);
+    }
+  }
+
+  &__main {
+    display: flex;
+    align-items: flex-start;
+    gap: 16px;
+    flex: 1 1 280px;
+    min-width: 0;
+  }
+
+  &__icon-wrap {
+    flex-shrink: 0;
+    width: 52px;
+    height: 52px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 14px;
+    font-size: 26px;
+    color: #d4380d;
+    background: rgba(255, 255, 255, 0.72);
+    border: 1px solid rgba(255, 187, 150, 0.8);
+    box-shadow: 0 4px 12px rgba(212, 56, 13, 0.1);
+  }
+
+  &__text {
+    min-width: 0;
+  }
+
+  &__title {
+    margin: 0;
+    font-size: 1.1rem;
+    font-weight: 600;
+    color: @store-primary;
+    line-height: 1.4;
+  }
+
+  &__desc {
+    margin: 6px 0 0;
+    font-size: 0.875rem;
+    line-height: 1.55;
+    color: @store-text-muted;
+  }
+
+  &__summary {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-top: 12px;
+  }
+
+  &__chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    padding: 4px 10px;
+    font-size: 0.8rem;
+    font-weight: 500;
+    color: #d4380d;
+    background: rgba(255, 255, 255, 0.75);
+    border: 1px solid rgba(255, 187, 150, 0.65);
+    border-radius: 999px;
+
+    &--muted {
+      color: @store-text-muted;
+      border-color: rgba(0, 0, 0, 0.06);
+      background: rgba(255, 255, 255, 0.55);
+      font-weight: 400;
+    }
+  }
+
+  &__actions {
+    flex-shrink: 0;
+  }
+
+  &__btn {
+    min-width: 148px;
+    border-radius: 999px !important;
+    font-weight: 600;
+    box-shadow: 0 4px 14px rgba(250, 140, 22, 0.28);
+
+    &:not(:disabled):hover {
+      box-shadow: 0 6px 18px rgba(250, 140, 22, 0.36);
+      transform: translateY(-1px);
+    }
+  }
+}
+
+@media (max-width: 768px) {
+  .refund-panel {
+    padding: 16px;
+
+    &__actions {
+      width: 100%;
+    }
+
+    &__btn {
+      width: 100%;
+      min-width: 0;
+    }
+  }
+}
+</style>

+ 39 - 0
src/components/store/OrderStateTag.vue

@@ -0,0 +1,39 @@
+<template>
+  <a-tag :color="meta.color" size="small" class="order-state-tag">
+    <span class="dot" :class="`dot--${state}`" />
+    {{ meta.label }}
+  </a-tag>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { getOrderStateMeta } from '@/utils/storeFormat'
+
+const props = defineProps({
+  state: { type: Number, required: true }
+})
+
+const meta = computed(() => getOrderStateMeta(props.state))
+const state = computed(() => props.state)
+</script>
+
+<style scoped lang="less">
+.order-state-tag {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: currentColor;
+
+  &--0 { opacity: 1; }
+  &--1 { opacity: 0.9; }
+  &--2 { opacity: 0.85; }
+  &--3 { opacity: 0.6; }
+  &--5 { opacity: 0.75; }
+}
+</style>

+ 74 - 0
src/components/store/PayMethodPicker.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="pay-methods">
+    <div
+      v-for="item in methods"
+      :key="item.value"
+      class="pay-method"
+      :class="{ active: modelValue === item.value }"
+      @click="$emit('update:modelValue', item.value)"
+    >
+      <component :is="item.icon" :size="28" />
+      <span>{{ item.label }}</span>
+      <icon-check-circle-fill v-if="modelValue === item.value" class="check" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import {
+  IconAlipayCircle,
+  IconWechatpay,
+  IconCheckCircleFill
+} from '@arco-design/web-vue/es/icon'
+
+defineProps({
+  modelValue: { type: String, default: '' }
+})
+
+defineEmits(['update:modelValue'])
+
+const methods = [
+  { value: 'alipay', label: '支付宝', icon: IconAlipayCircle },
+  { value: 'wxpay', label: '微信支付', icon: IconWechatpay }
+]
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.pay-methods {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.pay-method {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 14px 16px;
+  border: 2px solid @store-card-border;
+  border-radius: @store-radius-sm;
+  cursor: pointer;
+  transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
+  position: relative;
+  color: @store-primary;
+
+  &:hover {
+    border-color: fade(@store-accent, 50%);
+    background: fade(@store-accent, 6%);
+  }
+
+  &.active {
+    border-color: @store-accent;
+    background: fade(@store-accent, 10%);
+    box-shadow: 0 0 0 1px fade(@store-accent, 20%);
+  }
+
+  .check {
+    margin-left: auto;
+    color: @store-accent;
+    font-size: 20px;
+  }
+}
+</style>

+ 154 - 94
src/components/userCard/userCard.vue

@@ -1,93 +1,120 @@
 <template>
-  <a-card class="card">
-    <a-space size="large">
-      <a-statistic title="剩余乐跑里程" :value="Number(userCount?.lepao_count)" animation show-group-separator  :precision="2">
-        <template #suffix>Km</template>
-      </a-statistic>
-      <a-button type="primary" size="large" @click="$router.push('/store/goodsList')" style="margin-left: 20px;"
-        v-if="props.type === 'lepao'">
-        <span><icon-fire /> 去购买</span>
-      </a-button>
-      <a-button type="primary" size="large" @click="$router.push('/lepao/accountList')" style="margin-left: 20px;"
-        v-else>
-        <span><icon-thunderbolt /> 去乐跑</span>
-      </a-button>
-      <a-button type="primary" size="large" @click="SendCount()">
-        <span><icon-gift /> 赠送里程</span>
-      </a-button>
-    </a-space>
-  </a-card>
-
-  <a-modal v-model:visible="visible" title="赠送乐跑里程" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
-    :ok-loading="ok_loading" esc-to-close closable>
-    <a-form :model="sendform">
-      <a-form-item field="username" label="接收人">
-        <a-input v-model="sendform.username" placeholder="请填写接收人用户名,如:用户adfg45g" :max-length="20" />
+  <div class="user-card" :class="{ 'user-card--goods': props.type === 'goods' }">
+    <div class="user-card__main">
+      <div class="user-card__stat">
+        <span class="user-card__label">剩余乐跑次数</span>
+        <a-statistic
+          :value="userCount?.lepao_count ?? 0"
+          :value-style="{ fontSize: '2rem', fontWeight: 700, color: 'var(--stat-color)' }"
+          animation
+          show-group-separator
+        />
+      </div>
+      <div class="user-card__actions">
+        <a-button
+          v-if="props.type === 'lepao'"
+          type="primary"
+          size="large"
+          class="action-btn action-btn--primary"
+          @click="$router.push('/store/goodsList')"
+        >
+          <icon-fire /> 去购买
+        </a-button>
+        <a-button
+          v-else
+          type="primary"
+          size="large"
+          class="action-btn action-btn--primary"
+          @click="$router.push('/lepao/accountList')"
+        >
+          <icon-thunderbolt />&nbsp;去乐跑
+        </a-button>
+        <a-button
+          v-if="hasPermission('action.goods.sendCount')"
+          size="large"
+          class="action-btn"
+          @click="SendCount"
+        >
+          <icon-gift />&nbsp;赠送
+        </a-button>
+        <a-button size="large" class="action-btn" @click="goCountLedger">
+          <icon-list />&nbsp;明细
+        </a-button>
+      </div>
+    </div>
+  </div>
+
+  <a-modal
+    v-model:visible="visible"
+    title="赠送乐跑次数"
+    @cancel="handleCancel"
+    @before-ok="handleBeforeOk"
+    :ok-loading="ok_loading"
+    unmount-on-close
+  >
+    <a-form :model="sendform" layout="vertical">
+      <a-form-item field="username" label="接收人用户名">
+        <a-input
+          v-model="sendform.username"
+          placeholder="如:用户adfg45g"
+          :max-length="20"
+          allow-clear
+        />
       </a-form-item>
-      <a-form-item field="count" label="赠送里程">
-        <a-input-number v-model="sendform.count" placeholder="请填写要赠送的里程" mode="button" :step="0.01" :precision="2" />
+      <a-form-item field="count" label="赠送次数">
+        <a-input-number v-model="sendform.count" :min="1" :max="9999" mode="button" />
       </a-form-item>
     </a-form>
+    <div v-if="hasPermission('action.goods.sendCount')" class="send-modal-extra">
+      <a-link @click="goSendCountRecords">
+        <icon-list />&nbsp;赠送记录
+      </a-link>
+    </div>
   </a-modal>
 </template>
 
 <script setup>
 import { ref, reactive, onUnmounted, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
 import { getCount, sendCount } from '@/api/goods'
 import { Notification, Message } from '@arco-design/web-vue'
+import { hasPermission } from '@/utils/permission'
 
 const props = defineProps({
-  "type": {
-    type: String,
-    default: 'lepao'
-  }
-})
-
-const userCount = ref({
-  lepao_count: 0
+  type: { type: String, default: 'lepao' }
 })
-const loading = ref(false)
 
+const userCount = ref({ lepao_count: 0 })
+const router = useRouter()
 const visible = ref(false)
 const ok_loading = ref(false)
-const sendform = reactive({
-  username: '',
-  count: 1
-})
+const sendform = reactive({ username: '', count: 1 })
 
 const handleBeforeOk = async (done) => {
+  if (!hasPermission('action.goods.sendCount')) {
+    Notification.warning({ title: '无权限', content: '当前账号暂无赠送权限' })
+    return false
+  }
   try {
     ok_loading.value = true
     if (!sendform.username) {
-      Message.error('请填写接收人用户名!')
+      Message.error('请填写接收人用户名')
       return false
     }
-
-    if (!sendform.count || Number(sendform.count) <= 0 || Number(sendform.count) > 9999) {
-      Notification.error({
-        title: '赠送里程失败!',
-        content: '超出赠送的里程范围,请重新选择赠送里程!'
-      })
+    if (!sendform.count || sendform.count < 1 || sendform.count > 9999) {
+      Notification.error({ title: '赠送失败', content: '赠送次数需在 1~9999 之间' })
       return false
     }
-
     const res = await sendCount({ username: sendform.username, count: sendform.count })
     if (!res || res.code !== 0) {
-      Notification.error({
-        title: '赠送里程失败!',
-        content: res?.msg ?? '请稍后再试'
-      })
+      Notification.error({ title: '赠送失败', content: res?.msg ?? '请稍后再试' })
       return false
     }
-
-    Message.success('赠送成功!')
+    Message.success(res.msg || '赠送成功')
     done()
     GetCount()
   } catch (error) {
-    Notification.error({
-      title: '赠送里程失败!',
-      content: error.message || '请稍后再试'
-    })
+    Notification.error({ title: '赠送失败', content: error.message || '请稍后再试' })
     return false
   } finally {
     ok_loading.value = false
@@ -98,63 +125,96 @@ const handleCancel = () => {
   visible.value = false
 }
 
-const SendCount = async () => {
+const SendCount = () => {
   sendform.username = ''
   sendform.count = 1
   visible.value = true
 }
 
+const goCountLedger = () => router.push('/lepao/countLedger')
+
+const goSendCountRecords = () => {
+  visible.value = false
+  router.push('/store/sendCountRecords')
+}
+
 const GetCount = async () => {
   try {
     const res = await getCount()
-    if (!res || res.code !== 0)
-      return Notification.error({
-        title: '获取用户数据失败!',
-        content: res?.msg ?? '请稍后再试'
-      })
+    if (!res || res.code !== 0) return
     userCount.value = res.data
-  } catch (error) {
-    Notification.error({
-      title: '获取用户数据失败!',
-      content: error.message || '请稍后再试'
-    })
+  } catch {
+    /* 静默失败,避免轮询刷屏 */
   }
 }
 
 let timer = null
-
-// 轮询
 const startPolling = () => {
-    if (!timer) {
-        timer = setInterval(async () => {
-            await GetCount()
-        }, 5000)
-    }
+  if (!timer) timer = setInterval(GetCount, 8000)
 }
-
-// 停止轮询
 const stopPolling = () => {
-    if (timer) {
-        clearInterval(timer)
-        timer = null
-    }
+  if (timer) {
+    clearInterval(timer)
+    timer = null
+  }
 }
 
 onMounted(async () => {
-    loading.value = true
-    await GetCount()
-    loading.value = false
-    startPolling()
-})
-
-// 组件销毁时停止轮询
-onUnmounted(() => {
-    stopPolling()
+  await GetCount()
+  startPolling()
 })
+onUnmounted(stopPolling)
 </script>
 
-<style scoped>
-.card {
-  font-family: -apple-system, BlinkMacSystemFont;
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.user-card {
+  --stat-color: rgb(var(--primary-6));
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  box-shadow: @store-shadow;
+  padding: 20px 24px;
+
+  &--goods {
+    background: linear-gradient(135deg, #f4faf6 0%, #e8f5ec 100%);
+    --stat-color: @store-primary;
+  }
+
+  &__main {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 20px;
+  }
+
+  &__label {
+    display: block;
+    font-size: 0.85rem;
+    color: @store-text-muted;
+    margin-bottom: 4px;
+  }
+
+  &__actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+  }
+}
+
+.action-btn {
+  border-radius: 999px;
+
+  &--primary {
+    background: @store-primary !important;
+    border-color: @store-primary !important;
+  }
+}
+
+.send-modal-extra {
+  margin-top: 4px;
+  text-align: center;
 }
-</style>
+</style>

+ 51 - 0
src/hooks/useResponsiveTable.js

@@ -0,0 +1,51 @@
+import { ref, computed, onMounted, onUnmounted, unref } from 'vue'
+
+/** 与全局响应式断点一致:≤768px 视为移动端 */
+export const TABLE_MOBILE_BREAKPOINT = 768
+
+/** 移除列配置中的 fixed,用于移动端 */
+export function stripTableFixed(columns) {
+  if (!Array.isArray(columns)) return columns
+  return columns.map((col) => {
+    if (!col?.fixed) return col
+    const { fixed, ...rest } = col
+    return rest
+  })
+}
+
+/**
+ * 表格响应式:桌面端保留固定列,移动端取消固定列
+ */
+export function useResponsiveTable(breakpoint = TABLE_MOBILE_BREAKPOINT) {
+  const isMobile = ref(false)
+  let resizeHandler = null
+
+  const syncMobile = () => {
+    isMobile.value = window.innerWidth <= breakpoint
+  }
+
+  onMounted(() => {
+    resizeHandler = syncMobile
+    syncMobile()
+    window.addEventListener('resize', resizeHandler)
+  })
+
+  onUnmounted(() => {
+    if (resizeHandler) {
+      window.removeEventListener('resize', resizeHandler)
+    }
+  })
+
+  /** 供 a-table-column 的 :fixed 绑定 */
+  const tableFixed = (side = 'right') => (isMobile.value ? undefined : side)
+
+  /** 供 :columns 绑定,根据 isMobile 自动去掉 fixed */
+  const useTableColumns = (columnsSource) =>
+    computed(() => {
+      const cols =
+        typeof columnsSource === 'function' ? columnsSource() : unref(columnsSource)
+      return isMobile.value ? stripTableFixed(cols) : cols
+    })
+
+  return { isMobile, tableFixed, useTableColumns, stripTableFixed }
+}

+ 158 - 17
src/layout/default-layout.vue

@@ -1,16 +1,47 @@
 <template>
   <a-layout class="layout">
     <a-layout-header class="layout-navbar">
+      <a-button
+        class="mobile-menu-trigger"
+        type="text"
+        shape="circle"
+        @click="toggleSider"
+      >
+        <template #icon>
+          <icon-menu-unfold v-if="siderCollapsed" />
+          <icon-menu-fold v-else />
+        </template>
+      </a-button>
       <Navbar />
     </a-layout-header>
-    <a-layout>
-      <a-layout-sider class="layout-sider">
+    <a-layout
+      class="layout-body"
+      :class="{
+        'layout-body--mobile': isMobile,
+        'layout-body--menu-open': isMobile && !siderCollapsed
+      }"
+    >
+      <a-layout-sider
+        class="layout-sider"
+        :width="200"
+        :collapsed-width="0"
+        :collapsed="siderCollapsed"
+        :hide-trigger="true"
+        collapsible
+        breakpoint="lg"
+        @collapse="(val) => (siderCollapsed = val)"
+      >
         <Menu class="menu-wrapper" />
       </a-layout-sider>
       <a-layout-content class="layout-content">
-        <PageLayout style="margin-bottom: 30px;" />
+        <PageLayout class="page-layout-main" />
         <Footer />
       </a-layout-content>
+      <div
+        v-if="isMobile && !siderCollapsed"
+        class="mobile-menu-mask"
+        @click="siderCollapsed = true"
+      />
     </a-layout>
 
     <!-- <a-avatar class="aiButton" @click="chat = !chat">
@@ -47,6 +78,8 @@ import Footer from '@/components/Footer/index.vue'
 const chat = ref(false)
 const popupVisible = ref(false)
 const popupData = ref({})
+const siderCollapsed = ref(false)
+const isMobile = ref(false)
 
 // 按钮拖动
 let isDragging = false
@@ -87,6 +120,12 @@ onMounted(() => {
     button.addEventListener('mousedown', handleMouseDown)
   }
   eventBus.on('closeAI', () => { chat.value = false })
+  const mobile = window.innerWidth <= 992
+  isMobile.value = mobile
+  if (mobile) {
+    siderCollapsed.value = false
+  }
+  window.addEventListener('resize', syncSiderCollapsed)
   getPopup()
 })
 
@@ -94,6 +133,7 @@ onBeforeUnmount(() => {
   if (button) {
     button.removeEventListener('mousedown', handleMouseDown)
   }
+  window.removeEventListener('resize', syncSiderCollapsed)
 })
 
 onUnmounted(() => {
@@ -124,15 +164,38 @@ const handlePopupClose = async () => {
   } catch (_) {}
 }
 
+const syncSiderCollapsed = () => {
+  const mobile = window.innerWidth <= 992
+  isMobile.value = mobile
+  if (!mobile) {
+    siderCollapsed.value = false
+  }
+}
+
+const toggleSider = () => {
+  siderCollapsed.value = !siderCollapsed.value
+}
+
 </script>
 
 <style scoped lang="less">
+@import '@/styles/store-theme.less';
+
 @nav-size-height: 60px;
 
+
 .layout {
-  min-width: 1280px;
+  min-width: 0;
   width: 100%;
-  height: 100%;
+  min-height: 100vh;
+}
+
+.layout-body {
+  margin-top: @nav-size-height;
+  min-height: calc(100vh - @nav-size-height);
+  height: calc(100vh - @nav-size-height);
+  position: relative;
+  overflow: hidden;
 }
 
 .layout-navbar {
@@ -144,12 +207,15 @@ const handlePopupClose = async () => {
   height: @nav-size-height;
 }
 
+.mobile-menu-trigger {
+  display: none;
+}
+
 .layout-sider {
-  position: fixed;
-  top: 0;
-  left: 0;
+  position: sticky;
+  top: @nav-size-height;
   z-index: 99;
-  height: 100%;
+  height: calc(100vh - @nav-size-height);
   transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
 
   &::after {
@@ -166,7 +232,8 @@ const handlePopupClose = async () => {
 
 .menu-wrapper {
   user-select: none;
-  margin-top: 60px;
+  margin-top: 0;
+  height: 100%;
   overflow: auto;
   overflow-x: hidden;
 
@@ -190,14 +257,15 @@ const handlePopupClose = async () => {
 }
 
 .layout-content {
-  position: absolute;
-  top: @nav-size-height;
-  left: 200px;
-  width: calc(100% - 200px);
-  min-height: calc(100% - @nav-size-height);
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-width: 0;
+  min-height: calc(100vh - @nav-size-height);
+  overflow-x: hidden;
   overflow-y: auto;
-  background-color: var(--color-fill-2);
-  transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
+  background-color: @store-bg;
+  transition: transform 0.22s ease;
 
   &::-webkit-scrollbar {
     width: 10px;
@@ -221,6 +289,35 @@ const handlePopupClose = async () => {
   }
 }
 
+@media (min-width: 993px) {
+  .layout-body {
+    display: block;
+  }
+
+  .layout-sider {
+    position: fixed;
+    left: 0;
+    top: @nav-size-height;
+    width: 200px !important;
+    height: calc(100vh - @nav-size-height);
+  }
+
+  .layout-content {
+    margin-left: 200px;
+    height: calc(100vh - @nav-size-height);
+  }
+}
+
+.mobile-menu-mask {
+  position: fixed;
+  top: @nav-size-height;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 1000;
+  background: rgba(0, 0, 0, 0.28);
+}
+
 .aiButton {
   position: fixed;
   bottom: 100px;
@@ -306,4 +403,48 @@ const handlePopupClose = async () => {
     background-color: rgba(229, 233, 237, 1);
   }
 }
+
+@media (max-width: 992px) {
+  .mobile-menu-trigger {
+    display: inline-flex;
+    position: absolute;
+    left: 8px;
+    top: 50%;
+    transform: translateY(-50%);
+    z-index: 1001;
+  }
+
+  :deep(.navbar .left-side) {
+    padding-left: 48px;
+  }
+
+  .layout-body--mobile {
+    display: block;
+  }
+
+  .layout-body--mobile .layout-sider {
+    position: fixed;
+    left: 0;
+    top: @nav-size-height;
+    z-index: 1001;
+    height: calc(100vh - @nav-size-height);
+    width: 200px !important;
+    transform: translateX(-100%);
+    transition: transform 0.22s ease;
+    box-shadow: 2px 0 16px rgba(0, 0, 0, 0.14);
+  }
+
+  .layout-body--menu-open .layout-sider {
+    transform: translateX(0);
+  }
+
+  .layout-body--mobile .layout-content {
+    min-height: calc(100vh - @nav-size-height);
+    transform: translateX(0);
+  }
+
+  .layout-body--menu-open .layout-content {
+    transform: translateX(200px);
+  }
+}
 </style>

+ 4 - 7
src/layout/html-view.vue

@@ -39,18 +39,15 @@ import Footer from '@/components/Footer/index.vue'
   bottom: 0;
   width: 100%;
   height: calc(100% - @nav-size-height);
+  display: flex;
+  flex-direction: column;
+  overflow-x: hidden;
   overflow-y: auto;
   background-color: var(--color-fill-2);
 }
 
 .page {
-  height: 100%;
-  margin-bottom: 30px;
-}
-
-router-view {
-  display: block;
-  height: 100%;
+  padding-bottom: 8px;
 }
 
 </style>

+ 14 - 2
src/main.js

@@ -8,7 +8,8 @@ if (isElectron()) {
 }
 
 import ArcoVue from '@arco-design/web-vue'
-import '@arco-themes/vue-hst-oem/index.less'
+import '@arco-themes/vue-mengtu/index.less'
+import '@/styles/store-theme.less'
 
 import ArcoVueIcon from '@arco-design/web-vue/es/icon'
 
@@ -16,6 +17,17 @@ import App from './App.vue'
 import { router } from './router/'
 import { createPinia } from 'pinia'
 import globalComponents from '@/components'
+import trimInput from '@/plugins/trimInput'
+import { setupResponsiveModal } from '@/plugins/responsiveModal'
+
+setupResponsiveModal()
 
 const app = createApp(App)
-app.use(ArcoVue).use(ArcoVueIcon).use(router).use(createPinia()).use(globalComponents).mount('#app')
+app
+  .use(ArcoVue)
+  .use(trimInput)
+  .use(ArcoVueIcon)
+  .use(router)
+  .use(createPinia())
+  .use(globalComponents)
+  .mount('#app')

+ 24 - 29
src/pages/Login/Login.vue

@@ -1,41 +1,36 @@
 <template>
-    <div class="root">
-        <Header v-if="!isElectron()"/>
-        <Navbar v-else style="position: relative; z-index: 999;"/>
-        <Container />
-        <CanvasBackend />
-        <Footer />
-    </div>
+  <div class="auth-page">
+    <header class="auth-topbar">
+      <router-link to="/" class="auth-topbar__brand">
+        <img src="/logo.svg" height="32" width="32" alt="哪吒乐跑" />
+        <span>哪吒乐跑</span>
+      </router-link>
+      <router-link v-if="!isElectron()" to="/" class="auth-topbar__link">返回首页</router-link>
+    </header>
+
+    <Navbar v-if="isElectron()" class="auth-page__nav" />
+
+    <main class="auth-page__main">
+      <Container />
+    </main>
+
+    <p class="auth-page__legal">© {{ year }} 哪吒乐跑 · 智能校园乐跑平台</p>
+  </div>
 </template>
 
 <script setup>
-import CanvasBackend from '@/components/CanvasBackend/index.vue'
-import Header from '@/components/Header/index.vue'
 import Navbar from '@/components/Navbar/index.vue'
 import Container from './components/container.vue'
-import Footer from '@/components/Footer/index.vue'
 import { isElectron } from '@/utils/electron'
-</script>
 
-<style scoped>
-.root {
-    text-align: center;
-}
+const year = new Date().getFullYear()
+</script>
 
-.center {
-    position: absolute;
-    top: 50%;
-    left: 50%;
-    width: 800px;
-    height: 300px;
-    z-index: 2;
-    transform: translate(-50%, -50%);
-    pointer-events: auto;
-    /* center 可以接收鼠标事件 */
-}
+<style scoped lang="less">
+@import '@/styles/auth-marketing.less';
 
-.button {
-    position: absolute;
-    z-index: 101;
+.auth-page__nav {
+  position: relative;
+  z-index: 20;
 }
 </style>

+ 5 - 48
src/pages/Login/components/container.vue

@@ -1,5 +1,5 @@
 <template>
-    <div class='center'>
+    <div class="auth-card" :class="current">
         <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>
@@ -18,62 +18,19 @@ const route = useRoute()
 let current = ref('login')
 
 const changeMode = (mode) => {
-    document.querySelector('.center').className = `center ${mode}`
     current.value = mode
 }
 
 onMounted(() => {
     if(route.query.mode) {
         changeMode(route.query.mode)
+    } else if (route.query.type && route.query.code) {
+        changeMode('uniLogin')
     }
 })
 
 </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 scoped lang="less">
+@import '@/styles/auth-marketing.less';
 </style>

+ 8 - 48
src/pages/Login/components/login.vue

@@ -1,5 +1,5 @@
 <template>
-    <div class="root">
+    <div class="auth-form-panel">
         <div class="logo">
             <img alt="哪吒乐跑" src="/logo.svg" height="40">
             <span class="title">哪吒乐跑 | 用户登录</span>
@@ -35,7 +35,7 @@
             </a-form-item>
 
             <div class="forgetpass">
-                <!-- <a-button type="text" @click="emit('changeMode', 'uniLogin')">快捷登录</a-button> -->
+                <a-button type="text" @click="emit('changeMode', 'uniLogin')">快捷登录</a-button>
                 <!-- <div class="tip">由于业务变更 现无法通过QQ/微信快捷登录<br>快捷登录用户可在注册新账号后提交工单联系客服找回原账号</div> -->
                 <a-button type="text" @click="emit('changeMode', 'register')">注册账号</a-button>
             </div>
@@ -128,53 +128,13 @@ const requestFailed = (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: rgb(255, 52, 52);
-    font-size: 1.8em;
-    font-weight: bold;
-    margin-left: 15px;
-    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
-}
+<style scoped lang="less">
+@import '@/styles/auth-marketing.less';
 
 .tip {
-    color: #777;
-    font-size: 0.9em;
-    text-align: left;
-    margin-bottom: 20px;
-    margin-left: -8px;
-}
-
-.forgetpass {
-    width: 100%;
-    margin-top: -10px;
-    display: flex;
-    justify-content: space-between;
-}
-
-.captcha {
-    max-height: 35px;
-    margin-right: -10px;
+  color: #777;
+  font-size: 0.9em;
+  text-align: left;
+  margin-bottom: 12px;
 }
 </style>

+ 5 - 44
src/pages/Login/components/register.vue

@@ -1,5 +1,5 @@
 <template>
-    <div class="root">
+    <div class="auth-form-panel">
         <div class="logo">
             <img alt="哪吒乐跑" src="/logo.svg" height="40">
             <span class="title">哪吒乐跑 | 用户注册</span>
@@ -38,11 +38,11 @@
 
             <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">
+                <EmailAutoComplete placeholder="请输入邮箱" allow-clear v-model="form.email">
                     <template #prefix>
                         <icon-email />
                     </template>
-                </a-input>
+                </EmailAutoComplete>
             </a-form-item>
 
 
@@ -192,45 +192,6 @@ const requestFailed = (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: rgb(255, 52, 52);
-    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 scoped lang="less">
+@import '@/styles/auth-marketing.less';
 </style>

+ 44 - 37
src/pages/Login/uniLogin/uniLogin.vue

@@ -1,5 +1,5 @@
 <template>
-    <div class="root">
+    <div class="auth-form-panel uni-login-panel">
         <div class="logo">
             <img alt="哪吒乐跑" src="/logo.svg" height="40">
             <span class="title">哪吒乐跑 | 快捷登录</span>
@@ -9,18 +9,18 @@
             <div class="uniLoginButton">
                 <div class="button">
                     <a-tooltip content="QQ登录" mini>
-                        <a-avatar :size="64" :style="{ backgroundColor: 'rgb(255, 52, 52)' }"
+                        <a-avatar :size="64" :style="{ backgroundColor: '#FE82A5' }"
                             @click="GetLoginUrl('qq')"><icon-qq /></a-avatar>
                     </a-tooltip>
                 </div>
                 <div class="button">
                     <a-tooltip content="微信登录" mini>
-                        <a-avatar :size="64" :style="{ backgroundColor: 'rgb(255, 52, 52)' }"
+                        <a-avatar :size="64" :style="{ backgroundColor: '#FE82A5' }"
                             @click="GetLoginUrl('wx')"><icon-wechat /></a-avatar>
                     </a-tooltip>
                 </div>
             </div>
-            <div class="tip">未注册的账号登录后将自动注册<br>不同登录方式的账号资产和权限互不共享</div>
+            <div class="tip">未注册的第三方账号登录后将自动注册</div>
             <a-button type="text" class="forgetpass" @click="emit('changeMode', 'login')">账号密码登录</a-button>
         </a-form>
     </div>
@@ -72,16 +72,38 @@ const GetLoginUrl = async (type) => {
 const UniLogin = async (type, code) => {
     try {
         changeLoading(true)
-        userStore.uniLogin(type, code)
-            .then((res) => { loginSuccess(res) })
-            .catch((err) => {
-                requestFailed(err.message)
-            })
-            .finally(() => {
-                changeLoading(false)
-            })
+        const res = await userStore.uniLogin(type, code)
+        loginSuccess(res)
     } catch (error) {
-        requestFailed('登录失败!请稍后再试')
+        requestFailed(error.message || '登录失败!请稍后再试')
+    } finally {
+        changeLoading(false)
+    }
+}
+
+const BindSocial = async (type, code) => {
+    try {
+        changeLoading(true)
+        const user = await userStore.getInfo()
+        if (!user?.uuid || !user?.session)
+            throw new Error('请先登录后再绑定第三方账号')
+
+        await userStore.bindSocial(type, code)
+        Notification.success({
+            title: '绑定成功',
+            content: `${type === 'qq' ? 'QQ' : '微信'}账号已绑定到当前账户`,
+            duration: 2000
+        })
+
+        if (isElectron())
+            window.electron.openOldWindow(from || '/user/setting')
+        else
+            router.push(from || '/user/setting')
+    } catch (error) {
+        requestFailed(error.message || '绑定失败!请稍后再试')
+        router.push('/user/setting')
+    } finally {
+        changeLoading(false)
     }
 }
 
@@ -110,13 +132,18 @@ const requestFailed = (msg) => {
 
 onMounted(() => {
     if (route.query.type && route.query.code) {
-        UniLogin(route.query.type, route.query.code)
+        if (route.query.action === 'bind')
+            BindSocial(route.query.type, route.query.code)
+        else
+            UniLogin(route.query.type, route.query.code)
     }
 })
 
 </script>
 
 <style lang="less" scoped>
+@import '@/styles/auth-marketing.less';
+
 .loading {
     position: fixed;
     top: 0;
@@ -132,7 +159,7 @@ onMounted(() => {
         top: 50%;
         left: 50%;
         transform: translate(-50%, -50%);
-        color: rgb(255, 52, 52);
+        color: #FE82A5;
 
         .text {
             font-family: 'Alimama ShuHeiTi';
@@ -141,28 +168,8 @@ onMounted(() => {
     }
 }
 
-.root {
-    display: flex;
-    gap: 40px;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-}
-
-.form {
-    width: 100%;
-}
-
-.logo {
-    display: flex;
-
-    .title {
-        color: rgb(255, 52, 52);
-        font-size: 1.8em;
-        font-weight: bold;
-        margin-left: 15px;
-        font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
-    }
+.uni-login-panel {
+  position: relative;
 }
 
 .tip {

+ 343 - 67
src/pages/Main/Main.vue

@@ -1,25 +1,45 @@
 <template>
-    <div id="section1" class="section root">
-        <div class="videocontainer">
-            <video class="fullscreenvideo" playsinline="" autoplay="" muted="" loop=""
-                src="//xxoo365.top/background.webm" type="video/mp4" />
-            <div class="overlay" />
+    <div id="section1" class="section hero-section">
+        <div class="hero-bg" aria-hidden="true">
+            <div class="hero-bg-gradient" />
+            <div class="hero-bg-shimmer" />
+            <div class="hero-bg-orb hero-bg-orb--1" />
+            <div class="hero-bg-orb hero-bg-orb--2" />
+            <div class="hero-bg-orb hero-bg-orb--3" />
+            <div class="hero-bg-orb hero-bg-orb--4" />
+        </div>
+        <Header color="#1b3022" />
+        <div class="hero-inner">
+            <Center />
+            <div class="hero-visual" aria-hidden="true">
+                <img
+                    class="hero-illustration"
+                    src="@/assets/img/homePage/figma/hero-illustration.png"
+                    alt=""
+                />
+            </div>
         </div>
-        <Header color="white" />
-        <Center />
 
-        <a-button shape="circle" class="floating-button" size="large" @click="scrollToSection('section2')">
+        <a-button
+            shape="circle"
+            class="floating-button"
+            size="large"
+            @click="scrollToSection('section2')"
+        >
             <IconDown />
         </a-button>
     </div>
 
-    <div id="section2" class="section">
+    <div id="section2" class="section features-wrap">
+        <div class="features-bg" aria-hidden="true">
+            <div class="features-bg-gradient" />
+            <div class="features-bg-orb" />
+        </div>
         <Section2 />
     </div>
-    <div class="footer">
+    <footer class="site-footer">
         <span>© {{ new Date().getFullYear() }} 哪吒乐跑 - 智能校园乐跑平台</span>
-    </div>
-
+    </footer>
 </template>
 
 <script setup>
@@ -28,101 +48,357 @@ import Center from './components/center.vue'
 import Section2 from './components/section2.vue'
 
 const scrollToSection = (sectionId) => {
-    const section = document.getElementById(sectionId);
+    const section = document.getElementById(sectionId)
     if (section) {
-        section.scrollIntoView({ behavior: 'smooth' });
+        section.scrollIntoView({ behavior: 'smooth' })
     }
-};
-
+}
 </script>
 
 <style lang="less" scoped>
+@import './theme.less';
+@import '@/styles/store-theme.less';
+
 .section {
     padding: 0;
     margin: 0;
-    min-width: 1280px;
     width: 100%;
-    min-height: 100vh;
-    scroll-snap-align: start;
+    min-height: 100dvh;
     position: relative;
+    box-sizing: border-box;
 }
 
-.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%);
+.hero-section {
+    display: flex;
+    user-select: none;
+    flex-direction: column;
+    background: @home-gradient-pale;
+    overflow: hidden;
 }
 
-.videocontainer {
+.hero-bg {
     position: absolute;
-    width: 100%;
-    height: 100%;
+    inset: 0;
+    z-index: 0;
     overflow: hidden;
-    z-index: -100;
+    pointer-events: none;
 }
 
-.overlay {
+.hero-bg-gradient {
     position: absolute;
-    width: 100%;
-    height: 100%;
-    background-color: rgba(0, 0, 0, 0.4);
-    z-index: -99;
+    inset: 0;
+    background: linear-gradient(
+        125deg,
+        @home-gradient-pale 0%,
+        fade(@home-gradient-mint, 92%) 16%,
+        fade(@home-gradient-teal, 78%) 34%,
+        fade(@home-gradient-green, 82%) 52%,
+        fade(@home-gradient-lime, 55%) 68%,
+        fade(@home-gradient-mint, 88%) 84%,
+        fade(@home-gradient-emerald, 72%) 100%
+    );
+    background-size: 320% 320%;
+    animation: heroGradientFlow 12s ease-in-out infinite;
 }
 
-.center {
+.hero-bg-shimmer {
     position: absolute;
     top: 50%;
     left: 50%;
-    width: 800px;
-    height: 300px;
-    z-index: 2;
+    width: 140%;
+    height: 140%;
     transform: translate(-50%, -50%);
-    pointer-events: auto;
-    /* center 可以接收鼠标事件 */
+    background: conic-gradient(
+        from 0deg at 50% 50%,
+        fade(@home-gradient-teal, 0%) 0deg,
+        fade(@home-gradient-aqua, 28%) 72deg,
+        fade(@home-gradient-lime, 18%) 144deg,
+        fade(@home-gradient-emerald, 24%) 216deg,
+        fade(@home-gradient-mint, 14%) 288deg,
+        fade(@home-gradient-teal, 0%) 360deg
+    );
+    opacity: 0.55;
+    filter: blur(48px);
+    animation: shimmerRotate 18s linear infinite;
 }
 
-.button {
+.hero-bg-orb {
     position: absolute;
-    z-index: 101;
+    border-radius: 50%;
+    filter: blur(64px);
+    opacity: 0.55;
+    will-change: transform, opacity;
+}
+
+.hero-bg-orb--1 {
+    width: clamp(260px, 38vw, 520px);
+    height: clamp(260px, 38vw, 520px);
+    top: -14%;
+    right: -8%;
+    background: radial-gradient(circle, fade(@home-gradient-emerald, 72%) 0%, fade(@home-gradient-green, 38%) 42%, transparent 72%);
+    animation: orbFloatOne 10s ease-in-out infinite;
+}
+
+.hero-bg-orb--2 {
+    width: clamp(240px, 34vw, 420px);
+    height: clamp(240px, 34vw, 420px);
+    bottom: 0%;
+    left: -12%;
+    background: radial-gradient(circle, fade(@home-gradient-aqua, 68%) 0%, fade(@home-gradient-teal, 32%) 48%, transparent 74%);
+    animation: orbFloatTwo 13s ease-in-out infinite;
+}
+
+.hero-bg-orb--3 {
+    width: clamp(200px, 26vw, 340px);
+    height: clamp(200px, 26vw, 340px);
+    top: 28%;
+    left: 26%;
+    background: radial-gradient(circle, fade(#fff, 92%) 0%, fade(@home-gradient-lime, 36%) 38%, transparent 72%);
+    animation: orbFloatThree 9s ease-in-out infinite;
+}
+
+.hero-bg-orb--4 {
+    width: clamp(180px, 24vw, 320px);
+    height: clamp(180px, 24vw, 320px);
+    bottom: 18%;
+    right: 12%;
+    background: radial-gradient(circle, fade(@home-gradient-lime, 58%) 0%, fade(@home-gradient-deep, 28%) 50%, transparent 76%);
+    animation: orbFloatFour 11s ease-in-out infinite;
+}
+
+.hero-inner {
+    position: relative;
+    z-index: 1;
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: clamp(18px, 4vw, 56px);
+    max-width: 1180px;
+    width: 100%;
+    margin: 0 auto;
+    padding: clamp(88px, 11vh, 124px) @store-page-padding-x clamp(74px, 10vh, 104px);
+    box-sizing: border-box;
+}
+
+.hero-visual {
+    flex: 0 0 auto;
+    user-select: none;
+    max-width: min(44vw, 460px);
+}
+
+.hero-illustration {
+    width: 100%;
+    max-width: 420px;
+    height: auto;
+    object-fit: contain;
+    filter: drop-shadow(0 16px 32px rgba(27, 48, 34, 0.12));
 }
 
 .floating-button {
-    background-color: rgba(255, 255, 255, 0.5);
+    z-index: 2;
+    background-color: fade(#fff, 58%);
+    color: @home-primary;
     position: absolute;
-    bottom: 50px;
+    bottom: clamp(20px, 5vh, 44px);
     left: 50%;
     transform: translateX(-50%);
+    border: 1px solid fade(@home-primary, 16%);
+    backdrop-filter: blur(8px);
+    transition: transform 0.22s ease, background-color 0.22s ease, color 0.22s ease;
+
+    &:hover {
+        transform: translateX(-50%) translateY(-2px);
+        background-color: @home-primary;
+        color: #fff;
+    }
+}
+
+.features-wrap {
+    min-height: auto;
+    background: linear-gradient(180deg, #f2fbf5 0%, #edf8f1 100%);
+    overflow: hidden;
+}
+
+.features-bg {
+    position: absolute;
+    inset: 0;
+    z-index: 0;
+    pointer-events: none;
+    overflow: hidden;
+}
+
+.features-bg-gradient {
+    position: absolute;
+    inset: 0;
+    background: linear-gradient(
+        145deg,
+        fade(@home-bg-page, 96%) 0%,
+        fade(@home-gradient-mint, 48%) 35%,
+        fade(@home-gradient-teal, 38%) 65%,
+        fade(@home-gradient-aqua, 28%) 100%
+    );
+    background-size: 240% 240%;
+    animation: featuresGradientFlow 16s ease-in-out infinite alternate;
+}
+
+.features-bg-orb {
+    position: absolute;
+    width: clamp(280px, 42vw, 560px);
+    height: clamp(280px, 42vw, 560px);
+    top: -20%;
+    right: -15%;
     border-radius: 50%;
-    border: none;
-    padding: 10px;
-    transition: background-color 0.3s;
+    background: radial-gradient(circle, fade(@home-gradient-teal, 42%) 0%, fade(@home-gradient-mint, 18%) 50%, transparent 72%);
+    filter: blur(72px);
+    opacity: 0.5;
+    animation: featuresOrbFloat 14s ease-in-out infinite;
 }
 
-.footer {
-    cursor: pointer;
+.features-wrap > :not(.features-bg) {
     position: relative;
-    bottom: 15px;
-    left: 50%;
-    transform: translateX(-50%);
-    color: #777;
-    font-size: 12px;
-    display: flex;
-    min-width: 300px;
-    justify-content: center;
-    gap: 10px;
+    z-index: 1;
+}
 
+.site-footer {
+    background: linear-gradient(120deg, @home-primary 0%, fade(@home-primary, 88%) 100%);
+    color: fade(#fff, 82%);
+    font-size: 13px;
+    text-align: center;
+    padding: 18px 16px;
+    width: 100%;
+    box-sizing: border-box;
+}
+
+@media (max-width: @app-breakpoint-lg) {
+    .hero-inner {
+        flex-direction: column;
+        text-align: center;
+        justify-content: stretch;
+        align-items: stretch;
+        min-height: calc(100dvh - 72px);
+        padding-top: clamp(34px, 6vh, 58px);
+        padding-bottom: clamp(24px, 4vh, 40px);
+        gap: 8px;
+    }
+
+    .hero-visual {
+        display: none;
+    }
+
+    .floating-button {
+        bottom: 20px;
+    }
+}
+
+@keyframes heroGradientFlow {
+    0% {
+        background-position: 0% 0%;
+        filter: saturate(1) hue-rotate(0deg);
+    }
+    33% {
+        background-position: 100% 0%;
+        filter: saturate(1.12) hue-rotate(6deg);
+    }
+    66% {
+        background-position: 100% 100%;
+        filter: saturate(1.18) hue-rotate(-4deg);
+    }
+    100% {
+        background-position: 0% 100%;
+        filter: saturate(1.08) hue-rotate(0deg);
+    }
+}
+
+@keyframes shimmerRotate {
+    from {
+        transform: translate(-50%, -50%) rotate(0deg);
+    }
+    to {
+        transform: translate(-50%, -50%) rotate(360deg);
+    }
+}
+
+@keyframes featuresGradientFlow {
+    0% {
+        background-position: 0% 50%;
+    }
+    100% {
+        background-position: 100% 50%;
+    }
+}
+
+@keyframes featuresOrbFloat {
+    0%,
+    100% {
+        transform: translate3d(0, 0, 0) scale(1);
+        opacity: 0.42;
+    }
+    50% {
+        transform: translate3d(-28px, 24px, 0) scale(1.12);
+        opacity: 0.62;
+    }
+}
+
+@keyframes orbFloatOne {
+    0%,
+    100% {
+        transform: translate3d(0, 0, 0) scale(1);
+        opacity: 0.52;
+    }
+    50% {
+        transform: translate3d(-36px, 32px, 0) scale(1.14);
+        opacity: 0.78;
+    }
+}
+
+@keyframes orbFloatTwo {
+    0%,
+    100% {
+        transform: translate3d(0, 0, 0) scale(1);
+        opacity: 0.48;
+    }
+    50% {
+        transform: translate3d(40px, -28px, 0) scale(1.16);
+        opacity: 0.72;
+    }
+}
+
+@keyframes orbFloatThree {
+    0%,
+    100% {
+        transform: translate3d(0, 0, 0) scale(1);
+        opacity: 0.44;
+    }
+    50% {
+        transform: translate3d(-24px, -22px, 0) scale(1.18);
+        opacity: 0.68;
+    }
+}
+
+@keyframes orbFloatFour {
+    0%,
+    100% {
+        transform: translate3d(0, 0, 0) scale(1);
+        opacity: 0.4;
+    }
+    50% {
+        transform: translate3d(20px, 18px, 0) scale(1.1);
+        opacity: 0.64;
+    }
+}
+
+@media (prefers-reduced-motion: reduce) {
+    .hero-bg-gradient,
+    .hero-bg-shimmer,
+    .hero-bg-orb,
+    .features-bg-gradient,
+    .features-bg-orb {
+        animation: none;
+    }
 
-    a {
-        color: #777;
+    .hero-bg-shimmer {
+        opacity: 0.35;
     }
 }
 </style>

+ 99 - 78
src/pages/Main/components/center.vue

@@ -1,106 +1,127 @@
 <template>
     <div class="center">
-        <h1>哪吒乐跑</h1>
-        <h2>—— 智能自助校园乐跑平台</h2>
-        <div class="animated-button" @click="$router.push('/lepao')">
-            <span>立即体验</span>
+        <div class="hero-copy">
+            <p class="eyebrow">智能校园乐跑平台</p>
+            <h1>哪吒乐跑</h1>
+            <h2>轻松完成乐跑任务,成绩更省心</h2>
+        </div>
+        <div class="action-row">
+            <button type="button" class="cta-button" @click="$router.push('/lepao')">
+                立即体验
+            </button>
         </div>
     </div>
 </template>
 
-<style scoped>
+<style lang="less" scoped>
+@import '../theme.less';
+@import '@/styles/store-theme.less';
+
 .center {
-    margin-top: -200px;
+    flex: 1;
+    max-width: 600px;
+    text-align: left;
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
 }
 
-.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;
+.hero-copy {
+    display: flex;
+    flex-direction: column;
+}
+
+.eyebrow {
+    margin: 0 0 12px;
+    font-size: clamp(0.92rem, 2.4vw, 1.08rem);
+    font-weight: 600;
+    letter-spacing: 0.14em;
+    text-transform: uppercase;
+    color: @store-accent;
 }
 
-/* h1 特别设置字号和字体 */
 .center h1 {
-    font-size: 10em;
-    line-height: 1.3em;
-    font-family: AlibabaSans, -apple-system, BlinkMacSystemFont;
+    margin: 0;
+    font-size: clamp(3.2rem, 10vw, 6rem);
+    line-height: 1.1;
+    font-weight: 700;
+    color: @home-primary;
+    font-family: AlibabaSans, -apple-system, BlinkMacSystemFont, sans-serif;
 }
 
-/* h2 特别设置字号和字体 */
 .center h2 {
-    font-size: 2.5em;
-    margin-top: -50px;
-    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont;
+    margin: 16px 0 0;
+    font-size: clamp(1.15rem, 3.2vw, 1.8rem);
+    line-height: 1.7;
+    font-weight: 400;
+    color: @home-text-muted;
+    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont, sans-serif;
+    max-width: 24em;
 }
 
-/* 动态渐变动画 */
-@keyframes gradientMove {
-    0% {
-        background-position: 0% 50%;
-    }
+.cta-button {
+    margin-top: 28px;
+    cursor: pointer;
+    border: none;
+    border-radius: @home-radius-btn;
+    padding: 14px 32px;
+    font-size: 16px;
+    font-weight: 600;
+    color: #fff;
+    background: linear-gradient(135deg, @home-primary 0%, @store-primary-light 100%);
+    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont, sans-serif;
+    box-shadow: 0 10px 24px fade(@home-primary, 24%);
+    transition: transform 0.2s, box-shadow 0.2s, filter 0.2s;
 
-    50% {
-        background-position: 100% 50%;
+    &:hover {
+        transform: translateY(-1px);
+        box-shadow: 0 14px 28px fade(@home-primary, 28%);
+        filter: brightness(1.05);
     }
+}
 
-    100% {
-        background-position: 0% 50%;
-    }
+.action-row {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex-wrap: wrap;
 }
 
 
-.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;
-}
+@media (max-width: 992px) {
+    .center {
+        text-align: center;
+        align-items: center;
+        width: 100%;
+        min-height: clamp(430px, 62dvh, 620px);
+    }
 
-.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;
-}
+    .hero-copy {
+        margin-top: auto;
+        margin-bottom: auto;
+        transform: translateY(-4vh);
+    }
 
-.animated-button:hover::before {
-    transform: translateX(0);
-}
+    .center h2 {
+        margin-left: auto;
+        margin-right: auto;
+        max-width: 18em;
+    }
 
-.animated-button:hover {
-    color: rgb(22, 93, 255);
-}
+    .action-row {
+        justify-content: center;
+        width: 100%;
+        margin-top: auto;
+        padding-bottom: clamp(74px, 12vh, 112px);
+    }
 
-.animated-button span {
-    position: relative;
-    transition: color 0.1s;
+    .cta-button {
+        margin-top: 0;
+        min-width: min(150px, 84vw);
+        padding: 13px 24px;
+        font-size: 17px;
+    }
 }
-</style>
+
+</style>

+ 115 - 60
src/pages/Main/components/section2.vue

@@ -1,17 +1,23 @@
 <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="features-section">
+        <h2 class="section-title">核心能力</h2>
+        <p class="section-subtitle">为校园乐跑场景打造的全流程自助服务</p>
+        <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" />
+                    <img :src="card.icon" alt="" />
                     {{ card.title }}
                 </div>
                 <div class="dec">{{ card.dec }}</div>
             </div>
             <div class="img">
-                <img :src="card.img" />
+                <img :src="card.img" alt="" />
             </div>
         </div>
     </div>
@@ -19,35 +25,33 @@
 
 <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:\/\/lepao-cloud.xxoo365.top\/view.php\/f9192f9d310b16a7e8e4ef2f71ac8629.png').href,
+        img: new URL('https://lepao-cloud.xxoo365.top/view.php/f9192f9d310b16a7e8e4ef2f71ac8629.png').href,
         reverse: false
     },
     {
         title: '智能成绩上传系统',
         icon: new URL('@/assets/img/homePage/icons/ai.svg', import.meta.url).href,
         dec: '支持夜间上传与异常修复,忘跑也能及时补救,成绩无忧',
-        img: new URL('https:\/\/lepao-cloud.xxoo365.top\/view.php\/6cf4d3d8f5d9e6b41c525541c8949329.png').href,
+        img: new URL('https://lepao-cloud.xxoo365.top/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:\/\/lepao-cloud.xxoo365.top\/view.php\/3132165c9476e659c710114f963e6814.png').href,
+        img: new URL('https://lepao-cloud.xxoo365.top/view.php/3132165c9476e659c710114f963e6814.png').href,
         reverse: false
     }
 ]
 
-
 const cardRefs = ref([])
-const inView = ref(cards.map(() => false)) // 初始化每个卡片的状态
+const inView = ref(cards.map(() => false))
 
 let observers = []
 
@@ -70,76 +74,127 @@ onMounted(() => {
 onUnmounted(() => {
     observers.forEach(observer => observer.disconnect())
 })
-
 </script>
 
 <style lang="less" scoped>
+@import '../theme.less';
+@import '@/styles/store-theme.less';
+
 .features-section {
     position: relative;
-    background-image: linear-gradient(to bottom, #ebf2ff, hsla(0, 0%, 100%, 0));
-    padding: 80px 20px;
+    padding: clamp(52px, 9vh, 88px) @store-page-padding-x clamp(64px, 9vh, 108px);
     display: flex;
-    gap: 50px;
+    gap: clamp(18px, 3.6vh, 34px);
     flex-direction: column;
     align-items: center;
+    box-sizing: border-box;
+}
+
+.section-title {
+    margin: 0;
+    font-size: clamp(1.8rem, 4vw, 2.4rem);
+    color: @home-primary;
+    font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont, sans-serif;
+    text-align: center;
+}
+
+.section-subtitle {
+    margin: -4px 0 20px;
+    font-size: clamp(0.95rem, 2.3vw, 1.08rem);
+    color: @home-text-muted;
+    text-align: center;
+    max-width: 38rem;
+}
+
+.card {
+    width: min(1160px, 100%);
+    display: flex;
+    gap: clamp(20px, 3vw, 44px);
+    align-items: center;
     justify-content: center;
+    background: linear-gradient(145deg, fade(#fff, 95%) 0%, fade(#f2fbf6, 88%) 100%);
+    border: 1px solid fade(@home-card-border, 92%);
+    border-radius: 22px;
+    box-shadow: 0 16px 40px fade(@home-primary, 9%);
+    padding: clamp(18px, 2.4vw, 34px);
+    box-sizing: border-box;
+    opacity: 0;
+    transform: translateY(36px);
+    transition: opacity 0.8s ease-out, transform 0.8s ease-out;
+
+    &.in-view {
+        opacity: 1;
+        transform: translateY(0);
+    }
 
-    .reverse {
+    &.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;
+    .content {
+        flex: 1;
+        max-width: 500px;
+        min-width: 0;
+
+        .title {
+            font-weight: 700;
+            font-size: clamp(1.24rem, 3.2vw, 1.7rem);
+            color: @home-primary;
+            font-family: Alimama ShuHeiTi, -apple-system, BlinkMacSystemFont, sans-serif;
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            line-height: 1.35;
+
+            img {
+                width: 34px;
+                height: 34px;
             }
+        }
 
-            .dec {
-                margin-top: 15px;
-                font-size: 1.4em;
-                line-height: 35px;
-                color: rgb(102, 102, 102);
-            }
+        .dec {
+            margin-top: 16px;
+            font-size: clamp(0.94rem, 2.2vw, 1.02rem);
+            line-height: 1.75;
+            color: @home-text-muted;
+            max-width: 34ch;
         }
+    }
+
+    .img {
+        padding: clamp(10px, 1.4vw, 16px);
+        border-radius: 18px;
+        background: fade(#fff, 85%);
+        border: 1px solid fade(@home-card-border, 86%);
 
         img {
-            max-width: 500px;
+            max-width: min(420px, 38vw);
+            max-height: 280px;
             user-select: none;
+            object-fit: contain;
         }
     }
+}
+
+@media (max-width: @app-breakpoint-lg) {
+    .card,
+    .card.reverse {
+        flex-direction: column;
+        text-align: center;
+    }
 
-    // 动画定义
-    @keyframes slideFadeIn {
-        to {
-            opacity: 1;
-            transform: translateY(0);
-        }
+    .card .content .title {
+        justify-content: center;
     }
 
+    .card .content .dec {
+        margin-left: auto;
+        margin-right: auto;
+        max-width: 32ch;
+    }
+
+    .card .img img {
+        max-width: min(100%, 340px);
+    }
 }
-</style>
+</style>

+ 23 - 0
src/pages/Main/theme.less

@@ -0,0 +1,23 @@
+// Friendly Accounting Services — design tokens (from local .site export)
+@home-bg-hero: #d4f0dc;
+@home-bg-hero-end: #a6e7b8;
+@home-bg-page: #f4faf6;
+@home-primary: #1b3022;
+@home-primary-hover: #142618;
+@home-text-muted: #4a5c52;
+@home-card-bg: #ffffff;
+@home-card-border: #e2efe6;
+@home-radius-btn: 999px;
+@home-radius-card: 16px;
+@home-shadow-card: 0 8px 32px rgba(27, 48, 34, 0.08);
+
+// 动态渐变辅助色(拉大色相/明度差,便于流动动画可见)
+@home-gradient-pale: #f0fdf4;
+@home-gradient-mint: #bbf7d0;
+@home-gradient-green: #6ee7a0;
+@home-gradient-sage: #34d399;
+@home-gradient-teal: #5eead4;
+@home-gradient-aqua: #2dd4bf;
+@home-gradient-lime: #a3e635;
+@home-gradient-deep: #22c55e;
+@home-gradient-emerald: #10b981;

+ 24 - 1
src/pages/User/info/components/user-info-header.vue

@@ -48,6 +48,20 @@ getuser()
     border-radius: 4px;
 
     .user-msg {
+      max-width: 100%;
+
+      :deep(.arco-space) {
+        width: 100%;
+        justify-content: center;
+      }
+
+      :deep(.arco-typography) {
+        max-width: 70vw;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+
       .arco-icon {
         color: rgb(var(--gray-10));
       }
@@ -68,5 +82,14 @@ getuser()
     position: absolute;
   }
 
-  
+  @media (max-width: 768px) {
+    .header,
+    .header::after {
+      height: 176px;
+    }
+
+    .center {
+      width: calc(100% - 24px);
+    }
+  }
 </style>

+ 10 - 16
src/pages/User/info/index.vue

@@ -1,7 +1,7 @@
 <template>
   
-  <div class="container">
-    <Breadcrumb :items="['个人中心', '用户信息']" />
+  <div class="store-page">
+    <Breadcrumb />
     <UserInfoHeader />
     <div class="content">
         <a-grid :cols="24" :col-gap="16" :row-gap="16">
@@ -23,10 +23,6 @@ import MyProject from './components/my-project.vue'
 </script>
 
 <style scoped lang="less">
-.container {
-  padding: 0 20px 20px 20px;
-}
-
 .content {
   display: flex;
   margin-top: 12px;
@@ -50,21 +46,19 @@ import MyProject from './components/my-project.vue'
     padding: 0 16px 16px 16px;
   }
 }
-</style>
 
-<style lang="less" scoped>
-.mobile {
+@media (max-width: 768px) {
   .content {
     display: block;
+  }
 
-    &-left {
-      margin-right: 0;
-      margin-bottom: 16px;
-    }
+  .content-left {
+    margin-right: 0;
+    margin-bottom: 12px;
+  }
 
-    &-right {
-      width: 100%;
-    }
+  .content-right {
+    width: 100%;
   }
 }
 </style>

+ 38 - 3
src/pages/User/setting/components/basic-information.vue

@@ -21,11 +21,11 @@
       <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">
+          <EmailAutoComplete placeholder="请输入邮箱" allow-clear v-model="emailForm.email">
             <template #prefix>
               <icon-email />
             </template>
-          </a-input>
+          </EmailAutoComplete>
         </a-form-item>
 
         <a-form-item field="captcha" label="图片验证码" :rules="[{ length: 4, required: true, message: '请正确填写图片验证码' }]">
@@ -209,7 +209,7 @@ const changeEmail = async () => {
 
 <style scoped lang="less">
 .form {
-  width: 540px;
+  width: min(100%, 540px);
   margin: 0 auto;
   display: flex;
   flex-direction: column;
@@ -220,4 +220,39 @@ const changeEmail = async () => {
 .button {
   width: 150px;
 }
+
+:deep(.arco-form-item-label-col > label) {
+  white-space: nowrap;
+}
+
+@media (max-width: 768px) {
+  .form {
+    width: 100%;
+    align-items: stretch;
+  }
+
+  .button {
+    width: 100%;
+  }
+
+  :deep(.arco-form-item) {
+    margin-bottom: 12px;
+  }
+
+  :deep(.arco-form-item-label-col),
+  :deep(.arco-form-item-wrapper-col) {
+    flex: 0 0 100%;
+    max-width: 100%;
+  }
+
+  :deep(.arco-form-item-label-col) {
+    margin-bottom: 4px;
+  }
+
+  :deep(.arco-input-group-append .arco-btn) {
+    width: 86px !important;
+    padding-left: 0;
+    padding-right: 0;
+  }
+}
 </style>

+ 30 - 1
src/pages/User/setting/components/security-settings.vue

@@ -89,7 +89,7 @@ const changePassword = async () => {
 
 <style scoped lang="less">
 .form {
-  width: 560px;
+  width: min(100%, 560px);
   margin: 0 auto;
   display: flex;
   flex-direction: column;
@@ -100,4 +100,33 @@ const changePassword = async () => {
 .button {
   width: 150px;
 }
+
+:deep(.arco-form-item-label-col > label) {
+  white-space: nowrap;
+}
+
+@media (max-width: 768px) {
+  .form {
+    width: 100%;
+    align-items: stretch;
+  }
+
+  .button {
+    width: 100%;
+  }
+
+  :deep(.arco-form-item) {
+    margin-bottom: 12px;
+  }
+
+  :deep(.arco-form-item-label-col),
+  :deep(.arco-form-item-wrapper-col) {
+    flex: 0 0 100%;
+    max-width: 100%;
+  }
+
+  :deep(.arco-form-item-label-col) {
+    margin-bottom: 4px;
+  }
+}
 </style>

+ 194 - 0
src/pages/User/setting/components/social-bindings.vue

@@ -0,0 +1,194 @@
+<template>
+  <div class="social-bindings">
+    <div class="section-title">第三方账号</div>
+    <div class="description">绑定 QQ 和微信后,无论使用哪种方式登录,都会进入当前 哪吒乐跑 账户。</div>
+
+    <div class="account-list">
+      <div v-for="item in accounts" :key="item.type" class="account-card">
+        <div class="account-info">
+          <a-avatar :size="44" v-if="getBinding(item.type)?.avatar" :imageUrl="getBinding(item.type)?.avatar"></a-avatar>
+          <a-avatar :size="44" :style="{ backgroundColor: '#FE82A5' }" v-else>
+            <icon-qq v-if="item.type === 'qq'" />
+            <icon-wechat v-else />
+          </a-avatar>
+          <div>
+            <div class="account-name">{{ item.name }}</div>
+            <a-tag :color="isBound(item.type) ? 'green' : 'gray'" size="small">
+              {{ isBound(item.type) ? '已绑定' : '未绑定' }}
+            </a-tag>
+            <div v-if="isBound(item.type)" class="account-nickname">
+              昵称:{{ getBinding(item.type)?.nickname || '未获取' }}
+            </div>
+          </div>
+        </div>
+
+        <a-button
+          v-if="!isBound(item.type)"
+          type="primary"
+          :loading="state.loadingType === item.type"
+          @click="bind(item.type)"
+        >
+          立即绑定
+        </a-button>
+        <a-button
+          v-else
+          status="danger"
+          :loading="state.loadingType === item.type"
+          @click="unbind(item.type)"
+        >
+          解绑
+        </a-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onBeforeMount, reactive, ref } from 'vue'
+import { getLoginUrl } from '@/api/login'
+import { useUserStore } from '@/store/modules/user'
+import { isElectron } from '@/utils/electron'
+import { Message, Modal, Notification } from '@arco-design/web-vue'
+
+const userStore = useUserStore()
+const user = ref({})
+
+const state = reactive({
+  loadingType: ''
+})
+
+const accounts = [
+  { type: 'qq', name: 'QQ' },
+  { type: 'wx', name: '微信' }
+]
+
+const refreshUser = async () => {
+  user.value = await userStore.getInfoFromServer()
+}
+
+const isBound = (type) => {
+  return user.value?.boundTypes?.includes(type) ||
+    user.value?.socialBindings?.some(item => item.type === type && item.bound)
+}
+
+const getBinding = (type) => {
+  return user.value?.socialBindings?.find(item => item.type === type && item.bound) || null
+}
+
+const bind = async (type) => {
+  try {
+    state.loadingType = type
+    const res = await getLoginUrl({
+      type,
+      mode: 'uniLogin',
+      action: 'bind',
+      from: '/user/setting'
+    })
+    if (!res || res.code !== 0)
+      throw new Error(res?.msg ?? '获取绑定链接失败!')
+
+    if (isElectron())
+      window.electron.openNewWindow(res.data)
+    else
+      window.location.href = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取绑定链接失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    state.loadingType = ''
+  }
+}
+
+const unbind = (type) => {
+  const accountName = type === 'qq' ? 'QQ' : '微信'
+  Modal.confirm({
+    title: `解绑${accountName}`,
+    content: `解绑后将无法继续通过该${accountName}账号登录当前账户,是否继续?`,
+    onOk: async () => {
+      try {
+        state.loadingType = type
+        await userStore.unbindSocial(type)
+        await refreshUser()
+        Message.success(`${accountName}解绑成功`)
+      } catch (error) {
+        Notification.error({
+          title: `${accountName}解绑失败`,
+          content: error.message || '请稍后再试'
+        })
+      } finally {
+        state.loadingType = ''
+      }
+    }
+  })
+}
+
+onBeforeMount(refreshUser)
+</script>
+
+<style scoped lang="less">
+.social-bindings {
+  width: min(100%, 560px);
+  margin: 0 auto;
+}
+
+.description {
+  margin-bottom: 18px;
+  color: var(--color-text-3);
+}
+
+.account-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.account-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 18px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 8px;
+}
+
+.account-info {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.account-name {
+  margin-bottom: 6px;
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.account-nickname {
+  margin-top: 6px;
+  color: var(--color-text-3);
+  font-size: 12px;
+}
+
+@media (max-width: 768px) {
+  .social-bindings {
+    width: 100%;
+  }
+
+  .account-card {
+    flex-direction: column;
+    align-items: stretch;
+    gap: 12px;
+    padding: 14px;
+  }
+
+  .account-info {
+    align-items: flex-start;
+  }
+
+  .account-card :deep(.arco-btn) {
+    width: 100%;
+  }
+}
+</style>

+ 52 - 5
src/pages/User/setting/components/user-panel.vue

@@ -1,6 +1,6 @@
 <template>
   <a-card :bordered="false">
-    <a-space :size="54">
+    <div class="panel-inner">
       <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>
@@ -23,12 +23,9 @@
         <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>
+    </div>
   </a-card>
 </template>
 
@@ -138,6 +135,17 @@ const customRequest = async ({ onSuccess, onError, fileItem }) => {
   border-radius: 4px;
 }
 
+.panel-inner {
+  display: flex;
+  align-items: center;
+  gap: 54px;
+}
+
+:deep(.arco-descriptions-layout-inline-horizontal .arco-descriptions-item-label),
+:deep(.arco-descriptions-layout-inline-horizontal .arco-descriptions-item-value) {
+  white-space: nowrap;
+}
+
 :deep(.arco-avatar-trigger-icon-button) {
   width: 32px;
   height: 32px;
@@ -150,4 +158,43 @@ const customRequest = async ({ onSuccess, onError, fileItem }) => {
     font-size: 14px;
   }
 }
+
+@media (max-width: 768px) {
+  .arco-card {
+    padding: 10px 0 2px;
+  }
+
+  .panel-inner {
+    flex-direction: column;
+    align-items: center;
+    gap: 16px;
+  }
+
+  :deep(.arco-descriptions) {
+    width: 100%;
+  }
+
+  :deep(.arco-descriptions-view) {
+    width: 100%;
+  }
+
+  :deep(.arco-descriptions-layout-inline-horizontal .arco-descriptions-item) {
+    width: 100%;
+  }
+
+  :deep(.arco-descriptions-layout-inline-horizontal .arco-descriptions-item-label) {
+    min-width: 72px;
+    padding-right: 8px;
+  }
+
+  :deep(.arco-descriptions-layout-inline-horizontal .arco-descriptions-item-value) {
+    flex: 1;
+    min-width: 0;
+  }
+
+  :deep(.arco-descriptions-item-value > span) {
+    word-break: break-word;
+    overflow-wrap: anywhere;
+  }
+}
 </style>

+ 32 - 10
src/pages/User/setting/index.vue

@@ -1,20 +1,23 @@
 <template>
-  <div class="container">
-    <Breadcrumb :items="['个人中心', '用户设置']" />
-    <a-row style="margin-bottom: 16px">
+  <div class="store-page">
+    <Breadcrumb />
+    <a-row class="user-setting-head">
       <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-tabs default-active-key="1" type="rounded" class="settings-tabs">
           <a-tab-pane key="1" title="基础信息">
             <BasicInformation />
           </a-tab-pane>
           <a-tab-pane key="2" title="安全设置">
             <SecuritySettings />
           </a-tab-pane>
+          <a-tab-pane key="3" title="第三方账号">
+            <SocialBindings />
+          </a-tab-pane>
         </a-tabs>
       </a-col>
     </a-row>
@@ -25,18 +28,15 @@
 import UserPanel from './components/user-panel.vue'
 import BasicInformation from './components/basic-information.vue'
 import SecuritySettings from './components/security-settings.vue'
+import SocialBindings from './components/social-bindings.vue'
 </script>
 
 <style scoped lang="less">
-.container {
-  padding: 0 20px 20px 20px;
-}
-
 .wrapper {
-  padding: 20px 0 0 20px;
+  padding: 20px 20px 0;
   min-height: 580px;
   background-color: var(--color-bg-2);
-  border-radius: 4px;
+  border-radius: 8px;
 }
 
 :deep(.section-title) {
@@ -44,4 +44,26 @@ import SecuritySettings from './components/security-settings.vue'
   margin-bottom: 16px;
   font-size: 14px;
 }
+
+.user-setting-head {
+  margin-bottom: 16px;
+}
+
+@media (max-width: 768px) {
+  .wrapper {
+    min-height: auto;
+    padding: 12px 12px 0;
+    border-radius: 6px;
+  }
+
+  .user-setting-head {
+    margin-bottom: 12px;
+  }
+
+  .settings-tabs {
+    :deep(.arco-tabs-content) {
+      padding-top: 4px;
+    }
+  }
+}
 </style>

+ 419 - 0
src/pages/admin/article/index.vue

@@ -0,0 +1,419 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="文章管理">
+      <a-space wrap style="margin-bottom: 12px;">
+        <a-button type="primary" @click="openCreate">新增文章</a-button>
+        <a-select v-model="filterType" placeholder="分类" style="width: 140px;" allow-clear @change="onFilterChange">
+          <a-option value="all">全部分类</a-option>
+          <a-option v-for="cat in categories" :key="cat.slug" :value="cat.slug">{{ cat.name }}</a-option>
+        </a-select>
+        <a-select v-model="filterState" style="width: 120px;" @change="onFilterChange">
+          <a-option :value="1">已发布</a-option>
+          <a-option :value="0">草稿</a-option>
+        </a-select>
+      </a-space>
+
+      <a-table
+        :bordered="false"
+        :data="data"
+        :loading="loading"
+        :pagination="{
+          showPageSize: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="封面" :width="70">
+            <template #cell="{ record }">
+              <GoodsEmoji :icon="record.cover" size="sm" :fallback="DEFAULT_ARTICLE_EMOJI" aria-label="文章封面" />
+            </template>
+          </a-table-column>
+          <a-table-column title="标题" data-index="title" :width="200" ellipsis tooltip />
+          <a-table-column title="标识 (slug)" data-index="slug" :width="180" ellipsis tooltip />
+          <a-table-column title="分类" :width="100">
+            <template #cell="{ record }">{{ categoryName(record.type) }}</template>
+          </a-table-column>
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="record.state === 1 ? 'green' : 'gray'">{{ record.state === 1 ? '已发布' : '草稿' }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="浏览" data-index="views" :width="70" />
+          <a-table-column title="作者" data-index="author" :width="100" />
+          <a-table-column title="时间" :width="160">
+            <template #cell="{ record }">{{ formatTime(record.time) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="220">
+            <template #cell="{ record }">
+              <a-space wrap>
+                <a-button size="mini" @click="openEdit(record)">编辑</a-button>
+                <a-button size="mini" @click="togglePublic(record)">{{ record.state === 1 ? '撤回' : '发布' }}</a-button>
+                <a-button status="danger" size="mini" @click="remove(record)">删除</a-button>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="editorVisible" :title="form.id ? '编辑文章' : '新增文章'" width="980px" @before-ok="submitEditor">
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="标题" required>
+          <a-input v-model="form.title" allow-clear />
+        </a-form-item>
+        <a-form-item v-if="!form.id" label="文章标识 (slug)" required>
+          <a-input-group>
+            <a-input v-model="form.slug" allow-clear placeholder="小写字母、数字、连字符" />
+            <a-button @click="generateSlug">根据标题生成</a-button>
+          </a-input-group>
+        </a-form-item>
+        <a-form-item v-else label="文章标识 (slug)">
+          <a-input v-model="form.slug" readonly />
+        </a-form-item>
+        <a-form-item label="分类" required>
+          <a-select v-model="form.type" placeholder="选择分类">
+            <a-option v-for="cat in categories" :key="cat.slug" :value="cat.slug">{{ cat.name }}</a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="封面(Emoji)" required>
+          <div class="emoji-panel">
+            <div class="emoji-panel__preview">
+              <span class="emoji-panel__emoji">{{ displayEmoji }}</span>
+              <span class="emoji-panel__hint">当前封面</span>
+            </div>
+            <div class="emoji-panel__main">
+              <div class="emoji-panel__label">快速选择</div>
+              <div class="emoji-grid">
+                <button
+                  v-for="emoji in presetEmojis"
+                  :key="emoji"
+                  type="button"
+                  class="emoji-grid__item"
+                  :class="{ 'emoji-grid__item--active': form.cover === emoji }"
+                  :title="emoji"
+                  @click="form.cover = emoji"
+                >
+                  {{ emoji }}
+                </button>
+              </div>
+              <div class="emoji-panel__label emoji-panel__label--custom">自定义</div>
+              <a-input
+                v-model="form.cover"
+                class="emoji-custom-input"
+                placeholder="输入或粘贴一个 emoji"
+                :max-length="8"
+                allow-clear
+              />
+            </div>
+          </div>
+        </a-form-item>
+        <a-form-item label="摘要">
+          <a-textarea v-model="form.describe" :auto-size="{ minRows: 2 }" />
+        </a-form-item>
+        <a-form-item label="发布状态">
+          <a-radio-group v-model="form.state">
+            <a-radio :value="1">发布</a-radio>
+            <a-radio :value="0">草稿</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item label="正文(富文本)" required>
+          <WangEditor v-model="form.content" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, computed, onMounted } from 'vue'
+import { Modal, Notification, Message } from '@arco-design/web-vue'
+import WangEditor from '@/components/Editor/WangEditor.vue'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import { slugify, isValidSlug } from '@/utils/slugify'
+import { DEFAULT_ARTICLE_EMOJI, getArticleEmoji } from '@/utils/storeFormat'
+import {
+  getAdminArticleList,
+  getAdminArticle,
+  saveAdminArticle,
+  deleteAdminArticle,
+  toggleAdminArticlePublic,
+  getAdminCategoryList
+} from '@/api/article'
+
+const presetEmojis = ['📄', '📖', '📋', '❓', '📰', '🏃', '💡', '✅', '⚡', '✨']
+
+const loading = ref(false)
+const data = ref([])
+const categories = ref([])
+const pagination = reactive({ total: 0, current: 1, pagesize: 20 })
+const filterType = ref('all')
+const filterState = ref(1)
+
+const editorVisible = ref(false)
+const form = reactive({
+  id: null,
+  slug: '',
+  title: '',
+  cover: '',
+  describe: '',
+  content: '',
+  type: '',
+  state: 1
+})
+
+const displayEmoji = computed(() => getArticleEmoji(form.cover))
+
+const formatTime = (time) => new Date(Number(time)).toLocaleString('zh-CN')
+const categoryName = (slug) => categories.value.find(c => c.slug === slug)?.name || slug
+
+const loadCategories = async () => {
+  const res = await getAdminCategoryList({})
+  if (res?.code === 0) {
+    categories.value = res.data || []
+    if (!form.type && categories.value.length) form.type = categories.value[0].slug
+  }
+}
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await getAdminArticleList({
+      pagesize: pagination.pagesize,
+      current: pagination.current,
+      type: filterType.value || 'all',
+      state: filterState.value
+    })
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '获取文章列表失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const onFilterChange = () => {
+  pagination.current = 1
+  getList()
+}
+
+const generateSlug = () => {
+  if (!form.title) return Message.warning('请先填写标题')
+  form.slug = slugify(form.title)
+}
+
+const openCreate = () => {
+  form.id = null
+  form.slug = ''
+  form.title = ''
+  form.cover = DEFAULT_ARTICLE_EMOJI
+  form.describe = ''
+  form.content = ''
+  form.state = 1
+  form.type = categories.value[0]?.slug || ''
+  editorVisible.value = true
+}
+
+const openEdit = async (record) => {
+  const res = await getAdminArticle({ id: record.id })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '获取文章失败', content: res?.msg ?? '请稍后再试' })
+    return
+  }
+  const a = res.data
+  form.id = a.id
+  form.slug = a.slug
+  form.title = a.title
+  form.cover = getArticleEmoji(a.cover)
+  form.describe = a.describe || ''
+  form.content = a.content || ''
+  form.type = a.type
+  form.state = a.state
+  editorVisible.value = true
+}
+
+const submitEditor = async () => {
+  if (!form.title || !form.cover || !form.content || !form.type) {
+    Message.error('请填写必填项')
+    return false
+  }
+  if (!form.id) {
+    if (!form.slug || !isValidSlug(form.slug)) {
+      Message.error('请填写有效的文章标识 (slug)')
+      return false
+    }
+  }
+  const payload = {
+    id: form.id,
+    slug: form.slug,
+    title: form.title,
+    cover: getArticleEmoji(form.cover),
+    describe: form.describe,
+    content: form.content,
+    type: form.type,
+    state: form.state
+  }
+  const res = await saveAdminArticle(payload)
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '保存失败', content: res?.msg ?? '请稍后再试' })
+    return false
+  }
+  Message.success('保存成功')
+  getList()
+  return true
+}
+
+const togglePublic = async (record) => {
+  const res = await toggleAdminArticlePublic({ id: record.id })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '操作失败', content: res?.msg ?? '请稍后再试' })
+    return
+  }
+  Message.success('操作成功')
+  getList()
+}
+
+const remove = (record) => {
+  Modal.confirm({
+    title: '删除文章',
+    content: `确定删除「${record.title}」吗?`,
+    onOk: async () => {
+      const res = await deleteAdminArticle({ id: record.id })
+      if (!res || res.code !== 0) {
+        Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
+        return
+      }
+      Message.success('删除成功')
+      getList()
+    }
+  })
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(async () => {
+  await loadCategories()
+  getList()
+})
+</script>
+
+<style scoped lang="less">
+.emoji-panel {
+  display: flex;
+  gap: 24px;
+  padding: 20px;
+  background: var(--color-fill-1);
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  align-items: stretch;
+
+  @media (max-width: 640px) {
+    flex-direction: column;
+  }
+
+  &__preview {
+    flex-shrink: 0;
+    width: 120px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    background: var(--color-bg-2);
+    border-radius: 12px;
+    border: 1px solid var(--color-border-2);
+  }
+
+  &__emoji {
+    font-size: 3.5rem;
+    line-height: 1;
+  }
+
+  &__hint {
+    font-size: 12px;
+    color: var(--color-text-3);
+  }
+
+  &__main {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  &__label {
+    font-size: 13px;
+    font-weight: 500;
+    color: var(--color-text-2);
+
+    &--custom {
+      margin-top: 4px;
+    }
+  }
+}
+
+.emoji-grid {
+  display: grid;
+  grid-template-columns: repeat(5, 52px);
+  gap: 10px;
+
+  @media (max-width: 480px) {
+    grid-template-columns: repeat(5, 1fr);
+  }
+
+  &__item {
+    width: 52px;
+    height: 52px;
+    padding: 0;
+    font-size: 26px;
+    line-height: 1;
+    border: 2px solid var(--color-border-2);
+    border-radius: 10px;
+    background: var(--color-bg-2);
+    cursor: pointer;
+    transition: border-color 0.2s, background 0.2s, transform 0.15s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    @media (max-width: 480px) {
+      width: 100%;
+      aspect-ratio: 1;
+      height: auto;
+    }
+
+    &:hover {
+      transform: scale(1.06);
+      border-color: rgb(var(--primary-5));
+    }
+
+    &--active {
+      border-color: rgb(var(--primary-6));
+      background: var(--color-primary-light-1);
+      box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.15);
+    }
+  }
+}
+
+.emoji-custom-input {
+  max-width: 320px;
+  height: 40px;
+}
+</style>

+ 144 - 0
src/pages/admin/articleCategory/index.vue

@@ -0,0 +1,144 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="文档分类">
+      <a-space style="margin-bottom: 12px;">
+        <a-button type="primary" @click="openCreate">新增分类</a-button>
+      </a-space>
+
+      <a-table :bordered="false" :data="data" :loading="loading" :pagination="false">
+        <template #columns>
+          <a-table-column title="名称" data-index="name" :width="140" />
+          <a-table-column title="标识 (slug)" data-index="slug" :width="120" />
+          <a-table-column title="图标" :width="80">
+            <template #cell="{ record }">{{ record.icon || '-' }}</template>
+          </a-table-column>
+          <a-table-column title="排序" data-index="sort_order" :width="80" />
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="record.is_active ? 'green' : 'gray'">{{ record.is_active ? '启用' : '停用' }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="160">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button size="mini" @click="openEdit(record)">编辑</a-button>
+                <a-button status="danger" size="mini" @click="remove(record)">删除</a-button>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="editorVisible" :title="form.id ? '编辑分类' : '新增分类'" @before-ok="submitEditor">
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="名称" required>
+          <a-input v-model="form.name" allow-clear />
+        </a-form-item>
+        <a-form-item label="标识 (slug)" required>
+          <a-input v-model="form.slug" :readonly="!!form.id" allow-clear placeholder="如 doc、guide" />
+        </a-form-item>
+        <a-form-item label="图标 (emoji)">
+          <a-input v-model="form.icon" allow-clear placeholder="📖" :max-length="16" />
+        </a-form-item>
+        <a-form-item label="排序">
+          <a-input-number v-model="form.sort_order" mode="button" />
+        </a-form-item>
+        <a-form-item label="启用">
+          <a-switch v-model="form.is_active" :checked-value="1" :unchecked-value="0" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Modal, Notification, Message } from '@arco-design/web-vue'
+import { getAdminCategoryList, saveAdminCategory, deleteAdminCategory } from '@/api/article'
+import { isValidSlug } from '@/utils/slugify'
+
+const loading = ref(false)
+const data = ref([])
+const editorVisible = ref(false)
+const form = reactive({
+  id: null,
+  name: '',
+  slug: '',
+  icon: '',
+  sort_order: 0,
+  is_active: 1
+})
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await getAdminCategoryList({})
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '获取分类失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openCreate = () => {
+  form.id = null
+  form.name = ''
+  form.slug = ''
+  form.icon = ''
+  form.sort_order = 0
+  form.is_active = 1
+  editorVisible.value = true
+}
+
+const openEdit = (record) => {
+  form.id = record.id
+  form.name = record.name
+  form.slug = record.slug
+  form.icon = record.icon || ''
+  form.sort_order = record.sort_order || 0
+  form.is_active = record.is_active ? 1 : 0
+  editorVisible.value = true
+}
+
+const submitEditor = async () => {
+  if (!form.name || !form.slug) {
+    Message.error('请填写名称和标识')
+    return false
+  }
+  if (!isValidSlug(form.slug) || form.slug.length > 32) {
+    Message.error('标识格式无效(3-32位小写字母、数字、连字符)')
+    return false
+  }
+  const res = await saveAdminCategory({ ...form })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '保存失败', content: res?.msg ?? '请稍后再试' })
+    return false
+  }
+  Message.success('保存成功')
+  getList()
+  return true
+}
+
+const remove = (record) => {
+  Modal.confirm({
+    title: '删除分类',
+    content: `确定删除「${record.name}」吗?`,
+    onOk: async () => {
+      const res = await deleteAdminCategory({ id: record.id })
+      if (!res || res.code !== 0) {
+        Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
+        return
+      }
+      Message.success('删除成功')
+      getList()
+    }
+  })
+}
+
+onMounted(getList)
+</script>

+ 204 - 0
src/pages/admin/download/index.vue

@@ -0,0 +1,204 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="下载项管理">
+      <a-space style="margin-bottom: 12px;">
+        <a-button type="primary" @click="openCreate">新增下载项</a-button>
+      </a-space>
+
+      <a-table :bordered="false" :data="data" :loading="loading" :pagination="false">
+        <template #columns>
+          <a-table-column title="标题" data-index="title" :width="200" ellipsis tooltip />
+          <a-table-column title="图标" :width="70">
+            <template #cell="{ record }">{{ record.icon || '📦' }}</template>
+          </a-table-column>
+          <a-table-column title="版本" data-index="version" :width="90" />
+          <a-table-column title="平台" data-index="platform" :width="90" />
+          <a-table-column title="排序" data-index="sort_order" :width="70" />
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="record.is_active ? 'green' : 'gray'">{{ record.is_active ? '启用' : '停用' }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="160">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button size="mini" @click="openEdit(record)">编辑</a-button>
+                <a-button status="danger" size="mini" @click="remove(record)">删除</a-button>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="editorVisible" :title="form.id ? '编辑下载项' : '新增下载项'" width="720px" @before-ok="submitEditor">
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="标题" required>
+          <a-input v-model="form.title" allow-clear />
+        </a-form-item>
+        <a-form-item label="描述">
+          <a-textarea v-model="form.description" :auto-size="{ minRows: 2 }" />
+        </a-form-item>
+        <a-form-item label="下载链接" required>
+          <a-input v-model="form.download_url" allow-clear />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="版本号">
+              <a-input v-model="form.version" allow-clear placeholder="v1.0.0" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="图标 (emoji)">
+              <a-input v-model="form.icon" allow-clear :max-length="16" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="按钮文案">
+              <a-input v-model="form.button_text" allow-clear />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="平台">
+              <a-select v-model="form.platform" allow-clear placeholder="选择平台">
+                <a-option value="android">Android</a-option>
+                <a-option value="windows">Windows</a-option>
+                <a-option value="ios">iOS</a-option>
+                <a-option value="mac">macOS</a-option>
+              </a-select>
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="提取码">
+              <a-input v-model="form.extract_code" allow-clear />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="排序">
+              <a-input-number v-model="form.sort_order" mode="button" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-form-item label="启用">
+          <a-switch v-model="form.is_active" :checked-value="1" :unchecked-value="0" />
+        </a-form-item>
+        <a-form-item label="更新日志 (HTML)">
+          <a-textarea v-model="form.changelog_html" :auto-size="{ minRows: 3 }" placeholder="可选,支持 HTML" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Modal, Notification, Message } from '@arco-design/web-vue'
+import { getAdminDownloadList, saveAdminDownload, deleteAdminDownload } from '@/api/download'
+
+const loading = ref(false)
+const data = ref([])
+const editorVisible = ref(false)
+const form = reactive({
+  id: null,
+  title: '',
+  description: '',
+  download_url: '',
+  version: '',
+  icon: '📦',
+  button_text: '立即下载',
+  button_color: 'primary',
+  platform: '',
+  extract_code: '',
+  changelog_html: '',
+  sort_order: 0,
+  is_active: 1
+})
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await getAdminDownloadList({})
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '获取列表失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openCreate = () => {
+  form.id = null
+  form.title = ''
+  form.description = ''
+  form.download_url = ''
+  form.version = ''
+  form.icon = '📦'
+  form.button_text = '立即下载'
+  form.button_color = 'primary'
+  form.platform = ''
+  form.extract_code = ''
+  form.changelog_html = ''
+  form.sort_order = 0
+  form.is_active = 1
+  editorVisible.value = true
+}
+
+const openEdit = (record) => {
+  Object.assign(form, {
+    id: record.id,
+    title: record.title,
+    description: record.description || '',
+    download_url: record.download_url,
+    version: record.version || '',
+    icon: record.icon || '📦',
+    button_text: record.button_text || '立即下载',
+    button_color: record.button_color || 'primary',
+    platform: record.platform || '',
+    extract_code: record.extract_code || '',
+    changelog_html: record.changelog_html || '',
+    sort_order: record.sort_order || 0,
+    is_active: record.is_active ? 1 : 0
+  })
+  editorVisible.value = true
+}
+
+const submitEditor = async () => {
+  if (!form.title || !form.download_url) {
+    Message.error('请填写标题和下载链接')
+    return false
+  }
+  const res = await saveAdminDownload({ ...form })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '保存失败', content: res?.msg ?? '请稍后再试' })
+    return false
+  }
+  Message.success('保存成功')
+  getList()
+  return true
+}
+
+const remove = (record) => {
+  Modal.confirm({
+    title: '删除下载项',
+    content: `确定删除「${record.title}」吗?`,
+    onOk: async () => {
+      const res = await deleteAdminDownload({ id: record.id })
+      if (!res || res.code !== 0) {
+        Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
+        return
+      }
+      Message.success('删除成功')
+      getList()
+    }
+  })
+}
+
+onMounted(getList)
+</script>

+ 365 - 134
src/pages/admin/goods/addGoods.vue

@@ -1,169 +1,400 @@
-<template>
-    <div class="container">
-        <Breadcrumb :items="['网站管理', '添加商品']" />
-
-        <a-card title="添加商品">
-            <a-form :model="form" :rules="rules" layout="vertical" @submit-success="handleSubmit">
-                <a-form-item field="name" label="商品名称" :style="{ width: '600px' }">
-                    <a-input v-model="form.name" :max-length="25" allow-clear show-word-limit />
-                </a-form-item>
-
-                <a-form-item field="price" label="价格" :style="{ width: '300px' }">
-                    <a-input-number v-model="form.price" :step="0.01" :precision="2" />
-                </a-form-item>
-
-                <a-form-item field="lepao_count" label="乐跑里程(km)" :style="{ width: '300px' }">
-                    <a-input-number v-model="form.lepao_count" :step="0.5" :precision="2" />
-                </a-form-item>
-
-                <a-form-item field="num" label="库存数量" :style="{ width: '300px' }">
-                    <a-input-number v-model="form.num" :step="1" :precision="0" />
-                </a-form-item>
-
-                <a-form-item field="state" label="商品状态" :style="{ width: '300px' }">
-                    <a-radio-group v-model="form.state">
-                        <a-radio :value="1">正常</a-radio>
-                        <a-radio :value="0">下架</a-radio>
-                    </a-radio-group>
-                </a-form-item>
-
-                <a-form-item field="content" label="商品详情">
-                    <WangEditor v-model="form.content" @change="contentChange"></WangEditor>
-                </a-form-item>
-
-                <a-form-item>
-                    <a-button html-type="submit" :loading="loading">保存</a-button>
-                </a-form-item>
-            </a-form>
-        </a-card>
-    </div>
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card :bordered="false" class="page-card" :title="isEdit ? '编辑商品' : '新增商品'">
+      <a-form :model="form" :rules="rules" layout="vertical" @submit-success="handleSubmit">
+        <a-row :gutter="24">
+          <a-col :xs="24" :md="12">
+            <a-form-item field="name" label="商品名称">
+              <a-input v-model="form.name" :max-length="25" allow-clear show-word-limit />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :md="12">
+            <a-form-item field="state" label="上架状态">
+              <a-radio-group v-model="form.state" type="button">
+                <a-radio :value="1">上架</a-radio>
+                <a-radio :value="0">下架</a-radio>
+              </a-radio-group>
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24">
+            <a-form-item field="description" label="商品简介">
+              <a-textarea
+                v-model="form.description"
+                placeholder="展示在商城列表卡片上,建议 1~2 句话"
+                :max-length="80"
+                show-word-limit
+                :auto-size="{ minRows: 2, maxRows: 3 }"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="8">
+            <a-form-item field="price" label="价格(元)">
+              <a-input-number v-model="form.price" :step="0.01" :precision="2" :min="0" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="8">
+            <a-form-item field="lepao_count" label="乐跑次数">
+              <a-input-number v-model="form.lepao_count" :step="1" :precision="0" :min="0" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="8">
+            <a-form-item field="num" label="库存">
+              <a-input-number v-model="form.num" :step="1" :precision="0" :min="0" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24">
+            <a-form-item field="icon" label="商品图标(Emoji)">
+              <div class="emoji-panel">
+                <div class="emoji-panel__preview">
+                  <span class="emoji-panel__emoji">{{ displayEmoji }}</span>
+                  <span class="emoji-panel__hint">当前图标</span>
+                </div>
+                <div class="emoji-panel__main">
+                  <div class="emoji-panel__label">快速选择</div>
+                  <div class="emoji-grid">
+                    <button
+                      v-for="emoji in presetEmojis"
+                      :key="emoji"
+                      type="button"
+                      class="emoji-grid__item"
+                      :class="{ 'emoji-grid__item--active': form.icon === emoji }"
+                      :title="emoji"
+                      @click="form.icon = emoji"
+                    >
+                      {{ emoji }}
+                    </button>
+                  </div>
+                  <div class="emoji-panel__label emoji-panel__label--custom">自定义</div>
+                  <a-input
+                    v-model="form.icon"
+                    class="emoji-custom-input"
+                    placeholder="输入或粘贴一个 emoji"
+                    :max-length="8"
+                    allow-clear
+                  />
+                </div>
+              </div>
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24">
+            <a-form-item label="商品卖点">
+              <div class="features-editor">
+                <p class="features-editor__tip">展示在商城列表与详情页,最多 6 条</p>
+                <div v-for="(_, index) in featureItems" :key="index" class="features-editor__row">
+                  <span class="features-editor__index">{{ index + 1 }}</span>
+                  <a-input
+                    v-model="featureItems[index]"
+                    placeholder="例如:支付后自动到账"
+                    :max-length="40"
+                    allow-clear
+                  />
+                  <a-button
+                    type="text"
+                    status="danger"
+                    :disabled="featureItems.length <= 1"
+                    @click="removeFeature(index)"
+                  >
+                    <template #icon><icon-delete /></template>
+                  </a-button>
+                </div>
+                <a-button
+                  type="dashed"
+                  long
+                  class="features-editor__add"
+                  :disabled="featureItems.length >= 6"
+                  @click="addFeature"
+                >
+                  <template #icon><icon-plus /></template>
+                  添加卖点
+                </a-button>
+              </div>
+            </a-form-item>
+          </a-col>
+        </a-row>
+
+        <a-form-item field="content" label="商品详情(展示在商城详情页)">
+          <WangEditor v-model="form.content" @change="contentChange" />
+        </a-form-item>
+
+        <a-form-item>
+          <a-space>
+            <a-button type="primary" html-type="submit" :loading="loading">保存商品</a-button>
+            <a-button @click="$router.push('/goodsManage/goods/goodsList')">返回列表</a-button>
+          </a-space>
+        </a-form-item>
+      </a-form>
+    </a-card>
+  </div>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
 import { adminGetGoods, addGoods } from '@/api/goods'
 import { useRoute } from 'vue-router'
 import { Notification } from '@arco-design/web-vue'
 import WangEditor from '@/components/Editor/WangEditor.vue'
+import {
+  decodeGoodsContent,
+  DEFAULT_GOODS_EMOJI,
+  getGoodsEmoji,
+  parseFeatures,
+  serializeFeatures
+} from '@/utils/storeFormat'
+
+const presetEmojis = ['🏃', '⚡', '🎁', '🔥', '💪', '🏆', '📦', '⭐', '🎯', '✨']
 
 const loading = ref(false)
 const route = useRoute()
+const isEdit = computed(() => Boolean(route.params.id))
+const featureItems = ref([''])
+
+const displayEmoji = computed(() => getGoodsEmoji(form.icon))
 
 const rules = {
-    name: [
-        {
-            required: true,
-            message: '请输入商品名称',
-        },
-    ],
-    price: [
-        {
-            required: true,
-            message: '请填写商品价格',
-        }
-    ],
-    lepao_count: [
-        {
-            required: true,
-            message: '请填写乐跑里程(km)',
-        }
-    ],
-    num: [
-        {
-            required: true,
-            message: '请填写商品库存',
-        }
-    ],
-    state: [
-        {
-            required: true,
-            message: '请选择商品状态',
-        }
-    ]
+  name: [{ required: true, message: '请输入商品名称' }],
+  price: [{ required: true, message: '请填写商品价格' }],
+  lepao_count: [{ required: true, message: '请填写乐跑次数' }],
+  num: [{ required: true, message: '请填写商品库存' }],
+  state: [{ required: true, message: '请选择商品状态' }]
 }
 
 const form = reactive({
-    name: '',
-    price: 0.00,
-    num: 999999,
-    files: [],
-    lepao_count: 0,
-    ic_count: 0,
-    content: '',
-    state: 1
+  name: '',
+  description: '',
+  price: 0.0,
+  num: 999999,
+  lepao_count: 0,
+  ic_count: 0,
+  content: '',
+  state: 1,
+  icon: DEFAULT_GOODS_EMOJI
 })
 
+const addFeature = () => {
+  if (featureItems.value.length < 6) featureItems.value.push('')
+}
+
+const removeFeature = (index) => {
+  if (featureItems.value.length <= 1) {
+    featureItems.value[0] = ''
+    return
+  }
+  featureItems.value.splice(index, 1)
+}
+
 const contentChange = (value) => {
-    form.content = value
+  form.content = value
 }
 
 const handleSubmit = async () => {
-    try {
-        loading.value = true
-        let data = {
-            ...form,
-            id: route.params.id ?? null
-        }
-        data.content = btoa(encodeURI(data.content))
-        const res = await addGoods(data)
-        if (!res || res.code !== 0)
-            return Notification.error({
-                title: '保存商品失败!',
-                content: res?.msg ?? '请稍后再试'
-            })
-        Notification.success({
-            title: '保存成功!'
-        })
-        // router.push(`/service/orderDetail/${res.data}`)
-    } catch (error) {
-        Notification.error({
-            title: '保存商品失败!',
-            content: error.message || '请稍后再试'
-        })
-    } finally {
-        loading.value = false
+  try {
+    loading.value = true
+    const data = {
+      ...form,
+      id: route.params.id ?? null,
+      icon: getGoodsEmoji(form.icon),
+      features: serializeFeatures(featureItems.value)
+    }
+    data.content = btoa(encodeURI(data.content))
+    const res = await addGoods(data)
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '保存商品失败!',
+        content: res?.msg ?? '请稍后再试'
+      })
     }
+    Notification.success({ title: '保存成功!' })
+  } catch (error) {
+    Notification.error({
+      title: '保存商品失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
 }
 
 const getGoodsDetail = async () => {
-    if (!route.params.id) return
-    try {
-        loading.value = true
-        const res = await adminGetGoods({ id: route.params.id })
-        if (!res || res.code !== 0)
-            return Notification.error({
-                title: '获取商品信息失败!',
-                content: res?.msg ?? '请稍后再试'
-            })
-        const { name, price, num, content, state, lepao_count } = res.data
-        form.content = decodeURI(atob(content))
-        form.name = name
-        form.price = Number(price)
-        form.lepao_count = lepao_count
-        form.num = num
-        form.state = state
-    } catch (error) {
-        Notification.error({
-            title: '获取商品信息失败!',
-            content: error.message || '请稍后再试'
-        })
-    } finally {
-        loading.value = false
+  if (!route.params.id) return
+  try {
+    loading.value = true
+    const res = await adminGetGoods({ id: route.params.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取商品信息失败!',
+        content: res?.msg ?? '请稍后再试'
+      })
     }
+    const { name, price, num, content, state, lepao_count, icon, description, features } = res.data
+    form.content = decodeGoodsContent(content)
+    form.name = name
+    form.description = description || ''
+    form.price = Number(price)
+    form.lepao_count = lepao_count
+    form.num = num
+    form.state = state
+    form.icon = getGoodsEmoji(icon)
+    const parsed = parseFeatures(features)
+    featureItems.value = parsed.length ? [...parsed] : ['']
+  } catch (error) {
+    Notification.error({
+      title: '获取商品信息失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
 }
 
 onMounted(() => {
-    getGoodsDetail()
+  getGoodsDetail()
 })
-
 </script>
 
-<style scoped>
-.container {
-    padding: 0 20px 20px 20px;
+<style scoped lang="less">
+.page-card {
+  border-radius: 12px;
 }
 
-.table {
-    margin-top: 15px;
+.emoji-panel {
+  display: flex;
+  gap: 24px;
+  padding: 20px;
+  background: var(--color-fill-1);
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  align-items: stretch;
+
+  @media (max-width: 640px) {
+    flex-direction: column;
+  }
+
+  &__preview {
+    flex-shrink: 0;
+    width: 120px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    background: var(--color-bg-2);
+    border-radius: 12px;
+    border: 1px solid var(--color-border-2);
+  }
+
+  &__emoji {
+    font-size: 3.5rem;
+    line-height: 1;
+  }
+
+  &__hint {
+    font-size: 12px;
+    color: var(--color-text-3);
+  }
+
+  &__main {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  &__label {
+    font-size: 13px;
+    font-weight: 500;
+    color: var(--color-text-2);
+
+    &--custom {
+      margin-top: 4px;
+    }
+  }
+}
+
+.emoji-grid {
+  display: grid;
+  grid-template-columns: repeat(5, 52px);
+  gap: 10px;
+
+  @media (max-width: 480px) {
+    grid-template-columns: repeat(5, 1fr);
+  }
+
+  &__item {
+    width: 52px;
+    height: 52px;
+    padding: 0;
+    font-size: 26px;
+    line-height: 1;
+    border: 2px solid var(--color-border-2);
+    border-radius: 10px;
+    background: var(--color-bg-2);
+    cursor: pointer;
+    transition: border-color 0.2s, background 0.2s, transform 0.15s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    @media (max-width: 480px) {
+      width: 100%;
+      aspect-ratio: 1;
+      height: auto;
+    }
+
+    &:hover {
+      transform: scale(1.06);
+      border-color: rgb(var(--primary-5));
+    }
+
+    &--active {
+      border-color: rgb(var(--primary-6));
+      background: var(--color-primary-light-1);
+      box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.15);
+    }
+  }
+}
+
+.emoji-custom-input {
+  max-width: 320px;
+  height: 40px;
+}
+
+.features-editor {
+  padding: 16px;
+  background: var(--color-fill-1);
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+
+  &__tip {
+    margin: 0 0 12px;
+    font-size: 12px;
+    color: var(--color-text-3);
+  }
+
+  &__row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 10px;
+  }
+
+  &__index {
+    flex-shrink: 0;
+    width: 22px;
+    height: 22px;
+    border-radius: 50%;
+    background: var(--color-fill-3);
+    color: var(--color-text-2);
+    font-size: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  &__add {
+    margin-top: 4px;
+  }
 }
-</style>
+</style>

+ 261 - 0
src/pages/admin/goods/couponEdit.vue

@@ -0,0 +1,261 @@
+<template>
+  <div class="admin-page">
+    <Breadcrumb />
+
+    <a-card :bordered="false" class="page-card" :title="isEdit ? '编辑优惠码' : '新建优惠码'">
+      <a-skeleton animation :loading="pageLoading">
+        <a-skeleton-line :rows="14" />
+        <template #content>
+        <a-form :model="form" layout="vertical" @submit-success="handleSubmit">
+          <a-row :gutter="24">
+            <a-col :xs="24" :md="8">
+              <a-form-item label="优惠码" required>
+                <a-input
+                  v-model="form.code"
+                  placeholder="如 SPRING2026"
+                  :max-length="32"
+                  allow-clear
+                  :disabled="isEdit"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="名称(可选)">
+                <a-input v-model="form.name" placeholder="内部备注名称" :max-length="64" allow-clear />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="状态">
+                <a-radio-group v-model="form.state" type="button">
+                  <a-radio :value="1">启用</a-radio>
+                  <a-radio :value="0">停用</a-radio>
+                </a-radio-group>
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-divider orientation="left">折扣规则</a-divider>
+          <a-row :gutter="24">
+            <a-col :xs="24" :md="8">
+              <a-form-item label="折扣类型">
+                <a-radio-group v-model="form.discount_type" type="button">
+                  <a-radio value="percent">百分比</a-radio>
+                  <a-radio value="fixed">固定减免</a-radio>
+                </a-radio-group>
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item :label="form.discount_type === 'percent' ? '折扣比例(%)' : '减免金额(元)'">
+                <a-input-number
+                  v-model="form.discount_value"
+                  :min="form.discount_type === 'percent' ? 1 : 0.01"
+                  :max="form.discount_type === 'percent' ? 100 : 99999"
+                  :precision="form.discount_type === 'percent' ? 0 : 2"
+                  style="width: 100%"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="最低消费(元,0 表示不限)">
+                <a-input-number v-model="form.min_amount" :min="0" :precision="2" style="width: 100%" />
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-divider orientation="left">使用范围</a-divider>
+          <a-row :gutter="24">
+            <a-col :xs="24" :md="12">
+              <a-form-item label="适用用户">
+                <a-radio-group v-model="form.user_scope" direction="vertical">
+                  <a-radio :value="0">所有人可用</a-radio>
+                  <a-radio :value="1">仅指定用户可用</a-radio>
+                </a-radio-group>
+                <a-textarea
+                  v-if="form.user_scope === 1"
+                  v-model="form.allowed_usernames"
+                  class="scope-textarea"
+                  placeholder="每行一个用户名,或用逗号分隔"
+                  :auto-size="{ minRows: 3, maxRows: 6 }"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="12">
+              <a-form-item label="适用商品">
+                <a-radio-group v-model="form.goods_scope" direction="vertical">
+                  <a-radio :value="0">全部商品可用</a-radio>
+                  <a-radio :value="1">仅指定商品可用</a-radio>
+                </a-radio-group>
+                <a-select
+                  v-if="form.goods_scope === 1"
+                  v-model="form.allowed_goods_ids"
+                  multiple
+                  allow-search
+                  placeholder="选择适用商品"
+                  class="goods-select"
+                  :loading="goodsLoading"
+                >
+                  <a-option v-for="g in goodsOptions" :key="g.id" :value="g.id">
+                    {{ g.name }}(¥{{ g.price }})
+                  </a-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-divider orientation="left">使用限制</a-divider>
+          <a-row :gutter="24">
+            <a-col :xs="24" :md="8">
+              <a-form-item label="总使用次数(0=不限)">
+                <a-input-number v-model="form.total_limit" :min="0" :precision="0" style="width: 100%" />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="每人限用次数">
+                <a-input-number v-model="form.per_user_limit" :min="1" :precision="0" style="width: 100%" />
+              </a-form-item>
+            </a-col>
+            <a-col :xs="24" :md="8">
+              <a-form-item label="有效期">
+                <a-range-picker
+                  v-model="validityRange"
+                  show-time
+                  format="YYYY-MM-DD HH:mm"
+                  value-format="x"
+                  style="width: 100%"
+                />
+              </a-form-item>
+            </a-col>
+          </a-row>
+
+          <a-form-item>
+            <a-space>
+              <a-button type="primary" html-type="submit" :loading="submitting">保存</a-button>
+              <a-button @click="$router.push('/goodsManage/goods/couponList')">返回列表</a-button>
+            </a-space>
+          </a-form-item>
+        </a-form>
+        </template>
+      </a-skeleton>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { adminCouponDetail, adminSaveCoupon } from '@/api/coupon'
+import { adminGetGoodsList } from '@/api/goods'
+import { Notification } from '@arco-design/web-vue'
+
+const route = useRoute()
+const router = useRouter()
+const isEdit = computed(() => Boolean(route.params.id))
+
+const pageLoading = ref(false)
+const submitting = ref(false)
+const goodsLoading = ref(false)
+const goodsOptions = ref([])
+const validityRange = ref([])
+
+const form = reactive({
+  code: '',
+  name: '',
+  discount_type: 'percent',
+  discount_value: 10,
+  user_scope: 0,
+  goods_scope: 0,
+  total_limit: 0,
+  per_user_limit: 1,
+  min_amount: 0,
+  state: 1,
+  allowed_usernames: '',
+  allowed_goods_ids: []
+})
+
+const loadGoods = async () => {
+  try {
+    goodsLoading.value = true
+    const res = await adminGetGoodsList({ current: 1, pagesize: 500, keyword: '' })
+    if (res?.code === 0) goodsOptions.value = res.data || []
+  } finally {
+    goodsLoading.value = false
+  }
+}
+
+const loadDetail = async () => {
+  if (!route.params.id) return
+  pageLoading.value = true
+  try {
+    const res = await adminCouponDetail({ id: route.params.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '加载失败', content: res?.msg })
+    }
+    const d = res.data
+    form.code = d.code
+    form.name = d.name || ''
+    form.discount_type = d.discount_type
+    form.discount_value = Number(d.discount_value)
+    form.user_scope = Number(d.user_scope)
+    form.goods_scope = Number(d.goods_scope)
+    form.total_limit = Number(d.total_limit)
+    form.per_user_limit = Number(d.per_user_limit)
+    form.min_amount = Number(d.min_amount)
+    form.state = Number(d.state)
+    form.allowed_usernames = (d.allowedUsers || []).map((u) => u.username).filter(Boolean).join('\n')
+    form.allowed_goods_ids = (d.allowedGoods || []).map((g) => g.id).filter(Boolean)
+    if (d.start_time || d.end_time) {
+      validityRange.value = [d.start_time ? String(d.start_time) : null, d.end_time ? String(d.end_time) : null].filter(Boolean)
+    }
+  } finally {
+    pageLoading.value = false
+  }
+}
+
+const handleSubmit = async () => {
+  if (!form.code?.trim()) {
+    return Notification.warning({ title: '请填写优惠码' })
+  }
+  try {
+    submitting.value = true
+    const payload = {
+      id: route.params.id || null,
+      ...form,
+      code: form.code.trim().toUpperCase(),
+      start_time: validityRange.value?.[0] ? Number(validityRange.value[0]) : null,
+      end_time: validityRange.value?.[1] ? Number(validityRange.value[1]) : null
+    }
+    const res = await adminSaveCoupon(payload)
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '保存失败', content: res?.msg })
+    }
+    Notification.success({ title: '保存成功' })
+    router.push('/goodsManage/goods/couponList')
+  } catch (e) {
+    Notification.error({ title: '保存失败', content: e.message })
+  } finally {
+    submitting.value = false
+  }
+}
+
+onMounted(async () => {
+  await loadGoods()
+  await loadDetail()
+})
+</script>
+
+<style scoped>
+.admin-page {
+  padding: 0 20px 20px;
+}
+.page-card {
+  border-radius: 12px;
+  max-width: 960px;
+}
+.scope-textarea {
+  margin-top: 12px;
+}
+.goods-select {
+  margin-top: 12px;
+  width: 100%;
+}
+</style>

+ 191 - 0
src/pages/admin/goods/couponList.vue

@@ -0,0 +1,191 @@
+<template>
+  <div class="admin-page">
+    <Breadcrumb />
+
+    <a-card :bordered="false" class="page-card">
+      <template #title>
+        <div class="card-title-row">
+          <span>优惠码管理</span>
+          <a-button type="primary" @click="goEdit()">
+            <template #icon><icon-plus /></template>
+            新建优惠码
+          </a-button>
+        </div>
+      </template>
+
+      <a-form :model="query" layout="inline" class="filter-form">
+        <a-form-item label="关键词">
+          <a-input v-model="query.keyword" placeholder="优惠码 / 名称" allow-clear style="width: 200px" />
+        </a-form-item>
+        <a-form-item label="状态">
+          <a-select v-model="query.state" :options="stateOptions" style="width: 120px" />
+        </a-form-item>
+        <a-form-item>
+          <a-space>
+            <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-form-item>
+      </a-form>
+
+      <a-table
+        :data="list"
+        :loading="loading"
+        :pagination="{
+          total: pagination.total,
+          current: pagination.current,
+          pageSize: pagination.pageSize,
+          showTotal: true,
+          showPageSize: true
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="优惠码" data-index="code" :width="140">
+            <template #cell="{ record }">
+              <a-tag color="arcoblue">{{ record.code }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="名称" data-index="name" ellipsis />
+          <a-table-column title="折扣" :width="120">
+            <template #cell="{ record }">{{ formatDiscount(record) }}</template>
+          </a-table-column>
+          <a-table-column title="用户范围" :width="100">
+            <template #cell="{ record }">{{ record.user_scope === 1 ? '指定用户' : '所有人' }}</template>
+          </a-table-column>
+          <a-table-column title="商品范围" :width="100">
+            <template #cell="{ record }">{{ record.goods_scope === 1 ? '指定商品' : '全部商品' }}</template>
+          </a-table-column>
+          <a-table-column title="已用/总量" :width="110">
+            <template #cell="{ record }">
+              {{ record.used_count }} / {{ record.total_limit > 0 ? record.total_limit : '∞' }}
+            </template>
+          </a-table-column>
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="record.state === 1 ? 'green' : 'gray'">
+                {{ record.state === 1 ? '启用' : '停用' }}
+              </a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="有效期" :width="200">
+            <template #cell="{ record }">{{ formatValidity(record) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="100" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-button type="text" size="small" @click="goEdit(record.id)">编辑</a-button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { adminCouponList } from '@/api/coupon'
+import { Notification } from '@arco-design/web-vue'
+import { formatStoreTime } from '@/utils/storeFormat'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
+
+const router = useRouter()
+const loading = ref(false)
+const list = ref([])
+
+const query = reactive({ keyword: '', state: -1 })
+const pagination = reactive({ total: 0, current: 1, pageSize: 15 })
+
+const stateOptions = [
+  { label: '全部', value: -1 },
+  { label: '启用', value: 1 },
+  { label: '停用', value: 0 }
+]
+
+const formatDiscount = (row) => {
+  if (row.discount_type === 'percent') return `${row.discount_value}% 折扣`
+  return `立减 ¥${row.discount_value}`
+}
+
+const formatValidity = (row) => {
+  const start = row.start_time ? formatStoreTime(row.start_time) : '不限'
+  const end = row.end_time ? formatStoreTime(row.end_time) : '不限'
+  return `${start} ~ ${end}`
+}
+
+const fetchList = async () => {
+  try {
+    loading.value = true
+    const params = {
+      keyword: query.keyword || undefined,
+      current: pagination.current,
+      pagesize: pagination.pageSize
+    }
+    if (query.state !== -1 && query.state !== '-1') {
+      params.state = query.state
+    }
+    const res = await adminCouponList(params)
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '获取失败', content: res?.msg })
+    }
+    list.value = res.data || []
+    pagination.total = res.pagination?.total ?? 0
+  } catch (e) {
+    Notification.error({ title: '获取失败', content: e.message })
+  } finally {
+    loading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  fetchList()
+}
+
+const reset = () => {
+  query.keyword = ''
+  query.state = -1
+  pagination.current = 1
+  fetchList()
+}
+
+const onPageChange = (p) => {
+  pagination.current = p
+  fetchList()
+}
+
+const onPageSizeChange = (s) => {
+  pagination.pageSize = s
+  pagination.current = 1
+  fetchList()
+}
+
+const goEdit = (id) => {
+  if (id) router.push(`/goodsManage/goods/couponEdit/${id}`)
+  else router.push('/goodsManage/goods/couponEdit')
+}
+
+onMounted(fetchList)
+</script>
+
+<style scoped>
+.admin-page {
+  padding: 0 20px 20px;
+}
+.page-card {
+  border-radius: 12px;
+}
+.card-title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+}
+.filter-form {
+  margin-bottom: 16px;
+}
+</style>

+ 162 - 176
src/pages/admin/goods/goodsList.vue

@@ -1,209 +1,195 @@
 <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="keyword" label="商品名称">
-                                    <a-input v-model="queryData.keyword" placeholder="请输入商品名称关键词" />
-                                </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 #username="{ record }">
-                    <a-avatar :size="30">
-                        <IconUser v-if="!record.avatar" />
-                        <img :alt="record.username ?? ''" :src="record.avatar" v-else />
-                    </a-avatar>
-                    {{ record.username }}
+  <div class="admin-goods-page">
+    <Breadcrumb />
+
+    <a-card :bordered="false" class="page-card">
+      <template #title>
+        <div class="card-title-row">
+          <span>商品管理</span>
+          <a-button type="primary" @click="$router.push('/goodsManage/goods/addGoods')">
+            <template #icon><icon-plus /></template>
+            新增商品
+          </a-button>
+        </div>
+      </template>
+
+      <a-form :model="queryData" layout="inline" class="filter-form">
+        <a-form-item label="商品名称">
+          <a-input
+            v-model="queryData.keyword"
+            placeholder="关键词搜索"
+            allow-clear
+            style="width: 220px"
+            @press-enter="search"
+          />
+        </a-form-item>
+        <a-form-item>
+          <a-space>
+            <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-form-item>
+      </a-form>
+
+      <a-table
+        :bordered="false"
+        :data="data"
+        stripe
+        :loading="loading"
+        :columns="columns"
+        class="data-table"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="handlePageChange"
+        @page-size-change="handlePageSizeChange"
+      >
+                <template #icon="{ record }">
+                    <GoodsEmoji :icon="record.icon" size="sm" />
                 </template>
-                <template #create_time="{ record }">
-                    {{ stramptoTime(record.create_time) }}
-                </template>
-                <template #update_time="{ record }">
-                    {{ stramptoTime(record.update_time) }}
-                </template>
-                <template #state="{ record }">
-                    <a-tag color="orangered" v-if="record.state === 0">
-                        已下架
-                    </a-tag>
-                    <a-tag color="green" v-else-if="record.state === 1">
-                        正常
-                    </a-tag>
-                </template>
-                <template #optional="{ record }">
-                    <a-button @click="$router.push(`/goodsManage/goods/addGoods/${record.id}`)">编辑商品</a-button>
-                </template>
-            </a-table>
-        </a-card>
-    </div>
+                <template #price="{ record }">
+          <span class="price-cell">¥{{ record.price }}</span>
+        </template>
+        <template #create_time="{ record }">
+          {{ formatStoreTimeFull(record.create_time) }}
+        </template>
+        <template #update_time="{ record }">
+          {{ formatStoreTimeFull(record.update_time) }}
+        </template>
+        <template #state="{ record }">
+          <a-tag :color="record.state === 1 ? 'green' : 'orangered'">
+            {{ record.state === 1 ? '上架' : '下架' }}
+          </a-tag>
+        </template>
+        <template #optional="{ record }">
+          <a-button type="text" size="small" @click="$router.push(`/goodsManage/goods/addGoods/${record.id}`)">
+            编辑
+          </a-button>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
 </template>
 
 <script setup>
 import { ref, reactive, onMounted } from 'vue'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { adminGetGoodsList } from '@/api/goods'
 import { Notification } from '@arco-design/web-vue'
+import { formatStoreTimeFull } from '@/utils/storeFormat'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
 
-const pagination = reactive({
-    total: 0,
-    current: 1,
-    pagesize: 20
-})
+const pagination = reactive({ total: 0, current: 1, pagesize: 20 })
+const queryData = reactive({ keyword: '' })
+const loading = ref(false)
+const data = ref([])
 
-const queryData = reactive({
-    keyword: '',
-})
+const columnDefs = [
+    { title: 'ID', dataIndex: 'id', width: 70 },
+    { title: '图标', slotName: 'icon', width: 70 },
+    { title: '商品名称', dataIndex: 'name', ellipsis: true, tooltip: true },
+  { title: '价格', slotName: 'price', width: 100 },
+  { title: '库存', dataIndex: 'num', width: 80 },
+  { title: '乐跑次数', dataIndex: 'lepao_count', width: 100 },
+  { title: '浏览量', dataIndex: 'views', width: 90 },
+  { title: '状态', slotName: 'state', width: 90 },
+  { title: '创建时间', slotName: 'create_time', width: 170 },
+  { title: '创建人', dataIndex: 'create_user', width: 120, ellipsis: true },
+  { title: '更新时间', slotName: 'update_time', width: 170 },
+  { title: '操作', slotName: 'optional', fixed: 'right', width: 90 }
+]
+
+const { useTableColumns } = useResponsiveTable()
+const columns = useTableColumns(columnDefs)
 
 const search = () => {
-    pagination.current = 1
-    getList()
+  pagination.current = 1
+  getList()
 }
 
 const reset = () => {
-    pagination.current = 1
-    queryData.id = ''
-    queryData.state = -1
-    getList()
+  queryData.keyword = ''
+  pagination.current = 1
+  getList()
 }
 
-const columns = [
-    {
-        title: '商品ID',
-        dataIndex: 'id',
-    },
-    {
-        title: '商品名称',
-        dataIndex: 'name',
-    }, {
-        title: '商品价格',
-        dataIndex: 'price',
-    },
-    {
-        title: '库存数量',
-        dataIndex: 'num',
-    }, {
-        title: '乐跑里程(km)',
-        dataIndex: 'lepao_count',
-    }, {
-        title: '浏览量',
-        dataIndex: 'views',
-    }, {
-        title: '创建时间',
-        slotName: 'create_time'
-    }, 
-    {
-        title: '创建人',
-        dataIndex: 'create_user',
-    },{
-        title: '最后更新时间',
-        slotName: 'update_time'
-    }, {
-        title: '更新人',
-        dataIndex: 'update_user',
-    },{
-        title: '状态',
-        slotName: 'state'
-    }, {
-        title: '操作',
-        slotName: 'optional'
-    }
-]
-
-const loading = ref(false)
-const data = ref([])
-
 const getList = async () => {
-    try {
-        loading.value = true
-
-        const reqData = {
-            ...queryData,
-            pagesize: pagination.pagesize,
-            current: pagination.current
-        }
-
-        const res = await adminGetGoodsList(reqData)
-        if (!res || res.code !== 0)
-            return Notification.error({
-                title: '获取商品失败!',
-                content: res?.msg ?? '请稍后再试'
-            })
-
-        data.value = res.data
-        console.log(res.data)
-        pagination.total = res.pagination.total
-    } catch (error) {
-        Notification.error({
-            title: '获取商品失败!',
-            content: error.message || '请稍后再试'
-        })
-    } finally {
-        loading.value = false
+  try {
+    loading.value = true
+    const res = await adminGetGoodsList({
+      ...queryData,
+      pagesize: pagination.pagesize,
+      current: pagination.current
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取商品失败',
+        content: res?.msg ?? '请稍后再试'
+      })
     }
+    data.value = res.data ?? []
+    pagination.total = res.pagination?.total ?? 0
+  } catch (error) {
+    Notification.error({
+      title: '获取商品失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
 }
 
-// 分页 - 页码变化
 const handlePageChange = (page) => {
-    pagination.current = page
-    getOrderList()
+  pagination.current = page
+  getList()
 }
 
-// 分页 - 每页条数变化
 const handlePageSizeChange = (size) => {
-    pagination.pagesize = size
-    pagination.current = 1 // 页大小变化后回到第一页
-    getList()
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
 }
 
-onMounted(() => {
-    getList()
-})
+onMounted(getList)
+</script>
 
-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' })
+<style scoped lang="less">
+.admin-goods-page {
+  padding: 0 20px 20px;
+}
+
+.page-card {
+  border-radius: 12px;
+}
+
+.card-title-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+}
+
+.filter-form {
+  margin-bottom: 16px;
 }
-</script>
 
-<style scoped>
-.container {
-    padding: 0 20px 20px 20px;
+.data-table {
+  margin-top: 8px;
 }
 
-.table {
-    margin-top: 15px;
+.price-cell {
+  font-weight: 600;
+  color: #e85d04;
 }
-</style>
+</style>

+ 268 - 0
src/pages/admin/goods/orderDetail.vue

@@ -0,0 +1,268 @@
+<template>
+  <div class="admin-order-detail">
+    <Breadcrumb />
+    <a-skeleton animation :loading="loading" style="width: 100%">
+      <StoreDetailSkeleton variant="order" />
+      <template #content>
+        <div v-if="data.orderId" class="detail-grid">
+        <a-card :bordered="false" class="detail-card detail-card--status">
+          <template #title>订单进度</template>
+          <OrderProgressSteps
+            class="order-steps"
+            :mobile="isMobile"
+            :current="stepCurrent"
+            :status="stepStatus"
+            :labels="adminStepLabels"
+            :descriptions="adminStepDescriptions"
+          />
+          <div class="state-center">
+            <a-tag :color="stateMeta.color" size="large">{{ stateMeta.label }}</a-tag>
+          </div>
+        </a-card>
+
+        <OrderRefundPanel
+          v-if="data.state === 2 && (data.canRefund || data.refundDisabledReason)"
+          class="refund-panel-wrap"
+          :can-refund="!!data.canRefund"
+          :refund-disabled-reason="data.refundDisabledReason"
+          :loading="refundLoading"
+          :price="data.price"
+          :lepao-count="data.lepao_count ?? 0"
+          :user-lepao-count="data.user_lepao_count"
+          variant="admin"
+          @refund="handleRefundOrder"
+        />
+
+        <a-card :bordered="false" class="detail-card">
+          <template #title>订单信息</template>
+          <a-descriptions :column="{ xs: 1, sm: 2 }" bordered>
+            <a-descriptions-item label="订单号">{{ data.orderId }}</a-descriptions-item>
+            <a-descriptions-item label="商品">{{ data.name || '-' }}</a-descriptions-item>
+            <a-descriptions-item v-if="data.original_price" label="商品原价">
+              ¥{{ data.original_price }}
+            </a-descriptions-item>
+            <a-descriptions-item v-if="data.coupon_code" label="优惠码">
+              {{ data.coupon_code }}
+            </a-descriptions-item>
+            <a-descriptions-item v-if="data.discount_amount > 0" label="优惠减免">
+              ¥{{ data.discount_amount }}
+            </a-descriptions-item>
+            <a-descriptions-item label="实付金额">
+              <span class="price">¥{{ data.price }}</span>
+            </a-descriptions-item>
+            <a-descriptions-item label="支付方式">{{ getPayTypeLabel(data.pay_type) }}</a-descriptions-item>
+            <a-descriptions-item label="下单时间">{{ formatStoreTimeFull(data.create_time) }}</a-descriptions-item>
+            <a-descriptions-item label="支付时间">{{ formatStoreTimeFull(data.pay_time) }}</a-descriptions-item>
+            <a-descriptions-item v-if="data.pay_id" label="支付平台单号" :span="isMobile ? 1 : 2">
+              {{ data.pay_id }}
+            </a-descriptions-item>
+            <a-descriptions-item v-if="data.lepao_count != null" label="乐跑次数">
+              {{ data.lepao_count }}
+            </a-descriptions-item>
+            <a-descriptions-item v-if="data.ic_count != null" label="IC次数">
+              {{ data.ic_count }}
+            </a-descriptions-item>
+            <a-descriptions-item v-if="data.user_lepao_count != null" label="用户当前次数">
+              {{ data.user_lepao_count }}
+            </a-descriptions-item>
+          </a-descriptions>
+        </a-card>
+
+        <a-card :bordered="false" class="detail-card">
+          <template #title>买家信息</template>
+          <a-descriptions :column="{ xs: 1, sm: 2 }" bordered>
+            <a-descriptions-item label="用户名">{{ data.username || '-' }}</a-descriptions-item>
+            <a-descriptions-item label="邮箱">{{ data.user_email || '-' }}</a-descriptions-item>
+            <a-descriptions-item label="用户 UUID" :span="isMobile ? 1 : 2">{{ data.create_user || '-' }}</a-descriptions-item>
+          </a-descriptions>
+        </a-card>
+
+        <a-card v-if="goodsContent" :bordered="false" class="detail-card detail-card--full">
+          <template #title>商品详情</template>
+          <div class="goods-content" v-html="goodsContent" />
+        </a-card>
+
+        </div>
+      </template>
+    </a-skeleton>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { adminOrderDetail, adminRefundOrder } from '@/api/order'
+import { Notification, Message, Modal } from '@arco-design/web-vue'
+import OrderProgressSteps from '@/components/store/OrderProgressSteps.vue'
+import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
+import OrderRefundPanel from '@/components/store/OrderRefundPanel.vue'
+import {
+  formatStoreTimeFull,
+  getPayTypeLabel,
+  getOrderStateMeta,
+  decodeGoodsContent
+} from '@/utils/storeFormat'
+
+const route = useRoute()
+const loading = ref(true)
+const refundLoading = ref(false)
+const data = ref({})
+const goodsContent = ref('')
+const isMobile = ref(false)
+let resizeHandler = null
+
+const adminStepLabels = ['待支付', '待处理', '已完成']
+const adminStepDescriptions = computed(() =>
+  isMobile.value
+    ? ['', '', '']
+    : ['用户提交订单', '支付成功,系统处理', '权益已发放']
+)
+
+const stateMeta = computed(() => getOrderStateMeta(data.value?.state ?? -1))
+
+const stepCurrent = computed(() => {
+  const state = data.value?.state
+  if (state === 0 || state === 3) return 1
+  if (state === 1) return 2
+  if (state === 2 || state === 4 || state === 5) return 3
+  return 1
+})
+
+const stepStatus = computed(() => {
+  const state = data.value?.state
+  if (state === 3 || state === 4) return 'error'
+  if (state === 2 || state === 5) return 'finish'
+  return 'process'
+})
+
+const fetchDetail = async () => {
+  try {
+    loading.value = true
+    const res = await adminOrderDetail({ orderId: route.params.orderId })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取订单详情失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    data.value = res.data || {}
+    goodsContent.value = decodeGoodsContent(res.data?.content)
+  } catch (error) {
+    Notification.error({
+      title: '获取订单详情失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleRefundOrder = () => {
+  const purchased = data.value?.lepao_count ?? 0
+  const remaining = data.value?.user_lepao_count ?? 0
+  Modal.confirm({
+    title: '管理员退款',
+    content: `将为用户退款 ¥${data.value?.price},并扣回 ${purchased} 次乐跑次数(用户当前剩余 ${remaining} 次)。确定继续吗?`,
+    okText: '确认退款',
+    cancelText: '取消',
+    okButtonProps: { status: 'warning' },
+    onOk: async () => {
+      try {
+        refundLoading.value = true
+        const res = await adminRefundOrder({ orderId: route.params.orderId })
+        if (!res || res.code !== 0) {
+          Notification.error({
+            title: '退款失败',
+            content: res?.msg ?? '请稍后再试'
+          })
+          return
+        }
+        Message.success(res.msg || '退款成功')
+        await fetchDetail()
+      } catch (error) {
+        Notification.error({
+          title: '退款失败',
+          content: error.message || '请稍后再试'
+        })
+      } finally {
+        refundLoading.value = false
+      }
+    }
+  })
+}
+
+onMounted(async () => {
+  const syncMobile = () => {
+    isMobile.value = window.innerWidth <= 768
+  }
+  resizeHandler = syncMobile
+  syncMobile()
+  window.addEventListener('resize', resizeHandler)
+  await fetchDetail()
+})
+
+onUnmounted(() => {
+  if (resizeHandler) window.removeEventListener('resize', resizeHandler)
+})
+</script>
+
+<style scoped lang="less">
+.admin-order-detail {
+  padding: 0 20px 20px;
+}
+
+.detail-grid {
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: 20px;
+
+  :deep(.arco-card) {
+    margin-bottom: 0;
+  }
+
+  @media (min-width: 960px) {
+    grid-template-columns: 1fr 1fr;
+
+    .detail-card--status,
+    .detail-card--full,
+    .refund-panel-wrap {
+      grid-column: 1 / -1;
+    }
+  }
+}
+
+.detail-card {
+  border-radius: 12px;
+  border: 1px solid var(--color-border-2);
+}
+
+.state-center {
+  margin-top: 20px;
+  text-align: center;
+}
+
+.price {
+  font-weight: 700;
+  color: #e85d04;
+}
+
+.goods-content {
+  line-height: 1.75;
+
+  :deep(img) {
+    max-width: 100%;
+  }
+}
+
+@media (max-width: 768px) {
+  .admin-order-detail {
+    padding: 0 12px 16px;
+  }
+
+  :deep(.arco-descriptions-item-value) {
+    white-space: normal;
+    word-break: break-all;
+    overflow-wrap: anywhere;
+  }
+}
+</style>

+ 268 - 0
src/pages/admin/goods/orderList.vue

@@ -0,0 +1,268 @@
+<template>
+  <div class="admin-order-page">
+    <Breadcrumb />
+
+    <a-card :bordered="false" class="page-card" title="订单管理">
+      <AppQueryFilter>
+          <a-form :model="queryData" layout="vertical" class="filter-grid app-query-form">
+            <a-row :gutter="16">
+              <a-col :xs="24" :sm="12" :md="8" :lg="6">
+                <a-form-item label="订单号">
+                  <a-input v-model="queryData.orderId" placeholder="模糊搜索" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :xs="24" :sm="12" :md="8" :lg="6">
+                <a-form-item label="商品名称">
+                  <a-input v-model="queryData.goods_name" placeholder="模糊搜索" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :xs="24" :sm="12" :md="8" :lg="6">
+                <a-form-item label="用户名">
+                  <a-input v-model="queryData.username" placeholder="模糊搜索" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :xs="24" :sm="12" :md="8" :lg="6">
+                <a-form-item label="用户邮箱">
+                  <EmailAutoComplete v-model="queryData.user_email" placeholder="模糊搜索" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :xs="24" :sm="12" :md="8" :lg="6">
+                <a-form-item label="订单状态">
+                  <a-select v-model="queryData.state" :options="stateOptions" placeholder="全部" />
+                </a-form-item>
+              </a-col>
+              <a-col :xs="24" :sm="12" :md="8" :lg="6">
+                <a-form-item label="支付方式">
+                  <a-select v-model="queryData.pay_type" placeholder="全部" allow-clear>
+                    <a-option value="">全部</a-option>
+                    <a-option v-for="item in payTypeOptions" :key="item.value" :value="item.value">
+                      {{ item.label }}
+                    </a-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+              <a-col :xs="24" :sm="24" :md="16" :lg="12">
+                <a-form-item label="下单时间">
+                  <a-range-picker
+                    v-model="queryData.queryTime"
+                    show-time
+                    format="YY-MM-DD HH:mm"
+                    value-format="x"
+                    style="width: 100%"
+                  />
+                </a-form-item>
+              </a-col>
+            </a-row>
+            <a-space>
+              <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-form>
+      </AppQueryFilter>
+
+      <a-table
+        :bordered="false"
+        :data="data"
+        stripe
+        :loading="loading"
+        :columns="columns"
+        class="data-table"
+        :scroll="{ x: 1200 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="handlePageChange"
+        @page-size-change="handlePageSizeChange"
+      >
+        <template #username="{ record }">
+          <a-space>
+            <a-avatar :size="28">
+              <img v-if="record.avatar" :alt="record.username ?? ''" :src="record.avatar" />
+              <icon-user v-else />
+            </a-avatar>
+            {{ record.username || '-' }}
+          </a-space>
+        </template>
+        <template #price="{ record }">
+          <span class="price-cell">¥{{ record.price }}</span>
+        </template>
+        <template #pay_type="{ record }">
+          <a-space size="mini">
+            <icon-wechatpay v-if="record.pay_type === 'wxpay'" />
+            <icon-alipay-circle v-else-if="record.pay_type === 'alipay'" />
+            <icon-qq v-else-if="record.pay_type === 'qqpay'" />
+            <span>{{ getPayTypeLabel(record.pay_type) }}</span>
+          </a-space>
+        </template>
+        <template #state="{ record }">
+          <a-tag :color="getStateColor(record.state)">{{ getStateLabel(record.state) }}</a-tag>
+        </template>
+        <template #create_time="{ record }">
+          {{ formatStoreTimeFull(record.create_time) }}
+        </template>
+        <template #pay_time="{ record }">
+          {{ record.pay_time ? formatStoreTimeFull(record.pay_time) : '-' }}
+        </template>
+        <template #optional="{ record }">
+          <a-button type="text" size="small" @click="goDetail(record.orderId)">详情</a-button>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+import { useRouter } from 'vue-router'
+import { adminOrderList } from '@/api/order'
+import { Notification } from '@arco-design/web-vue'
+import { getSemesterTimestamps } from '@/utils/util'
+import { formatStoreTimeFull, getPayTypeLabel, getOrderStateMeta } from '@/utils/storeFormat'
+
+const router = useRouter()
+const pagination = reactive({ total: 0, current: 1, pagesize: 20 })
+
+const stateOptions = [
+  { label: '全部', value: -1 },
+  { label: '待支付', value: 0 },
+  { label: '待处理', value: 1 },
+  { label: '已完成', value: 2 },
+  { label: '已退款', value: 5 },
+  { label: '已关闭', value: 3 },
+  { label: '处理异常', value: 4 }
+]
+
+const payTypeOptions = [
+  { label: '微信支付', value: 'wxpay' },
+  { label: '支付宝', value: 'alipay' },
+  { label: 'QQ支付', value: 'qqpay' }
+]
+
+const queryData = reactive({
+  orderId: '',
+  goods_name: '',
+  username: '',
+  user_email: '',
+  state: -1,
+  pay_type: '',
+  queryTime: []
+})
+
+const loading = ref(false)
+const data = ref([])
+
+const columnDefs = [
+  { title: '订单号', dataIndex: 'orderId', width: 180, ellipsis: true, tooltip: true },
+  { title: '商品', dataIndex: 'goods_name', width: 140, ellipsis: true, tooltip: true },
+  { title: '用户', slotName: 'username', width: 150 },
+  { title: '邮箱', dataIndex: 'user_email', width: 180, ellipsis: true, tooltip: true },
+  { title: '金额', slotName: 'price', width: 90 },
+  { title: '支付', slotName: 'pay_type', width: 120 },
+  { title: '状态', slotName: 'state', width: 100 },
+  { title: '下单时间', slotName: 'create_time', width: 165 },
+  { title: '支付时间', slotName: 'pay_time', width: 165 },
+  { title: '操作', slotName: 'optional', fixed: 'right', width: 80 }
+]
+
+const { useTableColumns } = useResponsiveTable()
+const columns = useTableColumns(columnDefs)
+
+const getStateLabel = (state) => getOrderStateMeta(state).label
+const getStateColor = (state) => getOrderStateMeta(state).color
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  queryData.orderId = ''
+  queryData.goods_name = ''
+  queryData.username = ''
+  queryData.user_email = ''
+  queryData.state = -1
+  queryData.pay_type = ''
+  queryData.queryTime = getSemesterTimestamps()
+  pagination.current = 1
+  getList()
+}
+
+const getList = async () => {
+  try {
+    loading.value = true
+    const res = await adminOrderList({
+      ...queryData,
+      pagesize: pagination.pagesize,
+      current: pagination.current
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取订单列表失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    data.value = res.data || []
+    pagination.total = res.pagination?.total ?? 0
+  } catch (error) {
+    Notification.error({
+      title: '获取订单列表失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const goDetail = (orderId) => router.push(`/goodsManage/goods/orderDetail/${orderId}`)
+
+const handlePageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+
+const handlePageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(() => {
+  queryData.queryTime = getSemesterTimestamps()
+  getList()
+})
+</script>
+
+<style scoped>
+.admin-order-page {
+  padding: 0 20px 20px;
+}
+
+.page-card {
+  border-radius: 12px;
+}
+
+.filter-grid {
+  padding-top: 8px;
+}
+
+.data-table {
+  margin-top: 8px;
+}
+
+.price-cell {
+  font-weight: 600;
+  color: #e85d04;
+}
+</style>

+ 280 - 0
src/pages/admin/goods/sendCountRequestList.vue

@@ -0,0 +1,280 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="赠送审核列表">
+      <AppQueryFilter>
+      <a-row class="query-row app-query-form">
+        <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="status" label="审核状态">
+                  <a-select v-model="queryData.status" :options="statusOptions" placeholder="请选择状态" />
+                </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>
+      </AppQueryFilter>
+
+      <a-table :bordered="false"
+        :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 #created_at="{ record }">
+          {{ stramptoTime(record.created_at) }}
+        </template>
+        <template #reviewed_at="{ record }">
+          {{ stramptoTime(record.reviewed_at) }}
+        </template>
+        <template #status="{ record }">
+          <a-tag color="orangered" v-if="record.status === 'pending'">待审核</a-tag>
+          <a-tag color="green" v-else-if="record.status === 'approved'">已通过</a-tag>
+          <a-tag color="red" v-else-if="record.status === 'rejected'">已拒绝</a-tag>
+        </template>
+        <template #reject_reason="{ record }">
+          {{ record.reject_reason || '-' }}
+        </template>
+        <template #optional="{ record }">
+          <a-space v-if="record.status === 'pending' && hasPermission('action.goods.reviewSendCount')">
+            <a-button type="primary" size="small" @click="approveRequest(record.id)">通过</a-button>
+            <a-button status="danger" size="small" @click="openRejectModal(record.id)">拒绝</a-button>
+          </a-space>
+          <span v-else>-</span>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="rejectModalVisible"
+      title="拒绝赠送申请"
+      @before-ok="handleRejectBeforeOk"
+      @cancel="handleRejectCancel"
+    >
+      <a-form :model="rejectForm">
+        <a-form-item field="reason" label="拒绝原因">
+          <a-textarea v-model="rejectForm.reason" :max-length="255" placeholder="选填,最多255字" allow-clear />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import {
+  adminApproveSendCountRequest,
+  adminRejectSendCountRequest,
+  adminSendCountRequestList
+} from '@/api/goods'
+import { hasPermission } from '@/utils/permission'
+
+const loading = ref(false)
+const data = ref([])
+
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+
+const statusOptions = [
+  { label: '全部', value: '' },
+  { label: '待审核', value: 'pending' },
+  { label: '已通过', value: 'approved' },
+  { label: '已拒绝', value: 'rejected' }
+]
+
+const queryData = reactive({
+  status: ''
+})
+
+const columns = [
+  { title: '申请ID', dataIndex: 'id' },
+  { title: '赠送人', dataIndex: 'sender_username' },
+  { title: '接收人', dataIndex: 'receiver_username' },
+  { title: '赠送次数', dataIndex: 'count' },
+  { title: '状态', slotName: 'status' },
+  { title: '申请时间', slotName: 'created_at' },
+  { title: '审核时间', slotName: 'reviewed_at' },
+  { title: '拒绝原因', slotName: 'reject_reason' },
+  { title: '操作', slotName: 'optional' }
+]
+
+const rejectModalVisible = ref(false)
+const rejectForm = reactive({
+  id: 0,
+  reason: ''
+})
+
+const stramptoTime = (time) => {
+  if (!time) 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 getList = async () => {
+  try {
+    loading.value = true
+    const reqData = {
+      pagesize: pagination.pagesize,
+      current: pagination.current
+    }
+    if (queryData.status) reqData.status = queryData.status
+
+    const res = await adminSendCountRequestList(reqData)
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取赠送审核列表失败!',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } catch (error) {
+    Notification.error({
+      title: '获取赠送审核列表失败!',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const approveRequest = async (id) => {
+  try {
+    const res = await adminApproveSendCountRequest({ id })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '审核通过失败!',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+
+    Notification.success({
+      title: '操作成功',
+      content: '该赠送申请已审核通过'
+    })
+    getList()
+  } catch (error) {
+    Notification.error({
+      title: '审核通过失败!',
+      content: error.message || '请稍后再试'
+    })
+  }
+}
+
+const openRejectModal = (id) => {
+  rejectForm.id = id
+  rejectForm.reason = ''
+  rejectModalVisible.value = true
+}
+
+const handleRejectBeforeOk = async () => {
+  try {
+    const res = await adminRejectSendCountRequest({
+      id: rejectForm.id,
+      reject_reason: rejectForm.reason
+    })
+
+    if (!res || res.code !== 0) {
+      Notification.error({
+        title: '拒绝申请失败!',
+        content: res?.msg ?? '请稍后再试'
+      })
+      return false
+    }
+
+    Notification.success({
+      title: '操作成功',
+      content: '该赠送申请已拒绝'
+    })
+    getList()
+    return true
+  } catch (error) {
+    Notification.error({
+      title: '拒绝申请失败!',
+      content: error.message || '请稍后再试'
+    })
+    return false
+  }
+}
+
+const handleRejectCancel = () => {
+  rejectModalVisible.value = false
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  pagination.current = 1
+  queryData.status = ''
+  getList()
+}
+
+const handlePageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+
+const handlePageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped>
+.table {
+  margin-top: 15px;
+}
+</style>

+ 331 - 207
src/pages/admin/lepaoAccount/accountList.vue

@@ -1,11 +1,12 @@
 <template>
-    <div class="container">
-        <Breadcrumb :items="['网站管理', '乐跑账号管理']" />
+    <div class="store-page">
+        <Breadcrumb />
 
         <a-card title="乐跑账号管理">
-            <a-row>
-                <a-col :flex="1">
-                    <a-form :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
+            <AppQueryFilter>
+            <a-row class="query-row">
+                <a-col :flex="'1000px'" class="query-main">
+                    <a-form class="queryForm app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
                         label-align="left">
                         <a-row :gutter="16">
                             <a-col :span="6">
@@ -25,7 +26,7 @@
                             </a-col>
                             <a-col :span="6">
                                 <a-form-item field="email" label="用户邮箱">
-                                    <a-input v-model="queryData.email" allow-clear />
+                                    <EmailAutoComplete v-model="queryData.email" allow-clear />
                                 </a-form-item>
                             </a-col>
                             <a-col :span="6">
@@ -33,6 +34,11 @@
                                     <a-input v-model="queryData.username" allow-clear />
                                 </a-form-item>
                             </a-col>
+                            <a-col :span="6">
+                                <a-form-item field="notes" label="备注">
+                                    <a-input v-model="queryData.notes" allow-clear />
+                                </a-form-item>
+                            </a-col>
                             <a-col :span="6">
                                 <a-form-item field="student_num" label="学号">
                                     <a-input v-model="queryData.student_num" allow-clear />
@@ -73,9 +79,9 @@
                         </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-divider class="query-divider" style="height: 84px" direction="vertical" />
+                <a-col :flex="1" class="app-query-actions">
+                    <a-space class="query-actions-space" direction="vertical" :size="18">
                         <a-button type="primary" @click="search">
                             <template #icon>
                                 <icon-search />
@@ -91,8 +97,9 @@
                     </a-space>
                 </a-col>
             </a-row>
+            </AppQueryFilter>
 
-            <a-table :data="data" :bordered="false" class="table" :loading="loading" :columns="columns" :pagination="{
+            <a-table :data="data" :bordered="false" hoverable class="table table-clickable" :loading="loading" :columns="columns" :scroll="{ x: 2850 }" @row-click="onRowClick" :pagination="{
                 showPageSize: true,
                 showJumper: true,
                 showTotal: true,
@@ -120,13 +127,14 @@
                     {{ record.sex === 1 ? '男' : (record.sex === 2 ? '女' : '') }}
                 </template>
                 <template #auto_run="{ record }">
-                    <a-tag color="green" v-if="record.auto_run">开启</a-tag>
+                    <a-tag color="green" v-if="record.auto_run">{{ record.target_count === 0 ? '开启-∞次' :
+                        `开启-${record.target_count}次` }}</a-tag>
                     <a-tag color="red" v-else>关闭</a-tag>
                 </template>
                 <template #notice_type="{ record }">
                     <span v-if="record.notice_type === 'email'"><icon-email /> {{ record.email ?? '未绑定' }}</span>
                     <span v-else-if="record.notice_type === 'bot'"><icon-robot /> {{ record.bot_account ?? '未绑定'
-                        }}</span>
+                    }}</span>
                     <span v-else><icon-notification-close /> 无通知</span>
                 </template>
                 <template #auto_day="{ record }">
@@ -137,21 +145,9 @@
                     </span>
                     <span v-else>-</span>
                 </template>
-                <template #target_count="{ record }">
-                    {{ record.target_count === 0 ? '不限' : formatKm(record.target_count) }}
-                </template>
-                <template #auto_run_distance="{ record }">
-                    {{ record.auto_run ? formatAutoDistanceRangeLabel(record.auto_run_distance_min_km ?? 2, record.auto_run_distance_max_km ?? 2) : '—' }}
-                </template>
-                <template #auto_run_pace="{ record }">
-                    <span v-if="record.auto_run">{{ formatAutoPaceRangeLabel(record.pace_min_sec_per_km ?? 180, record.pace_max_sec_per_km ?? 600) }}</span>
-                    <span v-else>—</span>
-                </template>
-                <template #term_num="{ record }">
-                    {{ formatKm(record.term_num) }}
-                </template>
-                <template #total_num="{ record }">
-                    {{ record.total_num ? formatKm(record.total_num) : '-' }}
+                <template #num="{ record }">
+                    {{ record.term_num != record.total_num ? `${record.total_num} / ${record.term_num}` :
+                        '已完成' }}
                 </template>
                 <template #state="{ record }">
                     <div v-if="record.state === 0" class="state">
@@ -185,16 +181,18 @@
                     {{ record.update_time ? stramptoTime(record.update_time) : '待登录' }}
                 </template>
                 <template #optional="{ record }">
-                    <a-dropdown :popup-max-height="false" trigger="hover">
+                    <a-dropdown :popup-max-height="false" trigger="hover" @click.stop>
                         <a-button>操作 <icon-down /></a-button>
                         <template #content>
-                            <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.admin.updateAccount')" @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
                             <a-doption @click="faceRecoRef.openModal(record)"><icon-video-camera /> 人脸采集</a-doption>
                             <a-doption @click="bindBotRef.openModal(record)"><icon-robot-add /> 绑定智能机器人</a-doption>
-                            <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
-                            <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
+                            <a-doption @click="openBindAudit(record)"><icon-history /> 绑定解绑记录</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.singleRun')" @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.changeAutoRun')" @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
                                 '开启' }}自动乐跑</a-doption>
-                            <a-doption @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.admin.updateAccount')" @click="UpdateAccountInfo(record)"><icon-refresh /> 更新账号信息</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.deleteAccount')" @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
                         </template>
                     </a-dropdown>
                 </template>
@@ -217,7 +215,7 @@
                 </a-radio-group>
             </a-form-item>
             <a-form-item field="email" label="通知邮箱" v-if="form.notice_type === 'email'">
-                <a-input v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知" />
+                <EmailAutoComplete v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知" allow-clear />
             </a-form-item>
             <a-form-item field="area" label="乐跑跑区">
                 <a-select v-model="form.area" placeholder="请选择乐跑跑区" default-value="">
@@ -232,11 +230,10 @@
             <a-form-item field="auto_run" label="自动乐跑开关">
                 <a-switch v-model="form.auto_run" :checked-value="1" :unchecked-value="0" />
             </a-form-item>
-            <a-form-item field="target_count" label="目标里程(km)" v-if="form.auto_run">
-                <a-input-number v-model="form.target_count" placeholder="0–200,0 表示不限制" mode="button" :min="0"
-                    :max="200" :precision="2" :step="0.5" />
+            <a-form-item field="target_count" label="乐跑目标次数" v-if="form.auto_run">
+                <a-input-number v-model="form.target_count" placeholder="请输入乐跑目标次数" mode="button" />
                 <template #extra>
-                    <div>累计里程达到目标后自动乐跑将关闭;0 表示不限制(与后端校验一致)</div>
+                    <div>当学期有效次数达到目标次数时自动乐跑将关闭,0为不限次</div>
                 </template>
             </a-form-item>
             <a-form-item field="auto_day" label="自动乐跑星期" v-if="form.auto_run">
@@ -245,42 +242,8 @@
             <a-form-item field="auto_time" label="自动乐跑时段" v-if="form.auto_run">
                 <a-select v-model="form.auto_time" placeholder="请选择每天自动乐跑的时段" :options="auto_time" />
             </a-form-item>
-            <a-form-item field="auto_run_distance_range" label="自动距离区间(km)" v-if="form.auto_run">
-                <a-space>
-                    <a-input-number
-                        v-model="form.auto_run_distance_min_km"
-                        mode="button"
-                        :min="1"
-                        :max="AUTO_SINGLE_RUN_MAX_KM"
-                        :precision="2"
-                        :step="0.5"
-                    />
-                    <span>~</span>
-                    <a-input-number
-                        v-model="form.auto_run_distance_max_km"
-                        mode="button"
-                        :min="1"
-                        :max="AUTO_SINGLE_RUN_MAX_KM"
-                        :precision="2"
-                        :step="0.5"
-                    />
-                </a-space>
-                <template #extra>
-                    <div>每次自动乐跑在区间内随机距离(0.5km 步进);上下限可相同;上限 {{ AUTO_SINGLE_RUN_MAX_KM }}km</div>
-                </template>
-            </a-form-item>
-            <a-form-item field="pace_range" label="自动配速区间" v-if="form.auto_run">
-                <a-space>
-                    <a-input v-model="form.pace_min_str" allow-clear placeholder="如 3:00" style="width: 7.5rem" />
-                    <span>–</span>
-                    <a-input v-model="form.pace_max_str" allow-clear placeholder="如 10:00" style="width: 7.5rem" />
-                </a-space>
-                <template #extra>
-                    <div>每公里 分:秒(如 5:30);范围 3:00–10:00/km,自动乐跑在此区间内随机</div>
-                </template>
-            </a-form-item>
             <a-form-item field="notes" label="备注">
-                <a-textarea v-model="form.notes" placeholder="添加对账号的备注(非必填)" :max-length="{ length: 50 }" allow-clear
+                <a-textarea v-model="form.notes" no-trim placeholder="添加对账号的备注(非必填)" :max-length="{ length: 50 }" allow-clear
                     show-word-limit />
             </a-form-item>
         </a-form>
@@ -288,35 +251,84 @@
 
     <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
     <bindBot ref="bindBotRef" />
-    <SingleRunModal
-        v-model:visible="singleRunVisible"
-        :account="singleRunAccount"
-        @success="getAccounts"
-    />
+    <accountDetailCard ref="accountDetailRef" admin />
+
+    <a-modal v-model:visible="bindAuditVisible" title="绑定解绑记录" :footer="false" width="980px" draggable>
+        <a-table :bordered="false" :data="bindAuditData" :columns="bindAuditColumns" :loading="bindAuditLoading" :pagination="false"
+            :scroll="{ x: 1450, y: 420 }">
+            <template #lepao_user="{ record }">
+                <a-space>
+                    <a-avatar :size="26">
+                        <img :src="record.lepao_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                    </a-avatar>
+                    {{ record.lepao_name || '-' }}
+                </a-space>
+            </template>
+            <template #owner_user="{ record }">
+                <a-space>
+                    <a-avatar :size="26">
+                        <img :src="record.owner_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                    </a-avatar>
+                    {{ record.owner_username || '-' }}
+                </a-space>
+            </template>
+            <template #action="{ record }">
+                <a-tag>{{ actionLabel(record.action) }}</a-tag>
+            </template>
+            <template #source="{ record }">
+                {{ sourceLabel(record.source) }}
+            </template>
+            <template #operator_user="{ record }">
+                <a-space>
+                    <a-avatar :size="26">
+                        <img :src="record.operator_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                    </a-avatar>
+                    {{ record.operator_username || '-' }}
+                </a-space>
+            </template>
+            <template #detail_json="{ record }">
+                {{ formatDetail(record.detail_json) }}
+            </template>
+            <template #created_at="{ record }">
+                {{ stramptoTime(record.created_at) }}
+            </template>
+            <template #empty>
+                <a-empty description="暂无绑定解绑记录" />
+            </template>
+        </a-table>
+    </a-modal>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted, h } from 'vue'
-import { addAccount, adminAccountList, deleteAccount, changeAutoRun } from '@/api/lepao'
+import { ref, reactive, onMounted, onUnmounted, h } from 'vue'
+import { addAccount, adminAccountList, deleteAccount, changeAutoRun, singleRun, adminUpdateAccountInfo, getAdminBindAuditByAccount, getAdminBindAuditList } from '@/api/lepao'
 import { Modal, Notification, Message } from '@arco-design/web-vue'
 import faceModal from '@/components/FaceModal/faceModal.vue'
 import bindBot from '@/components/BindBot/bindBot.vue'
-import SingleRunModal from '@/components/SingleRunModal/SingleRunModal.vue'
+import accountDetailCard from '@/components/LepaoAccountCard/accountDetailCard.vue'
 import { getSemesterTimestamps } from '@/utils/util'
-import {
-    formatKm,
-    formatAutoPaceRangeLabel,
-    formatAutoDistanceRangeLabel,
-    formatSecPerKmAsMinSec,
-    parsePaceMinSecInput
-} from '@/utils/lepaoRecord'
-
-const AUTO_SINGLE_RUN_MAX_KM = 10
+import { hasPermission } from '@/utils/permission'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 
 const faceRecoRef = ref(null)
 const bindBotRef = ref(null)
-const singleRunVisible = ref(false)
-const singleRunAccount = ref(null)
+const accountDetailRef = ref(null)
+
+const ROW_DETAIL_CONFIRM_MS = 3000
+const pendingDetailAccountId = ref(null)
+let pendingDetailTimer = null
+
+const clearPendingDetail = () => {
+    pendingDetailAccountId.value = null
+    if (pendingDetailTimer) {
+        clearTimeout(pendingDetailTimer)
+        pendingDetailTimer = null
+    }
+}
+
+const bindAuditVisible = ref(false)
+const bindAuditLoading = ref(false)
+const bindAuditData = ref([])
 
 const queryData = reactive({
     area: '',
@@ -324,6 +336,7 @@ const queryData = reactive({
     user_uuid: '',
     email: '',
     username: '',
+    notes: '',
     bind_code: '',
     bot_account: '',
     state: -1,
@@ -348,10 +361,6 @@ const form = reactive({
     auto_run: 1,
     auto_time: -1,
     target_count: 30,
-    auto_run_distance_min_km: 2,
-    auto_run_distance_max_km: 2,
-    pace_min_str: '3:00',
-    pace_max_str: '10:00',
     auto_day: [0, 1, 2, 3, 4, 5, 6],
     notes: ''
 })
@@ -372,14 +381,14 @@ const auto_day = [
 const state = [
     { label: '全部', value: -1 }, { label: '需登录', value: 0 }, { label: '正常', value: 1 }, { label: '状态异常', value: 2 }
 ]
-const area = ["学府大道校区", "南山校区"]
+const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
 
-const columns = [
+const columnDefs = [
     {
         title: 'ID',
         dataIndex: 'id',
         fixed: 'left',
-        width: 65
+        width: 68
     }, {
         title: '创建用户',
         slotName: 'create_user',
@@ -391,34 +400,35 @@ const columns = [
     }, {
         title: '学号',
         dataIndex: 'student_num',
-        width: 110
+        width: 118
     }, {
         title: '性别',
         slotName: 'sex',
         width: 80
-    },{
-        title: '班级',
-        dataIndex: 'class_id',
-        width: 200,
-        ellipsis: true,
-        tooltip: true
+    }, {
+        title: '学院',
+        dataIndex: 'academy_name',
+        width: 200
+    }, {
+        title: '年级',
+        dataIndex: 'grade_id',
+        width: 80
     }, {
         title: '通知方式',
         slotName: 'notice_type',
         width: 200
-    },
-    {
-        title: '乐跑跑区',
-        dataIndex: 'area',
-        width: 120
     }, {
         title: '帐号状态',
         slotName: 'state',
         width: 100
+    }, {
+        title: '乐跑跑区',
+        dataIndex: 'area',
+        width: 150
     }, {
         title: '自动乐跑',
         slotName: 'auto_run',
-        width: 100
+        width: 90
     }, {
         title: '自动乐跑星期',
         slotName: 'auto_day',
@@ -428,25 +438,9 @@ const columns = [
         slotName: 'auto_time',
         width: 130
     }, {
-        title: '目标里程',
-        slotName: 'target_count',
-        width: 130
-    }, {
-        title: '自动距离(km)',
-        slotName: 'auto_run_distance',
-        width: 130
-    }, {
-        title: '自动配速区间',
-        slotName: 'auto_run_pace',
-        width: 175
-    }, {
-        title: '本月里程',
-        slotName: 'term_num',
-        width: 130
-    }, {
-        title: '累计里程',
-        slotName: 'total_num',
-        width: 130
+        title: '学期目标',
+        slotName: 'num',
+        width: 90
     }, {
         title: '手机型号',
         dataIndex: 'deviceModel',
@@ -480,6 +474,20 @@ const columns = [
         width: 90
     }]
 
+const { useTableColumns } = useResponsiveTable()
+const columns = useTableColumns(columnDefs)
+
+const bindAuditColumns = [
+    { title: '学号', dataIndex: 'student_num', width: 140 },
+    { title: '乐跑账号', slotName: 'lepao_user', width: 180 },
+    { title: '所属用户', slotName: 'owner_user', width: 180 },
+    { title: '动作', slotName: 'action', width: 120 },
+    { title: '来源', slotName: 'source', width: 140 },
+    { title: '操作者', slotName: 'operator_user', width: 180 },
+    { title: '详情', slotName: 'detail_json', width: 220, ellipsis: true, tooltip: true },
+    { title: '时间', slotName: 'created_at', width: 180 }
+]
+
 const search = () => {
     pagination.current = 1
     getAccounts()
@@ -491,6 +499,7 @@ const reset = () => {
     queryData.user_uuid = ''
     queryData.email = ''
     queryData.username = ''
+    queryData.notes = ''
     queryData.bind_code = ''
     queryData.bot_account = ''
     queryData.state = -1
@@ -526,6 +535,24 @@ const getAccounts = async () => {
     }
 }
 
+const onRowClick = (record, ev) => {
+    if (ev?.target?.closest?.('.arco-dropdown, .arco-btn')) return
+
+    const accountId = record?.id
+    if (accountId == null) return
+
+    if (pendingDetailAccountId.value === accountId) {
+        clearPendingDetail()
+        accountDetailRef.value?.openModal(record)
+        return
+    }
+
+    clearPendingDetail()
+    pendingDetailAccountId.value = accountId
+    Message.info('再点一次即可查看账号详情')
+    pendingDetailTimer = setTimeout(clearPendingDetail, ROW_DETAIL_CONFIRM_MS)
+}
+
 const editAccount = (item) => {
     if (item) {
         form.id = item.id
@@ -536,15 +563,7 @@ const editAccount = (item) => {
         form.auto_time = item.auto_time
         form.auto_day = item.auto_day
         form.notice_type = item.notice_type || 'email'
-        form.target_count = Number(item.target_count)
-        form.auto_run_distance_min_km = Number(item.auto_run_distance_min_km ?? 2)
-        form.auto_run_distance_max_km = Number(item.auto_run_distance_max_km ?? item.auto_run_distance_min_km ?? 2)
-        {
-            const a = formatSecPerKmAsMinSec(item.pace_min_sec_per_km ?? 180)
-            const b = formatSecPerKmAsMinSec(item.pace_max_sec_per_km ?? 600)
-            form.pace_min_str = a === '—' ? '3:00' : a
-            form.pace_max_str = b === '—' ? '10:00' : b
-        }
+        form.target_count = item.target_count
         form.notes = item.notes
     } else {
         form.id = null
@@ -552,10 +571,6 @@ const editAccount = (item) => {
         form.auto_run = 1
         form.auto_time = 7
         form.target_count = 30
-        form.auto_run_distance_min_km = 2
-        form.auto_run_distance_max_km = 2
-        form.pace_min_str = '3:00'
-        form.pace_max_str = '10:00'
         form.auto_day = [0, 1, 2, 3, 4, 5, 6]
         form.email = ''
         form.notes = ''
@@ -572,9 +587,9 @@ const handleBeforeOk = async (done) => {
             return false
         }
 
-        const sid = String(student_num || '').trim()
-        if (!/^[A-Za-z0-9_-]{2,64}$/.test(sid)) {
-            Message.error('学号/账号编码格式不正确(2–64 位字母数字下划线横线)')
+        const studentNumRegex = /^\d{10}$/
+        if (!studentNumRegex.test(student_num)) {
+            Message.error('请检查学号格式是否正确')
             return false
         }
 
@@ -584,61 +599,8 @@ const handleBeforeOk = async (done) => {
             return false
         }
 
-        const pLo = parsePaceMinSecInput(form.pace_min_str)
-        const pHi = parsePaceMinSecInput(form.pace_max_str)
-        if (pLo == null || pHi == null) {
-            Message.error('配速格式不正确,请使用 分:秒,如 5:30 或 10:00')
-            return false
-        }
-        if (pLo < 180 || pLo > 600 || pHi < 180 || pHi > 600) {
-            Message.error('配速须在 3:00–10:00/km 范围内')
-            return false
-        }
-        if (pLo > pHi) {
-            Message.error('配速下限不能慢于上限(左侧应更快,分:秒更小)')
-            return false
-        }
-
-        if (form.auto_run) {
-            const tk = Number(form.target_count)
-            if (!Number.isFinite(tk) || tk < 0 || tk > 200) {
-                Message.error('目标里程须在 0–200 公里之间')
-                return false
-            }
-            const dMin = Number(form.auto_run_distance_min_km)
-            const dMax = Number(form.auto_run_distance_max_km)
-            if (
-                !Number.isFinite(dMin) ||
-                !Number.isFinite(dMax) ||
-                dMin < 1 ||
-                dMin > AUTO_SINGLE_RUN_MAX_KM ||
-                dMax < 1 ||
-                dMax > AUTO_SINGLE_RUN_MAX_KM
-            ) {
-                Message.error(`自动乐跑距离区间须在 1–${AUTO_SINGLE_RUN_MAX_KM} 公里之间`)
-                return false
-            }
-            if (dMin > dMax) {
-                Message.error('自动乐跑距离下限不能大于上限')
-                return false
-            }
-        }
-
-        const data = {
-            id: form.id,
-            student_num: form.student_num,
-            email: form.email,
-            area: form.area,
-            auto_run: form.auto_run,
-            auto_time: form.auto_time,
-            notice_type: form.notice_type,
-            target_count: form.target_count,
-            auto_run_distance_min_km: form.auto_run_distance_min_km,
-            auto_run_distance_max_km: form.auto_run_distance_max_km,
-            auto_day: form.auto_day,
-            notes: form.notes,
-            pace_min_sec_per_km: pLo,
-            pace_max_sec_per_km: pHi
+        let data = {
+            ...form
         }
 
         const res = await addAccount(data)
@@ -725,15 +687,139 @@ const ChangeAutoRun = async (record) => {
     }
 }
 
-const SingleRun = (item) => {
-    if (item.state !== 1) {
+const SingleRun = async (item) => {
+    if (item.state !== 1)
         return Notification.warning({
             title: '当前乐跑账号需登录,请登录后再试',
             content: '如有疑问请联系客服'
         })
+    Modal.confirm({
+        title: '开始乐跑',
+        content: () => h('div', [
+            h('p', `您是否要为${item.name}(${item.student_num})乐跑?若乐跑成功将扣减乐跑次数`)
+        ]),
+        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 UpdateAccountInfo = async (record) => {
+    try {
+        const res = await adminUpdateAccountInfo({
+            student_num: record.student_num
+        })
+        if (!res || res.code !== 0) {
+            return Notification.error({
+                title: '更新账号信息失败!',
+                content: res?.msg ?? '请稍后再试'
+            })
+        }
+        Message.success('已异步触发账号信息更新')
+        getAccounts()
+    } catch (error) {
+        Notification.error({
+            title: '更新账号信息失败!',
+            content: error.message || '请稍后再试'
+        })
+    }
+}
+
+const actionLabel = (action) => {
+    const map = {
+        platform_bind: '平台绑定',
+        platform_unbind: '平台解绑',
+        bot_bind: '机器人绑定',
+        bot_unbind: '机器人解绑'
+    }
+    return map[action] || action
+}
+
+const sourceLabel = (source) => {
+    const map = {
+        user_api: '用户接口',
+        admin_api: '管理员接口',
+        service_api: '客服接口',
+        mcp_qq: '机器人MCP',
+        mcp_work_order: '工单MCP'
+    }
+    return map[source] || source
+}
+
+const formatDetail = (detail) => {
+    if (!detail) return '-'
+    if (typeof detail === 'string') return detail
+    const keyMap = {
+        via: '触发方式',
+        sender: '发送者',
+        old_owner_uuid: '原所属用户UUID',
+        new_owner_uuid: '新所属用户UUID'
+    }
+    const valueMap = {
+        AddAccount: '用户新增账号',
+        DeleteAccount: '账号解绑接口',
+        'AddAccount:auto_unbind_rebind': '新增账号自动解绑并重绑'
+    }
+    return Object.keys(detail)
+        .map((key) => {
+            const label = keyMap[key] || key
+            const rawValue = detail[key]
+            const value = (typeof rawValue === 'string' && valueMap[rawValue]) ? valueMap[rawValue] : rawValue
+            return `${label}:${value}`
+        })
+        .join(';')
+}
+
+const openBindAudit = async (record) => {
+    bindAuditVisible.value = true
+    bindAuditLoading.value = true
+    bindAuditData.value = []
+    try {
+        const studentNum = String(record.student_num || '')
+        if (!studentNum) {
+            Message.warning('该账号缺少学号,无法查询审计记录')
+            return
+        }
+
+        const res = await getAdminBindAuditByAccount({
+            student_num: studentNum,
+            pagesize: 100,
+            current: 1
+        })
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '获取绑定解绑记录失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return
+        }
+        const primaryData = Array.isArray(res.data) ? res.data : []
+        if (primaryData.length > 0) {
+            bindAuditData.value = primaryData
+            return
+        }
+
+        const fallbackRes = await getAdminBindAuditList({
+            student_num: studentNum,
+            pagesize: 100,
+            current: 1
+        })
+        const fallbackData = (fallbackRes && fallbackRes.code === 0 && Array.isArray(fallbackRes.data)) ? fallbackRes.data : []
+        bindAuditData.value = fallbackData
+    } catch (error) {
+        Notification.error({
+            title: '获取绑定解绑记录失败',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        bindAuditLoading.value = false
     }
-    singleRunAccount.value = item
-    singleRunVisible.value = true
 }
 
 onMounted(() => {
@@ -741,6 +827,10 @@ onMounted(() => {
     getAccounts()
 })
 
+onUnmounted(() => {
+    clearPendingDetail()
+})
+
 const stramptoTime = (time) => {
     return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
 }
@@ -763,8 +853,22 @@ const autoTimeLabel = (record) => {
 </script>
 
 <style scoped>
-.container {
-    padding: 0 20px 20px 20px;
+.query-row {
+    margin-bottom: 10px;
+}
+
+.query-main {
+    min-width: 0;
+}
+
+.query-actions-space {
+    width: 100%;
+}
+
+.table-clickable {
+    :deep(.arco-table-tr) {
+        cursor: pointer;
+    }
 }
 
 .table {
@@ -797,4 +901,24 @@ const autoTimeLabel = (record) => {
         }
     }
 }
+
+@media (max-width: 768px) {
+    .query-row {
+        display: block;
+    }
+
+    .query-divider {
+        display: none;
+    }
+
+    .query-actions-space {
+        margin-top: 4px;
+        flex-direction: row !important;
+        gap: 10px !important;
+    }
+
+    .query-actions-space :deep(.arco-btn) {
+        flex: 1;
+    }
+}
 </style>

+ 296 - 0
src/pages/admin/lepaoBindAudit/index.vue

@@ -0,0 +1,296 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="绑定解绑审计">
+      <AppQueryFilter>
+      <a-row class="query-row">
+        <a-col :flex="'1000px'" class="query-main">
+          <a-form class="queryForm app-query-form" :model="query" :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="student_num" label="学号">
+                  <a-input v-model="query.student_num" placeholder="请输入学号" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="owner_uuid" label="所属用户UUID">
+                  <a-input v-model="query.owner_uuid" placeholder="用于精确筛选(可选)" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="operator_uuid" label="操作者UUID">
+                  <a-input v-model="query.operator_uuid" placeholder="用于精确筛选(可选)" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="action" label="动作">
+                  <a-select v-model="query.action" placeholder="请选择动作" allow-clear>
+                    <a-option value="">全部动作</a-option>
+                    <a-option value="platform_bind">平台绑定</a-option>
+                    <a-option value="platform_unbind">平台解绑</a-option>
+                    <a-option value="bot_bind">机器人绑定</a-option>
+                    <a-option value="bot_unbind">机器人解绑</a-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="source" label="来源">
+                  <a-select v-model="query.source" placeholder="请选择来源" allow-clear>
+                    <a-option value="">全部来源</a-option>
+                    <a-option value="user_api">用户接口</a-option>
+                    <a-option value="admin_api">管理员接口</a-option>
+                    <a-option value="service_api">客服接口</a-option>
+                    <a-option value="mcp_qq">机器人MCP</a-option>
+                    <a-option value="mcp_work_order">工单MCP</a-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="queryTime" label="时间范围">
+                  <a-range-picker v-model="query.queryTime" show-time format="YYYY-MM-DD HH:mm" value-format="x" />
+                </a-form-item>
+              </a-col>
+            </a-row>
+          </a-form>
+        </a-col>
+        <a-divider class="query-divider" style="height: 84px" direction="vertical" />
+        <a-col :flex="1" class="app-query-actions">
+          <a-space class="query-actions-space" direction="vertical" :size="18">
+            <a-button type="primary" @click="search">搜索</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-col>
+      </a-row>
+      </AppQueryFilter>
+
+      <a-table :bordered="false"
+        class="table"
+        :data="data"
+        :loading="loading"
+        :scroll="{ x: 1450 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="学号" data-index="student_num" :width="130" />
+          <a-table-column title="乐跑账号" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.lepao_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                </a-avatar>
+                {{ record.lepao_name || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="所属用户" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.owner_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                </a-avatar>
+                {{ record.owner_username || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="动作" :width="130">
+            <template #cell="{ record }">
+              <a-tag>{{ actionLabel(record.action) }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="来源" :width="150">
+            <template #cell="{ record }">
+              {{ sourceLabel(record.source) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="操作者" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.operator_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                </a-avatar>
+                {{ record.operator_username || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="详情" :width="220" ellipsis tooltip>
+            <template #cell="{ record }">{{ formatDetail(record.detail_json) }}</template>
+          </a-table-column>
+          <a-table-column title="时间" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.created_at) }}</template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getAdminBindAuditList } from '@/api/lepao'
+
+const loading = ref(false)
+const data = ref([])
+const query = reactive({
+  student_num: '',
+  owner_uuid: '',
+  operator_uuid: '',
+  action: '',
+  source: '',
+  queryTime: []
+})
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+
+const actionLabel = (action) => {
+  const map = {
+    platform_bind: '平台绑定',
+    platform_unbind: '平台解绑',
+    bot_bind: '机器人绑定',
+    bot_unbind: '机器人解绑'
+  }
+  return map[action] || action
+}
+
+const sourceLabel = (source) => {
+  const map = {
+    user_api: '用户接口',
+    admin_api: '管理员接口',
+    service_api: '客服接口',
+    mcp_qq: '机器人MCP',
+    mcp_work_order: '工单MCP'
+  }
+  return map[source] || source
+}
+
+const formatDetail = (detail) => {
+  if (!detail) return '-'
+  if (typeof detail === 'string') return detail
+  const keyMap = {
+    via: '触发方式',
+    sender: '发送者',
+    old_owner_uuid: '原所属用户UUID',
+    new_owner_uuid: '新所属用户UUID'
+  }
+  const valueMap = {
+    AddAccount: '用户新增账号',
+    DeleteAccount: '账号解绑接口',
+    'AddAccount:auto_unbind_rebind': '新增账号自动解绑并重绑'
+  }
+  return Object.keys(detail)
+    .map((key) => {
+      const label = keyMap[key] || key
+      const rawValue = detail[key]
+      const value = (typeof rawValue === 'string' && valueMap[rawValue]) ? valueMap[rawValue] : rawValue
+      return `${label}:${value}`
+    })
+    .join(';')
+}
+
+const formatTime = (time) => new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+
+const getList = async () => {
+  try {
+    loading.value = true
+    const res = await getAdminBindAuditList({
+      ...query,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取审计记录失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } catch (error) {
+    Notification.error({
+      title: '获取审计记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  query.student_num = ''
+  query.owner_uuid = ''
+  query.operator_uuid = ''
+  query.action = ''
+  query.source = ''
+  query.queryTime = []
+  pagination.current = 1
+  getList()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped>
+.query-row {
+  margin-bottom: 10px;
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
+}
+
+.table {
+  margin-top: 16px;
+}
+
+@media (max-width: 768px) {
+  .query-row {
+    display: block;
+  }
+
+  .query-divider {
+    display: none;
+  }
+
+  .query-actions-space {
+    margin-top: 4px;
+    flex-direction: row !important;
+    gap: 10px !important;
+  }
+
+  .query-actions-space :deep(.arco-btn) {
+    flex: 1;
+  }
+}
+</style>

+ 279 - 0
src/pages/admin/lepaoCountLedger/index.vue

@@ -0,0 +1,279 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="乐跑次数明细">
+      <AppQueryFilter>
+      <a-row class="queryForm app-query-form">
+        <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="6">
+                <a-form-item field="username" label="用户名">
+                  <a-input v-model="queryData.username" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="student_num" label="学号">
+                  <a-input v-model="queryData.student_num" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="user_uuid" label="用户UUID">
+                  <a-input v-model="queryData.user_uuid" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="biz_type" label="变动类型">
+                  <a-select v-model="queryData.biz_type" allow-clear placeholder="全部类型">
+                    <a-option value="">全部</a-option>
+                    <a-option v-for="item in typeOptions" :key="item.value" :value="item.value">{{ item.label }}</a-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="operator_uuid" label="操作员UUID">
+                  <a-input v-model="queryData.operator_uuid" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="remark" label="备注搜索">
+                  <a-input v-model="queryData.remark" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="6">
+                <a-form-item field="queryTime" label="变动时间">
+                  <a-range-picker v-model="queryData.queryTime" show-time format="YY-MM-DD HH:mm" value-format="x" />
+                </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>
+      </AppQueryFilter>
+
+      <a-table
+        :data="data"
+        :loading="loading"
+        :bordered="false"
+        :scroll="{ x: 1280 }"
+        :summary="tableSummary"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="handlePageChange"
+        @page-size-change="handlePageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="时间" :width="170">
+            <template #cell="{ record }">{{ stramptoTime(record.created_at) }}</template>
+          </a-table-column>
+          <a-table-column title="用户" :width="200">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="28">
+                  <img
+                    :src="record.user_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'"
+                    :alt="record.username || 'user-avatar'"
+                  />
+                </a-avatar>
+                <span>{{ record.username || '-' }}</span>
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="类型" :width="160">
+            <template #cell="{ record }">{{ typeLabel(record.biz_type) }}</template>
+          </a-table-column>
+          <a-table-column title="变动值" :width="100">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.delta) >= 0 ? 'green' : 'red'">
+                {{ Number(record.delta) >= 0 ? `+${record.delta}` : record.delta }}
+              </a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="变动前" data-index="balance_before" :width="100" />
+          <a-table-column title="变动后" data-index="balance_after" :width="100" />
+          <a-table-column title="操作人" :width="150">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="28">
+                  <img
+                    :src="record.operator_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'"
+                    :alt="record.operator_name || 'operator-avatar'"
+                  />
+                </a-avatar>
+                <span>
+                  {{ record.operator_name || '-' }}
+                </span>
+              </a-space>
+            </template>
+          </a-table-column>
+          <!-- <a-table-column title="业务单号" data-index="biz_id" :width="220" ellipsis tooltip /> -->
+          <a-table-column title="备注" data-index="remark" :width="220" ellipsis tooltip />
+        </template>
+        <template #summary-cell="{ column }">
+          <template v-if="column.title === '时间'">
+            <strong>合计</strong>
+          </template>
+          <template v-else-if="column.title === '变动值'">
+            <a-tag :color="Number(summary.total_delta) >= 0 ? 'green' : 'red'">
+              {{ formatDelta(summary.total_delta) }}
+            </a-tag>
+          </template>
+          <template v-else-if="column.title === '备注'">
+            <span class="summary-muted">共 {{ summary.total_records }} 条</span>
+          </template>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getAdminCountLedger } from '@/api/lepao'
+import { getSemesterTimestamps } from '@/utils/util'
+
+const loading = ref(false)
+const data = ref([])
+const summary = ref({
+  total_delta: 0,
+  total_records: 0
+})
+const tableSummary = () => [{}]
+const formatDelta = (value) => {
+  const num = Number(value) || 0
+  return num >= 0 ? `+${num}` : `${num}`
+}
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+const queryData = reactive({
+  user_uuid: '',
+  username: '',
+  student_num: '',
+  operator_uuid: '',
+  remark: '',
+  biz_type: '',
+  queryTime: []
+})
+
+const typeOptions = [
+  { label: '乐跑扣除', value: 'run_consume' },
+  { label: '乐跑返还', value: 'run_refund' },
+  { label: '购买增加', value: 'purchase' },
+  { label: '购买退款', value: 'purchase_refund' },
+  { label: '赠送冻结', value: 'gift_send_lock' },
+  { label: '赠送驳回返还', value: 'gift_send_refund' },
+  { label: '接收赠送', value: 'gift_receive' },
+  { label: '系统调整', value: 'admin_adjust' }
+]
+
+const typeLabel = (value) => typeOptions.find(item => item.value === value)?.label || value || '-'
+const stramptoTime = (time) =>
+  new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
+
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const [start, end] = queryData.queryTime || []
+    const res = await getAdminCountLedger({
+      current: pagination.current,
+      pagesize: pagination.pagesize,
+      user_uuid: queryData.user_uuid,
+      username: queryData.username,
+      student_num: queryData.student_num,
+      operator_uuid: queryData.operator_uuid,
+      remark: queryData.remark,
+      biz_type: queryData.biz_type,
+      start_time: start,
+      end_time: end
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取次数明细失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    data.value = res.data || []
+    summary.value = res.summary || { total_delta: 0, total_records: 0 }
+    pagination.total = res.pagination?.total || 0
+  } catch (error) {
+    Notification.error({
+      title: '获取次数明细失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  fetchData()
+}
+
+const reset = () => {
+  queryData.user_uuid = ''
+  queryData.username = ''
+  queryData.student_num = ''
+  queryData.operator_uuid = ''
+  queryData.remark = ''
+  queryData.biz_type = ''
+  queryData.queryTime = getSemesterTimestamps()
+  pagination.current = 1
+  fetchData()
+}
+
+const handlePageChange = (page) => {
+  pagination.current = page
+  fetchData()
+}
+
+const handlePageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  fetchData()
+}
+
+onMounted(() => {
+  queryData.queryTime = getSemesterTimestamps()
+  fetchData()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.queryForm {
+  @media (min-width: (@app-breakpoint-md + 1px)) {
+    margin-bottom: 16px;
+  }
+}
+
+.summary-muted {
+  color: var(--color-text-3);
+  font-size: 13px;
+}
+</style>

+ 96 - 62
src/pages/admin/lepaoRecords/lepaoRecords.vue

@@ -1,11 +1,12 @@
 <template>
 
-  <div class="container">
-    <Breadcrumb :items="['校园乐跑', '乐跑记录']" />
+  <div class="store-page">
+    <Breadcrumb />
     <a-card title="乐跑记录">
-      <a-row>
-        <a-col :flex="'1000px'">
-          <a-form :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
+      <AppQueryFilter>
+      <a-row class="query-row">
+        <a-col :flex="'1000px'" class="query-main">
+          <a-form class="queryForm app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
             label-align="left">
             <a-row :gutter="16">
               <a-col :span="12">
@@ -20,12 +21,13 @@
               </a-col>
               <a-col :span="12">
                 <a-form-item field="lepao_account" label="学号">
-                  <a-input v-model="queryData.lepao_account" placeholder="请输入学号" allow-clear />
+                  <a-input-number v-model="queryData.lepao_account" placeholder="请输入学号" :step="1" :precision="0"
+                    allow-clear />
                 </a-form-item>
               </a-col>
               <a-col :span="12">
                 <a-form-item field="email" label="通知邮箱">
-                  <a-input v-model="queryData.email" placeholder="请输入通知邮箱" allow-clear />
+                  <EmailAutoComplete v-model="queryData.email" placeholder="请输入通知邮箱" allow-clear />
                 </a-form-item>
               </a-col>
               <a-col :span="12">
@@ -37,9 +39,9 @@
             </a-row>
           </a-form>
         </a-col>
-        <a-divider style="height: 84px" direction="vertical" />
-        <a-col :flex="1">
-          <a-space direction="vertical" :size="18">
+        <a-divider class="query-divider" style="height: 84px" direction="vertical" />
+        <a-col :flex="1" class="app-query-actions">
+          <a-space class="query-actions-space" direction="vertical" :size="18">
             <a-button type="primary" @click="search">
               <template #icon>
                 <icon-search />
@@ -55,8 +57,9 @@
           </a-space>
         </a-col>
       </a-row>
+      </AppQueryFilter>
 
-      <a-table :data="data" :bordered="false" hoverable class="table" :loading="loading" :pagination="{
+      <a-table :data="data" :bordered="false" hoverable class="table" :loading="loading" :scroll="{ x: 1650 }" :pagination="{
         showPageSize: true,
         showJumper: true,
         showTotal: true,
@@ -105,63 +108,54 @@
             slotName: 'name-filter',
             icon: () => h(IconSearch)
           }"></a-table-column>
+          <a-table-column title="乐跑类型" :width="120" ellipsis tooltip>
+            <template #cell="{ record }">
+              <a-tag :color="record.run_mode === 'manual' ? 'orangered' : 'arcoblue'">
+                {{ record.run_mode === 'manual' ? '手动乐跑' : '自动乐跑' }}
+              </a-tag>
+            </template>
+          </a-table-column>
           <a-table-column title="状态" ellipsis tooltip :width="230">
             <template #cell="{ record }">
               <div class="state">
-                <div
-                  class="circle"
-                  :class="recordDisplay(record).statusLevel"
-                ></div>
-                {{ recordDisplay(record).statusText }}
-                <div class="progress" v-if="record.state === 0 && record.lepao_schedule">
-                  <a-progress size="mini" :percent="record.lepao_schedule.current / record.lepao_schedule.total"/>
-                </div>
+                <div class="circle one" v-if="record.result.record_failed_reason === '自动确认有效'"></div>
+                <div class="circle else" v-else></div>
+                {{ record.result.record_failed_reason }}
               </div>
             </template>
           </a-table-column>
-          <a-table-column title="路线跑区" :filterable="{
-            filter: (value, record) => (recordDisplay(record).routeRunZoneName ?? '').includes(value),
+          <a-table-column title="跑区" :filterable="{
+            filter: (value, record) => (record.result.pass_tit ?? '').includes(value),
             slotName: 'name-filter',
             icon: () => h(IconSearch)
-          }" :width="160">
+          }" :width="220">
             <template #cell="{ record }">
-              <a-tag v-if="recordDisplay(record).routeRunZoneName" size="small">
-                {{ recordDisplay(record).routeRunZoneName }}
-              </a-tag>
-              <span v-else>—</span>
+              {{ record.result.pass_tit }}
             </template>
           </a-table-column>
-          <a-table-column title="预计距离" :width="120" ellipsis tooltip>
+          <a-table-column title="乐跑距离" :width="120" ellipsis tooltip>
             <template #cell="{ record }">
-              <span v-if="recordDisplay(record).expectedKm != null">
-                {{ formatKm(recordDisplay(record).expectedKm) }}
-              </span>
-              <span v-else>—</span>
-            </template>
-          </a-table-column>
-          <a-table-column title="实际距离" :width="120" ellipsis tooltip>
-            <template #cell="{ record }">
-              {{ formatKm(recordDisplay(record).distance) }}
+              {{ record.result.distance }} Km
             </template>
           </a-table-column>
           <a-table-column title="跑步时长" :width="120" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ formatSecondsToMinSec(recordDisplay(record).timeSec) }}
+              {{ formatSecondsToMinSec(record.result.time) }}
             </template>
           </a-table-column>
           <a-table-column title="平均配速" :width="120" ellipsis tooltip>
             <template #cell="{ record }">
-              {{ formatPace(recordDisplay(record).timeSec, recordDisplay(record).distance) }}
+              {{ calculatePace(record.result.time, record.result.distance) }}
             </template>
           </a-table-column>
-          <a-table-column title="乐跑时间" :width="170" ellipsis tooltip>
+          <a-table-column title="乐跑时间" :width="175" ellipsis tooltip>
             <template #cell="{ record }">
               {{ stramptoTime(record.time) }}
             </template>
           </a-table-column>
-          <a-table-column title="" :width="100" fixed="right">
+          <a-table-column title="" :width="100" :fixed="tableFixed('right')">
             <template #cell="{ record }">
-              <a-button @click="$router.push(`/admin/lepaoRecords/${record.id}`)">查看详情</a-button>
+              <a-button @click="openDetail(record)">查看详情</a-button>
             </template>
           </a-table-column>
         </template>
@@ -173,19 +167,26 @@
 
 <script setup>
 import { ref, reactive, onMounted, h } from 'vue'
+import { useRouter } from 'vue-router'
 import { adminLepaoRecords } from '@/api/lepao'
-import {
-  parseLepaoRecordRow,
-  formatSecondsToMinSec,
-  formatPace,
-  formatKm
-} from '@/utils/lepaoRecord'
-import { Notification } from '@arco-design/web-vue'
+import { Notification, Message } from '@arco-design/web-vue'
 import { IconSearch } from '@arco-design/web-vue/es/icon'
 import { getSemesterTimestamps } from '@/utils/util'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
 
 const data = ref([])
 const loading = ref(false)
+const router = useRouter()
+
+const openDetail = (record) => {
+  const key = record.public_id || record.id
+  if (!key) {
+    return Message.warning('该记录缺少可用标识,无法打开详情')
+  }
+  return router.push(`/admin/lepaoRecords/${encodeURIComponent(String(key))}`)
+}
 
 const queryData = reactive({
   name: '',
@@ -260,7 +261,20 @@ 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' })
 }
 
-const recordDisplay = (record) => parseLepaoRecordRow(record)
+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')}秒`;
+}
 
 onMounted(() => {
   queryData.queryTime = getSemesterTimestamps()
@@ -269,8 +283,16 @@ onMounted(() => {
 </script>
 
 <style scoped lang="less">
-.container {
-  padding: 0 20px 20px 20px;
+.query-row {
+  margin-bottom: 10px;
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
 }
 
 .table {
@@ -281,10 +303,6 @@ onMounted(() => {
     display: flex;
     align-items: center;
 
-    .progress {
-      margin-left: 5px;
-    }
-
     .circle {
       border-radius: 50%;
       height: 8px;
@@ -294,19 +312,15 @@ onMounted(() => {
       margin-right: 5px;
     }
 
-    .running {
+    .zero {
       background-color: rgb(var(--orange-6));
     }
 
-    .success {
+    .one {
       background-color: rgb(var(--green-6));
     }
 
-    .sync {
-      background-color: rgb(var(--blue-6));
-    }
-
-    .fail {
+    .else {
       background-color: rgb(var(--red-6));
     }
   }
@@ -324,4 +338,24 @@ onMounted(() => {
   display: flex;
   justify-content: space-between;
 }
+
+@media (max-width: 768px) {
+  .query-row {
+    display: block;
+  }
+
+  .query-divider {
+    display: none;
+  }
+
+  .query-actions-space {
+    margin-top: 4px;
+    flex-direction: row !important;
+    gap: 10px !important;
+  }
+
+  .query-actions-space :deep(.arco-btn) {
+    flex: 1;
+  }
+}
 </style>

+ 84 - 47
src/pages/admin/lepaoRecords/recordDetail.vue

@@ -1,69 +1,50 @@
 <template>
-    <div class="container">
-        <Breadcrumb :items="['乐跑记录', '记录详情']" />
+    <div class="store-page">
+        <Breadcrumb />
         <a-card title="记录详情">
             <a-skeleton animation :loading="loading">
-                <a-space direction="vertical" :style="{ width: '100%' }" size="large">
-                    <a-skeleton-shape size="large" />
-                    <a-skeleton-line :rows="5" />
-                </a-space>
+                <StoreRecordSkeleton />
+                <template #content>
+                    <a-descriptions
+                        :data="info"
+                        :column="isMobile ? 1 : 2"
+                        class="record-descriptions"
+                    />
+                    <MapContainer v-if="showMap" :point_list="data.result.point_list" :log_list="data.point_data" :pathData="data.data" threeD
+                        style="margin-top: 10px;" />
+                </template>
             </a-skeleton>
-            <a-descriptions :data="info" :column="2" />
-            <MapContainer
-                v-if="showMap"
-                :point_list="pointListForMap"
-                :log_list="logListForMap"
-                :pathData="pathForMap"
-                threeD
-                style="margin-top: 10px;"
-            />
         </a-card>
     </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, onMounted, onUnmounted } from 'vue'
 import { adminGetRecordDetail } from '@/api/lepao'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import MapContainer from '@/components/Map/MapContainer.vue'
-import {
-    buildRecordDetailInfo,
-    parseLepaoRecordRow,
-    normalizePointData
-} from '@/utils/lepaoRecord'
+import StoreRecordSkeleton from '@/components/skeleton/StoreRecordSkeleton.vue'
 
 const route = useRoute()
+const uuidLike = /^[0-9a-fA-F-]{16,}$/
 
 const loading = ref(false)
 const showMap = ref(false)
 const data = ref({})
 const info = ref([])
+const isMobile = ref(false)
+let mobileResizeHandler = null
 
-const pathForMap = computed(() => {
-    const poly = data.value.path_polyline
-    if (Array.isArray(poly) && poly.length > 0) return poly
-    return []
-})
-
-const pointListForMap = computed(() => {
-    const row = parseLepaoRecordRow({
-        result: data.value.result,
-        jkes_record: data.value.jkes_record,
-        run_zone_name: data.value.run_zone_name,
-        path_run_zone_name: data.value.path_run_zone_name,
-        path_distance: data.value.path_distance
-    })
-    return row.pointList || []
-})
-
-const logListForMap = computed(() => normalizePointData(data.value.point_data))
-
-const fetchDetail = async (id) => {
+const GetRecordDetail = async (routeId) => {
     try {
         loading.value = true
         showMap.value = false
-        const res = await adminGetRecordDetail({ id })
+        const raw = decodeURIComponent(String(routeId || ''))
+        const payload = uuidLike.test(raw)
+            ? { public_id: raw }
+            : { id: raw }
+        const res = await adminGetRecordDetail(payload)
         if (!res || res.code !== 0)
             return Notification.error({
                 title: '获取路径数据失败!',
@@ -72,7 +53,21 @@ const fetchDetail = async (id) => {
 
         data.value = res.data
         showMap.value = true
-        info.value = buildRecordDetailInfo(res.data, { isAdmin: true })
+
+        info.value = [
+            { label: '账号名称', value: res.data.name },
+            { label: '乐跑账号', value: res.data.lepao_account },
+            { label: '乐跑类型', value: res.data.run_mode === 'manual' ? '手动乐跑' : '自动乐跑' },
+            { label: '跑区名称', value: res.data.result.pass_tit },
+            { label: '路线ID', value: res.data.path_id },
+            { label: '记录时间', value: stramptoTime(res.data.time) },
+            { label: '开始时间', value: stramptoTime(res.data.result.start_time * 1000) },
+            { label: '乐跑状态', value: res.data.result.record_failed_reason },
+            { 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: '获取路径数据失败!',
@@ -84,12 +79,54 @@ const fetchDetail = async (id) => {
 }
 
 onMounted(() => {
-    fetchDetail(route.params.id)
+    mobileResizeHandler = () => {
+        isMobile.value = window.innerWidth <= 768
+    }
+    mobileResizeHandler()
+    window.addEventListener('resize', mobileResizeHandler)
+    GetRecordDetail(route.params.id)
+})
+
+onUnmounted(() => {
+    if (mobileResizeHandler) window.removeEventListener('resize', mobileResizeHandler)
 })
+
+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;
+.record-descriptions {
+    margin-top: 8px;
+}
+
+@media (max-width: 768px) {
+    .record-descriptions :deep(.arco-descriptions-item-label) {
+        white-space: nowrap;
+        width: 88px !important;
+    }
+
+    .record-descriptions :deep(.arco-descriptions-item-value) {
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+    }
 }
-</style>
+</style>

+ 6 - 8
src/pages/admin/mqQueue/index.vue

@@ -1,6 +1,6 @@
-<template>
-  <div class="container">
-    <Breadcrumb :items="['网站管理', '任务队列']" />
+<template>
+  <div class="store-page">
+    <Breadcrumb />
 
     <a-card title="队列概览" class="card-block">
       <template #extra>
@@ -54,7 +54,8 @@
     </a-card>
 
     <a-card title="队列消息窥视(reject_requeue_true,不消费)" class="card-block">
-      <a-row :gutter="16" align="center">
+      <AppQueryFilter label="窥视条件">
+      <a-row :gutter="16" align="center" class="query-row app-query-form">
         <a-col :span="6">
           <a-form-item label="队列" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
             <a-select v-model="peekForm.queue" placeholder="选择队列">
@@ -80,6 +81,7 @@
           </a-space>
         </a-col>
       </a-row>
+      </AppQueryFilter>
 
       <a-alert v-if="peekMeta.managementError" type="warning" style="margin-bottom: 12px">
         {{ peekMeta.managementError }}
@@ -303,10 +305,6 @@ onUnmounted(() => {
 </script>
 
 <style scoped>
-.container {
-  padding: 0 20px 20px 20px;
-}
-
 .card-block {
   margin-bottom: 16px;
 }

+ 4 - 7
src/pages/admin/notice/index.vue

@@ -1,6 +1,6 @@
-<template>
-  <div class="container">
-    <Breadcrumb :items="['公告管理', '横幅公告管理']" />
+<template>
+  <div class="store-page">
+    <Breadcrumb />
     <a-card title="横幅公告管理">
       <a-space style="margin-bottom: 12px;">
         <a-input v-model="query.keyword" placeholder="按标识或内容搜索" allow-clear style="width: 280px" />
@@ -8,7 +8,7 @@
         <a-button @click="openCreate">新增公告</a-button>
       </a-space>
 
-      <a-table
+      <a-table :bordered="false"
         :data="data"
         :loading="loading"
         :pagination="{
@@ -168,7 +168,4 @@ onMounted(() => {
 </script>
 
 <style scoped>
-.container {
-  padding: 0 20px 20px 20px;
-}
 </style>

+ 5 - 8
src/pages/admin/popup/index.vue

@@ -1,12 +1,12 @@
-<template>
-  <div class="container">
-    <Breadcrumb :items="['公告管理', '首页弹窗公告']" />
+<template>
+  <div class="store-page">
+    <Breadcrumb />
     <a-card title="首页弹窗公告">
       <a-space style="margin-bottom: 12px;">
         <a-button type="primary" @click="openCreate">新增公告</a-button>
       </a-space>
 
-      <a-table
+      <a-table :bordered="false"
         :data="data"
         :loading="loading"
         :pagination="{
@@ -81,7 +81,7 @@
     </a-modal>
 
     <a-modal v-model:visible="readVisible" title="已读用户" :footer="false" width="860px" draggable>
-      <a-table
+      <a-table :bordered="false"
         :data="readData"
         :columns="readColumns"
         :loading="readLoading"
@@ -279,7 +279,4 @@ onMounted(() => {
 </script>
 
 <style scoped>
-.container {
-  padding: 0 20px 20px 20px;
-}
 </style>

+ 325 - 0
src/pages/admin/powerTask/index.vue

@@ -0,0 +1,325 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="电费查询任务">
+      <AppQueryFilter>
+        <a-row class="query-row">
+          <a-col :flex="'1000px'" class="query-main">
+            <a-form class="queryForm app-query-form" :model="query" :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="create_user" label="用户UUID">
+                    <a-input v-model="query.create_user" placeholder="创建人 UUID" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="username" label="用户名">
+                    <a-input v-model="query.username" placeholder="平台用户名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="email" label="通知邮箱">
+                    <a-input v-model="query.email" placeholder="任务通知邮箱" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="area" label="校区">
+                    <a-input v-model="query.area" placeholder="校区" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="building" label="楼栋">
+                    <a-input v-model="query.building" placeholder="楼栋" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="room" label="宿舍号">
+                    <a-input v-model="query.room" placeholder="宿舍号" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="queryTime" label="更新时间">
+                    <a-range-picker v-model="query.queryTime" show-time format="YYYY-MM-DD HH:mm" value-format="x" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 84px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-space class="query-actions-space" direction="vertical" :size="18">
+              <a-button type="primary" @click="search">搜索</a-button>
+              <a-button @click="reset">重置</a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-table
+        :bordered="false"
+        class="table"
+        :data="data"
+        :loading="loading"
+        :scroll="{ x: 1670 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="创建用户" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.avatar || defaultAvatar" />
+                </a-avatar>
+                {{ record.username || record.create_user || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="宿舍" :width="220">
+            <template #cell="{ record }">
+              {{ record.area }} · {{ record.building }} · {{ record.room }}
+            </template>
+          </a-table-column>
+          <a-table-column title="余额" :width="100">
+            <template #cell="{ record }">
+              <a-tag :color="isLowBalance(record) ? 'red' : 'green'">¥{{ record.balance }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="阈值" :width="90">
+            <template #cell="{ record }">¥{{ record.lowest }}</template>
+          </a-table-column>
+          <a-table-column title="通知邮箱" data-index="email" :width="180" ellipsis tooltip />
+          <a-table-column title="扣费时间" data-index="koufei_date" :width="170" ellipsis tooltip />
+          <a-table-column title="已提醒" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.is_notice) === 1 ? 'orange' : 'gray'">
+                {{ Number(record.is_notice) === 1 ? '是' : '否' }}
+              </a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="备注" data-index="notes" :width="140" ellipsis tooltip />
+          <a-table-column title="添加时间" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.create_time) }}</template>
+          </a-table-column>
+          <a-table-column title="更新时间" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="120" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-button type="text" size="small" @click="openChangeRecord(record)">账单记录</a-button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="recordVisible"
+      title="电费变更记录"
+      width="auto"
+      hide-cancel
+      esc-to-close
+      draggable
+    >
+      <a-table
+        :bordered="false"
+        :data="changeList"
+        :loading="recordLoading"
+        :scroll="{ x: 720 }"
+        :pagination="false"
+        stripe
+      >
+        <template #columns>
+          <a-table-column title="记录时间" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.time) }}</template>
+          </a-table-column>
+          <a-table-column title="扣费时间" data-index="change_time" :width="170" ellipsis tooltip />
+          <a-table-column title="电费余额" :width="110">
+            <template #cell="{ record }">¥{{ record.balance }}</template>
+          </a-table-column>
+          <a-table-column title="原余额" :width="110">
+            <template #cell="{ record }">¥{{ record.old_balance }}</template>
+          </a-table-column>
+          <a-table-column title="变动金额" :width="110">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.balance) < Number(record.old_balance) ? 'red' : 'green'">
+                ¥{{ (Number(record.balance) - Number(record.old_balance)).toFixed(2) }}
+              </a-tag>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { adminPowerTaskList, adminPowerGetChangeRecord } from '@/api/power'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
+const defaultAvatar = 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'
+const loading = ref(false)
+const data = ref([])
+const query = reactive({
+  create_user: '',
+  username: '',
+  email: '',
+  area: '',
+  building: '',
+  room: '',
+  queryTime: []
+})
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+
+const recordVisible = ref(false)
+const recordLoading = ref(false)
+const changeList = ref([])
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const isLowBalance = (record) => Number(record.balance) <= Number(record.lowest)
+
+const getList = async () => {
+  try {
+    loading.value = true
+    const res = await adminPowerTaskList({
+      ...query,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取电费任务失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } catch (error) {
+    Notification.error({
+      title: '获取电费任务失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const openChangeRecord = async (record) => {
+  try {
+    recordLoading.value = true
+    recordVisible.value = true
+    changeList.value = []
+    const res = await adminPowerGetChangeRecord({ id: record.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取账单记录失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    changeList.value = res.data || []
+  } catch (error) {
+    Notification.error({
+      title: '获取账单记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    recordLoading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  query.create_user = ''
+  query.username = ''
+  query.email = ''
+  query.area = ''
+  query.building = ''
+  query.room = ''
+  query.queryTime = []
+  pagination.current = 1
+  getList()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped>
+.query-row {
+  margin-bottom: 10px;
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
+}
+
+.table {
+  margin-top: 16px;
+}
+
+@media (max-width: 768px) {
+  .query-row {
+    display: block;
+  }
+
+  .query-divider {
+    display: none;
+  }
+
+  .query-actions-space {
+    margin-top: 4px;
+    flex-direction: row !important;
+    gap: 10px !important;
+  }
+
+  .query-actions-space :deep(.arco-btn) {
+    flex: 1;
+  }
+}
+</style>

+ 175 - 0
src/pages/admin/qkBatch/index.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="抢课批次管理">
+      <div class="toolbar">
+        <a-button v-if="canManage" type="primary" @click="openModal()">
+          <template #icon><icon-plus /></template>
+          新建批次
+        </a-button>
+        <a-button @click="getList()">
+          <template #icon><icon-refresh /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-alert type="info" class="info-alert">
+        停用批次会自动收回该批次下所有进行中的任务;修改批次 ID 后会自动将已分配任务重新放回待分配队列。
+      </a-alert>
+
+      <a-table
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table"
+        :loading="loading"
+        :pagination="false"
+        :scroll="{ x: 980 }"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="名称" data-index="name" :width="180" ellipsis tooltip />
+          <a-table-column title="批次 ID" data-index="jx0502zbid" :width="260" ellipsis tooltip />
+          <a-table-column title="状态" :width="100">
+            <template #cell="{ record }">
+              <a-tag :color="record.enabled ? 'green' : 'gray'">{{ record.enabled ? '启用' : '停用' }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="更新时间" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="100" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-button v-if="canManage" type="text" size="small" @click="openModal(record)">编辑</a-button>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无抢课批次" />
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      :title="form.id ? '编辑批次' : '新建批次'"
+      :width="560"
+      @ok="submitBatch"
+    >
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="批次名称" required>
+          <a-input v-model="form.name" placeholder="例如:2025春公选课" allow-clear />
+        </a-form-item>
+        <a-form-item label="批次 ID(jx0502zbid)" required>
+          <a-input v-model="form.jx0502zbid" placeholder="教务系统选课批次 ID" allow-clear />
+        </a-form-item>
+        <a-form-item label="是否启用">
+          <a-switch v-model="form.enabled" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { IconPlus, IconRefresh } from '@arco-design/web-vue/es/icon'
+import { adminQkBatchList, adminQkBatchSave } from '@/api/qk'
+import { hasPermission } from '@/utils/permission'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
+const canManage = computed(() => hasPermission('action.qk.admin.batchManage'))
+
+const data = ref([])
+const loading = ref(false)
+const visible = ref(false)
+const form = reactive({
+  id: '',
+  name: '',
+  jx0502zbid: '',
+  enabled: true
+})
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN')
+}
+
+const resetForm = () => {
+  Object.assign(form, {
+    id: '',
+    name: '',
+    jx0502zbid: '',
+    enabled: true
+  })
+}
+
+const openModal = (record) => {
+  resetForm()
+  if (record) {
+    Object.assign(form, {
+      id: record.id,
+      name: record.name,
+      jx0502zbid: record.jx0502zbid,
+      enabled: !!record.enabled
+    })
+  }
+  visible.value = true
+}
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await adminQkBatchList()
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '获取批次失败', content: res?.msg || '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+  } catch (error) {
+    Notification.error({ title: '获取批次失败', content: error.message || '请稍后再试' })
+  } finally {
+    loading.value = false
+  }
+}
+
+const submitBatch = async () => {
+  const res = await adminQkBatchSave({
+    id: form.id || undefined,
+    name: form.name,
+    jx0502zbid: form.jx0502zbid,
+    enabled: form.enabled
+  })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '保存失败', content: res?.msg || '请稍后再试' })
+    return false
+  }
+  Notification.success({ title: '保存成功' })
+  visible.value = false
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.toolbar {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+
+.info-alert {
+  margin-bottom: 16px;
+}
+
+.table {
+  margin-top: 4px;
+}
+</style>

+ 493 - 0
src/pages/admin/qkClient/index.vue

@@ -0,0 +1,493 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="抢课客户端">
+      <a-row :gutter="[16, 16]" class="stats-row" align="stretch">
+        <a-col :xs="24" :md="10" :lg="8" class="stats-col">
+          <div class="stats-panel stats-highlight">
+            <a-progress
+              type="circle"
+              :percent="clientStats.occupancyRate / 100"
+              :stroke-width="8"
+              :width="isMobile ? 96 : 108"
+              :color="occupancyColor"
+            >
+              <template #text>
+                <div class="progress-text">
+                  <div class="progress-value">{{ clientStats.occupancyRate }}%</div>
+                  <div class="progress-label">槽位占用</div>
+                </div>
+              </template>
+            </a-progress>
+            <div class="stats-highlight-meta">
+              <div class="highlight-title">在线客户端容量</div>
+              <div class="highlight-desc">
+                已占用 <strong>{{ clientStats.usedSlots }}</strong> / 总容量 <strong>{{ clientStats.totalSlots }}</strong>
+              </div>
+              <div class="highlight-desc">
+                剩余可用槽位 <strong class="highlight-free">{{ clientStats.freeSlots }}</strong>
+              </div>
+            </div>
+          </div>
+        </a-col>
+        <a-col :xs="24" :md="14" :lg="16" class="stats-col">
+          <div class="stats-panel stats-grid-panel">
+            <a-row :gutter="[12, 12]" class="stats-grid">
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="任务总数" :value="taskStats.total" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="抢课中" :value="taskStats.active" value-style="color: rgb(var(--orange-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="待分配" :value="taskStats.pending" value-style="color: rgb(var(--arcoblue-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="客户端总数" :value="clientStats.total" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="在线客户端" :value="clientStats.online" value-style="color: rgb(var(--green-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="已禁用" :value="clientStats.disabled" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="累计完成" :value="clientStats.totalCompleted" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="累计成功" :value="clientStats.totalSuccess" value-style="color: rgb(var(--green-6))" />
+              </div>
+            </a-col>
+            </a-row>
+          </div>
+        </a-col>
+      </a-row>
+
+      <a-table
+        row-key="client_id"
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table app-data-table"
+        :loading="loading"
+        :pagination="false"
+        :scroll="{ x: isMobile ? 960 : 1250 }"
+      >
+        <template #columns>
+          <a-table-column title="客户端 ID" data-index="client_id" :width="180" ellipsis tooltip />
+          <a-table-column title="备注" data-index="label" :width="140" ellipsis tooltip />
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.enabled) === 1 ? 'arcoblue' : 'red'">
+                {{ Number(record.enabled) === 1 ? '启用' : '已禁用' }}
+              </a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="在线" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="Number(record.online) === 1 ? 'green' : 'gray'">
+                {{ Number(record.online) === 1 ? '在线' : '离线' }}
+              </a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="槽位" :width="120">
+            <template #cell="{ record }">
+              <div class="slot-cell">
+                <div class="slot-cell__text">{{ record.current_slots }} / {{ record.max_slots }}</div>
+                <div class="slot-bar">
+                  <div
+                    class="slot-bar__inner"
+                    :style="{
+                      width: `${slotPercent(record)}%`,
+                      backgroundColor: slotBarColor(record)
+                    }"
+                  />
+                </div>
+              </div>
+            </template>
+          </a-table-column>
+          <a-table-column title="机器名" data-index="hostname" :width="140" ellipsis tooltip />
+          <a-table-column title="登录用户" data-index="os_username" :width="120" ellipsis tooltip />
+          <a-table-column title="CPU" :width="220">
+            <template #cell="{ record }">
+              <AppTableEllipsis
+                :row-key="`${record.client_id}-cpu`"
+                :content="`${record.cpu_model || '-'} (${record.cpu_threads || '-'}线程)`"
+              />
+            </template>
+          </a-table-column>
+          <a-table-column title="内存" :width="140">
+            <template #cell="{ record }">
+              {{ record.free_mem_mb || '-' }} / {{ record.total_mem_mb || '-' }} MB
+            </template>
+          </a-table-column>
+          <a-table-column title="系统" data-index="platform" :width="160" ellipsis tooltip />
+          <a-table-column title="完成/成功" :width="120">
+            <template #cell="{ record }">{{ record.total_completed }} / {{ record.total_success }}</template>
+          </a-table-column>
+          <a-table-column title="最后心跳" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.last_heartbeat_at) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="120" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-popconfirm
+                v-if="Number(record.enabled) === 1"
+                content="确认禁用该客户端?"
+                @ok="deleteClient(record)"
+              >
+                <a-button type="text" status="danger" size="small">禁用</a-button>
+              </a-popconfirm>
+              <a-popconfirm
+                v-else
+                content="确认解禁该客户端?解禁后客户端可重新注册上线。"
+                @ok="enableClient(record)"
+              >
+                <a-button type="text" status="success" size="small">解禁</a-button>
+              </a-popconfirm>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无抢课客户端" />
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { adminQkClientDelete, adminQkClientEnable, adminQkClientList, adminQkTaskList } from '@/api/qk'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed, isMobile } = useResponsiveTable()
+
+const data = ref([])
+const loading = ref(false)
+const taskStats = reactive({
+  total: 0,
+  active: 0,
+  pending: 0
+})
+
+const clientStats = computed(() => {
+  const list = data.value || []
+  const enabled = list.filter(item => Number(item.enabled) === 1)
+  const online = enabled.filter(item => Number(item.online) === 1)
+  const totalSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.max_slots) || 0), 0)
+  const usedSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.current_slots) || 0), 0)
+  const freeSlots = Math.max(0, totalSlots - usedSlots)
+  const occupancyRate = totalSlots > 0 ? Math.min(100, Math.round((usedSlots / totalSlots) * 100)) : 0
+  return {
+    total: list.length,
+    online: online.length,
+    disabled: list.length - enabled.length,
+    totalSlots,
+    usedSlots,
+    freeSlots,
+    occupancyRate,
+    totalCompleted: list.reduce((sum, item) => sum + (Number(item.total_completed) || 0), 0),
+    totalSuccess: list.reduce((sum, item) => sum + (Number(item.total_success) || 0), 0)
+  }
+})
+
+const occupancyColor = computed(() => {
+  const rate = clientStats.value.occupancyRate
+  if (rate >= 90) return '#F53F3F'
+  if (rate >= 70) return '#FF7D00'
+  return '#165DFF'
+})
+
+const slotPercent = (record) => {
+  const max = Math.max(0, Number(record.max_slots) || 0)
+  const current = Math.max(0, Number(record.current_slots) || 0)
+  if (!max) return 0
+  return Math.min(100, Math.round((current / max) * 100))
+}
+
+const slotBarColor = (record) => {
+  const percent = slotPercent(record)
+  if (percent >= 90) return '#F53F3F'
+  if (Number(record.online) !== 1 || Number(record.enabled) !== 1) return '#C9CDD4'
+  return '#165DFF'
+}
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const fetchTaskStats = async () => {
+  try {
+    const [allRes, runningRes, assignedRes, pendingRes] = await Promise.all([
+      adminQkTaskList({ current: 1, pagesize: 1 }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'running' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'assigned' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'pending' })
+    ])
+    if (allRes?.code === 0) taskStats.total = allRes.data?.total || 0
+    const running = runningRes?.code === 0 ? (runningRes.data?.total || 0) : 0
+    const assigned = assignedRes?.code === 0 ? (assignedRes.data?.total || 0) : 0
+    taskStats.active = running + assigned
+    if (pendingRes?.code === 0) taskStats.pending = pendingRes.data?.total || 0
+  } catch (_) {}
+}
+
+const getList = async (silent = false) => {
+  try {
+    if (!silent) loading.value = true
+    const res = await adminQkClientList()
+    if (!res || res.code !== 0) {
+      if (!silent) {
+        Notification.error({ title: '获取客户端失败', content: res?.msg || '请稍后再试' })
+      }
+      return
+    }
+    data.value = res.data || []
+  } catch (error) {
+    if (!silent) {
+      Notification.error({ title: '获取客户端失败', content: error.message || '请稍后再试' })
+    }
+  } finally {
+    if (!silent) loading.value = false
+  }
+}
+
+const deleteClient = async (record) => {
+  const res = await adminQkClientDelete({ client_id: record.client_id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '禁用客户端失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已禁用' })
+  getList()
+  fetchTaskStats()
+}
+
+const enableClient = async (record) => {
+  const res = await adminQkClientEnable({ client_id: record.client_id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '解禁客户端失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已解禁', content: '客户端下次上线时可正常注册' })
+  getList()
+  fetchTaskStats()
+}
+
+let pollTimer = null
+let statsTimer = null
+
+const startPolling = () => {
+  if (!pollTimer) {
+    pollTimer = setInterval(() => getList(true), 5000)
+  }
+  if (!statsTimer) {
+    statsTimer = setInterval(fetchTaskStats, 30000)
+  }
+}
+
+const stopPolling = () => {
+  if (pollTimer) {
+    clearInterval(pollTimer)
+    pollTimer = null
+  }
+  if (statsTimer) {
+    clearInterval(statsTimer)
+    statsTimer = null
+  }
+}
+
+onMounted(() => {
+  getList()
+  fetchTaskStats()
+  startPolling()
+})
+
+onUnmounted(() => {
+  stopPolling()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.stats-row {
+  margin-bottom: 24px;
+}
+
+.stats-col {
+  display: flex;
+}
+
+.stats-panel {
+  width: 100%;
+  min-height: 148px;
+  box-sizing: border-box;
+}
+
+.stats-highlight {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  height: 100%;
+  padding: 16px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  background: linear-gradient(135deg, rgba(var(--primary-1), 0.35), rgba(var(--primary-1), 0.08));
+}
+
+.stats-grid-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 12px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  background: var(--color-bg-1);
+}
+
+.stats-grid {
+  flex: 1;
+  align-content: stretch;
+}
+
+.stat-card {
+  height: 100%;
+  min-height: 72px;
+  padding: 12px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 10px;
+  background: var(--color-bg-2);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  box-sizing: border-box;
+}
+
+:deep(.stat-card .arco-statistic-title) {
+  margin-bottom: 4px;
+  font-size: 13px;
+}
+
+:deep(.stat-card .arco-statistic-value) {
+  font-size: 22px;
+  line-height: 1.2;
+}
+
+.slot-cell {
+  width: 96px;
+  max-width: 100%;
+}
+
+.slot-cell__text {
+  font-size: 13px;
+  line-height: 1.4;
+  white-space: nowrap;
+}
+
+.slot-bar {
+  height: 6px;
+  margin-top: 6px;
+  border-radius: 999px;
+  background: var(--color-fill-3);
+  overflow: hidden;
+}
+
+.slot-bar__inner {
+  height: 100%;
+  border-radius: 999px;
+  min-width: 0;
+  max-width: 100%;
+  transition: width 0.2s ease;
+}
+
+.progress-text {
+  text-align: center;
+}
+
+.progress-value {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 1.2;
+}
+
+.progress-label {
+  margin-top: 2px;
+  font-size: 12px;
+  color: var(--color-text-3);
+}
+
+.stats-highlight-meta {
+  flex: 1;
+  min-width: 0;
+}
+
+.highlight-title {
+  font-size: 15px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.highlight-desc {
+  color: var(--color-text-2);
+  font-size: 13px;
+  line-height: 1.6;
+
+  strong {
+    color: var(--color-text-1);
+  }
+}
+
+.highlight-free {
+  color: rgb(var(--green-6));
+}
+
+.info-alert {
+  margin-bottom: 24px;
+}
+
+.table {
+  margin-top: 4px;
+}
+
+@media (max-width: 768px) {
+  .stats-panel {
+    min-height: auto;
+  }
+
+  .stats-highlight {
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+  }
+
+  .stats-highlight-meta {
+    width: 100%;
+  }
+
+  .stat-card {
+    min-height: 64px;
+  }
+}
+</style>

+ 328 - 0
src/pages/admin/qkReport/index.vue

@@ -0,0 +1,328 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="抢课回报记录">
+      <AppQueryFilter>
+        <a-row class="query-row">
+          <a-col :flex="'1000px'" class="query-main">
+            <a-form class="queryForm app-query-form" :model="query" :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="query.name" placeholder="任务备注姓名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="student_num" label="学号">
+                    <a-input v-model="query.student_num" placeholder="学号" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="username" label="用户名">
+                    <a-input v-model="query.username" placeholder="平台用户名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="client_label" label="客户端名称">
+                    <a-input v-model="query.client_label" placeholder="客户端备注名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="task_id" label="任务 ID">
+                    <a-input v-model="query.task_id" placeholder="任务 ID" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="client_id" label="客户端 ID">
+                    <a-input v-model="query.client_id" placeholder="client_id" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="event" label="回报类型">
+                    <a-select v-model="query.event" placeholder="全部" allow-clear>
+                      <a-option value="request_result">单次请求</a-option>
+                      <a-option value="progress_snapshot">定时进度</a-option>
+                      <a-option value="grab_success">抢课成功</a-option>
+                      <a-option value="grab_fail">抢课失败</a-option>
+                    </a-select>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="queryTime" label="回报时间">
+                    <a-range-picker v-model="query.queryTime" show-time format="YYYY-MM-DD HH:mm" value-format="x" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 120px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-space class="query-actions-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>
+      </AppQueryFilter>
+
+      <a-table
+        row-key="id"
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table"
+        :loading="loading"
+        :scroll="{ x: 1500 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="任务" :width="240">
+            <template #cell="{ record }">
+              <div>#{{ record.task_id }} {{ record.task_name || '' }}</div>
+              <div class="sub-text">学号:{{ record.student_num || '-' }}</div>
+              <div v-if="record.username" class="sub-text">用户:{{ record.username }}</div>
+            </template>
+          </a-table-column>
+          <a-table-column title="客户端" :width="220">
+            <template #cell="{ record }">
+              <div>{{ record.client_label || record.client_id || '-' }}</div>
+              <div class="sub-text">{{ record.client_id || '-' }}</div>
+            </template>
+          </a-table-column>
+          <a-table-column title="类型" data-index="event" :width="120">
+            <template #cell="{ record }">
+              <a-tag :color="eventColor(record.event)">{{ eventLabel(record.event) }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="课程" :width="180">
+            <template #cell="{ record }">
+              <AppTableEllipsis :row-key="`${record.id}-course`" :content="record.course_name || '-'" />
+            </template>
+          </a-table-column>
+          <a-table-column title="回报信息" :width="320">
+            <template #cell="{ record }">
+              <AppTableEllipsis
+                :row-key="`${record.id}-message`"
+                :content="record.display_message || record.message || '-'"
+              />
+            </template>
+          </a-table-column>
+          <a-table-column title="任务状态" data-index="task_status" :width="100">
+            <template #cell="{ record }">
+              {{ taskStatusLabel(record.task_status) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="回报时间" :width="180">
+            <template #cell="{ record }">
+              {{ formatTime(record.create_time) }}
+            </template>
+          </a-table-column>
+          <a-table-column title="详情" :width="90" fixed="right">
+            <template #cell="{ record }">
+              <a-button type="text" size="small" @click="openDetail(record)">查看</a-button>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无回报记录" />
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="detailVisible" title="回报详情" :footer="false" width="720px">
+      <a-descriptions :column="1" bordered size="small">
+        <a-descriptions-item label="任务 ID">{{ detail.task_id }}</a-descriptions-item>
+        <a-descriptions-item label="客户端">{{ detail.client_id }}</a-descriptions-item>
+        <a-descriptions-item label="类型">{{ eventLabel(detail.event) }}</a-descriptions-item>
+        <a-descriptions-item label="课程">{{ detail.course_name || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="信息">{{ detail.display_message || detail.message || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="时间">{{ formatTime(detail.create_time) }}</a-descriptions-item>
+        <a-descriptions-item label="原始数据">
+          <pre class="payload-pre">{{ formatPayload(detail.payload_json) }}</pre>
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { IconRefresh, IconSearch } from '@arco-design/web-vue/es/icon'
+import Breadcrumb from '@/components/Breadcrumb/index.vue'
+import { adminQkReportList } from '@/api/qk'
+
+const loading = ref(false)
+const data = ref([])
+const detailVisible = ref(false)
+const detail = ref({})
+const pagination = reactive({
+  current: 1,
+  pagesize: 20,
+  total: 0
+})
+const query = reactive({
+  name: '',
+  student_num: '',
+  username: '',
+  client_label: '',
+  task_id: '',
+  client_id: '',
+  event: '',
+  queryTime: []
+})
+
+const eventMap = {
+  request_result: '单次请求',
+  progress_snapshot: '定时进度',
+  grab_success: '抢课成功',
+  grab_fail: '抢课失败'
+}
+
+const taskStatusMap = {
+  paused: '未开始',
+  pending: '待分配',
+  assigned: '已分配',
+  running: '抢课中',
+  success: '成功',
+  failed: '失败',
+  cancelled: '已取消'
+}
+
+function eventLabel(event) {
+  return eventMap[event] || event || '-'
+}
+
+function eventColor(event) {
+  if (event === 'grab_success') return 'green'
+  if (event === 'grab_fail') return 'red'
+  if (event === 'progress_snapshot') return 'orange'
+  return 'blue'
+}
+
+function taskStatusLabel(status) {
+  return taskStatusMap[status] || status || '-'
+}
+
+function formatTime(value) {
+  if (!value) return '-'
+  return new Date(Number(value)).toLocaleString()
+}
+
+function formatPayload(payload) {
+  if (!payload) return '-'
+  try {
+    return JSON.stringify(payload, null, 2)
+  } catch (_) {
+    return String(payload)
+  }
+}
+
+function buildParams() {
+  const params = {
+    current: pagination.current,
+    pagesize: pagination.pagesize
+  }
+  if (query.name) params.name = query.name
+  if (query.student_num) params.student_num = query.student_num
+  if (query.username) params.username = query.username
+  if (query.client_label) params.client_label = query.client_label
+  if (query.task_id) params.task_id = query.task_id
+  if (query.client_id) params.client_id = query.client_id
+  if (query.event) params.event = query.event
+  if (Array.isArray(query.queryTime) && query.queryTime.length === 2) {
+    params.start_time = query.queryTime[0]
+    params.end_time = query.queryTime[1]
+  }
+  return params
+}
+
+function fetchList() {
+  loading.value = true
+  adminQkReportList(buildParams())
+    .then((res) => {
+      if (res?.code === 0) {
+        data.value = res.data?.list || []
+        pagination.total = res.data?.total || 0
+        pagination.current = res.data?.current || pagination.current
+        pagination.pagesize = res.data?.pagesize || pagination.pagesize
+      } else {
+        Notification.error({ title: '获取回报记录失败', content: res?.msg || '请稍后再试' })
+      }
+    })
+    .catch((error) => {
+      Notification.error({ title: '获取回报记录失败', content: error.message || '请稍后再试' })
+    })
+    .finally(() => {
+      loading.value = false
+    })
+}
+
+function search() {
+  pagination.current = 1
+  fetchList()
+}
+
+function reset() {
+  query.name = ''
+  query.student_num = ''
+  query.username = ''
+  query.client_label = ''
+  query.task_id = ''
+  query.client_id = ''
+  query.event = ''
+  query.queryTime = []
+  search()
+}
+
+function onPageChange(page) {
+  pagination.current = page
+  fetchList()
+}
+
+function onPageSizeChange(size) {
+  pagination.pagesize = size
+  pagination.current = 1
+  fetchList()
+}
+
+function openDetail(record) {
+  detail.value = record
+  detailVisible.value = true
+}
+
+onMounted(() => {
+  fetchList()
+})
+</script>
+
+<style scoped>
+.sub-text {
+  color: var(--color-text-3);
+  font-size: 12px;
+}
+
+.payload-pre {
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-all;
+  max-height: 320px;
+  overflow: auto;
+}
+</style>

+ 937 - 0
src/pages/admin/qkTask/index.vue

@@ -0,0 +1,937 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="抢课任务监控">
+      <div class="stats-section">
+      <a-row :gutter="[16, 16]" class="stats-row" align="stretch">
+        <a-col :xs="24" :md="10" :lg="8" class="stats-col">
+          <div class="stats-panel stats-highlight">
+            <a-progress
+              type="circle"
+              :percent="summary.activeRate / 100"
+              :stroke-width="8"
+              :width="isMobile ? 96 : 108"
+              :color="activeRateColor"
+            >
+              <template #text>
+                <div class="progress-text">
+                  <div class="progress-value">{{ summary.activeRate }}%</div>
+                  <div class="progress-label">活跃占比</div>
+                </div>
+              </template>
+            </a-progress>
+            <div class="stats-highlight-meta">
+              <div class="highlight-title">任务执行概况</div>
+              <div class="highlight-desc">
+                抢课中 <strong>{{ summary.grabbing }}</strong> · 待分配 <strong>{{ summary.pending }}</strong>
+              </div>
+              <div class="highlight-desc">
+                任务总数 <strong>{{ summary.total }}</strong> · 未开始 <strong>{{ summary.paused }}</strong>
+              </div>
+            </div>
+          </div>
+        </a-col>
+        <a-col :xs="24" :md="14" :lg="16" class="stats-col">
+          <div class="stats-panel stats-grid-panel">
+            <a-row :gutter="[12, 12]" class="stats-grid">
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="任务总数" :value="summary.total" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="抢课中" :value="summary.grabbing" value-style="color: rgb(var(--orange-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="待分配" :value="summary.pending" value-style="color: rgb(var(--arcoblue-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="未开始" :value="summary.paused" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="成功" :value="summary.success" value-style="color: rgb(var(--green-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="失败" :value="summary.failed" value-style="color: rgb(var(--red-6))" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="已取消" :value="summary.cancelled" />
+              </div>
+            </a-col>
+            <a-col :xs="12" :sm="8" :md="6">
+              <div class="stat-card">
+                <a-statistic title="剩余槽位" :value="summary.freeSlots" value-style="color: rgb(var(--green-6))" />
+                <div class="stat-sub">占用 {{ summary.usedSlots }} / {{ summary.totalSlots }}</div>
+              </div>
+            </a-col>
+            </a-row>
+          </div>
+        </a-col>
+      </a-row>
+      </div>
+
+      <AppQueryFilter class="query-filter">
+        <a-row class="query-row">
+          <a-col :flex="'1000px'" class="query-main">
+            <a-form class="queryForm app-query-form" :model="query" :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="status" label="状态">
+                    <a-select v-model="query.status" placeholder="全部" allow-clear>
+                      <a-option value="paused">未开始</a-option>
+                      <a-option value="pending">待分配</a-option>
+                      <a-option value="assigned">已分配</a-option>
+                      <a-option value="running">抢课中</a-option>
+                      <a-option value="success">成功</a-option>
+                      <a-option value="failed">失败</a-option>
+                      <a-option value="cancelled">已取消</a-option>
+                    </a-select>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="client_id" label="客户端">
+                    <a-input v-model="query.client_id" placeholder="client_id" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="student_num" label="学号">
+                    <a-input v-model="query.student_num" placeholder="学号" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="username" label="用户名">
+                    <a-input v-model="query.username" placeholder="平台用户名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="course_name" label="课程名称">
+                    <a-input v-model="query.course_name" placeholder="模糊匹配课程或分组" allow-clear />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 120px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-space class="query-actions-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>
+      </AppQueryFilter>
+
+      <a-table
+        row-key="id"
+        :data="data"
+        :bordered="false"
+        hoverable
+        class="table"
+        :loading="loading"
+        :scroll="{ x: isMobile ? 1200 : 1880 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="创建用户" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.avatar || defaultAvatar" alt="" />
+                </a-avatar>
+                {{ record.username || record.create_user || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="学号" data-index="student_num" :width="130" />
+          <a-table-column title="客户端" data-index="assigned_client_id" :width="180" ellipsis tooltip />
+          <a-table-column title="状态" :width="100">
+            <template #cell="{ record }">
+              <a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="课程" :width="260">
+            <template #cell="{ record }">
+              <AppTableEllipsis :row-key="`${record.id}-course`" :content="formatCourseList(record)" />
+            </template>
+          </a-table-column>
+          <a-table-column title="错误" :width="220">
+            <template #cell="{ record }">
+              <AppTableEllipsis :row-key="`${record.id}-error`" :content="record.error_msg || '-'" />
+            </template>
+          </a-table-column>
+          <a-table-column title="备注" :width="140" ellipsis tooltip>
+            <template #cell="{ record }">{{ record.name || '-' }}</template>
+          </a-table-column>
+          <a-table-column title="创建时间" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.create_time) }}</template>
+          </a-table-column>
+          <a-table-column title="更新时间" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.update_time) }}</template>
+          </a-table-column>
+          <a-table-column v-if="canManage()" title="操作" :width="300" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-space wrap>
+                <a-button
+                  v-if="canStart(record)"
+                  :key="`${record.id}-start`"
+                  type="text"
+                  size="small"
+                  status="success"
+                  @click="startTask(record)"
+                >
+                  开启
+                </a-button>
+                <a-button
+                  v-if="canPause(record)"
+                  :key="`${record.id}-pause`"
+                  type="text"
+                  size="small"
+                  status="warning"
+                  @click="pauseTask(record)"
+                >
+                  暂停
+                </a-button>
+                <a-button
+                  v-if="canEdit(record)"
+                  :key="`${record.id}-edit`"
+                  type="text"
+                  size="small"
+                  @click="openModal(record)"
+                >
+                  编辑
+                </a-button>
+                <a-popconfirm
+                  v-if="canRetry(record)"
+                  :key="`${record.id}-retry`"
+                  content="确认重试该任务?将重新进入待分配队列。"
+                  @ok="retryTask(record)"
+                >
+                  <a-button type="text" size="small">重试</a-button>
+                </a-popconfirm>
+                <a-popconfirm
+                  v-if="canCancel(record)"
+                  :key="`${record.id}-cancel`"
+                  :content="cancelConfirmText(record)"
+                  @ok="cancelTask(record)"
+                >
+                  <a-button type="text" status="danger" size="small">取消</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+        <template #empty>
+          <a-empty description="暂无抢课任务" />
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      title="编辑抢课任务"
+      :width="isMobile ? '100%' : '720px'"
+      draggable
+      esc-to-close
+      :ok-loading="saving"
+      @ok="submitTask"
+    >
+      <a-alert v-if="editingAssigned" type="warning" class="edit-alert">
+        该任务正在客户端执行,保存后将收回并变为未开始状态。
+      </a-alert>
+      <a-form :model="form" layout="vertical" class="task-form">
+        <a-form-item label="备注">
+          <a-textarea
+            v-model="form.name"
+            no-trim
+            placeholder="添加对任务的备注(非必填)"
+            :max-length="{ length: 50 }"
+            allow-clear
+            show-word-limit
+            :auto-size="{ minRows: 2, maxRows: 4 }"
+          />
+        </a-form-item>
+        <a-form-item label="选课批次" required>
+          <a-select v-model="form.batch_id" placeholder="请选择抢课批次" allow-clear allow-search>
+            <a-option v-for="item in batchOptions" :key="item.id" :value="item.id">
+              {{ item.name }}({{ item.jx0502zbid }}){{ item.enabled ? '' : ' [已停用]' }}
+            </a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="统一身份认证账号" required>
+          <a-select
+            v-model="form.jw_account_id"
+            placeholder="请选择该用户已验证的统一身份认证账号"
+            allow-search
+            allow-clear
+            :loading="jwAccountLoading"
+          >
+            <a-option v-for="item in jwAccounts" :key="item.id" :value="item.id">
+              {{ item.username }}({{ item.realname || '未知' }})
+            </a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="课程名称(每行一个,精确匹配)">
+          <a-textarea v-model="form.coursesText" :auto-size="{ minRows: 3 }" placeholder="每行填写一门课程名称" />
+          <template #extra>{{ targetHint }}</template>
+        </a-form-item>
+        <a-form-item label="课程分组(体育课请在此填写项目名称)">
+          <a-textarea v-model="form.courseGroupsText" :auto-size="{ minRows: 2 }" placeholder="每行填写一个课程分组" />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="抢课间隔(ms)">
+              <a-input-number v-model="form.interval_ms" :min="minInterval" :max="10000" style="width: 100%" />
+              <template #extra>当前最低 {{ minInterval }}ms</template>
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="启用公选课接口">
+              <a-switch v-model="form.enable_ggxxk" />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="12">
+            <a-form-item label="登录失效后自动重新登录">
+              <a-switch v-model="form.auto_relogin" />
+              <template #extra>开启后抢课过程中会话失效将自动重新登录并继续</template>
+            </a-form-item>
+          </a-col>
+        </a-row>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import {
+  adminQkClientList,
+  adminQkTaskList,
+  adminQkTaskDetail,
+  adminQkTaskUpdate,
+  adminQkTaskCancel,
+  adminQkTaskRetry,
+  adminQkTaskStart,
+  adminQkTaskPause,
+  adminQkBatchList,
+  adminGetJwAccount
+} from '@/api/qk'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+import { hasPermission } from '@/utils/permission'
+import {
+  getMinQkInterval,
+  getQkTargetHint,
+  splitQkLines,
+  validateQkTaskTargets
+} from '@/utils/qkTaskRules'
+
+const { tableFixed, isMobile } = useResponsiveTable()
+const canManage = () => hasPermission('action.qk.admin.taskManage')
+const defaultAvatar = 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'
+
+const loading = ref(false)
+const saving = ref(false)
+const visible = ref(false)
+const editingAssigned = ref(false)
+const data = ref([])
+const batchOptions = ref([])
+const jwAccounts = ref([])
+const jwAccountLoading = ref(false)
+const query = reactive({
+  status: '',
+  client_id: '',
+  student_num: '',
+  username: '',
+  course_name: ''
+})
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+const statusStats = reactive({
+  total: 0,
+  paused: 0,
+  pending: 0,
+  assigned: 0,
+  running: 0,
+  success: 0,
+  failed: 0,
+  cancelled: 0,
+  usedSlots: 0,
+  totalSlots: 0,
+  freeSlots: 0
+})
+
+const summary = computed(() => {
+  const grabbing = statusStats.running + statusStats.assigned
+  const active = grabbing + statusStats.pending
+  const total = statusStats.total || 0
+  const activeRate = total > 0 ? Math.min(100, Math.round((active / total) * 100)) : 0
+  return {
+    total,
+    paused: statusStats.paused,
+    pending: statusStats.pending,
+    grabbing,
+    success: statusStats.success,
+    failed: statusStats.failed,
+    cancelled: statusStats.cancelled,
+    activeRate,
+    usedSlots: statusStats.usedSlots,
+    totalSlots: statusStats.totalSlots,
+    freeSlots: statusStats.freeSlots
+  }
+})
+
+const activeRateColor = computed(() => {
+  const rate = summary.value.activeRate
+  if (rate >= 80) return '#F53F3F'
+  if (rate >= 50) return '#FF7D00'
+  return '#165DFF'
+})
+
+const form = reactive({
+  id: '',
+  name: '',
+  batch_id: '',
+  jw_account_id: undefined,
+  create_user: '',
+  coursesText: '',
+  courseGroupsText: '',
+  enable_ggxxk: true,
+  auto_relogin: false,
+  interval_ms: 500
+})
+
+const targetCount = computed(() => {
+  return splitQkLines(form.coursesText).length + splitQkLines(form.courseGroupsText).length
+})
+const minInterval = computed(() => getMinQkInterval(targetCount.value))
+const targetHint = computed(() => getQkTargetHint(form.coursesText, form.courseGroupsText))
+
+const splitLines = splitQkLines
+
+const formatCourseList = (record) => {
+  const list = [...(record.courses || []), ...(record.course_groups || [])]
+  return list.length ? list.join('、') : '-'
+}
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const statusText = (status) => ({
+  paused: '未开始',
+  pending: '待分配',
+  assigned: '已分配',
+  running: '抢课中',
+  success: '成功',
+  failed: '失败',
+  cancelled: '已取消'
+}[status] || status)
+
+const statusColor = (status) => ({
+  paused: 'gray',
+  pending: 'blue',
+  assigned: 'arcoblue',
+  running: 'orange',
+  success: 'green',
+  failed: 'red',
+  cancelled: 'gray'
+}[status] || 'gray')
+
+const canEdit = (record) => record.status !== 'success'
+const canStart = (record) => ['paused', 'failed'].includes(record.status)
+const canPause = (record) => ['pending', 'assigned', 'running'].includes(record.status)
+const canRetry = (record) => ['failed', 'cancelled'].includes(record.status)
+const canCancel = (record) => ['paused', 'pending', 'assigned', 'running', 'failed'].includes(record.status)
+
+const cancelConfirmText = (record) => {
+  if (['assigned', 'running'].includes(record.status)) {
+    return '该任务正在执行,确认取消并收回?'
+  }
+  return '确认取消该抢课任务?'
+}
+
+const resetForm = () => {
+  Object.assign(form, {
+    id: '',
+    name: '',
+    batch_id: '',
+    jw_account_id: undefined,
+    create_user: '',
+    coursesText: '',
+    courseGroupsText: '',
+    enable_ggxxk: true,
+    auto_relogin: false,
+    interval_ms: 500
+  })
+  jwAccounts.value = []
+  editingAssigned.value = false
+}
+
+const loadJwAccounts = async (createUser) => {
+  if (!createUser) {
+    jwAccounts.value = []
+    return
+  }
+  jwAccountLoading.value = true
+  try {
+    const res = await adminGetJwAccount({ create_user: createUser })
+    if (res?.code === 0) {
+      jwAccounts.value = res.data || []
+    }
+  } finally {
+    jwAccountLoading.value = false
+  }
+}
+
+const openModal = async (record) => {
+  resetForm()
+  try {
+    const res = await adminQkTaskDetail({ id: record.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '获取任务详情失败', content: res?.msg || '请稍后再试' })
+    }
+    const task = res.data || record
+    editingAssigned.value = ['assigned', 'running'].includes(task.status)
+    await loadJwAccounts(task.create_user)
+    Object.assign(form, {
+      id: task.id,
+      name: task.name,
+      batch_id: task.batch_id || '',
+      jw_account_id: task.jw_account_id || jwAccounts.value.find(item => item.username === task.student_num)?.id,
+      create_user: task.create_user,
+      coursesText: (task.courses || []).join('\n'),
+      courseGroupsText: (task.course_groups || []).join('\n'),
+      enable_ggxxk: task.enable_ggxxk,
+      auto_relogin: !!task.auto_relogin,
+      interval_ms: task.interval_ms
+    })
+    visible.value = true
+  } catch (error) {
+    Notification.error({ title: '获取任务详情失败', content: error.message || '请稍后再试' })
+  }
+}
+
+const getList = async (silent = false) => {
+  try {
+    if (!silent) loading.value = true
+    const res = await adminQkTaskList({
+      ...query,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) {
+      if (!silent) {
+        Notification.error({ title: '获取抢课任务失败', content: res?.msg || '请稍后再试' })
+      }
+      return
+    }
+    data.value = res.data?.list || []
+    pagination.total = res.data?.total || 0
+  } catch (error) {
+    if (!silent) {
+      Notification.error({ title: '获取抢课任务失败', content: error.message || '请稍后再试' })
+    }
+  } finally {
+    if (!silent) loading.value = false
+  }
+}
+
+const fetchStats = async () => {
+  try {
+    const [
+      allRes,
+      pausedRes,
+      pendingRes,
+      assignedRes,
+      runningRes,
+      successRes,
+      failedRes,
+      cancelledRes,
+      clientRes
+    ] = await Promise.all([
+      adminQkTaskList({ current: 1, pagesize: 1 }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'paused' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'pending' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'assigned' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'running' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'success' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'failed' }),
+      adminQkTaskList({ current: 1, pagesize: 1, status: 'cancelled' }),
+      adminQkClientList()
+    ])
+    if (allRes?.code === 0) statusStats.total = allRes.data?.total || 0
+    if (pausedRes?.code === 0) statusStats.paused = pausedRes.data?.total || 0
+    if (pendingRes?.code === 0) statusStats.pending = pendingRes.data?.total || 0
+    if (assignedRes?.code === 0) statusStats.assigned = assignedRes.data?.total || 0
+    if (runningRes?.code === 0) statusStats.running = runningRes.data?.total || 0
+    if (successRes?.code === 0) statusStats.success = successRes.data?.total || 0
+    if (failedRes?.code === 0) statusStats.failed = failedRes.data?.total || 0
+    if (cancelledRes?.code === 0) statusStats.cancelled = cancelledRes.data?.total || 0
+
+    const clients = clientRes?.code === 0 ? (clientRes.data || []) : []
+    const online = clients.filter(item => Number(item.enabled) === 1 && Number(item.online) === 1)
+    const totalSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.max_slots) || 0), 0)
+    const usedSlots = online.reduce((sum, item) => sum + Math.max(0, Number(item.current_slots) || 0), 0)
+    statusStats.totalSlots = totalSlots
+    statusStats.usedSlots = usedSlots
+    statusStats.freeSlots = Math.max(0, totalSlots - usedSlots)
+  } catch (_) {}
+}
+
+const submitTask = async () => {
+  const validated = validateQkTaskTargets(form.coursesText, form.courseGroupsText, form.interval_ms)
+  if (!validated.ok) {
+    return Notification.error({ title: '保存失败', content: validated.message })
+  }
+  if (!form.jw_account_id) {
+    return Notification.error({ title: '保存失败', content: '请选择统一身份认证账号' })
+  }
+  try {
+    saving.value = true
+    const res = await adminQkTaskUpdate({
+      id: form.id,
+      name: form.name,
+      batch_id: form.batch_id,
+      jw_account_id: form.jw_account_id,
+      courses: validated.courses,
+      course_groups: validated.courseGroups,
+      enable_ggxxk: form.enable_ggxxk,
+      auto_relogin: form.auto_relogin,
+      interval_ms: form.interval_ms
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({ title: '保存失败', content: res?.msg || '请稍后再试' })
+    }
+    Notification.success({ title: '保存成功' })
+    visible.value = false
+    getList()
+    fetchStats()
+  } catch (error) {
+    Notification.error({ title: '保存失败', content: error.message || '请稍后再试' })
+  } finally {
+    saving.value = false
+  }
+}
+
+const cancelTask = async (record) => {
+  const res = await adminQkTaskCancel({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '取消失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已取消' })
+  getList()
+  fetchStats()
+}
+
+const startTask = async (record) => {
+  const res = await adminQkTaskStart({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '开启失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已开启', content: '任务已进入待分配队列' })
+  getList()
+  fetchStats()
+}
+
+const pauseTask = async (record) => {
+  const res = await adminQkTaskPause({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '暂停失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已暂停' })
+  getList()
+  fetchStats()
+}
+
+const retryTask = async (record) => {
+  const res = await adminQkTaskRetry({ id: record.id })
+  if (!res || res.code !== 0) {
+    return Notification.error({ title: '重试失败', content: res?.msg || '请稍后再试' })
+  }
+  Notification.success({ title: '已重新加入待分配队列' })
+  getList()
+  fetchStats()
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  query.status = ''
+  query.client_id = ''
+  query.student_num = ''
+  query.username = ''
+  query.course_name = ''
+  search()
+}
+
+const onPageChange = (current) => {
+  pagination.current = current
+  getList()
+}
+
+const onPageSizeChange = (pageSize) => {
+  pagination.pagesize = pageSize
+  pagination.current = 1
+  getList()
+}
+
+let pollTimer = null
+let statsTimer = null
+
+const startPolling = () => {
+  if (!pollTimer) {
+    pollTimer = setInterval(() => getList(true), 5000)
+  }
+  if (!statsTimer) {
+    statsTimer = setInterval(fetchStats, 30000)
+  }
+}
+
+const stopPolling = () => {
+  if (pollTimer) {
+    clearInterval(pollTimer)
+    pollTimer = null
+  }
+  if (statsTimer) {
+    clearInterval(statsTimer)
+    statsTimer = null
+  }
+}
+
+const loadBatches = async () => {
+  try {
+    const res = await adminQkBatchList()
+    if (res?.code === 0) {
+      batchOptions.value = res.data || []
+    }
+  } catch (_) {}
+}
+
+onMounted(() => {
+  loadBatches()
+  getList()
+  fetchStats()
+  startPolling()
+})
+
+onUnmounted(() => {
+  stopPolling()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.stats-section {
+  padding-bottom: 40px;
+}
+
+.stats-row {
+  margin-bottom: 0;
+}
+
+:deep(.query-filter.app-query-filter) {
+  margin-top: 0;
+  margin-bottom: 24px;
+}
+
+.stats-col {
+  display: flex;
+}
+
+.stats-panel {
+  width: 100%;
+  min-height: 148px;
+  box-sizing: border-box;
+}
+
+.stats-highlight {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  height: 100%;
+  padding: 16px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  background: linear-gradient(135deg, rgba(var(--primary-1), 0.35), rgba(var(--primary-1), 0.08));
+}
+
+.stats-grid-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 12px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  background: var(--color-bg-1);
+}
+
+.stats-grid {
+  flex: 1;
+  align-content: stretch;
+}
+
+.progress-text {
+  text-align: center;
+}
+
+.progress-value {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 1.2;
+}
+
+.progress-label {
+  margin-top: 2px;
+  font-size: 12px;
+  color: var(--color-text-3);
+}
+
+.stats-highlight-meta {
+  flex: 1;
+  min-width: 0;
+}
+
+.highlight-title {
+  font-size: 15px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.highlight-desc {
+  color: var(--color-text-2);
+  font-size: 13px;
+  line-height: 1.6;
+
+  strong {
+    color: var(--color-text-1);
+  }
+}
+
+.stat-card {
+  height: 100%;
+  min-height: 72px;
+  padding: 12px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 10px;
+  background: var(--color-bg-2);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  box-sizing: border-box;
+}
+
+:deep(.stat-card .arco-statistic-title) {
+  margin-bottom: 4px;
+  font-size: 13px;
+}
+
+:deep(.stat-card .arco-statistic-value) {
+  font-size: 22px;
+  line-height: 1.2;
+}
+
+.stat-sub {
+  margin-top: 4px;
+  font-size: 12px;
+  color: var(--color-text-3);
+}
+
+.query-row {
+  margin-bottom: 0;
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
+}
+
+.table {
+  margin-top: 4px;
+}
+
+.edit-alert {
+  margin-bottom: 16px;
+}
+
+@media (max-width: 768px) {
+  .stats-panel {
+    min-height: auto;
+  }
+
+  .stats-highlight {
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+  }
+
+  .stats-highlight-meta {
+    width: 100%;
+  }
+
+  .stat-card {
+    min-height: 64px;
+  }
+
+  .query-row {
+    display: block;
+  }
+
+  .query-divider {
+    display: none;
+  }
+
+  .query-actions-space {
+    margin-top: 4px;
+    flex-direction: row !important;
+    gap: 10px !important;
+  }
+
+  .query-actions-space :deep(.arco-btn) {
+    flex: 1;
+  }
+}
+</style>

+ 312 - 0
src/pages/admin/qxsAccount/index.vue

@@ -0,0 +1,312 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="趣选书查询管理">
+      <AppQueryFilter>
+        <a-row class="query-row">
+          <a-col :flex="'1000px'" class="query-main">
+            <a-form class="queryForm app-query-form" :model="query" :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="username" label="趣选书账号">
+                    <a-input v-model="query.username" placeholder="登录账号" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="realname" label="姓名">
+                    <a-input v-model="query.realname" placeholder="真实姓名" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="email" label="邮箱">
+                    <a-input v-model="query.email" placeholder="邮箱" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="mobile" label="手机号">
+                    <a-input v-model="query.mobile" placeholder="手机号" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="className" label="班级">
+                    <a-input v-model="query.className" placeholder="班级名称" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="termName" label="学期">
+                    <a-input v-model="query.termName" placeholder="学期名称" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item field="queryTime" label="查询时间">
+                    <a-range-picker v-model="query.queryTime" show-time format="YYYY-MM-DD HH:mm" value-format="x" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 84px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-space class="query-actions-space" direction="vertical" :size="18">
+              <a-button type="primary" @click="search">搜索</a-button>
+              <a-button @click="reset">重置</a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-table
+        :bordered="false"
+        class="table"
+        :data="data"
+        :loading="loading"
+        :scroll="{ x: 1320 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="ID" data-index="id" :width="80" />
+          <a-table-column title="趣选书账号" data-index="username" :width="160" ellipsis tooltip />
+          <a-table-column title="姓名" data-index="realname" :width="120" ellipsis tooltip />
+          <a-table-column title="班级" data-index="className" :width="160" ellipsis tooltip />
+          <a-table-column title="邮箱" data-index="email" :width="180" ellipsis tooltip />
+          <a-table-column title="手机号" data-index="mobile" :width="130" ellipsis tooltip />
+          <a-table-column title="学期" data-index="termName" :width="180" ellipsis tooltip />
+          <a-table-column title="书籍数量" data-index="book_count" :width="100" />
+          <a-table-column title="查询时间" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.create_time) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="100" :fixed="tableFixed('right')">
+            <template #cell="{ record }">
+              <a-button type="text" size="small" @click="openDetail(record)">查看书单</a-button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="detailVisible"
+      :title="detailTitle"
+      width="960px"
+      hide-cancel
+      esc-to-close
+      draggable
+    >
+      <a-descriptions v-if="detail" :column="2" bordered size="medium" style="margin-bottom: 16px">
+        <a-descriptions-item label="账号">{{ detail.username }}</a-descriptions-item>
+        <a-descriptions-item label="姓名">{{ detail.realname || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="班级">{{ detail.className || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="邮箱">{{ detail.email || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="手机号">{{ detail.mobile || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="学期" :span="2">{{ detail.termName || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="查询时间" :span="2">{{ formatTime(detail.create_time) }}</a-descriptions-item>
+      </a-descriptions>
+
+      <a-table
+        :bordered="false"
+        :data="detail?.bookList || []"
+        :loading="detailLoading"
+        :pagination="false"
+        :scroll="{ x: 900 }"
+        stripe
+      >
+        <template #columns>
+          <a-table-column title="封面" :width="80">
+            <template #cell="{ record }">
+              <a-image v-if="record.imgUrl" width="50" height="50" fit="contain" :src="record.imgUrl" />
+              <a-avatar v-else shape="square" :size="50">
+                <icon-book />
+              </a-avatar>
+            </template>
+          </a-table-column>
+          <a-table-column title="书名" data-index="title" :width="220" ellipsis tooltip />
+          <a-table-column title="ISBN" data-index="isbn" :width="150" ellipsis tooltip />
+          <a-table-column title="课程" :width="180" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ record.crouseName || record.courseName || '-' }}
+              <span v-if="record.courseType">({{ record.courseType }})</span>
+            </template>
+          </a-table-column>
+          <a-table-column title="价格" :width="120">
+            <template #cell="{ record }">
+              ¥{{ record.textbookDiscountedPrice ?? record.totalDiscountedPrice ?? record.textbookPrice ?? '-' }}
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, computed, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { adminQxsAccountList, adminQxsAccountDetail } from '@/api/qxs'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const { tableFixed } = useResponsiveTable()
+const loading = ref(false)
+const data = ref([])
+const query = reactive({
+  username: '',
+  realname: '',
+  email: '',
+  mobile: '',
+  className: '',
+  termName: '',
+  queryTime: []
+})
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+
+const detailVisible = ref(false)
+const detailLoading = ref(false)
+const detail = ref(null)
+
+const detailTitle = computed(() => {
+  if (!detail.value) return '书单详情'
+  return `书单详情 · ${detail.value.realname || detail.value.username || ''}`
+})
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+const getList = async () => {
+  try {
+    loading.value = true
+    const res = await adminQxsAccountList({
+      ...query,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取查询记录失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } catch (error) {
+    Notification.error({
+      title: '获取查询记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const openDetail = async (record) => {
+  try {
+    detailLoading.value = true
+    detailVisible.value = true
+    detail.value = null
+    const res = await adminQxsAccountDetail({ id: record.id })
+    if (!res || res.code !== 0) {
+      return Notification.error({
+        title: '获取书单详情失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    }
+    detail.value = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取书单详情失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  query.username = ''
+  query.realname = ''
+  query.email = ''
+  query.mobile = ''
+  query.className = ''
+  query.termName = ''
+  query.queryTime = []
+  pagination.current = 1
+  getList()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped>
+.query-row {
+  margin-bottom: 10px;
+}
+
+.query-main {
+  min-width: 0;
+}
+
+.query-actions-space {
+  width: 100%;
+}
+
+.table {
+  margin-top: 16px;
+}
+
+@media (max-width: 768px) {
+  .query-row {
+    display: block;
+  }
+
+  .query-divider {
+    display: none;
+  }
+
+  .query-actions-space {
+    margin-top: 4px;
+    flex-direction: row !important;
+    gap: 10px !important;
+  }
+
+  .query-actions-space :deep(.arco-btn) {
+    flex: 1;
+  }
+}
+</style>

+ 7 - 9
src/pages/admin/reqLog/index.vue

@@ -1,9 +1,10 @@
-<template>
-    <div class="container">
-        <Breadcrumb :items="['日志记录', '请求日志']" />
+<template>
+    <div class="store-page">
+        <Breadcrumb />
 
         <a-card title="请求日志">
-            <a-row>
+            <AppQueryFilter>
+            <a-row class="query-row app-query-form">
                 <a-col :flex="1">
                     <a-form :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
                         label-align="left">
@@ -56,8 +57,9 @@
                     </a-space>
                 </a-col>
             </a-row>
+            </AppQueryFilter>
 
-            <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
+            <a-table :bordered="false" :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
                 :pagination="{
                     showPageSize: true,
                     showJumper: true,
@@ -284,10 +286,6 @@ const stramptoTime = (time) => {
 </script>
 
 <style scoped>
-.container {
-    padding: 0 20px 20px 20px;
-}
-
 .table {
     margin-top: 15px;
 }

+ 577 - 43
src/pages/admin/user/userList.vue

@@ -1,11 +1,12 @@
 <template>
-    <div class="container">
-        <Breadcrumb :items="['网站管理', '用户管理']" />
+    <div class="store-page">
+        <Breadcrumb />
 
         <a-card title="用户管理">
-            <a-row>
-                <a-col :flex="1">
-                    <a-form :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
+            <AppQueryFilter>
+            <a-row class="query-row">
+                <a-col :flex="'1000px'" class="query-main">
+                    <a-form class="queryForm app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
                         label-align="left">
                         <a-row :gutter="16">
                             <a-col :span="8">
@@ -15,7 +16,7 @@
                             </a-col>
                             <a-col :span="8">
                                 <a-form-item field="email" label="用户邮箱">
-                                    <a-input v-model="queryData.email" />
+                                    <EmailAutoComplete v-model="queryData.email" allow-clear />
                                 </a-form-item>
                             </a-col>
                             <a-col :span="8">
@@ -31,9 +32,9 @@
                         </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-divider class="query-divider" style="height: 84px" direction="vertical" />
+                <a-col :flex="1" class="app-query-actions">
+                    <a-space class="query-actions-space" direction="vertical" :size="18">
                         <a-button type="primary" @click="search">
                             <template #icon>
                                 <icon-search />
@@ -49,9 +50,12 @@
                     </a-space>
                 </a-col>
             </a-row>
+            </AppQueryFilter>
 
-            <a-table :data="data" :bordered="false" hoverable column-resizable class="table" :loading="loading"
-                :columns="columns" :pagination="{
+            <div class="table-wrap">
+            <a-table :data="data" :bordered="false" hoverable class="table" :loading="loading"
+                :scroll="{ x: TABLE_SCROLL_X }"
+                :columns="tableColumns" :pagination="{
                     showPageSize: true,
                     showJumper: true,
                     showTotal: true,
@@ -60,11 +64,13 @@
                     total: pagination.total
                 }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
                 <template #username="{ record }">
-                    <a-avatar :size="35">
-                        <img :alt="record.username ?? ''"
-                            :src="record.avatar ?? 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
-                    </a-avatar>
-                    {{ record.username }}
+                    <div class="user-cell">
+                        <a-avatar :size="35" class="user-cell__avatar">
+                            <img :alt="record.username ?? ''"
+                                :src="record.avatar ?? 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                        </a-avatar>
+                        <span class="user-cell__name">{{ record.username || '-' }}</span>
+                    </div>
                 </template>
                 <template #registTime="{ record }">
                     {{ stramptoTime(record.registTime) }}
@@ -72,10 +78,55 @@
                 <template #lastTime="{ record }">
                     {{ stramptoTime(record.lastTime ?? record.registTime) }}
                 </template>
+                <template #qq_bind="{ record }">
+                    <div v-if="record.qq_social_nickname || record.qq_social_avatar" class="bind-cell">
+                        <a-avatar :size="24" class="bind-cell__avatar">
+                            <img v-if="record.qq_social_avatar" :alt="record.qq_social_nickname || 'QQ'" :src="record.qq_social_avatar" />
+                            <icon-qq v-else />
+                        </a-avatar>
+                        <span class="bind-cell__text">{{ record.qq_social_nickname || '未获取' }}</span>
+                    </div>
+                    <span v-else class="muted">未绑定</span>
+                </template>
+                <template #wx_bind="{ record }">
+                    <div v-if="record.wx_social_nickname || record.wx_social_avatar" class="bind-cell">
+                        <a-avatar :size="24" class="bind-cell__avatar">
+                            <img v-if="record.wx_social_avatar" :alt="record.wx_social_nickname || '微信'" :src="record.wx_social_avatar" />
+                            <icon-wechat v-else />
+                        </a-avatar>
+                        <span class="bind-cell__text">{{ record.wx_social_nickname || '未获取' }}</span>
+                    </div>
+                    <span v-else class="muted">未绑定</span>
+                </template>
+                <template #last_login_type="{ record }">
+                    <span>{{ formatLastLoginType(record.last_login_type) }}</span>
+                </template>
+                <template #is_banned_status="{ record }">
+                    <a-tag v-if="isBanned(record)" color="red">已封禁</a-tag>
+                    <a-tag v-else color="green">正常</a-tag>
+                </template>
                 <template #optional="{ record }">
-                    <a-button @click="changeCount(record)">更改里程</a-button>
+                    <a-dropdown :popup-max-height="false" trigger="hover">
+                        <a-button>操作 <icon-down /></a-button>
+                        <template #content>
+                            <a-doption v-if="hasPermission('action.user.changeCount')" @click="changeCount(record)">
+                                <icon-edit /> 更改次数
+                            </a-doption>
+                            <a-doption v-if="hasPermission('action.user.setSendCountAutoApprove')" @click="onSendCountAutoApproveChange(record, !isSendCountAutoApprove(record))">
+                                <icon-check-circle /> {{ isSendCountAutoApprove(record) ? '关闭赠送免审' : '开启赠送免审' }}
+                            </a-doption>
+                            <a-doption v-if="hasPermission('action.user.ban')" @click="onBanChange(record, !isBanned(record))">
+                                <icon-minus-circle /> {{ isBanned(record) ? '解除封禁' : '封禁账户' }}
+                            </a-doption>
+                            <a-doption v-if="hasPermission('action.user.permissionManage')" @click="openPermissionManage(record)">
+                                <icon-user /> 权限管理
+                            </a-doption>
+                            <a-doption v-if="!hasAnyUserOperationPermission" disabled>暂无可用操作</a-doption>
+                        </template>
+                    </a-dropdown>
                 </template>
             </a-table>
+            </div>
         </a-card>
     </div>
 
@@ -85,23 +136,111 @@
             <a-form-item field="userid" label="用户UUID">
                 <a-input v-model="change_lepao_count.userid" disabled />
             </a-form-item>
-            <a-form-item field="lepao_count" label="乐跑里程(km)">
-                <a-input-number v-model="change_lepao_count.lepao_count" :step="0.01" :precision="2" />
+            <a-form-item field="lepao_count" label="乐跑次数">
+                <a-input-number v-model="change_lepao_count.lepao_count" :step="1" />
+            </a-form-item>
+            <a-form-item field="remark" label="备注">
+                <a-textarea v-model="change_lepao_count.remark" no-trim placeholder="可选,不填默认系统调整次数" :max-length="{ length: 255 }"
+                    show-word-limit allow-clear />
             </a-form-item>
         </a-form>
     </a-modal>
+
+    <a-modal v-model:visible="permissionVisible" title="权限管理" width="860px" @before-ok="handlePermissionBeforeOk"
+        :ok-loading="permissionSaving" :mask-closable="false" draggable esc-to-close closable>
+        <a-skeleton animation :loading="permissionLoading">
+            <a-skeleton-line :rows="8" />
+            <template #content>
+            <a-tabs v-model:active-key="permissionTab">
+                <a-tab-pane key="user" title="用户权限">
+                    <a-space direction="vertical" fill>
+                        <a-alert>
+                            正在配置用户 {{ permissionUser?.username || permissionUser?.uuid || '-' }} 的权限。乐跑账号、赠送次数、发起工单等基础操作默认全员开放,取消勾选即可单独关闭;管理端等高级权限需额外勾选。旧角色继承权限会展示在下方,但不会随本窗口保存而改变。
+                        </a-alert>
+                        <a-radio-group v-model="permissionCategory" type="button">
+                            <a-radio value="">全部</a-radio>
+                            <a-radio value="page">页面</a-radio>
+                            <a-radio value="action">操作</a-radio>
+                        </a-radio-group>
+                        <a-checkbox-group v-model="selectedPermissionCodes" class="permission-list">
+                            <a-grid :cols="2" :col-gap="12" :row-gap="12">
+                                <a-grid-item v-for="item in filteredPermissionPoints" :key="item.code">
+                                    <a-card size="small" :bordered="true" :class="{ 'permission-card-basic': isBasicPermission(item.code) }">
+                                        <a-checkbox :value="item.code">
+                                            <div class="permission-title">
+                                                {{ item.name }}
+                                                <a-tag v-if="isBasicPermission(item.code)" size="small" color="arcoblue">默认开放</a-tag>
+                                            </div>
+                                            <div class="permission-code">{{ item.code }}</div>
+                                            <div v-if="item.remark" class="permission-remark">{{ item.remark }}</div>
+                                        </a-checkbox>
+                                    </a-card>
+                                </a-grid-item>
+                            </a-grid>
+                        </a-checkbox-group>
+                        <div>
+                            <div class="permission-section-title">当前生效权限</div>
+                            <a-space wrap>
+                                <a-tag v-for="code in effectivePermissionCodes" :key="code">{{ code }}</a-tag>
+                                <span v-if="effectivePermissionCodes.length === 0" class="muted">暂无</span>
+                            </a-space>
+                        </div>
+                    </a-space>
+                </a-tab-pane>
+                <a-tab-pane key="resource" title="权限规则">
+                    <a-alert style="margin-bottom: 12px;">
+                        这里配置页面、操作或接口需要哪些权限。关闭规则或清空权限后,该资源默认不需要权限。
+                    </a-alert>
+                    <div class="permission-resource-list">
+                        <a-card v-for="rule in permissionResources" :key="rule.id" size="small" class="permission-resource-card">
+                            <a-space direction="vertical" fill>
+                                <a-row align="center">
+                                    <a-col :flex="1">
+                                        <div class="permission-title">{{ formatResourceRule(rule) }}</div>
+                                        <div class="permission-code">{{ rule.resource_type }}:{{ rule.resource_key }}</div>
+                                    </a-col>
+                                    <a-col>
+                                        <a-switch v-model="rule.enabled" :checked-value="1" :unchecked-value="0" />
+                                    </a-col>
+                                </a-row>
+                                <a-select v-model="rule.required_codes" multiple allow-search allow-clear placeholder="请选择该资源需要的权限点">
+                                    <a-option v-for="item in permissionPoints" :key="item.code" :value="item.code">
+                                        {{ item.name }}({{ item.code }})
+                                    </a-option>
+                                </a-select>
+                                <a-button size="small" type="primary" :loading="resourceSavingId === rule.id" @click="saveResourceRule(rule)">
+                                    保存规则
+                                </a-button>
+                            </a-space>
+                        </a-card>
+                    </div>
+                </a-tab-pane>
+            </a-tabs>
+            </template>
+        </a-skeleton>
+    </a-modal>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
-import { adminGetUserList, adminChangeLepaoCount } from '@/api/user'
-import { Notification, Message } from '@arco-design/web-vue'
+import { ref, reactive, onMounted, computed } from 'vue'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+import { adminGetUserList, adminChangeLepaoCount, adminSetSendCountAutoApprove, adminSetUserBan } from '@/api/user'
+import { getPermissionPoints, getPermissionResources, getUserPermissions, setUserPermissions, updatePermissionResource } from '@/api/permission'
+import { Notification, Message, Modal } from '@arco-design/web-vue'
+import {
+    buildSelectedPermissionCodes,
+    extractDeniedBasicPermissionCodes,
+    filterSavablePermissionCodes,
+    hasPermission,
+    isBasicPermission
+} from '@/utils/permission'
 
 const visible = ref(false)
 const ok_loading = ref(false)
 const change_lepao_count = reactive({
     userid: '',
-    lepao_count: 0
+    lepao_count: 0,
+    remark: ''
 })
 
 const queryData = reactive({
@@ -119,38 +258,301 @@ const pagination = reactive({
 
 const loading = ref(false)
 const data = ref([])
+const whitelistLoadingUuid = ref('')
+const banLoadingUuid = ref('')
+const permissionVisible = ref(false)
+const permissionLoading = ref(false)
+const permissionSaving = ref(false)
+const permissionCategory = ref('')
+const permissionTab = ref('user')
+const permissionUser = ref(null)
+const permissionPoints = ref([])
+const permissionResources = ref([])
+const selectedPermissionCodes = ref([])
+const effectivePermissionCodes = ref([])
+const resourceSavingId = ref(0)
+
+const hasAnyUserOperationPermission = computed(() => {
+    return [
+        'action.user.changeCount',
+        'action.user.setSendCountAutoApprove',
+        'action.user.ban',
+        'action.user.permissionManage'
+    ].some(code => hasPermission(code))
+})
+
+const filteredPermissionPoints = computed(() => {
+    if (!permissionCategory.value) return permissionPoints.value
+    return permissionPoints.value.filter(item => item.category === permissionCategory.value)
+})
+
+const isSendCountAutoApprove = (record) => Number(record?.send_count_auto_approve) === 1
+const isBanned = (record) => Number(record?.is_banned) === 1
+
+const onSendCountAutoApproveChange = async (record, checked) => {
+    if (!record?.uuid || whitelistLoadingUuid.value) return
+    const next = checked === true ? 1 : 0
+    whitelistLoadingUuid.value = record.uuid
+    try {
+        const res = await adminSetSendCountAutoApprove({
+            userid: record.uuid,
+            send_count_auto_approve: next
+        })
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '保存失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return
+        }
+        record.send_count_auto_approve = next
+        Message.success(next ? '已加入赠送免审白名单' : '已关闭赠送免审')
+    } catch (error) {
+        Notification.error({
+            title: '保存失败',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        whitelistLoadingUuid.value = ''
+    }
+}
+
+const setUserBan = async (record, next) => {
+    banLoadingUuid.value = record.uuid
+    try {
+        const res = await adminSetUserBan({
+            userid: record.uuid,
+            is_banned: next
+        })
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '操作失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return
+        }
+        record.is_banned = next
+        Message.success(next ? '已封禁该用户' : '已解除封禁')
+    } catch (error) {
+        Notification.error({
+            title: '操作失败',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        banLoadingUuid.value = ''
+    }
+}
+
+const onBanChange = async (record, checked) => {
+    if (!record?.uuid || banLoadingUuid.value) return
+    const next = checked === true ? 1 : 0
+    if (next === 1) {
+        Modal.confirm({
+            title: '确认封禁',
+            content: `确定封禁用户「${record.username || record.uuid}」?封禁后该用户将立即下线且无法登录。`,
+            onOk: async () => {
+                await setUserBan(record, next)
+            }
+        })
+        return
+    }
+    await setUserBan(record, 0)
+}
+
+const openPermissionManage = async (record) => {
+    if (!record?.uuid) return
+    permissionUser.value = record
+    permissionVisible.value = true
+    permissionLoading.value = true
+    permissionTab.value = 'user'
+    permissionCategory.value = ''
+    selectedPermissionCodes.value = []
+    effectivePermissionCodes.value = []
+
+    try {
+        const [pointRes, userRes, resourceRes] = await Promise.all([
+            getPermissionPoints(),
+            getUserPermissions({ userid: record.uuid }),
+            getPermissionResources()
+        ])
+
+        if (!pointRes || pointRes.code !== 0)
+            throw new Error(pointRes?.msg || '获取权限点失败')
+        if (!userRes || userRes.code !== 0)
+            throw new Error(userRes?.msg || '获取用户权限失败')
+        if (!resourceRes || resourceRes.code !== 0)
+            throw new Error(resourceRes?.msg || '获取权限规则失败')
+
+        permissionPoints.value = pointRes.data || []
+        permissionResources.value = (resourceRes.data || []).map(item => ({
+            ...item,
+            enabled: Number(item.enabled) === 0 ? 0 : 1,
+            required_codes: item.required_codes || []
+        }))
+        selectedPermissionCodes.value = buildSelectedPermissionCodes({
+            directPermissionCodes: userRes.data?.directPermissionCodes,
+            deniedBasicPermissionCodes: userRes.data?.deniedBasicPermissionCodes
+        })
+        effectivePermissionCodes.value = userRes.data?.effectivePermissionCodes || []
+    } catch (error) {
+        Notification.error({
+            title: '获取权限失败',
+            content: error.message || '请稍后再试'
+        })
+        permissionVisible.value = false
+    } finally {
+        permissionLoading.value = false
+    }
+}
+
+const formatResourceRule = (rule) => {
+    if (rule.resource_type === 'page') return `页面:${rule.resource_key}`
+    if (rule.resource_type === 'action') return `操作:${rule.resource_key}`
+    if (rule.resource_type === 'api') return `接口:${rule.api_method || ''} ${rule.api_path || rule.resource_key}`
+    return rule.resource_key
+}
+
+const saveResourceRule = async (rule) => {
+    resourceSavingId.value = rule.id
+    try {
+        const res = await updatePermissionResource({
+            id: rule.id,
+            enabled: rule.enabled,
+            required_codes: rule.required_codes || []
+        })
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '保存权限规则失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return
+        }
+        Message.success('权限规则已保存')
+    } catch (error) {
+        Notification.error({
+            title: '保存权限规则失败',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        resourceSavingId.value = 0
+    }
+}
 
-const columns = [
+const handlePermissionBeforeOk = async (done) => {
+    if (!permissionUser.value?.uuid) return false
+    permissionSaving.value = true
+    try {
+        const res = await setUserPermissions({
+            userid: permissionUser.value.uuid,
+            permissionCodes: filterSavablePermissionCodes(selectedPermissionCodes.value),
+            deniedBasicPermissionCodes: extractDeniedBasicPermissionCodes(selectedPermissionCodes.value)
+        })
 
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '保存权限失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return false
+        }
+
+        effectivePermissionCodes.value = res.data?.effectivePermissionCodes || []
+        Message.success('权限已保存')
+        done()
+        return true
+    } catch (error) {
+        Notification.error({
+            title: '保存权限失败',
+            content: error.message || '请稍后再试'
+        })
+        return false
+    } finally {
+        permissionSaving.value = false
+    }
+}
+
+const { useTableColumns } = useResponsiveTable()
+
+/** 列宽合计,移动端横向滚动查看全部字段 */
+const TABLE_SCROLL_X = 1650
+
+const columnDefs = [
     {
         title: 'UUID',
         dataIndex: 'uuid',
-    }, {
+        width: 200,
+        ellipsis: true,
+        tooltip: true
+    },
+    {
         title: '用户名',
         slotName: 'username',
-    }, {
-        title: '昵称',
-        dataIndex: 'nickname',
-    }, {
+        width: 180,
+        ellipsis: true,
+        tooltip: true
+    },
+    {
         title: '邮箱',
         dataIndex: 'email',
+        width: 200,
+        ellipsis: true,
+        tooltip: true
+    },
+    {
+        title: 'QQ 绑定',
+        slotName: 'qq_bind',
+        width: 160,
+        ellipsis: true,
+        tooltip: true
+    },
+    {
+        title: '微信绑定',
+        slotName: 'wx_bind',
+        width: 160,
+        ellipsis: true,
+        tooltip: true
+    },
+    {
+        title: '登录方式',
+        slotName: 'last_login_type',
+        width: 120,
+        ellipsis: true,
+        tooltip: true
     },
     {
-        title: '登陆方式',
-        dataIndex: 'social_type',
-    }, {
         title: '注册时间',
-        slotName: 'registTime'
-    }, {
+        slotName: 'registTime',
+        width: 170,
+        ellipsis: true,
+        tooltip: true
+    },
+    {
         title: '上次登录时间',
-        slotName: 'lastTime'
-    }, {
-        title: '剩余乐跑里程(km)',
+        slotName: 'lastTime',
+        width: 170,
+        ellipsis: true,
+        tooltip: true
+    },
+    {
+        title: '剩余乐跑次数',
         dataIndex: 'lepao_count',
-    }, {
+        width: 120
+    },
+    {
+        title: '账号状态',
+        slotName: 'is_banned_status',
+        width: 100
+    },
+    {
         title: '操作',
-        slotName: 'optional'
-    }]
+        slotName: 'optional',
+        width: 120,
+        align: 'center',
+        fixed: 'right'
+    }
+]
+
+const tableColumns = useTableColumns(columnDefs)
 
 const search = () => {
     pagination.current = 1
@@ -196,6 +598,7 @@ const changeCount = (item) => {
     if (item) {
         change_lepao_count.userid = item.uuid
         change_lepao_count.lepao_count = item.lepao_count
+        change_lepao_count.remark = ''
     }
     else return
     visible.value = true
@@ -257,14 +660,145 @@ const stramptoTime = (time) => {
     return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
 }
 
+/** login_history.type:最近一次登录方式 */
+const formatLastLoginType = (type) => {
+    if (type === null || type === undefined || type === '') return '无记录'
+    const t = String(type).toLowerCase()
+    if (t === 'password') return '账号密码'
+    if (t === 'qq') return 'QQ'
+    if (t === 'wx') return '微信'
+    return type
+}
+
 </script>
 
 <style scoped>
-.container {
-    padding: 0 20px 20px 20px;
+.query-row {
+    margin-bottom: 10px;
 }
 
-.table {
+.query-main {
+    min-width: 0;
+}
+
+.query-actions-space {
+    width: 100%;
+}
+
+.table-wrap {
+    width: 100%;
+    overflow-x: auto;
+    -webkit-overflow-scrolling: touch;
     margin-top: 15px;
 }
+
+.table {
+    margin-top: 0;
+    min-width: 100%;
+}
+
+.table :deep(.arco-table-td),
+.table :deep(.arco-table-th) {
+    overflow: hidden;
+}
+
+.user-cell {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    max-width: 100%;
+    min-width: 0;
+    vertical-align: middle;
+}
+
+.user-cell__avatar {
+    flex-shrink: 0;
+}
+
+.user-cell__name {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.bind-cell {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    max-width: 100%;
+    min-width: 0;
+    vertical-align: middle;
+}
+
+.bind-cell__avatar {
+    flex-shrink: 0;
+}
+
+.bind-cell__text {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.muted {
+    color: var(--color-text-3);
+}
+
+.permission-list {
+    width: 100%;
+    max-height: 420px;
+    overflow-y: auto;
+}
+
+.permission-title {
+    font-weight: 500;
+    color: var(--color-text-1);
+}
+
+.permission-code,
+.permission-remark {
+    margin-top: 4px;
+    color: var(--color-text-3);
+    font-size: 12px;
+}
+
+.permission-section-title {
+    margin-bottom: 8px;
+    font-weight: 500;
+}
+
+.permission-card-basic {
+    opacity: 0.85;
+}
+
+.permission-resource-list {
+    max-height: 480px;
+    overflow-y: auto;
+}
+
+.permission-resource-card + .permission-resource-card {
+    margin-top: 12px;
+}
+
+@media (max-width: 768px) {
+    .query-row {
+        display: block;
+    }
+
+    .query-divider {
+        display: none;
+    }
+
+    .query-actions-space {
+        margin-top: 4px;
+        flex-direction: row !important;
+        gap: 10px !important;
+    }
+
+    .query-actions-space :deep(.arco-btn) {
+        flex: 1;
+    }
+}
 </style>

+ 99 - 40
src/pages/admin/workOrder/orderDetail.vue

@@ -1,8 +1,19 @@
-<template>
-    <div class="container">
-        <Breadcrumb :items="['网站管理', '工单管理']" />
+<template>
+    <div class="store-page">
+        <Breadcrumb />
         <a-card title="工单详情" :loading="loading">
-            <a-descriptions :data="info" :column="2" />
+            <a-descriptions :column="{ xs: 1, sm: 2 }" bordered size="medium" class="order-info-desc">
+                <a-descriptions-item label="工单标题">{{ data.title || '-' }}</a-descriptions-item>
+                <a-descriptions-item label="工单ID">{{ data.id || '-' }}</a-descriptions-item>
+                <a-descriptions-item label="发起人">
+                    {{ data.userInfo?.[data.create_user]?.username || '-' }}
+                </a-descriptions-item>
+                <a-descriptions-item label="发起人UUID">{{ data.create_user || '-' }}</a-descriptions-item>
+                <a-descriptions-item label="通知邮箱">{{ data.email || '-' }}</a-descriptions-item>
+                <a-descriptions-item label="当前状态">{{ getState(data.state) }}</a-descriptions-item>
+                <a-descriptions-item label="创建时间">{{ data.create_time ? stramptoTime(data.create_time) : '-' }}</a-descriptions-item>
+                <a-descriptions-item label="最后更新时间">{{ data.update_time ? stramptoTime(data.update_time) : '-' }}</a-descriptions-item>
+            </a-descriptions>
             <div class="buttonGroup">
                 <a-button type="primary" size="large" :loading="buttonLoading" @click="CloseOrder()"
                     :disabled="data.state === 2">{{ data.state === 2 ? '已关闭' :
@@ -14,10 +25,12 @@
             <div class="message" v-for="(msg, index) in data.msg">
                 <a-divider v-if="index !== 0" />
                 <div class="head">
-                    <a-avatar>
-                        <IconUser v-if="!data.userInfo[msg.uuid]?.avatar" />
-                        <img alt="" :src="data.userInfo[msg.uuid]?.avatar" v-else />
-                    </a-avatar>
+                    <div class="head__avatar-wrap">
+                        <a-avatar :size="36">
+                            <IconUser v-if="!data.userInfo[msg.uuid]?.avatar" />
+                            <img alt="" :src="data.userInfo[msg.uuid]?.avatar" v-else />
+                        </a-avatar>
+                    </div>
 
                     <div class="right">
                         <div class="username">
@@ -40,6 +53,12 @@
                                 </template>
                                 客服回复
                             </a-tag>
+                            <a-tag color="purple" v-else-if="msg.type === 'ai'">
+                                <template #icon>
+                                    <icon-robot />
+                                </template>
+                                AI回复
+                            </a-tag>
                         </div>
                         <div class="time">
                             {{ stramptoTime(msg.time) }}
@@ -59,7 +78,7 @@
         </a-card>
 
         <a-card title="回复工单" style="margin-top: 15px;" v-if="data.state !== 2">
-            <a-form :model="form" :rules="rules" layout="vertical" :style="{ width: '600px' }"
+            <a-form :model="form" :rules="rules" layout="vertical" class="reply-form"
                 @submit-success="handleSubmit">
                 <a-form-item field="content" label="内容">
                     <a-textarea v-model="form.content" placeholder="请详细说明您遇到的问题,详细的阐述有助于我们快速为您解决问题..." :max-length="200"
@@ -93,7 +112,6 @@ const loading = ref(false)
 const formLoading = ref(false)
 const buttonLoading = ref(false)
 const data = ref({})
-const info = ref([])
 
 const form = reactive({
     content: '',
@@ -127,6 +145,8 @@ function getState(state) {
             return '已回复'
         case 2:
             return '已关闭'
+        case 3:
+            return 'AI回复'
     }
     return '未知'
 }
@@ -193,17 +213,6 @@ const getOrderDetail = async () => {
             })
 
         data.value = res.data
-
-        info.value = [
-            { label: '工单标题', value: res.data.title },
-            { label: '工单ID', value: res.data.id },
-            { label: '发起人', value: res.data.userInfo[res.data.create_user]?.username },
-            { label: '发起人UUID', value: res.data.create_user },
-            { label: '通知邮箱', value: res.data.email },
-            { label: '创建时间', value: stramptoTime(res.data.create_time) },
-            { label: '最后更新时间', value: stramptoTime(res.data.update_time) },
-            { label: '当前状态', value: getState(res.data.state) }
-        ]
     } catch (error) {
         Notification.error({
             title: '获取路径数据失败!',
@@ -249,50 +258,100 @@ const stramptoTime = (time) => {
 </script>
 
 <style scoped lang="less">
-.container {
-    padding: 0 20px 20px 20px;
-
-    .buttonGroup {
-        display: flex;
-        justify-content: center;
-        gap: 20px;
-        margin: 15px;
-    }
-}
+@import '@/styles/store-theme.less';
 
 .message {
     display: flex;
     flex-direction: column;
-    margin-bottom: 5px;
+    margin-bottom: 12px;
 
     .head {
         display: flex;
+        align-items: flex-start;
+        gap: 12px;
+
+        .head__avatar-wrap {
+            flex: 0 0 36px;
+            width: 36px;
+            min-width: 36px;
+            height: 36px;
+            overflow: hidden;
+            border-radius: 50%;
+
+            :deep(.arco-avatar) {
+                display: block;
+                width: 36px;
+                height: 36px;
+            }
+
+            :deep(img) {
+                width: 36px;
+                height: 36px;
+                object-fit: cover;
+            }
+        }
 
         .right {
+            flex: 1;
+            min-width: 0;
             display: flex;
             flex-direction: column;
-            margin-left: 10px;
+            gap: 4px;
 
             .username {
-                font-size: 1.2em;
+                font-size: 1rem;
+                font-weight: 600;
                 display: flex;
-                gap: 10px
+                flex-wrap: wrap;
+                align-items: center;
+                gap: 8px;
+                color: @store-primary;
             }
 
             .time {
-                font-size: 0.9em;
-                color: #777;
+                font-size: 0.8rem;
+                color: @store-text-muted;
             }
         }
     }
 
     .content {
-        margin-top: 10px;
+        margin-top: 8px;
+        margin-left: 48px;
+        display: inline-block;
+        width: fit-content;
+        max-width: calc(100% - 48px);
+        padding: 12px 14px;
+        border-radius: @store-radius-sm;
+        background: @store-bg;
+        line-height: 1.65;
+        word-break: break-word;
+        color: @store-text-muted;
     }
 
     .filebox {
-        margin-top: -10px;
-        max-width: 500px;
+        margin-top: 8px;
+        margin-left: 48px;
+        max-width: calc(100% - 48px);
+    }
+}
+
+.order-info-desc {
+    :deep(.arco-descriptions-item-label) {
+        white-space: nowrap;
     }
+
+    :deep(.arco-descriptions-item-value) {
+        word-break: break-all;
+    }
+}
+
+.buttonGroup {
+    margin-top: 16px;
+}
+
+.reply-form {
+    max-width: 640px;
+    width: 100%;
 }
 </style>

+ 72 - 18
src/pages/admin/workOrder/orderList.vue

@@ -1,11 +1,12 @@
 <template>
-    <div class="container">
-        <Breadcrumb :items="['网站管理', '工单管理']" />
+    <div class="store-page">
+        <Breadcrumb />
 
         <a-card title="工单列表">
-            <a-row>
-                <a-col :flex="1">
-                    <a-form :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
+            <AppQueryFilter>
+            <a-row class="query-row">
+                <a-col :flex="'1000px'" class="query-main">
+                    <a-form class="queryForm app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
                         label-align="left">
                         <a-row :gutter="16">
                             <a-col :span="8">
@@ -17,16 +18,16 @@
 
                             <a-col :span="8">
                                 <a-form-item field="state" label="状态">
-                                    <a-select v-model="queryData.state" :options="state" placeholder="请选择路径状态"
+                                    <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-divider class="query-divider" style="height: 84px" direction="vertical" />
+                <a-col :flex="1" class="app-query-actions">
+                    <a-space class="query-actions-space" direction="vertical" :size="18">
                         <a-button type="primary" @click="search">
                             <template #icon>
                                 <icon-search />
@@ -42,9 +43,11 @@
                     </a-space>
                 </a-col>
             </a-row>
+            </AppQueryFilter>
 
 
-            <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
+            <a-table :bordered="false" :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
+                :scroll="{ x: 980 }"
                 :pagination="{
                     showPageSize: true,
                     showJumper: true,
@@ -85,6 +88,12 @@
                         </template>
                         已关闭
                     </a-tag>
+                    <a-tag color="purple" v-else-if="record.state === 3">
+                        <template #icon>
+                            <icon-robot />
+                        </template>
+                        AI回复
+                    </a-tag>
                 </template>
                 <template #optional="{ record }">
                     <a-button @click="$router.push(`/admin/service/orderDetail/${record.id}`)">查看详情</a-button>
@@ -98,6 +107,7 @@
 import { ref, reactive, onMounted } from 'vue'
 import { adminOrderList } from '@/api/workOrder'
 import { Notification } from '@arco-design/web-vue'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 
 const pagination = reactive({
     total: 0,
@@ -106,7 +116,7 @@ const pagination = reactive({
 })
 
 const state = [
-    { label: '全部', value: -1 }, { label: '待处理', value: 0 }, { label: '已回复', value: 1 }, { label: '已关闭', value: 2 }
+    { label: '全部', value: -1 }, { label: '待处理', value: 0 }, { label: '已回复', value: 1 }, { label: '已关闭', value: 2 }, { label: 'AI回复', value: 3 }
 ]
 const queryData = reactive({
     id: '',
@@ -125,36 +135,52 @@ const reset = () => {
     getOrderList()
 }
 
-const columns = [
+const columnDefs = [
     {
         title: '工单ID',
         dataIndex: 'id',
+        width: 90
     },
     {
         title: '工单标题',
         dataIndex: 'title',
+        width: 180,
+        ellipsis: true,
+        tooltip: true
     }, {
         title: '发起人',
         slotName: 'username',
+        width: 140
     },
     {
         title: '提醒邮箱',
         dataIndex: 'email',
+        width: 180,
+        ellipsis: true,
+        tooltip: true
     }, {
         title: '创建时间',
-        slotName: 'create_time'
+        slotName: 'create_time',
+        width: 170
     }, {
         title: '最后更新时间',
-        slotName: 'update_time'
+        slotName: 'update_time',
+        width: 170
     }, {
         title: '状态',
-        slotName: 'state'
+        slotName: 'state',
+        width: 110
     }, {
         title: '操作',
-        slotName: 'optional'
+        slotName: 'optional',
+        width: 120,
+        fixed: 'right'
     }
 ]
 
+const { useTableColumns } = useResponsiveTable()
+const columns = useTableColumns(columnDefs)
+
 const loading = ref(false)
 const data = ref([])
 
@@ -210,11 +236,39 @@ const stramptoTime = (time) => {
 </script>
 
 <style scoped>
-.container {
-    padding: 0 20px 20px 20px;
+.query-row {
+    margin-bottom: 10px;
+}
+
+.query-main {
+    min-width: 0;
+}
+
+.query-actions-space {
+    width: 100%;
 }
 
 .table {
     margin-top: 15px;
 }
+
+@media (max-width: 768px) {
+    .query-row {
+        display: block;
+    }
+
+    .query-divider {
+        display: none;
+    }
+
+    .query-actions-space {
+        margin-top: 4px;
+        flex-direction: row !important;
+        gap: 10px !important;
+    }
+
+    .query-actions-space :deep(.arco-btn) {
+        flex: 1;
+    }
+}
 </style>

+ 150 - 0
src/pages/doc/detail.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-skeleton animation :loading="loading" class="doc-detail-skeleton">
+      <StoreDetailSkeleton />
+      <template #content>
+        <article v-if="article" class="doc-article">
+          <header class="doc-article__header">
+            <div class="doc-article__title-row">
+              <GoodsEmoji :icon="article.cover" size="xl" :fallback="DEFAULT_ARTICLE_EMOJI" :aria-label="article.title" />
+              <h1 class="store-section-title">{{ article.title }}</h1>
+            </div>
+            <div class="doc-article__meta">
+              <a-tag size="small">{{ categoryName }}</a-tag>
+              <span>{{ formatTime(article.time) }}</span>
+            </div>
+          </header>
+          <div class="rich-content" v-html="article.content" />
+        </article>
+        <a-empty v-else description="文档不存在或已下线">
+          <a-button type="primary" @click="$router.push('/doc')">返回文档中心</a-button>
+        </a-empty>
+      </template>
+    </a-skeleton>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { Notification } from '@arco-design/web-vue'
+import { getArticle, getArticleCategories } from '@/api/article'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
+import { DEFAULT_ARTICLE_EMOJI } from '@/utils/storeFormat'
+
+const route = useRoute()
+const loading = ref(false)
+const article = ref(null)
+const categories = ref([])
+
+const categoryName = computed(() => {
+  if (!article.value) return ''
+  const cat = categories.value.find(c => c.slug === article.value.type)
+  return cat?.name || article.value.type || '文档'
+})
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN')
+}
+
+const loadCategories = async () => {
+  const res = await getArticleCategories()
+  if (res?.code === 0) categories.value = res.data || []
+}
+
+const loadArticle = async () => {
+  const slug = route.params.slug
+  if (!slug) return
+  loading.value = true
+  article.value = null
+  try {
+    const res = await getArticle({ slug })
+    if (!res || res.code !== 0) {
+      return
+    }
+    article.value = res.data
+    if (article.value?.title) {
+      document.title = `${article.value.title} - 哪吒乐跑`
+    }
+  } catch (e) {
+    Notification.error({ title: '加载文档失败', content: e.message || '请稍后再试' })
+  } finally {
+    loading.value = false
+  }
+}
+
+watch(() => route.params.slug, loadArticle)
+onMounted(async () => {
+  await loadCategories()
+  await loadArticle()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.doc-detail-skeleton {
+  width: 100%;
+}
+
+.doc-article {
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  padding: clamp(16px, 4vw, 32px);
+  box-shadow: @store-shadow;
+
+  &__header {
+    margin-bottom: 20px;
+  }
+
+  &__title-row {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    flex-wrap: wrap;
+  }
+
+  &__meta {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px 16px;
+    font-size: 0.8rem;
+    color: @store-text-muted;
+    margin-top: 12px;
+  }
+}
+
+.rich-content {
+  line-height: 1.75;
+  color: @store-text-muted;
+  word-break: break-word;
+
+  :deep(img) {
+    max-width: 100%;
+    height: auto;
+  }
+
+  :deep(h1),
+  :deep(h2),
+  :deep(h3) {
+    color: @store-primary;
+    margin-top: 1.25em;
+  }
+
+  :deep(a) {
+    color: @store-accent;
+  }
+
+  :deep(pre),
+  :deep(code) {
+    background: @store-bg;
+    border-radius: 4px;
+  }
+}
+</style>

+ 220 - 0
src/pages/doc/index.vue

@@ -0,0 +1,220 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <header class="page-header">
+      <div>
+        <h1 class="store-section-title">文档中心</h1>
+        <p class="store-section-desc">使用指南、操作说明与常见问题</p>
+      </div>
+    </header>
+
+    <a-skeleton animation :loading="categoriesLoading" class="doc-tabs-skeleton">
+      <a-skeleton-line :widths="['88px', '96px', '80px', '72px']" :rows="1" />
+      <template #content>
+        <a-tabs v-if="categories.length" v-model:active-key="activeType" @change="onTabChange">
+          <a-tab-pane v-for="cat in categories" :key="cat.slug">
+            <template #title>
+              <span>{{ cat.icon || '' }} {{ cat.name }}</span>
+            </template>
+          </a-tab-pane>
+        </a-tabs>
+        <a-empty v-else description="暂无文档分类" />
+      </template>
+    </a-skeleton>
+
+    <a-skeleton animation :loading="listLoading" class="doc-list-skeleton">
+      <StoreGridSkeleton :count="6" />
+      <template #content>
+        <div v-if="articles.length" class="doc-grid">
+          <article
+            v-for="item in articles"
+            :key="item.slug"
+            class="doc-card"
+            @click="goDetail(item.slug)"
+          >
+            <div class="doc-card__visual">
+              <GoodsEmoji :icon="item.cover" size="lg" :fallback="DEFAULT_ARTICLE_EMOJI" :aria-label="item.title" />
+            </div>
+            <div class="doc-card__body">
+              <h3 class="doc-card__title">{{ item.title }}</h3>
+              <p v-if="item.describe" class="doc-card__desc">{{ item.describe }}</p>
+              <div class="doc-card__meta">
+                <!-- <span>{{ item.author || 'RunForge' }}</span> -->
+                <span>{{ formatTime(item.time) }}</span>
+                <!-- <span>{{ item.views ?? 0 }} 次阅读</span> -->
+              </div>
+            </div>
+          </article>
+        </div>
+        <a-empty v-else-if="activeType" description="该分类下暂无文档" />
+      </template>
+    </a-skeleton>
+
+    <div v-if="pagination.total > pagination.pagesize" class="doc-pagination">
+      <a-pagination
+        :total="pagination.total"
+        :current="pagination.current"
+        :page-size="pagination.pagesize"
+        show-total
+        @change="onPageChange"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { Notification } from '@arco-design/web-vue'
+import { getArticleCategories, getArticleList } from '@/api/article'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import StoreGridSkeleton from '@/components/skeleton/StoreGridSkeleton.vue'
+import { DEFAULT_ARTICLE_EMOJI } from '@/utils/storeFormat'
+
+const router = useRouter()
+const categories = ref([])
+const categoriesLoading = ref(false)
+const listLoading = ref(false)
+const articles = ref([])
+const activeType = ref('')
+const pagination = reactive({ total: 0, current: 1, pagesize: 12 })
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleDateString('zh-CN')
+}
+
+const loadCategories = async () => {
+  categoriesLoading.value = true
+  try {
+    const res = await getArticleCategories()
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '加载分类失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    categories.value = res.data || []
+    if (categories.value.length) {
+      activeType.value = categories.value[0].slug
+      await loadArticles()
+    }
+  } finally {
+    categoriesLoading.value = false
+  }
+}
+
+const loadArticles = async () => {
+  if (!activeType.value) return
+  listLoading.value = true
+  try {
+    const res = await getArticleList({
+      type: activeType.value,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '加载文档失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    articles.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } finally {
+    listLoading.value = false
+  }
+}
+
+const onTabChange = () => {
+  pagination.current = 1
+  loadArticles()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  loadArticles()
+}
+
+const goDetail = (slug) => {
+  router.push(`/doc/${slug}`)
+}
+
+onMounted(loadCategories)
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.doc-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
+  gap: 16px;
+}
+
+.doc-card {
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  overflow: hidden;
+  cursor: pointer;
+  transition: transform 0.2s, box-shadow 0.2s;
+
+  &:hover {
+    transform: translateY(-2px);
+    box-shadow: @store-shadow-hover;
+  }
+
+  &__visual {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 120px;
+    background: @store-cover-bg;
+    border-bottom: 1px solid @store-card-border;
+  }
+
+  &__body {
+    padding: 16px;
+  }
+
+  &__title {
+    margin: 0 0 8px;
+    font-size: 1.05rem;
+    color: @store-primary;
+    font-weight: 600;
+    line-height: 1.4;
+  }
+
+  &__desc {
+    margin: 0 0 12px;
+    font-size: 0.875rem;
+    color: @store-text-muted;
+    line-height: 1.5;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  &__meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px 12px;
+    font-size: 0.75rem;
+    color: fade(@store-text-muted, 80%);
+  }
+}
+
+.doc-tabs-skeleton {
+  width: 100%;
+}
+
+.doc-list-skeleton {
+  width: 100%;
+  margin-top: 16px;
+}
+
+.doc-pagination {
+  margin-top: 24px;
+  display: flex;
+  justify-content: center;
+}
+</style>

File diff suppressed because it is too large
+ 50 - 74
src/pages/download/index.vue


+ 255 - 0
src/pages/ic/freeSeats.vue

@@ -0,0 +1,255 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="座位查询">
+      <AppQueryFilter>
+        <a-row class="queryForm app-query-form query-row">
+          <a-col :flex="'900px'" class="query-main">
+            <a-form :model="query" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
+              <a-row :gutter="16">
+                <a-col :span="8">
+                  <a-form-item label="查询账号">
+                    <a-select v-model="query.username" placeholder="请选择账号" allow-search>
+                      <a-option v-for="item in accounts" :key="item.username" :value="item.username">
+                        {{ item.username }}({{ item.realname || '未知' }})
+                      </a-option>
+                    </a-select>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="8">
+                  <a-form-item label="座位区域">
+                    <a-cascader
+                      v-model="query.roomId"
+                      :options="menu"
+                      placeholder="请选择座位区域"
+                      :field-names="{ label: 'name', value: 'id', children: 'children' }"
+                      check-strictly
+                      allow-clear
+                    />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="8">
+                  <a-form-item label="查询日期">
+                    <a-date-picker v-model="query.date" placeholder="请选择日期" style="width: 100%" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 42px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-button type="primary" @click="searchSeats">
+              <template #icon><icon-search /></template>
+              查询
+            </a-button>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+    </a-card>
+
+    <a-card style="margin-top: 16px">
+      <a-result v-if="noAccount" status="warning" title="您还没有可使用的统一身份认证账号">
+        <template #extra>
+          <a-button type="primary" @click="$router.push({ name: 'jwAccount.accountList' })">去添加</a-button>
+        </template>
+      </a-result>
+
+      <a-spin v-else :loading="loading" style="width: 100%">
+        <a-empty v-if="!loading && seats.length === 0" description="请选择条件后查询座位" />
+        <a-row v-else :gutter="16">
+          <a-col v-for="item in seats" :key="item.devId" :xs="24" :sm="12" :md="8" :lg="6" style="margin-bottom: 16px">
+            <a-card hoverable>
+              <template #title>
+                <a-space>
+                  <a-badge :status="item.resvInfo?.length ? 'warning' : 'success'" />
+                  {{ item.devName }}
+                </a-space>
+              </template>
+              <p><b>座位区域:</b>{{ item.labName }} - {{ item.roomName }}</p>
+              <p>
+                <b>占用时段:</b>
+                <template v-if="item.resvInfo?.length">
+                  <span v-for="(record, index) in item.resvInfo" :key="index">
+                    <span v-if="index">、</span>
+                    {{ formatTimestamp(record.startTime) }}~{{ formatTimeOnly(record.endTime) }}
+                  </span>
+                </template>
+                <span v-else>空闲</span>
+              </p>
+              <p><b>预约时长:</b>{{ item.resvRule?.minResvTime }}~{{ item.resvRule?.maxResvTime }}分</p>
+              <p><b>提前预约:</b>{{ item.resvRule?.deadlineTime }}~{{ item.resvRule?.rangeNum }}天</p>
+              <template #actions>
+                <span @click="openOrderModal(item)"><icon-rocket /> 立即预约</span>
+                <span @click="handleAddStar(item)"><icon-plus-circle /> 加入志愿</span>
+              </template>
+            </a-card>
+          </a-col>
+        </a-row>
+      </a-spin>
+    </a-card>
+
+    <a-modal
+      v-model:visible="orderVisible"
+      title="预定座位"
+      @before-ok="submitOrder"
+      :ok-loading="orderSaving"
+      draggable
+      esc-to-close
+    >
+      <a-form :model="orderForm" layout="vertical">
+        <a-form-item label="申请人" required>
+          <a-select v-model="orderForm.username" placeholder="请选择申请账号">
+            <a-option v-for="item in accounts" :key="item.username" :value="item.username">
+              {{ item.username }}({{ item.realname || '未知' }})
+            </a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="申请时间" required>
+          <a-range-picker
+            v-model="orderForm.dateRange"
+            show-time
+            format="YYYY-MM-DD HH:mm"
+            style="width: 100%"
+          />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getJwAccount } from '@/api/jwAccount'
+import { getSeatMenu, getSeatInfo, orderSeat, saveStar } from '@/api/ic'
+import { formatTimestamp, formatTimeOnly, formatDateCompact } from '@/utils/icTime'
+
+const loading = ref(false)
+const noAccount = ref(false)
+const orderVisible = ref(false)
+const orderSaving = ref(false)
+const accounts = ref([])
+const menu = ref([])
+const seats = ref([])
+const currentSeat = ref(null)
+
+const query = reactive({
+  username: '',
+  roomId: [],
+  date: new Date()
+})
+
+const orderForm = reactive({
+  username: '',
+  dateRange: []
+})
+
+const loadAccounts = async () => {
+  const res = await getJwAccount()
+  if (res?.code !== 0) {
+    Notification.error({ title: '获取账号失败', content: res?.msg })
+    return
+  }
+  const verified = (res.data || []).filter((item) => item.state === 1)
+  accounts.value = verified
+  noAccount.value = verified.length === 0
+  if (verified.length) {
+    query.username = verified[0].username
+    orderForm.username = verified[0].username
+    await loadMenu()
+  }
+}
+
+const loadMenu = async () => {
+  if (!query.username) return
+  const res = await getSeatMenu({ username: query.username })
+  if (res?.code !== 0) {
+    Notification.error({ title: '获取位置目录失败', content: res?.msg })
+    return
+  }
+  menu.value = res.data || []
+}
+
+const searchSeats = async () => {
+  if (!query.username || !query.roomId?.length || !query.date) {
+    Notification.warning({ title: '请完整填写查询条件' })
+    return
+  }
+  loading.value = true
+  try {
+    const roomId = query.roomId[query.roomId.length - 1]
+    const date = formatDateCompact(query.date instanceof Date ? query.date : new Date(query.date))
+    const res = await getSeatInfo({ username: query.username, roomId, date })
+    if (res?.code !== 0) {
+      Notification.error({ title: '获取座位信息失败', content: res?.msg })
+      return
+    }
+    seats.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openOrderModal = (item) => {
+  currentSeat.value = item
+  const baseDate = query.date instanceof Date ? query.date : new Date(query.date)
+  orderForm.dateRange = [baseDate, baseDate]
+  orderVisible.value = true
+}
+
+const formatDateTime = (date) => {
+  const d = date instanceof Date ? date : new Date(date)
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  const h = String(d.getHours()).padStart(2, '0')
+  const min = String(d.getMinutes()).padStart(2, '0')
+  return `${y}-${m}-${day} ${h}:${min}:00`
+}
+
+const submitOrder = async () => {
+  if (!orderForm.username || orderForm.dateRange?.length !== 2) {
+    Notification.error({ title: '请完整填写预约信息' })
+    return false
+  }
+  orderSaving.value = true
+  try {
+    const res = await orderSeat({
+      resvDev: [currentSeat.value.devId],
+      resvBeginTime: formatDateTime(orderForm.dateRange[0]),
+      resvEndTime: formatDateTime(orderForm.dateRange[1]),
+      username: orderForm.username
+    })
+    if (res?.code !== 0) {
+      Notification.error({ title: '预约失败', content: res?.msg })
+      return false
+    }
+    Notification.success({ title: '预约成功', content: '请按时前往预定位置,避免违约' })
+    return true
+  } catch (error) {
+    Notification.error({ title: '预约失败', content: error.message })
+    return false
+  } finally {
+    orderSaving.value = false
+  }
+}
+
+const handleAddStar = async (item) => {
+  const res = await saveStar({ devId: item.devId, name: item.devName })
+  if (res?.code !== 0) {
+    Notification.error({ title: '添加志愿失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '添加成功', content: '您可在志愿列表中查看' })
+}
+
+onMounted(loadAccounts)
+</script>
+
+<style scoped>
+p {
+  margin: 4px 0;
+  font-size: 13px;
+}
+</style>

+ 183 - 0
src/pages/ic/myOrder.vue

@@ -0,0 +1,183 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="我的预约">
+      <AppQueryFilter>
+        <a-row class="queryForm app-query-form query-row">
+          <a-col :flex="'700px'" class="query-main">
+            <a-form :model="query" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
+              <a-row :gutter="16">
+                <a-col :span="12">
+                  <a-form-item label="查询账号">
+                    <a-select v-model="query.username" placeholder="请选择账号">
+                      <a-option v-for="item in accounts" :key="item.username" :value="item.username">
+                        {{ item.username }}({{ item.realname || '未知' }})
+                      </a-option>
+                    </a-select>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="查询日期">
+                    <a-range-picker v-model="query.dateRange" style="width: 100%" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 42px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-button type="primary" @click="refreshTable">
+              <template #icon><icon-search /></template>
+              查询
+            </a-button>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-result v-if="noAccount" status="warning" title="您还没有可使用的统一身份认证账号">
+        <template #extra>
+          <a-button type="primary" @click="$router.push({ name: 'jwAccount.accountList' })">去添加</a-button>
+        </template>
+      </a-result>
+
+      <a-table
+        v-else
+        row-key="uuid"
+        :data="data"
+        :loading="loading"
+        :pagination="pagination"
+        @page-change="onPageChange"
+        :scroll="{ x: 1100 }"
+      >
+        <template #columns>
+          <a-table-column title="预约日期" data-index="resvDate" :width="120" />
+          <a-table-column title="开始时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.resvBeginTime) }}</template>
+          </a-table-column>
+          <a-table-column title="结束时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.resvEndTime) }}</template>
+          </a-table-column>
+          <a-table-column title="预定时间" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.resvCheckTime) }}</template>
+          </a-table-column>
+          <a-table-column title="实际结束" :width="170">
+            <template #cell="{ record }">{{ formatTimestamp(record.resvEndRealTime) }}</template>
+          </a-table-column>
+          <a-table-column title="座位信息" :width="180">
+            <template #cell="{ record }">
+              {{ record.resvDevInfoList?.[0]?.roomName }}-{{ record.resvDevInfoList?.[0]?.devName }}
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="180" fixed="right">
+            <template #cell="{ record }">
+              <a-space>
+                <a-popconfirm content="该操作不可逆,确认取消预约?" @ok="handleDelete(record)">
+                  <a-button type="text" status="danger" size="small">取消预约</a-button>
+                </a-popconfirm>
+                <a-popconfirm content="确认提前结束本次预约?" @ok="handleEndAhead(record)">
+                  <a-button type="text" size="small">提前结束</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getJwAccount } from '@/api/jwAccount'
+import { myOrder, deleteSeat, endAhead } from '@/api/ic'
+import { formatTimestamp, formatDateStr } from '@/utils/icTime'
+
+const loading = ref(false)
+const noAccount = ref(false)
+const accounts = ref([])
+const data = ref([])
+
+const query = reactive({
+  username: '',
+  dateRange: [new Date(), new Date()]
+})
+
+const pagination = reactive({
+  current: 1,
+  pageSize: 10,
+  total: 0
+})
+
+const loadAccounts = async () => {
+  const res = await getJwAccount()
+  if (res?.code !== 0) {
+    Notification.error({ title: '获取账号失败', content: res?.msg })
+    return
+  }
+  const verified = (res.data || []).filter((item) => item.state === 1)
+  accounts.value = verified
+  noAccount.value = verified.length === 0
+  if (verified.length) {
+    query.username = verified[0].username
+    await loadList()
+  }
+}
+
+const loadList = async () => {
+  if (!accounts.value.length) return
+  loading.value = true
+  try {
+    const beginDate = formatDateStr(query.dateRange[0] instanceof Date ? query.dateRange[0] : new Date(query.dateRange[0]))
+    const endDate = formatDateStr(query.dateRange[1] instanceof Date ? query.dateRange[1] : new Date(query.dateRange[1]))
+    const res = await myOrder({
+      username: query.username,
+      beginDate,
+      endDate,
+      page: pagination.current,
+      pageNum: pagination.pageSize
+    })
+    if (res?.code !== 0) {
+      Notification.error({ title: '获取预约信息失败', content: res?.msg })
+      return
+    }
+    data.value = Array.isArray(res.data) ? res.data : res.data?.list || []
+    pagination.total = res.data?.count ?? data.value.length
+  } finally {
+    loading.value = false
+  }
+}
+
+const refreshTable = () => {
+  pagination.current = 1
+  loadList()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  loadList()
+}
+
+const handleDelete = async (record) => {
+  const res = await deleteSeat({ username: record.logonName, seatuuid: record.uuid })
+  if (res?.code !== 0) {
+    Notification.error({ title: '取消预约失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '取消预约成功' })
+  loadList()
+}
+
+const handleEndAhead = async (record) => {
+  const res = await endAhead({ username: record.logonName, seatuuid: record.uuid })
+  if (res?.code !== 0) {
+    Notification.error({ title: '提前结束失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '提前结束成功' })
+  loadList()
+}
+
+onMounted(loadAccounts)
+</script>

+ 302 - 0
src/pages/ic/rule.vue

@@ -0,0 +1,302 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-card title="占座规则">
+      <div class="buttonGroup">
+        <a-button type="primary" @click="openModal()">
+          <template #icon><icon-plus /></template>
+          添加占座规则
+        </a-button>
+        <a-button @click="loadList">
+          <template #icon><icon-refresh /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-alert closable style="margin-bottom: 16px">
+        占座将在每晚 22:30 开始,请在每天 22:20 前修改占座规则及志愿,否则修改可能无效。
+      </a-alert>
+
+      <a-table row-key="id" :data="data" :loading="loading" :pagination="false" :scroll="{ x: 900 }">
+        <template #columns>
+          <a-table-column title="规则名" data-index="name" :width="160" />
+          <a-table-column title="占座模式" :width="140">
+            <template #cell="{ record }">{{ RULE_MODE_MAP[record.mode] || record.mode }}</template>
+          </a-table-column>
+          <a-table-column title="执行次数" :width="120">
+            <template #cell="{ record }">{{ record.loop === 0 ? '单次' : '每周循环' }}</template>
+          </a-table-column>
+          <a-table-column title="执行星期" :width="180">
+            <template #cell="{ record }">
+              <span v-for="(day, index) in record.day_of_week || []" :key="day">
+                <span v-if="index">、</span>{{ getWeekdayLabel(day) }}
+              </span>
+            </template>
+          </a-table-column>
+          <a-table-column title="预约时间" :width="140">
+            <template #cell="{ record }">{{ record.beginTime }}~{{ record.endTime }}</template>
+          </a-table-column>
+          <a-table-column title="参与账号" :width="180" ellipsis tooltip>
+            <template #cell="{ record }">{{ (record.users || []).join('、') }}</template>
+          </a-table-column>
+          <a-table-column title="是否启用" :width="100">
+            <template #cell="{ record }">
+              <a-switch
+                :model-value="record.state === 1"
+                :loading="toggleLoading === record.id"
+                @change="() => toggleRule(record)"
+              />
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="140" fixed="right">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button type="text" size="small" @click="openModal(record)">编辑</a-button>
+                <a-popconfirm content="删除后无法恢复,确认删除?" @ok="handleDelete(record)">
+                  <a-button type="text" status="danger" size="small">删除</a-button>
+                </a-popconfirm>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      :title="form.id ? '编辑占座规则' : '添加占座规则'"
+      width="640px"
+      @before-ok="handleSave"
+      :ok-loading="saving"
+      draggable
+      esc-to-close
+    >
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="规则名称" required>
+          <a-input v-model="form.name" placeholder="仅供识别使用" />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="开始时间" required>
+              <a-time-picker v-model="form.beginTime" format="HH:mm" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="结束时间" required>
+              <a-time-picker v-model="form.endTime" format="HH:mm" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-form-item label="执行次数" required>
+          <a-radio-group v-model="form.loop">
+            <a-radio :value="0">单次执行</a-radio>
+            <a-radio :value="1">每周循环</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item v-if="form.loop === 1" label="执行星期" required>
+          <a-checkbox-group v-model="form.day_of_week">
+            <a-checkbox v-for="item in WEEKDAY_OPTIONS" :key="item.value" :value="item.value">
+              {{ item.label }}
+            </a-checkbox>
+          </a-checkbox-group>
+        </a-form-item>
+        <a-form-item label="执行模式" required>
+          <a-select v-model="form.mode" placeholder="请选择执行模式">
+            <a-option v-for="item in RULE_MODE_OPTIONS" :key="item.value" :value="item.value">
+              {{ item.label }}
+            </a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="参与账号" required>
+          <a-select v-model="form.users" multiple placeholder="请选择参与账号">
+            <a-option v-for="item in accounts" :key="item.username" :value="item.username">
+              {{ item.username }}({{ item.realname || '未知' }})
+            </a-option>
+          </a-select>
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getJwAccount } from '@/api/jwAccount'
+import { getRule, saveRule, deleteRule } from '@/api/ic'
+import { RULE_MODE_MAP, RULE_MODE_OPTIONS, WEEKDAY_OPTIONS, getWeekdayLabel } from '@/utils/icTime'
+
+const loading = ref(false)
+const saving = ref(false)
+const visible = ref(false)
+const toggleLoading = ref(null)
+const data = ref([])
+const accounts = ref([])
+
+const defaultForm = () => ({
+  id: undefined,
+  name: '',
+  beginTime: undefined,
+  endTime: undefined,
+  loop: 0,
+  day_of_week: [],
+  mode: 0,
+  users: [],
+  state: 0
+})
+
+const form = reactive(defaultForm())
+
+const parseTime = (timeStr) => {
+  if (!timeStr) return undefined
+  const [h, m, s = '0'] = timeStr.split(':')
+  const d = new Date()
+  d.setHours(+h, +m, +s, 0)
+  return d
+}
+
+const formatTime = (date) => {
+  if (!date) return ''
+  const d = date instanceof Date ? date : new Date(date)
+  const h = String(d.getHours()).padStart(2, '0')
+  const m = String(d.getMinutes()).padStart(2, '0')
+  return `${h}:${m}:00`
+}
+
+const validateTime = (beginTime, endTime) => {
+  const begin = new Date(`1970-01-01T${beginTime}`)
+  const end = new Date(`1970-01-01T${endTime}`)
+  if (begin >= end) throw new Error('开始时间不得晚于结束时间')
+  const diffMin = (end - begin) / 60000
+  if (diffMin < 60 || diffMin > 880) throw new Error('预约时长应在 60~880 分钟内')
+  if (begin.getHours() < 8 || begin.getHours() > 22 || end.getHours() < 8 || end.getHours() > 22) {
+    throw new Error('预约时间必须在 8:00~22:00 之间')
+  }
+}
+
+const loadAccounts = async () => {
+  const res = await getJwAccount()
+  if (res?.code === 0) {
+    accounts.value = (res.data || []).filter((item) => item.state === 1)
+  }
+}
+
+const loadList = async () => {
+  loading.value = true
+  try {
+    const res = await getRule()
+    if (res?.code !== 0) {
+      Notification.error({ title: '获取占座规则失败', content: res?.msg })
+      return
+    }
+    data.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openModal = (record) => {
+  Object.assign(form, defaultForm())
+  if (record) {
+    Object.assign(form, {
+      id: record.id,
+      name: record.name,
+      beginTime: parseTime(record.beginTime),
+      endTime: parseTime(record.endTime),
+      loop: record.loop,
+      day_of_week: [...(record.day_of_week || [])],
+      mode: record.mode,
+      users: [...(record.users || [])],
+      state: record.state
+    })
+  }
+  visible.value = true
+}
+
+const handleSave = async () => {
+  if (!form.name?.trim()) {
+    Notification.error({ title: '请填写规则名称' })
+    return false
+  }
+  if (!form.beginTime || !form.endTime) {
+    Notification.error({ title: '请选择预约时间' })
+    return false
+  }
+  if (!form.users?.length) {
+    Notification.error({ title: '请选择参与账号' })
+    return false
+  }
+  saving.value = true
+  try {
+    const beginTime = formatTime(form.beginTime)
+    const endTime = formatTime(form.endTime)
+    validateTime(beginTime, endTime)
+    const res = await saveRule({
+      id: form.id,
+      name: form.name.trim(),
+      beginTime,
+      endTime,
+      loop: form.loop,
+      mode: form.mode,
+      users: form.users,
+      state: form.state,
+      day_of_week: form.loop === 1 ? form.day_of_week : []
+    })
+    if (res?.code !== 0) {
+      Notification.error({ title: '保存失败', content: res?.msg })
+      return false
+    }
+    Notification.success({ title: '保存成功' })
+    await loadList()
+    return true
+  } catch (error) {
+    Notification.error({ title: '保存失败', content: error.message })
+    return false
+  } finally {
+    saving.value = false
+  }
+}
+
+const toggleRule = async (record) => {
+  toggleLoading.value = record.id
+  try {
+    const payload = { ...record, state: record.state === 0 ? 1 : 0 }
+    const res = await saveRule(payload)
+    if (res?.code !== 0) {
+      Notification.error({ title: '更改失败', content: res?.msg })
+      return
+    }
+    Notification.success({
+      title: `${payload.state === 1 ? '启用' : '禁用'}成功`,
+      content: payload.state === 1 ? '该规则将在预定时间执行' : '当前规则将不被执行'
+    })
+    loadList()
+  } finally {
+    toggleLoading.value = null
+  }
+}
+
+const handleDelete = async (record) => {
+  const res = await deleteRule({ id: record.id })
+  if (res?.code !== 0) {
+    Notification.error({ title: '删除失败', content: res?.msg })
+    return
+  }
+  Notification.success({ title: '删除成功' })
+  loadList()
+}
+
+onMounted(async () => {
+  await loadAccounts()
+  await loadList()
+})
+</script>
+
+<style scoped>
+.buttonGroup {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+</style>

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