Browse Source

chore: initial project import

Initialize repository and add current frontend project files.

Made-with: Cursor
ALIEZ 1 tháng trước cách đây
commit
aade72e5b0
60 tập tin đã thay đổi với 9687 bổ sung0 xóa
  1. 4 0
      .env.development
  2. 5 0
      .env.example
  3. 2 0
      .env.production
  4. 3 0
      .env.test
  5. 24 0
      .gitignore
  6. 3 0
      .vscode/extensions.json
  7. 5 0
      README.md
  8. 19 0
      index.html
  9. 2526 0
      package-lock.json
  10. 34 0
      package.json
  11. 0 0
      public/favicon.svg
  12. 24 0
      public/icons.svg
  13. 43 0
      src/App.vue
  14. 8 0
      src/api/index.ts
  15. 44 0
      src/api/modules/auth.ts
  16. 42 0
      src/api/modules/course.ts
  17. 30 0
      src/api/modules/courses/commonQuestion.ts
  18. 105 0
      src/api/modules/courses/goods.ts
  19. 89 0
      src/api/modules/courses/goodsVideo.ts
  20. 31 0
      src/api/modules/courses/rewardQuestion.ts
  21. 30 0
      src/api/modules/finance/customer.ts
  22. 43 0
      src/api/modules/finance/order.ts
  23. 214 0
      src/api/modules/finance/withdraw.ts
  24. 153 0
      src/api/request.ts
  25. 41 0
      src/api/types.ts
  26. BIN
      src/assets/hero.png
  27. 0 0
      src/assets/vite.svg
  28. 1 0
      src/assets/vue.svg
  29. 173 0
      src/auto-imports.d.ts
  30. 49 0
      src/components.d.ts
  31. 176 0
      src/components/AdminSearchPanel.vue
  32. 51 0
      src/components/AdminTablePageBar.vue
  33. 95 0
      src/components/AdminTableToolbar.vue
  34. 155 0
      src/components/RouteTabBar.vue
  35. 3 0
      src/composables/index.ts
  36. 35 0
      src/composables/useConfirmRowDelete.ts
  37. 88 0
      src/composables/usePagedKeywordList.ts
  38. 69 0
      src/composables/useTableColumnsControl.ts
  39. 11 0
      src/config/env.ts
  40. 281 0
      src/layouts/AdminLayout.vue
  41. 8 0
      src/main.ts
  42. 115 0
      src/router/index.ts
  43. 11 0
      src/stores/app.ts
  44. 38 0
      src/stores/tabs.ts
  45. 183 0
      src/styles/global.css
  46. 143 0
      src/views/DashboardView.vue
  47. 147 0
      src/views/LoginView.vue
  48. 42 0
      src/views/SettingsView.vue
  49. 226 0
      src/views/courses/CommonQuestionView.vue
  50. 691 0
      src/views/courses/GoodsVideoManageModal.vue
  51. 577 0
      src/views/courses/GoodsView.vue
  52. 312 0
      src/views/courses/RewardQuestionView.vue
  53. 202 0
      src/views/finance/CustomerListView.vue
  54. 457 0
      src/views/finance/OrderListView.vue
  55. 1730 0
      src/views/finance/WithdrawApplyView.vue
  56. 16 0
      src/vite-env.d.ts
  57. 17 0
      tsconfig.app.json
  58. 7 0
      tsconfig.json
  59. 24 0
      tsconfig.node.json
  60. 32 0
      vite.config.ts

+ 4 - 0
.env.development

@@ -0,0 +1,4 @@
+# 本地开发(npm run dev / dev:local)
+# 后端根地址,不要末尾斜杠;接口路径如 /user/login 会拼在此地址后
+# VITE_API_BASE=http://192.168.0.33:8505
+VITE_API_BASE=http://103.158.191.66:8505

+ 5 - 0
.env.example

@@ -0,0 +1,5 @@
+# 复制为 .env.development / .env.test / .env.production 或 .env.local 覆盖
+# 必须以 VITE_ 开头才能在浏览器中访问
+
+VITE_API_BASE=http://127.0.0.1:8080/api
+VITE_USE_HASH_HISTORY=false

+ 2 - 0
.env.production

@@ -0,0 +1,2 @@
+# 线上环境(npm run build / build:prod)
+VITE_API_BASE=https://api.example.com/api

+ 3 - 0
.env.test

@@ -0,0 +1,3 @@
+# 测试环境(npm run dev:test / build:test)
+VITE_API_BASE=http://103.158.191.66:8505/
+VITE_USE_HASH_HISTORY=true

+ 24 - 0
.gitignore

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

+ 3 - 0
.vscode/extensions.json

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

+ 5 - 0
README.md

@@ -0,0 +1,5 @@
+# Vue 3 + TypeScript + Vite
+
+This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+
+Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

+ 19 - 0
index.html

@@ -0,0 +1,19 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+    <link
+      href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400..800;1,400..800&display=swap"
+      rel="stylesheet"
+    />
+    <title>课程后台</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 2526 - 0
package-lock.json

@@ -0,0 +1,2526 @@
+{
+  "name": "kecheng-admin",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "kecheng-admin",
+      "version": "0.0.0",
+      "dependencies": {
+        "@vicons/ionicons5": "^0.13.0",
+        "@vueuse/core": "^14.2.1",
+        "axios": "^1.15.0",
+        "naive-ui": "^2.44.1",
+        "pinia": "^3.0.4",
+        "vue": "^3.5.32",
+        "vue-router": "^5.0.4"
+      },
+      "devDependencies": {
+        "@types/node": "^24.12.2",
+        "@vitejs/plugin-vue": "^6.0.5",
+        "@vue/tsconfig": "^0.9.1",
+        "typescript": "~6.0.2",
+        "unplugin-auto-import": "^21.0.0",
+        "unplugin-vue-components": "^32.0.0",
+        "vite": "^8.0.4",
+        "vue-tsc": "^3.2.6"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.29.1",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "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/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/@css-render/plugin-bem": {
+      "version": "0.15.14",
+      "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
+      "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "css-render": "~0.15.14"
+      }
+    },
+    "node_modules/@css-render/vue3-ssr": {
+      "version": "0.15.14",
+      "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
+      "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.0.11"
+      }
+    },
+    "node_modules/@emnapi/core": {
+      "version": "1.9.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+      "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.1",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.9.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+      "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+      "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+      "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "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/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@juggle/resize-observer": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+      "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
+      "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.124.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
+      "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
+      "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
+      "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
+      "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
+      "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
+      "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
+      "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
+      "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
+      "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
+      "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
+      "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
+      "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
+      "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
+      "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "1.9.2",
+        "@emnapi/runtime": "1.9.2",
+        "@napi-rs/wasm-runtime": "^1.1.3"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
+      "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
+      "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-rc.13",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
+      "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.1",
+      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+      "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "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/lodash": {
+      "version": "4.17.24",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+      "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "24.12.2",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
+      "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.16.0"
+      }
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.21",
+      "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+      "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+      "license": "MIT"
+    },
+    "node_modules/@vicons/ionicons5": {
+      "version": "0.13.0",
+      "resolved": "https://registry.npmjs.org/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
+      "integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.6",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
+      "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "1.0.0-rc.13"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
+      "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "2.4.28"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
+      "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@volar/typescript": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
+      "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.28",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "node_modules/@vue-macros/common": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz",
+      "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-sfc": "^3.5.22",
+        "ast-kit": "^2.1.2",
+        "local-pkg": "^1.1.2",
+        "magic-string-ast": "^1.0.2",
+        "unplugin-utils": "^0.3.0"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/vue-macros"
+      },
+      "peerDependencies": {
+        "vue": "^2.7.0 || ^3.2.25"
+      },
+      "peerDependenciesMeta": {
+        "vue": {
+          "optional": true
+        }
+      }
+    },
+    "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-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/language-core": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz",
+      "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.28",
+        "@vue/compiler-dom": "^3.5.0",
+        "@vue/shared": "^3.5.0",
+        "alien-signals": "^3.0.0",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1",
+        "picomatch": "^4.0.2"
+      }
+    },
+    "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/@vue/tsconfig": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz",
+      "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "typescript": ">= 5.8",
+        "vue": "^3.4.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        },
+        "vue": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vueuse/core": {
+      "version": "14.2.1",
+      "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
+      "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.21",
+        "@vueuse/metadata": "14.2.1",
+        "@vueuse/shared": "14.2.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "14.2.1",
+      "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
+      "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "14.2.1",
+      "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
+      "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.16.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/alien-signals": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
+      "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ast-kit": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
+      "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "pathe": "^2.0.3"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      }
+    },
+    "node_modules/ast-walker-scope": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
+      "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.4",
+        "ast-kit": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "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.15.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
+      "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.11",
+        "form-data": "^4.0.5",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "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/chokidar": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+      "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+      "license": "MIT",
+      "dependencies": {
+        "readdirp": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "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/confbox": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+      "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+      "license": "MIT"
+    },
+    "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/css-render": {
+      "version": "0.15.14",
+      "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
+      "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
+      "license": "MIT",
+      "dependencies": {
+        "@emotion/hash": "~0.8.0",
+        "csstype": "~3.0.5"
+      }
+    },
+    "node_modules/css-render/node_modules/csstype": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
+      "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
+      "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/date-fns": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+      "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/kossnocorp"
+      }
+    },
+    "node_modules/date-fns-tz": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
+      "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "date-fns": "^3.0.0 || ^4.0.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/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "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/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/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/escape-string-regexp": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+      "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "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/evtd": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz",
+      "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
+      "license": "MIT"
+    },
+    "node_modules/exsolve": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
+      "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
+      "license": "MIT"
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "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-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/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/highlight.js": {
+      "version": "11.11.1",
+      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+      "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "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/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/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.32.0",
+        "lightningcss-darwin-arm64": "1.32.0",
+        "lightningcss-darwin-x64": "1.32.0",
+        "lightningcss-freebsd-x64": "1.32.0",
+        "lightningcss-linux-arm-gnueabihf": "1.32.0",
+        "lightningcss-linux-arm64-gnu": "1.32.0",
+        "lightningcss-linux-arm64-musl": "1.32.0",
+        "lightningcss-linux-x64-gnu": "1.32.0",
+        "lightningcss-linux-x64-musl": "1.32.0",
+        "lightningcss-win32-arm64-msvc": "1.32.0",
+        "lightningcss-win32-x64-msvc": "1.32.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/local-pkg": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
+      "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
+      "license": "MIT",
+      "dependencies": {
+        "mlly": "^1.7.4",
+        "pkg-types": "^2.3.0",
+        "quansync": "^0.2.11"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+      "license": "MIT"
+    },
+    "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/magic-string-ast": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
+      "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
+      "license": "MIT",
+      "dependencies": {
+        "magic-string": "^0.30.19"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      }
+    },
+    "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/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-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/mlly": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+      "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+      "license": "MIT",
+      "dependencies": {
+        "acorn": "^8.16.0",
+        "pathe": "^2.0.3",
+        "pkg-types": "^1.3.1",
+        "ufo": "^1.6.3"
+      }
+    },
+    "node_modules/mlly/node_modules/confbox": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+      "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+      "license": "MIT"
+    },
+    "node_modules/mlly/node_modules/pkg-types": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+      "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+      "license": "MIT",
+      "dependencies": {
+        "confbox": "^0.1.8",
+        "mlly": "^1.7.4",
+        "pathe": "^2.0.1"
+      }
+    },
+    "node_modules/muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "license": "MIT"
+    },
+    "node_modules/naive-ui": {
+      "version": "2.44.1",
+      "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz",
+      "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==",
+      "license": "MIT",
+      "dependencies": {
+        "@css-render/plugin-bem": "^0.15.14",
+        "@css-render/vue3-ssr": "^0.15.14",
+        "@types/lodash": "^4.17.20",
+        "@types/lodash-es": "^4.17.12",
+        "async-validator": "^4.2.5",
+        "css-render": "^0.15.14",
+        "csstype": "^3.1.3",
+        "date-fns": "^4.1.0",
+        "date-fns-tz": "^3.2.0",
+        "evtd": "^0.2.4",
+        "highlight.js": "^11.8.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "seemly": "^0.3.10",
+        "treemate": "^0.3.11",
+        "vdirs": "^0.1.8",
+        "vooks": "^0.2.12",
+        "vueuc": "^0.4.65"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "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/obug": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+      "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT"
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "license": "MIT"
+    },
+    "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==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "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/pkg-types": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
+      "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
+      "license": "MIT",
+      "dependencies": {
+        "confbox": "^0.2.2",
+        "exsolve": "^1.0.7",
+        "pathe": "^2.0.3"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.9",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
+      "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+      "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/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/quansync": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
+      "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/antfu"
+        },
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/sxzz"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/readdirp": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+      "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "type": "individual",
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "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/rolldown": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
+      "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.124.0",
+        "@rolldown/pluginutils": "1.0.0-rc.15"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.0.0-rc.15",
+        "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
+        "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
+        "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
+        "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
+        "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
+        "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
+        "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
+        "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
+        "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
+        "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
+        "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
+        "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
+        "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
+      }
+    },
+    "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-rc.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
+      "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/scule": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
+      "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
+      "license": "MIT"
+    },
+    "node_modules/seemly": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz",
+      "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
+      "license": "MIT"
+    },
+    "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/strip-literal": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+      "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^9.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "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/tinyglobby": {
+      "version": "0.2.16",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+      "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/treemate": {
+      "version": "0.3.11",
+      "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz",
+      "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
+      "license": "MIT"
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/typescript": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
+      "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/ufo": {
+      "version": "1.6.3",
+      "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
+      "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
+      "license": "MIT"
+    },
+    "node_modules/undici-types": {
+      "version": "7.16.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+      "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unimport": {
+      "version": "5.7.0",
+      "resolved": "https://registry.npmjs.org/unimport/-/unimport-5.7.0.tgz",
+      "integrity": "sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "acorn": "^8.16.0",
+        "escape-string-regexp": "^5.0.0",
+        "estree-walker": "^3.0.3",
+        "local-pkg": "^1.1.2",
+        "magic-string": "^0.30.21",
+        "mlly": "^1.8.0",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3",
+        "pkg-types": "^2.3.0",
+        "scule": "^1.3.0",
+        "strip-literal": "^3.1.0",
+        "tinyglobby": "^0.2.15",
+        "unplugin": "^2.3.11",
+        "unplugin-utils": "^0.3.1"
+      },
+      "engines": {
+        "node": ">=18.12.0"
+      }
+    },
+    "node_modules/unimport/node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/unimport/node_modules/unplugin": {
+      "version": "2.3.11",
+      "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
+      "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.5",
+        "acorn": "^8.15.0",
+        "picomatch": "^4.0.3",
+        "webpack-virtual-modules": "^0.6.2"
+      },
+      "engines": {
+        "node": ">=18.12.0"
+      }
+    },
+    "node_modules/unplugin": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
+      "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.5",
+        "picomatch": "^4.0.3",
+        "webpack-virtual-modules": "^0.6.2"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/unplugin-auto-import": {
+      "version": "21.0.0",
+      "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz",
+      "integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "local-pkg": "^1.1.2",
+        "magic-string": "^0.30.21",
+        "picomatch": "^4.0.3",
+        "unimport": "^5.6.0",
+        "unplugin": "^2.3.11",
+        "unplugin-utils": "^0.3.1"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@nuxt/kit": "^4.0.0",
+        "@vueuse/core": "*"
+      },
+      "peerDependenciesMeta": {
+        "@nuxt/kit": {
+          "optional": true
+        },
+        "@vueuse/core": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/unplugin-auto-import/node_modules/unplugin": {
+      "version": "2.3.11",
+      "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
+      "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.5",
+        "acorn": "^8.15.0",
+        "picomatch": "^4.0.3",
+        "webpack-virtual-modules": "^0.6.2"
+      },
+      "engines": {
+        "node": ">=18.12.0"
+      }
+    },
+    "node_modules/unplugin-utils": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
+      "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
+      "license": "MIT",
+      "dependencies": {
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      }
+    },
+    "node_modules/unplugin-vue-components": {
+      "version": "32.0.0",
+      "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.0.0.tgz",
+      "integrity": "sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "chokidar": "^5.0.0",
+        "local-pkg": "^1.1.2",
+        "magic-string": "^0.30.21",
+        "mlly": "^1.8.2",
+        "obug": "^2.1.1",
+        "picomatch": "^4.0.3",
+        "tinyglobby": "^0.2.15",
+        "unplugin": "^3.0.0",
+        "unplugin-utils": "^0.3.1"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@nuxt/kit": "^3.2.2 || ^4.0.0",
+        "vue": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@nuxt/kit": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vdirs": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
+      "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
+      "license": "MIT",
+      "dependencies": {
+        "evtd": "^0.2.2"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.11"
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.0.8",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
+      "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.32.0",
+        "picomatch": "^4.0.4",
+        "postcss": "^8.5.8",
+        "rolldown": "1.0.0-rc.15",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.1.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "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/vooks": {
+      "version": "0.2.12",
+      "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
+      "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "evtd": "^0.2.2"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/vscode-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+      "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "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-router": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.4.tgz",
+      "integrity": "sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/generator": "^7.28.6",
+        "@vue-macros/common": "^3.1.1",
+        "@vue/devtools-api": "^8.0.6",
+        "ast-walker-scope": "^0.8.3",
+        "chokidar": "^5.0.0",
+        "json5": "^2.2.3",
+        "local-pkg": "^1.1.2",
+        "magic-string": "^0.30.21",
+        "mlly": "^1.8.0",
+        "muggle-string": "^0.4.1",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3",
+        "scule": "^1.3.0",
+        "tinyglobby": "^0.2.15",
+        "unplugin": "^3.0.0",
+        "unplugin-utils": "^0.3.1",
+        "yaml": "^2.8.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "@pinia/colada": ">=0.21.2",
+        "@vue/compiler-sfc": "^3.5.17",
+        "pinia": "^3.0.4",
+        "vue": "^3.5.0"
+      },
+      "peerDependenciesMeta": {
+        "@pinia/colada": {
+          "optional": true
+        },
+        "@vue/compiler-sfc": {
+          "optional": true
+        },
+        "pinia": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router/node_modules/@vue/devtools-api": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz",
+      "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-kit": "^8.1.1"
+      }
+    },
+    "node_modules/vue-router/node_modules/@vue/devtools-kit": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz",
+      "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-shared": "^8.1.1",
+        "birpc": "^2.6.1",
+        "hookable": "^5.5.3",
+        "perfect-debounce": "^2.0.0"
+      }
+    },
+    "node_modules/vue-router/node_modules/@vue/devtools-shared": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz",
+      "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==",
+      "license": "MIT"
+    },
+    "node_modules/vue-router/node_modules/perfect-debounce": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+      "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
+      "license": "MIT"
+    },
+    "node_modules/vue-tsc": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz",
+      "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "2.4.28",
+        "@vue/language-core": "3.2.6"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    },
+    "node_modules/vueuc": {
+      "version": "0.4.65",
+      "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz",
+      "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@css-render/vue3-ssr": "^0.15.10",
+        "@juggle/resize-observer": "^3.3.1",
+        "css-render": "^0.15.10",
+        "evtd": "^0.2.4",
+        "seemly": "^0.3.6",
+        "vdirs": "^0.1.4",
+        "vooks": "^0.2.4"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.11"
+      }
+    },
+    "node_modules/webpack-virtual-modules": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+      "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+      "license": "MIT"
+    },
+    "node_modules/yaml": {
+      "version": "2.8.3",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+      "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+      "license": "ISC",
+      "bin": {
+        "yaml": "bin.mjs"
+      },
+      "engines": {
+        "node": ">= 14.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/eemeli"
+      }
+    }
+  }
+}

+ 34 - 0
package.json

@@ -0,0 +1,34 @@
+{
+  "name": "kecheng-admin",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite --mode development",
+    "dev:local": "vite --mode development",
+    "dev:test": "vite --mode test",
+    "build": "vue-tsc -b && vite build --mode production",
+    "build:prod": "vue-tsc -b && vite build --mode production",
+    "build:test": "vue-tsc -b && vite build --mode test",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@vicons/ionicons5": "^0.13.0",
+    "@vueuse/core": "^14.2.1",
+    "axios": "^1.15.0",
+    "naive-ui": "^2.44.1",
+    "pinia": "^3.0.4",
+    "vue": "^3.5.32",
+    "vue-router": "^5.0.4"
+  },
+  "devDependencies": {
+    "@types/node": "^24.12.2",
+    "@vitejs/plugin-vue": "^6.0.5",
+    "@vue/tsconfig": "^0.9.1",
+    "typescript": "~6.0.2",
+    "unplugin-auto-import": "^21.0.0",
+    "unplugin-vue-components": "^32.0.0",
+    "vite": "^8.0.4",
+    "vue-tsc": "^3.2.6"
+  }
+}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
public/favicon.svg


+ 24 - 0
public/icons.svg

@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+  <symbol id="bluesky-icon" viewBox="0 0 16 17">
+    <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
+    <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
+  </symbol>
+  <symbol id="discord-icon" viewBox="0 0 20 19">
+    <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
+  </symbol>
+  <symbol id="documentation-icon" viewBox="0 0 21 20">
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
+  </symbol>
+  <symbol id="github-icon" viewBox="0 0 19 19">
+    <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
+  </symbol>
+  <symbol id="social-icon" viewBox="0 0 20 20">
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
+  </symbol>
+  <symbol id="x-icon" viewBox="0 0 19 19">
+    <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
+  </symbol>
+</svg>

+ 43 - 0
src/App.vue

@@ -0,0 +1,43 @@
+<script setup lang="ts">
+import type { GlobalThemeOverrides } from 'naive-ui'
+import { zhCN, dateZhCN } from 'naive-ui'
+
+const themeOverrides: GlobalThemeOverrides = {
+  common: {
+    primaryColor: '#6366f1',
+    primaryColorHover: '#818cf8',
+    primaryColorPressed: '#4f46e5',
+    primaryColorSuppl: '#a5b4fc',
+    borderRadius: '10px',
+    fontFamily:
+      "'Plus Jakarta Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
+    boxShadow1: '0 1px 2px rgba(15, 23, 42, 0.05)',
+    boxShadow2: '0 4px 16px rgba(15, 23, 42, 0.06)',
+    boxShadow3: '0 12px 40px rgba(15, 23, 42, 0.08)',
+  },
+  Card: {
+    borderRadius: '14px',
+    paddingMedium: '22px',
+  },
+  Layout: {
+    colorEmbedded: '#f4f5f8',
+  },
+}
+</script>
+
+<template>
+  <NConfigProvider
+    :locale="zhCN"
+    :date-locale="dateZhCN"
+    :theme="null"
+    :theme-overrides="themeOverrides"
+  >
+    <NMessageProvider>
+      <NDialogProvider>
+        <NNotificationProvider>
+          <RouterView />
+        </NNotificationProvider>
+      </NDialogProvider>
+    </NMessageProvider>
+  </NConfigProvider>
+</template>

+ 8 - 0
src/api/index.ts

@@ -0,0 +1,8 @@
+export { request, axiosInstance, setToken, getToken } from './request'
+export * from './types'
+export * as courseApi from './modules/course'
+export * as authApi from './modules/auth'
+export * as commonQuestionApi from './modules/courses/commonQuestion'
+export * as rewardQuestionApi from './modules/courses/rewardQuestion'
+export * as goodsApi from './modules/courses/goods'
+export * as goodsVideoApi from './modules/courses/goodsVideo'

+ 44 - 0
src/api/modules/auth.ts

@@ -0,0 +1,44 @@
+import { request, setToken } from '../request'
+
+export interface LoginPayload {
+  loginName: string
+  password: string
+}
+
+/**
+ * 从登录接口结果里取 token。
+ * 拦截器已按 { code, data } 解包:常见为 data 直接是 token 字符串;
+ * 若未解包或兼容其它形态,再尝试对象字段。
+ */
+export function pickTokenFromLoginResponse(data: unknown): string | null {
+  if (typeof data === 'string' && data.length > 0) return data
+  if (data === null || typeof data !== 'object') return null
+  const o = data as Record<string, unknown>
+  const direct =
+    (typeof o.token === 'string' && o.token) ||
+    (typeof o.accessToken === 'string' && o.accessToken) ||
+    (typeof o.access_token === 'string' && o.access_token)
+  if (direct) return direct
+  if (typeof o.data === 'string' && o.data.length > 0) return o.data
+  if (o.data !== null && typeof o.data === 'object') {
+    const d = o.data as Record<string, unknown>
+    return (
+      (typeof d.token === 'string' && d.token) ||
+      (typeof d.accessToken === 'string' && d.accessToken) ||
+      (typeof d.access_token === 'string' && d.access_token) ||
+      null
+    )
+  }
+  return null
+}
+
+export function loginRequest(payload: LoginPayload) {
+  return request.post<unknown>('/user/login', payload)
+}
+
+export async function login(payload: LoginPayload) {
+  const data = await loginRequest(payload)
+  const token = pickTokenFromLoginResponse(data)
+  if (token) setToken(token)
+  return { data, token }
+}

+ 42 - 0
src/api/modules/course.ts

@@ -0,0 +1,42 @@
+import { request } from '../request'
+
+/** 课程列表项(示例字段,按真实接口改) */
+export interface CourseItem {
+  id: number
+  name: string
+  teacher: string
+  students: number
+  status: 'draft' | 'published' | 'review'
+  updated: string
+}
+
+export interface CourseListParams {
+  page?: number
+  pageSize?: number
+  keyword?: string
+}
+
+export interface PageResult<T> {
+  list: T[]
+  total: number
+}
+
+export function fetchCourseList(params?: CourseListParams) {
+  return request.get<PageResult<CourseItem>>('/courses', { params })
+}
+
+export function fetchCourseDetail(id: number) {
+  return request.get<CourseItem>(`/courses/${id}`)
+}
+
+export function createCoursePayload(data: Partial<CourseItem>) {
+  return request.post<CourseItem>('/courses', data)
+}
+
+export function updateCourse(id: number, data: Partial<CourseItem>) {
+  return request.put<CourseItem>(`/courses/${id}`, data)
+}
+
+export function deleteCourse(data: { ids: number[] }) {
+  return request.delete<void>('/courses', { data })
+}

+ 30 - 0
src/api/modules/courses/commonQuestion.ts

@@ -0,0 +1,30 @@
+import { request } from '../../request'
+import { type PageParam, unwrapPageList } from '../../types'
+
+export interface CommonQuestionItem {
+  id: number
+  question: string
+  answer: string
+}
+
+export interface CommonQuestionSearchParams {
+  question?: string
+  page?: PageParam
+}
+
+export function addCommonQuestion(data: { question: string; answer: string }) {
+  return request.post<unknown>('/common/question/add', data)
+}
+
+export function updateCommonQuestion(data: { id: number; question: string; answer: string }) {
+  return request.post<unknown>('/common/question/update', data)
+}
+
+export function deleteCommonQuestion(data: { ids: number[] }) {
+  return request.post<unknown>('/common/question/delete', data)
+}
+
+export async function searchCommonQuestionList(params: CommonQuestionSearchParams = {}) {
+  const raw = await request.post<unknown>('/common/question/search/list', params)
+  return unwrapPageList<CommonQuestionItem>(raw)
+}

+ 105 - 0
src/api/modules/courses/goods.ts

@@ -0,0 +1,105 @@
+import type { AxiosHeaders } from 'axios'
+import { apiBase } from '@/config/env'
+import { axiosInstance, request } from '../../request'
+import { type PageParam, unwrapPageList } from '../../types'
+
+export type GoodsType = 1 | 2 | 3 | 4 | 5
+
+export interface GoodsItem {
+  id: number
+  goodsName: string
+  frontUrl?: string
+  title: string
+  introduction: string
+  goodsType: GoodsType | number
+  goodsPrice: number | string
+  download: string
+}
+
+export interface GoodsSearchParams {
+  goodsName?: string
+  goodsType?: number
+  page?: PageParam
+}
+
+export interface GoodsFormPayload {
+  goodsName: string
+  frontUrl: string
+  title: string
+  introduction: string
+  goodsType: GoodsType | number
+  goodsPrice: number | string
+  download: string
+}
+
+function toAbsoluteUrl(url: string): string {
+  const s = url.trim()
+  if (!s) return s
+  if (/^(https?:)?\/\//i.test(s) || s.startsWith('data:') || s.startsWith('blob:')) {
+    return s
+  }
+  const apiOrigin = (() => {
+    try {
+      if (!apiBase) return ''
+      return new URL(apiBase).origin
+    } catch {
+      return ''
+    }
+  })()
+  if (apiOrigin) {
+    const path = s.startsWith('/') ? s : `/${s}`
+    return new URL(path, apiOrigin).toString()
+  }
+  if (typeof window === 'undefined') return s
+  const path = s.startsWith('/') ? s : `/${s}`
+  return new URL(path, window.location.origin).toString()
+}
+
+function normalizeUploadCoverUrl(raw: unknown): string {
+  if (typeof raw === 'string') {
+    const s = raw.trim()
+    if (s) return toAbsoluteUrl(s)
+  }
+  if (raw !== null && typeof raw === 'object') {
+    const o = raw as Record<string, unknown>
+    for (const k of ['frontUrl', 'fileUrl', 'url', 'fileURL', 'path'] as const) {
+      const v = o[k]
+      if (typeof v === 'string' && v.trim()) return toAbsoluteUrl(v)
+    }
+  }
+  throw new Error('上传返回无有效地址')
+}
+
+export async function uploadGoodsCover(file: File): Promise<string> {
+  const fd = new FormData()
+  fd.append('file', file)
+  const raw = await axiosInstance.post<unknown>('/goods/upload', fd, {
+    timeout: 120_000,
+    maxBodyLength: Infinity,
+    maxContentLength: Infinity,
+    transformRequest: [
+      (data, headers) => {
+        ;(headers as AxiosHeaders | undefined)?.delete?.('Content-Type')
+        return data
+      },
+    ],
+  })
+  return normalizeUploadCoverUrl(raw)
+}
+
+export function addGoods(data: GoodsFormPayload) {
+  return request.post<unknown>('/goods/add', data)
+}
+
+export function updateGoods(data: { id: number } & GoodsFormPayload) {
+  return request.post<unknown>('/goods/update', data)
+}
+
+export function deleteGoods(data: { ids: number[] }) {
+  return request.post<unknown>('/goods/delete', data)
+}
+
+export async function searchGoodsList(params: GoodsSearchParams = {}) {
+  const raw = await request.post<unknown>('/goods/search/list', params)
+  return unwrapPageList<GoodsItem>(raw)
+}

+ 89 - 0
src/api/modules/courses/goodsVideo.ts

@@ -0,0 +1,89 @@
+import type { AxiosHeaders } from 'axios'
+import { axiosInstance, request } from '../../request'
+import { type PageParam, unwrapPageList } from '../../types'
+
+function normalizeUploadVideoUrl(raw: unknown): string {
+  if (typeof raw === 'string') {
+    const s = raw.trim()
+    if (s) return s
+  }
+  if (raw !== null && typeof raw === 'object') {
+    const o = raw as Record<string, unknown>
+    for (const k of ['fileUrl', 'url', 'fileURL', 'path'] as const) {
+      const v = o[k]
+      if (typeof v === 'string' && v.trim()) return v.trim()
+    }
+  }
+  throw new Error('上传返回无有效地址')
+}
+
+/** 上传视频文件,返回可写入保存接口的 fileUrl */
+export async function uploadGoodsVideoFile(goodsId: number, file: File): Promise<string> {
+  const fd = new FormData()
+  fd.append('goodsId', String(goodsId))
+  fd.append('file', file)
+  const raw = await axiosInstance.post<unknown>('/goods/video/upload', fd, {
+    timeout: 600_000,
+    maxBodyLength: Infinity,
+    maxContentLength: Infinity,
+    transformRequest: [
+      (data, headers) => {
+        ;(headers as AxiosHeaders | undefined)?.delete?.('Content-Type')
+        return data
+      },
+    ],
+  })
+  return normalizeUploadVideoUrl(raw)
+}
+
+export interface GoodsVideoItem {
+  id: number
+  goodsId: number
+  videoName: string
+  title: string
+  introduction?: string
+  frontUrl: string
+  /** 0 否 1 是 */
+  payType: 0 | 1 | number
+  /** 1 上传视频 2 外部连接 */
+  fileType: 1 | 2 | number
+  fileUrl: string
+  linkUrl: string
+  sort: number
+}
+
+export interface GoodsVideoSearchParams {
+  goodsId: number
+  videoName?: string
+  page?: PageParam
+}
+
+export interface GoodsVideoPayload {
+  goodsId: number
+  videoName: string
+  title: string
+  introduction: string
+  frontUrl: string
+  payType: 0 | 1
+  fileType: 1 | 2
+  fileUrl: string
+  linkUrl: string
+  sort: number
+}
+
+export function addGoodsVideo(data: GoodsVideoPayload) {
+  return request.post<unknown>('/goods/video/add', data)
+}
+
+export function updateGoodsVideo(data: { id: number } & GoodsVideoPayload) {
+  return request.post<unknown>('/goods/video/update', data)
+}
+
+export function deleteGoodsVideo(data: { ids: number[] }) {
+  return request.post<unknown>('/goods/video/delete', data)
+}
+
+export async function searchGoodsVideoList(params: GoodsVideoSearchParams) {
+  const raw = await request.post<unknown>('/goods/video/search/list', params)
+  return unwrapPageList<GoodsVideoItem>(raw)
+}

+ 31 - 0
src/api/modules/courses/rewardQuestion.ts

@@ -0,0 +1,31 @@
+import { request } from '../../request'
+import { type PageParam, unwrapPageList } from '../../types'
+
+export interface RewardQuestionItem {
+  id: number
+  question: string
+  /** 0 错 1 对 */
+  answer: 0 | 1
+}
+
+export interface RewardQuestionSearchParams {
+  question?: string
+  page?: PageParam
+}
+
+export function addRewardQuestion(data: { question: string; answer: 0 | 1 }) {
+  return request.post<unknown>('/reward/question/add', data)
+}
+
+export function updateRewardQuestion(data: { id: number; question: string; answer: 0 | 1 }) {
+  return request.post<unknown>('/reward/question/update', data)
+}
+
+export function deleteRewardQuestion(data: { ids: number[] }) {
+  return request.post<unknown>('/reward/question/delete', data)
+}
+
+export async function searchRewardQuestionList(params: RewardQuestionSearchParams = {}) {
+  const raw = await request.post<unknown>('/reward/question/search/list', params)
+  return unwrapPageList<RewardQuestionItem>(raw)
+}

+ 30 - 0
src/api/modules/finance/customer.ts

@@ -0,0 +1,30 @@
+import { request } from '../../request'
+import { type PageParam, unwrapPageList } from '../../types'
+
+export interface CustomerItem {
+  id?: number
+  cId?: string
+  email?: string
+  name?: string
+  phone?: string
+  identity?: string
+  totalSpendingAmount?: number | string
+  addTime?: string | number
+}
+
+export interface CustomerSearchParams {
+  cId?: string
+  email?: string
+  name?: string
+  phone?: string
+  page?: PageParam
+}
+
+export function searchCustomerList(params: CustomerSearchParams) {
+  return request.post<unknown>('/custom/search/real', params)
+}
+
+export async function searchCustomerPage(params: CustomerSearchParams) {
+  const raw = await searchCustomerList(params)
+  return unwrapPageList<CustomerItem>(raw)
+}

+ 43 - 0
src/api/modules/finance/order.ts

@@ -0,0 +1,43 @@
+import { request } from '../../request'
+import { type PageParam, unwrapPageList } from '../../types'
+
+export interface OrderItem {
+  id: number
+  cId?: string
+  serial?: string
+  payName?: string
+  payPhone?: string
+  email?: string
+  amount?: number
+  currency?: string
+  transformAmount?: number
+  transformCurrency?: string
+  channelCode?: string
+  /** 1:未支付 2:已支付 3:支付失败 4:已过期 5:已取消 */
+  status?: number
+  addTime?: string
+  payTime?: string
+  details?: string | unknown
+}
+
+export interface OrderSearchParams {
+  serial?: string
+  status?: number
+  payName?: string
+  startDate?: string
+  endDate?: string
+  page?: PageParam
+}
+
+export function searchOrderList(params: OrderSearchParams) {
+  return request.post<unknown>('/order/search/list', params)
+}
+
+export async function searchOrderPage(params: OrderSearchParams) {
+  const raw = await searchOrderList(params)
+  return unwrapPageList<OrderItem>(raw)
+}
+
+export function completeOrder(data: { id: number }) {
+  return request.post<unknown>('/order/complete', data)
+}

+ 214 - 0
src/api/modules/finance/withdraw.ts

@@ -0,0 +1,214 @@
+import { request } from '../../request'
+import { type PageParam, unwrapPageList } from '../../types'
+import { apiBase } from '@/config/env'
+import axios from 'axios'
+import { getToken } from '@/api/request'
+
+export interface WithdrawItem {
+  id: number
+  cId?: string
+  pIbNo?: string
+  salesNo?: string
+  login?: string
+  platform?: string
+  name?: string
+  serial?: string
+  amount?: number
+  currency?: string
+  remitChannelName?: string
+  withdrawAmount?: number
+  withdrawCurrency?: string
+  transformAmount?: number
+  transformCurrency?: string
+  addTime?: string
+  submitTime?: string
+  approveDesc?: string
+  server?: string
+  status?: number
+  withdrawStatus?: number
+  callbackStatus?: number | null
+  submitStatus?: number
+  backstageStatus?: number
+  infoStatus?: number
+  feeReduction?: number
+  feeAmount?: number
+  feeReductionAmount?: number
+  salesSettingLevel?: number
+}
+
+export interface DepositItem {
+  id: number
+  cId?: string
+  login?: string
+  serial?: string
+  amount?: number
+  currency?: string
+  payTypeName?: string
+  addTime?: string
+  status?: number
+}
+
+export interface RemitChannelItem {
+  code: string
+  name?: string
+  enName?: string
+  type?: string
+  icon?: string
+  withdrawInfoUrl?: string
+}
+
+export interface WithdrawSearchParams {
+  cId?: string
+  login?: string
+  serial?: string
+  pIbNo?: string
+  name?: string
+  status?: number | null
+  mtStatus?: number | null
+  submitStatus?: number | null
+  backstageStatus?: number | null
+  infoStatus?: number | null
+  salaryLogin?: number | null
+  country?: string | null
+  channelCodes?: string[]
+  startDate?: string
+  endDate?: string
+  startSubmitDate?: string
+  endSubmitDate?: string
+  page?: PageParam
+}
+
+export interface RefusalReasonItem {
+  id: number
+  content: string
+  enContent?: string
+}
+
+export interface WithdrawCommentItem {
+  id?: number
+  serial?: string
+  comment?: string
+  addName?: string
+  addTime?: string
+}
+
+export function searchWithdrawList(params: WithdrawSearchParams) {
+  return request.post<unknown>('/finance/withdraw/searcher/list', params)
+}
+
+export function searchDepositList(params: {
+  cId?: string
+  login?: string
+  serial?: string
+  startDate?: string
+  endDate?: string
+  page?: PageParam
+}) {
+  return request.post<unknown>('/finance/deposit/searcher/list', params)
+}
+
+export async function searchWithdrawPage(params: WithdrawSearchParams) {
+  const raw = await searchWithdrawList(params)
+  return unwrapPageList<WithdrawItem>(raw)
+}
+
+export async function searchDepositPage(params: {
+  cId?: string
+  login?: string
+  serial?: string
+  startDate?: string
+  endDate?: string
+  page?: PageParam
+}) {
+  const raw = await searchDepositList(params)
+  return unwrapPageList<DepositItem>(raw)
+}
+
+export function getWithdrawChannelCodes() {
+  return request.post<Array<{ code: string; name: string; enName?: string }>>(
+    '/remit/channel/get/code',
+    {},
+  )
+}
+
+export function getWithdrawRemitChannels() {
+  return request.post<RemitChannelItem[]>('/finance/withdraw/remit/channel', {})
+}
+
+export function getWithdrawRemitChannelInfo(data: {
+  id: number
+  withdrawInfoUrl?: string
+  payType: string
+}) {
+  return request.post<Record<string, unknown>>('/finance/withdraw/api/info', data)
+}
+
+export function submitWithdrawApi(data: Record<string, unknown>) {
+  return request.post<{ result?: string }>('/finance/withdraw/withdrawapi', data)
+}
+
+export function getRefusalReasons() {
+  return request.post<RefusalReasonItem[]>('/reasons/refusal/list', { type: 10 })
+}
+
+export function approveWithdraw(data: { id: number; status: 2 | 3; approveDesc?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve', data)
+}
+
+export function approveWithdrawMT(data: { id: number; withdrawStatus: 2 | 3; approveDesc?: string; withdrawTicket?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve/manager', data)
+}
+
+export function approveWithdrawReceipt(data: { id: number; callbackStatus: 1 | 2; approveDesc?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve/channel', data)
+}
+
+export function approveWithdrawSubmit(data: { id: number; submitStatus: 2 | 3; approveDesc?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve/submit/channel', data)
+}
+
+export function approveWithdrawBackstage(data: { id: number; backstageStatus: 2 | 3; approveDesc?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve/backstage', data)
+}
+
+export function approveWithdrawInfo(data: { id: number; infoStatus: 2 | 3; approveDesc?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve/info', data)
+}
+
+export function addWithdrawComment(data: { serial: string; comment: string; type?: number }) {
+  return request.post<unknown>('/finance/comment/add', { type: 1, ...data })
+}
+
+export async function listWithdrawComments(serial: string) {
+  return request.post<WithdrawCommentItem[]>('/finance/comment/list', { serial })
+}
+
+export function batchApproveWithdrawSubmit(data: { ids: number[]; submitStatus: 2 | 3; approveDesc?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve/submits/channel', data)
+}
+
+export function batchApproveWithdrawReceipt(data: { ids: number[]; callbackStatus: 1 | 2; approveDesc?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve/channels', data)
+}
+
+export function batchApproveWithdrawInfo(data: { ids: number[]; infoStatus: 2 | 3; approveDesc?: string }) {
+  return request.post<unknown>('/finance/withdraw/approve/infos', data)
+}
+
+export async function exportWithdrawList(params: WithdrawSearchParams): Promise<{ blob: Blob; fileName: string }> {
+  const token = getToken()
+  const res = await axios.get(`${apiBase || '/api'}/finance/withdraw/searcher/list/export`, {
+    params,
+    responseType: 'blob',
+    headers: token
+      ? {
+          Authorization: `Bearer ${token}`,
+          'access-token': token,
+        }
+      : undefined,
+  })
+  const disposition = String(res.headers['content-disposition'] ?? '')
+  const m = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^"]+)"?/)
+  const fileName = decodeURIComponent(m?.[1] ?? m?.[2] ?? 'withdraw.xlsx')
+  return { blob: res.data as Blob, fileName }
+}

+ 153 - 0
src/api/request.ts

@@ -0,0 +1,153 @@
+import axios, {
+  type AxiosError,
+  type AxiosInstance,
+  type AxiosRequestConfig,
+  type AxiosResponse,
+  type InternalAxiosRequestConfig,
+} from 'axios'
+import { createDiscreteApi } from 'naive-ui'
+import { apiBase } from '@/config/env'
+import { isApiEnvelope, SUCCESS_CODES } from './types'
+
+const TOKEN_KEY = 'kecheng-admin-token'
+
+/** 业务约定:token 失效 */
+const TOKEN_EXPIRED_CODE = 600
+
+const { message, dialog } = createDiscreteApi(['message', 'dialog'])
+
+let tokenExpiredDialogShown = false
+
+export function setToken(token: string | null) {
+  if (token) localStorage.setItem(TOKEN_KEY, token)
+  else localStorage.removeItem(TOKEN_KEY)
+}
+
+export function getToken(): string | null {
+  return localStorage.getItem(TOKEN_KEY)
+}
+
+function handleTokenExpired(tip?: string) {
+  if (tokenExpiredDialogShown) return
+  tokenExpiredDialogShown = true
+  setToken(null)
+  const content = tip?.trim() || '登录已失效,请重新登录'
+  dialog.warning({
+    title: '提示',
+    content,
+    positiveText: '确定',
+    maskClosable: false,
+    closable: false,
+    onPositiveClick: () => {
+      tokenExpiredDialogShown = false
+      void import('@/router').then(({ default: router }) => {
+        const r = router.currentRoute.value
+        const redirect = r.path === '/login' ? undefined : r.fullPath
+        void router.replace({
+          path: '/login',
+          query: redirect ? { redirect } : undefined,
+        })
+      })
+      return true
+    },
+    onClose: () => {
+      tokenExpiredDialogShown = false
+    },
+  })
+}
+
+function unwrapData<T>(response: AxiosResponse<unknown>): T {
+  const body = response.data
+  if (body !== null && typeof body === 'object' && 'code' in body) {
+    const code = (body as { code: number }).code
+    if (code === TOKEN_EXPIRED_CODE) {
+      const tip =
+        (body as { message?: string; msg?: string }).message ??
+        (body as { message?: string; msg?: string }).msg
+      handleTokenExpired(tip)
+      return Promise.reject(new Error(tip ?? '登录已失效')) as never
+    }
+  }
+  if (isApiEnvelope(body)) {
+    if (!SUCCESS_CODES.has(body.code)) {
+      const msg = body.message ?? body.msg ?? '请求失败'
+      message.error(msg)
+      return Promise.reject(new Error(msg)) as never
+    }
+    return body.data as T
+  }
+  return body as T
+}
+
+function onRejected(error: AxiosError<{ message?: string; msg?: string }>) {
+  const status = error.response?.status
+  const raw = error.response?.data
+  const bizMsg =
+    raw && typeof raw === 'object'
+      ? (raw as { message?: string; msg?: string }).message ??
+        (raw as { message?: string; msg?: string }).msg
+      : undefined
+  const msg =
+    bizMsg ??
+    error.message ??
+    (status === 401 ? '未登录或登录已过期' : '网络异常,请稍后重试')
+
+  if (status === 401) {
+    setToken(null)
+    message.warning(msg)
+  } else if (status && status >= 500) {
+    message.error(msg)
+  } else if (!error.response) {
+    message.error(msg)
+  } else {
+    message.error(msg)
+  }
+
+  return Promise.reject(error)
+}
+
+const instance: AxiosInstance = axios.create({
+  baseURL: apiBase || '/api',
+  timeout: 15_000,
+  headers: {
+    'Content-Type': 'application/json',
+  },
+})
+
+instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
+  const token = getToken()
+  if (token) {
+    const h = config.headers ?? {}
+    config.headers = h
+    // 后端若只认其一,可删掉不需要的那种
+    h.Authorization = `Bearer ${token}`
+    h['access-token'] = token
+  }
+  return config
+})
+
+instance.interceptors.response.use(
+  (response: AxiosResponse<unknown>) => unwrapData(response),
+  onRejected,
+)
+
+/** 请求方法(响应已按壳解包,失败时 reject) */
+export const request = {
+  get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
+    return instance.get<unknown, T>(url, config)
+  },
+  post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
+    return instance.post<unknown, T>(url, data, config)
+  },
+  put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
+    return instance.put<unknown, T>(url, data, config)
+  },
+  patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
+    return instance.patch<unknown, T>(url, data, config)
+  },
+  delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
+    return instance.delete<unknown, T>(url, config)
+  },
+}
+
+export { instance as axiosInstance }

+ 41 - 0
src/api/types.ts

@@ -0,0 +1,41 @@
+/** 分页请求体(与后端约定:嵌套在 page 下) */
+export interface PageParam {
+  current: number
+  row: number
+}
+
+/** 与后端约定的通用响应壳(若后端直出 JSON 无壳,拦截器会原样返回) */
+export interface ApiEnvelope<T = unknown> {
+  code: number
+  message?: string
+  /** 部分接口使用 msg,与 message 二选一 */
+  msg?: string
+  data: T
+}
+
+export function isApiEnvelope(x: unknown): x is ApiEnvelope {
+  return (
+    x !== null &&
+    typeof x === 'object' &&
+    'code' in x &&
+    typeof (x as ApiEnvelope).code === 'number' &&
+    'data' in x
+  )
+}
+
+/** 业务成功码:按你的后端约定改,例如 0 / 200 */
+export const SUCCESS_CODES = new Set([0, 200])
+
+/** 兼容 list / records、total / totalCount 等常见分页字段 */
+export function unwrapPageList<T>(res: unknown): { list: T[]; total: number } {
+  if (Array.isArray(res)) return { list: res as T[], total: res.length }
+  if (res !== null && typeof res === 'object') {
+    const o = res as Record<string, unknown>
+    const rawList = o.list ?? o.records
+    const list = Array.isArray(rawList) ? (rawList as T[]) : []
+    const totalRaw = o.total ?? o.totalCount ?? o.totalElements
+    const total = typeof totalRaw === 'number' ? totalRaw : list.length
+    return { list, total }
+  }
+  return { list: [], total: 0 }
+}

BIN
src/assets/hero.png


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
src/assets/vite.svg


+ 1 - 0
src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 173 - 0
src/auto-imports.d.ts

@@ -0,0 +1,173 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+// biome-ignore lint: disable
+export {}
+declare global {
+  const EffectScope: typeof import('vue').EffectScope
+  const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
+  const computed: typeof import('vue').computed
+  const createApp: typeof import('vue').createApp
+  const createPinia: typeof import('pinia').createPinia
+  const customRef: typeof import('vue').customRef
+  const defineAsyncComponent: typeof import('vue').defineAsyncComponent
+  const defineComponent: typeof import('vue').defineComponent
+  const defineStore: typeof import('pinia').defineStore
+  const effectScope: typeof import('vue').effectScope
+  const getActivePinia: typeof import('pinia').getActivePinia
+  const getCurrentInstance: typeof import('vue').getCurrentInstance
+  const getCurrentScope: typeof import('vue').getCurrentScope
+  const getCurrentWatcher: typeof import('vue').getCurrentWatcher
+  const h: typeof import('vue').h
+  const inject: typeof import('vue').inject
+  const isProxy: typeof import('vue').isProxy
+  const isReactive: typeof import('vue').isReactive
+  const isReadonly: typeof import('vue').isReadonly
+  const isRef: typeof import('vue').isRef
+  const isShallow: typeof import('vue').isShallow
+  const mapActions: typeof import('pinia').mapActions
+  const mapGetters: typeof import('pinia').mapGetters
+  const mapState: typeof import('pinia').mapState
+  const mapStores: typeof import('pinia').mapStores
+  const mapWritableState: typeof import('pinia').mapWritableState
+  const markRaw: typeof import('vue').markRaw
+  const nextTick: typeof import('vue').nextTick
+  const onActivated: typeof import('vue').onActivated
+  const onBeforeMount: typeof import('vue').onBeforeMount
+  const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
+  const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
+  const onBeforeUnmount: typeof import('vue').onBeforeUnmount
+  const onBeforeUpdate: typeof import('vue').onBeforeUpdate
+  const onDeactivated: typeof import('vue').onDeactivated
+  const onErrorCaptured: typeof import('vue').onErrorCaptured
+  const onMounted: typeof import('vue').onMounted
+  const onRenderTracked: typeof import('vue').onRenderTracked
+  const onRenderTriggered: typeof import('vue').onRenderTriggered
+  const onScopeDispose: typeof import('vue').onScopeDispose
+  const onServerPrefetch: typeof import('vue').onServerPrefetch
+  const onUnmounted: typeof import('vue').onUnmounted
+  const onUpdated: typeof import('vue').onUpdated
+  const onWatcherCleanup: typeof import('vue').onWatcherCleanup
+  const provide: typeof import('vue').provide
+  const reactive: typeof import('vue').reactive
+  const readonly: typeof import('vue').readonly
+  const ref: typeof import('vue').ref
+  const resolveComponent: typeof import('vue').resolveComponent
+  const setActivePinia: typeof import('pinia').setActivePinia
+  const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
+  const shallowReactive: typeof import('vue').shallowReactive
+  const shallowReadonly: typeof import('vue').shallowReadonly
+  const shallowRef: typeof import('vue').shallowRef
+  const storeToRefs: typeof import('pinia').storeToRefs
+  const toRaw: typeof import('vue').toRaw
+  const toRef: typeof import('vue').toRef
+  const toRefs: typeof import('vue').toRefs
+  const toValue: typeof import('vue').toValue
+  const triggerRef: typeof import('vue').triggerRef
+  const unref: typeof import('vue').unref
+  const useAttrs: typeof import('vue').useAttrs
+  const useCssModule: typeof import('vue').useCssModule
+  const useCssVars: typeof import('vue').useCssVars
+  const useId: typeof import('vue').useId
+  const useLink: typeof import('vue-router').useLink
+  const useModel: typeof import('vue').useModel
+  const useRoute: typeof import('vue-router').useRoute
+  const useRouter: typeof import('vue-router').useRouter
+  const useSlots: typeof import('vue').useSlots
+  const useTemplateRef: typeof import('vue').useTemplateRef
+  const watch: typeof import('vue').watch
+  const watchEffect: typeof import('vue').watchEffect
+  const watchPostEffect: typeof import('vue').watchPostEffect
+  const watchSyncEffect: typeof import('vue').watchSyncEffect
+}
+// for type re-export
+declare global {
+  // @ts-ignore
+  export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
+  import('vue')
+}
+
+// for vue template auto import
+import { UnwrapRef } from 'vue'
+declare module 'vue' {
+  interface GlobalComponents {}
+  interface ComponentCustomProperties {
+    readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
+    readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
+    readonly computed: UnwrapRef<typeof import('vue')['computed']>
+    readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
+    readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
+    readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
+    readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
+    readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
+    readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
+    readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
+    readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
+    readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
+    readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
+    readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
+    readonly h: UnwrapRef<typeof import('vue')['h']>
+    readonly inject: UnwrapRef<typeof import('vue')['inject']>
+    readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
+    readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
+    readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
+    readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
+    readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
+    readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
+    readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
+    readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
+    readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
+    readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
+    readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
+    readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
+    readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
+    readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
+    readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
+    readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
+    readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
+    readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
+    readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
+    readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
+    readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
+    readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
+    readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
+    readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
+    readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
+    readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
+    readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
+    readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
+    readonly provide: UnwrapRef<typeof import('vue')['provide']>
+    readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
+    readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
+    readonly ref: UnwrapRef<typeof import('vue')['ref']>
+    readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
+    readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
+    readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
+    readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
+    readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
+    readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
+    readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
+    readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
+    readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
+    readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
+    readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
+    readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
+    readonly unref: UnwrapRef<typeof import('vue')['unref']>
+    readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
+    readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
+    readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
+    readonly useId: UnwrapRef<typeof import('vue')['useId']>
+    readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
+    readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
+    readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
+    readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
+    readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
+    readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
+    readonly watch: UnwrapRef<typeof import('vue')['watch']>
+    readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
+    readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
+    readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
+  }
+}

+ 49 - 0
src/components.d.ts

@@ -0,0 +1,49 @@
+/* eslint-disable */
+// @ts-nocheck
+// biome-ignore lint: disable
+// oxlint-disable
+// ------
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+  export interface GlobalComponents {
+    AdminSearchPanel: typeof import('./components/AdminSearchPanel.vue')['default']
+    AdminTablePageBar: typeof import('./components/AdminTablePageBar.vue')['default']
+    AdminTableToolbar: typeof import('./components/AdminTableToolbar.vue')['default']
+    NAlert: typeof import('naive-ui')['NAlert']
+    NAvatar: typeof import('naive-ui')['NAvatar']
+    NBreadcrumb: typeof import('naive-ui')['NBreadcrumb']
+    NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem']
+    NButton: typeof import('naive-ui')['NButton']
+    NCard: typeof import('naive-ui')['NCard']
+    NCheckbox: typeof import('naive-ui')['NCheckbox']
+    NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
+    NConfigProvider: typeof import('naive-ui')['NConfigProvider']
+    NDataTable: typeof import('naive-ui')['NDataTable']
+    NDatePicker: typeof import('naive-ui')['NDatePicker']
+    NDialogProvider: typeof import('naive-ui')['NDialogProvider']
+    NDropdown: typeof import('naive-ui')['NDropdown']
+    NForm: typeof import('naive-ui')['NForm']
+    NFormItem: typeof import('naive-ui')['NFormItem']
+    NInput: typeof import('naive-ui')['NInput']
+    NLayout: typeof import('naive-ui')['NLayout']
+    NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
+    NLayoutSider: typeof import('naive-ui')['NLayoutSider']
+    NMenu: typeof import('naive-ui')['NMenu']
+    NMessageProvider: typeof import('naive-ui')['NMessageProvider']
+    NModal: typeof import('naive-ui')['NModal']
+    NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
+    NPagination: typeof import('naive-ui')['NPagination']
+    NPopover: typeof import('naive-ui')['NPopover']
+    NScrollbar: typeof import('naive-ui')['NScrollbar']
+    NSelect: typeof import('naive-ui')['NSelect']
+    NSpace: typeof import('naive-ui')['NSpace']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    RouteTabBar: typeof import('./components/RouteTabBar.vue')['default']
+  }
+}

+ 176 - 0
src/components/AdminSearchPanel.vue

@@ -0,0 +1,176 @@
+<script setup lang="ts">
+import { ChevronForwardOutline, ReloadOutline, SearchOutline } from '@vicons/ionicons5'
+import {
+  NButton,
+  NCollapseTransition,
+  NForm,
+  NFormItemGi,
+  NGrid,
+  NIcon,
+  NSpace,
+} from 'naive-ui'
+
+const props = defineProps<{
+  /** 搜索条件项数量(与插槽内 NFormItemGi 个数一致),用于计算「重置/搜索」在 5 列栅格中占几列并贴右对齐 */
+  fieldCount: number
+}>()
+
+const expanded = ref(true)
+
+function toggle() {
+  expanded.value = !expanded.value
+}
+
+const emit = defineEmits<{
+  search: []
+  reset: []
+}>()
+
+/** 5 列栅格下按钮占位:少条件时占末几列与条件同行贴右;整行满 5 个条件后单独新开一行占满并贴右 */
+const buttonSpan5 = computed(() => {
+  const k = Math.max(0, Math.floor(props.fieldCount))
+  if (k === 0) return 5
+  const rem = k % 5
+  return rem === 0 ? 5 : 5 - rem
+})
+
+/** 窄屏 1~2 列:按钮独占一行;≥m 用 5 列规则 */
+const buttonSpan = computed(() => `1 s:1 m:${buttonSpan5.value}`)
+</script>
+
+<template>
+  <div class="admin-search-card">
+    <button
+      type="button"
+      class="admin-search-card__header"
+      :aria-expanded="expanded"
+      @click="toggle"
+    >
+      <NIcon
+        :component="ChevronForwardOutline"
+        class="admin-search-card__chevron"
+        :class="{ 'is-open': expanded }"
+      />
+      <span class="admin-search-card__title">搜索</span>
+    </button>
+    <NCollapseTransition :show="expanded" appear>
+      <div class="admin-search-card__inner">
+        <NForm :show-feedback="false" label-placement="left" label-width="auto">
+          <NGrid
+            :cols="'1 s:2 m:5'"
+            :x-gap="12"
+            :y-gap="12"
+            responsive="screen"
+            class="admin-search-grid"
+          >
+            <slot />
+            <NFormItemGi
+              :span="buttonSpan"
+              :show-label="false"
+              :show-feedback="false"
+              class="admin-search-card__btn-cell"
+            >
+              <NSpace justify="end" :size="12" class="admin-search-card__btn-row">
+                <NButton secondary @click="emit('reset')">
+                  <template #icon>
+                    <NIcon :component="ReloadOutline" />
+                  </template>
+                  重置
+                </NButton>
+                <NButton type="primary" @click="emit('search')">
+                  <template #icon>
+                    <NIcon :component="SearchOutline" />
+                  </template>
+                  搜索
+                </NButton>
+              </NSpace>
+            </NFormItemGi>
+          </NGrid>
+        </NForm>
+      </div>
+    </NCollapseTransition>
+  </div>
+</template>
+
+<style scoped>
+.admin-search-card {
+  box-sizing: border-box;
+  width: 100%;
+  margin-bottom: 16px;
+  flex-shrink: 0;
+  background: var(--admin-surface, #fff);
+  border: 1px solid var(--admin-border);
+  border-radius: 14px;
+  box-shadow: var(--admin-shadow);
+  overflow: hidden;
+}
+
+.admin-search-card__header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  margin: 0;
+  padding: 12px 18px;
+  border: none;
+  background: transparent;
+  font: inherit;
+  font-weight: 600;
+  font-size: 0.9375rem;
+  color: #0f172a;
+  cursor: pointer;
+  text-align: left;
+  transition: background 0.15s;
+}
+
+.admin-search-card__header:hover {
+  background: rgba(15, 23, 42, 0.03);
+}
+
+.admin-search-card__chevron {
+  flex-shrink: 0;
+  transition: transform 0.2s ease;
+  color: var(--admin-text-muted, #64748b);
+}
+
+.admin-search-card__chevron.is-open {
+  transform: rotate(90deg);
+}
+
+.admin-search-card__title {
+  user-select: none;
+}
+
+.admin-search-card__inner {
+  padding: 0 18px 16px;
+  border-top: 1px solid var(--admin-border);
+}
+
+.admin-search-grid {
+  padding-top: 16px;
+  width: 100%;
+}
+
+/* 参考图:输入、选择有合适宽度,不把整格外拉满 */
+.admin-search-card__inner :deep(.n-form-item:not(.admin-search-card__btn-cell) .n-input),
+.admin-search-card__inner :deep(.n-form-item:not(.admin-search-card__btn-cell) .n-base-selection) {
+  width: 100%;
+  max-width: 260px;
+  min-width: 0;
+}
+
+/* 按钮格内贴右,占满所跨列 */
+.admin-search-grid :deep(.admin-search-card__btn-cell) {
+  align-self: end;
+}
+
+.admin-search-grid :deep(.admin-search-card__btn-cell .n-form-item-blank) {
+  width: 100%;
+}
+
+.admin-search-card__btn-row {
+  width: 100%;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+</style>

+ 51 - 0
src/components/AdminTablePageBar.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import AdminTableToolbar from '@/components/AdminTableToolbar.vue'
+
+defineProps<{
+  /** 表格区域标题,显示在卡片内左上,与工具栏同一行 */
+  title: string
+  columnOptions: { label: string; value: string }[]
+  refreshLoading?: boolean
+}>()
+
+const visibleKeys = defineModel<string[]>('visibleKeys', { required: true })
+
+const emit = defineEmits<{
+  refresh: []
+}>()
+</script>
+
+<template>
+  <div class="table-page-bar">
+    <h1 class="table-page-bar__title">{{ title }}</h1>
+    <AdminTableToolbar
+      v-model:visible-keys="visibleKeys"
+      :column-options="columnOptions"
+      :refresh-loading="refreshLoading"
+      @refresh="emit('refresh')"
+    >
+      <slot />
+    </AdminTableToolbar>
+  </div>
+</template>
+
+<style scoped>
+.table-page-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px 16px;
+  padding-bottom: 14px;
+  flex-shrink: 0;
+}
+
+.table-page-bar__title {
+  margin: 0;
+  font-size: 1.125rem;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  color: #0f172a;
+  line-height: 1.35;
+}
+</style>

+ 95 - 0
src/components/AdminTableToolbar.vue

@@ -0,0 +1,95 @@
+<script setup lang="ts">
+import { RefreshOutline, SettingsOutline } from '@vicons/ionicons5'
+import { NIcon } from 'naive-ui'
+
+defineProps<{
+  columnOptions: { label: string; value: string }[]
+  refreshLoading?: boolean
+}>()
+
+const visibleKeys = defineModel<string[]>('visibleKeys', { required: true })
+
+const emit = defineEmits<{
+  refresh: []
+}>()
+</script>
+
+<template>
+  <div class="admin-table-toolbar">
+    <NSpace :size="10" align="center" wrap>
+      <slot />
+      <NButton
+        secondary
+        :loading="refreshLoading"
+        @click="emit('refresh')"
+      >
+        <template #icon>
+          <NIcon :component="RefreshOutline" />
+        </template>
+        刷新
+      </NButton>
+      <NPopover
+        v-if="columnOptions.length > 0"
+        trigger="click"
+        placement="bottom-end"
+        :show-arrow="false"
+        display-directive="show"
+      >
+        <template #trigger>
+          <NButton secondary>
+            <template #icon>
+              <NIcon :component="SettingsOutline" />
+            </template>
+            列设置
+          </NButton>
+        </template>
+        <div class="column-setting">
+          <div class="column-setting__title">展示列</div>
+          <NCheckboxGroup v-model:value="visibleKeys" class="column-setting__list">
+            <div v-for="opt in columnOptions" :key="opt.value" class="column-setting__row">
+              <NCheckbox :value="opt.value" :label="opt.label" />
+            </div>
+          </NCheckboxGroup>
+        </div>
+      </NPopover>
+    </NSpace>
+  </div>
+</template>
+
+<style scoped>
+.admin-table-toolbar {
+  display: flex;
+  flex-shrink: 0;
+}
+
+.column-setting {
+  min-width: 200px;
+  max-width: 280px;
+  padding: 4px 0;
+}
+
+.column-setting__title {
+  font-size: 0.8rem;
+  font-weight: 600;
+  color: #64748b;
+  margin-bottom: 8px;
+  padding: 0 4px;
+}
+
+.column-setting__list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  max-height: 280px;
+  overflow-y: auto;
+}
+
+.column-setting__row {
+  padding: 2px 4px;
+  border-radius: 6px;
+}
+
+.column-setting__row:hover {
+  background: rgba(15, 23, 42, 0.04);
+}
+</style>

+ 155 - 0
src/components/RouteTabBar.vue

@@ -0,0 +1,155 @@
+<script setup lang="ts">
+import type { TabItem } from '@/stores/tabs'
+import {
+  BookOutline,
+  CloseOutline,
+  DocumentTextOutline,
+  GridOutline,
+  HelpCircleOutline,
+  SettingsOutline,
+  TrophyOutline,
+} from '@vicons/ionicons5'
+import { NIcon } from 'naive-ui'
+import { useTabStore } from '@/stores/tabs'
+
+const tabStore = useTabStore()
+const router = useRouter()
+const route = useRoute()
+
+const activePath = computed(() => route.path)
+
+const pathIconMap: Record<string, typeof GridOutline> = {
+  '/dashboard': GridOutline,
+  '/courses/common-questions': HelpCircleOutline,
+  '/courses/reward-questions': TrophyOutline,
+  '/courses/goods': BookOutline,
+  '/settings': SettingsOutline,
+}
+
+function tabIcon(path: string) {
+  return pathIconMap[path] ?? DocumentTextOutline
+}
+
+function onTabClick(tab: TabItem) {
+  if (tab.path === route.path) return
+  void router.push(tab.path)
+}
+
+async function onClose(e: MouseEvent, tab: TabItem) {
+  e.stopPropagation()
+  e.preventDefault()
+  if (tab.affix) return
+  const i = tabStore.tabs.findIndex((t) => t.path === tab.path)
+  if (i === -1) return
+  /** 先跳转再走 sync,否则当前路由仍会触发 sync 把刚删的标签加回来 */
+  if (route.path === tab.path) {
+    const next = tabStore.tabs[i + 1] ?? tabStore.tabs[i - 1]
+    if (!next) return
+    await router.push(next.path)
+  }
+  tabStore.removeTab(tab.path)
+}
+</script>
+
+<template>
+  <div class="route-tab-bar">
+    <NScrollbar x-scrollable trigger="none">
+      <div class="route-tab-bar__track">
+        <button
+          v-for="tab in tabStore.tabs"
+          :key="tab.path"
+          type="button"
+          class="route-tab"
+          :class="{ 'is-active': activePath === tab.path }"
+          @click="onTabClick(tab)"
+        >
+          <NIcon :component="tabIcon(tab.path)" class="route-tab__icon" />
+          <span class="route-tab__text">{{ tab.title }}</span>
+          <span
+            v-if="!tab.affix"
+            class="route-tab__close"
+            role="button"
+            tabindex="0"
+            @click="(e) => onClose(e, tab)"
+          >
+            <NIcon :component="CloseOutline" :size="14" />
+          </span>
+        </button>
+      </div>
+    </NScrollbar>
+  </div>
+</template>
+
+<style scoped>
+.route-tab-bar {
+  width: 100%;
+}
+
+.route-tab-bar__track {
+  display: inline-flex;
+  align-items: stretch;
+  gap: 6px;
+  padding: 8px 0 10px;
+  min-height: 44px;
+  box-sizing: border-box;
+}
+
+.route-tab {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 10px 6px 12px;
+  margin: 0;
+  border: 1px solid transparent;
+  border-radius: 8px;
+  background: transparent;
+  color: var(--admin-text-muted);
+  font-size: 0.875rem;
+  font-family: inherit;
+  cursor: pointer;
+  white-space: nowrap;
+  transition:
+    background 0.15s,
+    color 0.15s,
+    border-color 0.15s;
+}
+
+.route-tab:hover {
+  color: #334155;
+  background: rgba(15, 23, 42, 0.04);
+}
+
+.route-tab.is-active {
+  color: var(--n-color-target, #6366f1);
+  background: rgba(99, 102, 241, 0.1);
+  border-color: rgba(99, 102, 241, 0.35);
+  font-weight: 600;
+}
+
+.route-tab__icon {
+  flex-shrink: 0;
+  opacity: 0.9;
+}
+
+.route-tab__text {
+  max-width: 160px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.route-tab__close {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 2px;
+  padding: 2px;
+  border-radius: 4px;
+  color: var(--admin-text-muted);
+  transition: background 0.15s, color 0.15s;
+}
+
+.route-tab__close:hover {
+  background: rgba(15, 23, 42, 0.08);
+  color: #0f172a;
+}
+</style>

+ 3 - 0
src/composables/index.ts

@@ -0,0 +1,3 @@
+export * from './usePagedKeywordList'
+export * from './useConfirmRowDelete'
+export * from './useTableColumnsControl'

+ 35 - 0
src/composables/useConfirmRowDelete.ts

@@ -0,0 +1,35 @@
+import type { DialogApi, MessageApi } from 'naive-ui'
+
+export interface UseConfirmRowDeleteOptions<T extends { id: number }> {
+  dialog: DialogApi
+  message: MessageApi
+  title: string
+  content: string
+  deleteRow: (row: T) => Promise<unknown>
+  /** 删除成功后的回调(通常传 fetchList) */
+  onAfterDelete?: () => void | Promise<void>
+  successText?: string
+}
+
+/**
+ * 表格行「删除」二次确认,封装 naive-ui Dialog + 成功提示 + 可选刷新列表。
+ */
+export function useConfirmRowDelete<T extends { id: number }>(
+  options: UseConfirmRowDeleteOptions<T>,
+) {
+  function confirmDelete(row: T) {
+    options.dialog.warning({
+      title: options.title,
+      content: options.content,
+      positiveText: '删除',
+      negativeText: '取消',
+      onPositiveClick: async () => {
+        await options.deleteRow(row)
+        options.message.success(options.successText ?? '已删除')
+        await options.onAfterDelete?.()
+      },
+    })
+  }
+
+  return { confirmDelete }
+}

+ 88 - 0
src/composables/usePagedKeywordList.ts

@@ -0,0 +1,88 @@
+import type { PageParam } from '@/api/types'
+
+/** 与后端列表请求体常见字段(page 在业务类型里多为可选,实际请求时由 buildQuery 保证带上) */
+export type PagedKeywordQuery = {
+  question?: string
+  keyword?: string
+  page?: PageParam
+}
+
+export interface UsePagedKeywordListOptions<T, Q extends object = PagedKeywordQuery> {
+  /** 拉取列表;由调用方把 Q 转成具体 API 参数 */
+  fetcher: (q: Q) => Promise<{ list: T[]; total: number }>
+  /** 将通用查询转成 fetcher 需要的形状,默认按关键词字段 question */
+  buildQuery?: (ctx: {
+    keywordTrimmed: string | undefined
+    page: PageParam
+  }) => Q
+  /** 对列表行做映射(如枚举规范化) */
+  mapList?: (rows: T[]) => T[]
+  initialPageSize?: number
+  /** 是否在挂载时立即请求,默认 true */
+  immediate?: boolean
+}
+
+export function usePagedKeywordList<T, Q extends object = PagedKeywordQuery>(
+  options: UsePagedKeywordListOptions<T, Q>,
+) {
+  const loading = ref(false)
+  const list = ref<T[]>([]) as Ref<T[]>
+  const total = ref(0)
+  const page = ref(1)
+  const pageSize = ref(options.initialPageSize ?? 10)
+  const keyword = ref('')
+
+  const defaultBuildQuery = (ctx: {
+    keywordTrimmed: string | undefined
+    page: PageParam
+  }): Q =>
+    ({
+      question: ctx.keywordTrimmed || undefined,
+      page: ctx.page,
+    }) as unknown as Q
+
+  const build = options.buildQuery ?? defaultBuildQuery
+
+  async function fetchList() {
+    loading.value = true
+    try {
+      const kw = keyword.value.trim()
+      const q = build({
+        keywordTrimmed: kw || undefined,
+        page: { current: page.value, row: pageSize.value },
+      })
+      const { list: rows, total: tc } = await options.fetcher(q)
+      list.value = options.mapList ? options.mapList(rows) : rows
+      total.value = tc
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function onSearch() {
+    page.value = 1
+    void fetchList()
+  }
+
+  watch([page, pageSize], () => {
+    void fetchList()
+  })
+
+  const immediate = options.immediate !== false
+  if (immediate) {
+    onMounted(() => {
+      void fetchList()
+    })
+  }
+
+  return {
+    loading,
+    list,
+    total,
+    page,
+    pageSize,
+    keyword,
+    fetchList,
+    onSearch,
+  }
+}

+ 69 - 0
src/composables/useTableColumnsControl.ts

@@ -0,0 +1,69 @@
+import type { DataTableColumns } from 'naive-ui'
+
+type RowCol<T> = DataTableColumns<T>[number]
+
+function asDataCol<T>(col: RowCol<T>): { title?: unknown; key?: unknown } {
+  return col as { title?: unknown; key?: unknown }
+}
+
+function columnLabel<T>(col: RowCol<T>): string {
+  const c = asDataCol(col)
+  const t = c.title
+  if (typeof t === 'string') return t
+  return String(c.key ?? '')
+}
+
+function colKey<T>(col: RowCol<T>): string {
+  return String(asDataCol(col).key ?? '')
+}
+
+export function useTableColumnsControl<T>(
+  columns: Ref<DataTableColumns<T>>,
+  opts: { frozenKeys?: string[] } = {},
+) {
+  const frozen = computed(() => new Set(opts.frozenKeys ?? ['actions']))
+
+  const toggleableKeys = computed(() =>
+    columns.value.map(colKey).filter((k) => k && !frozen.value.has(k)),
+  )
+
+  const visibleKeys = ref<string[]>([])
+
+  watch(
+    toggleableKeys,
+    (keys) => {
+      if (keys.length === 0) {
+        visibleKeys.value = []
+        return
+      }
+      const allowed = new Set(keys)
+      const kept = visibleKeys.value.filter((k) => allowed.has(k))
+      const added = keys.filter((k) => !kept.includes(k))
+      visibleKeys.value = [...kept, ...added]
+    },
+    { immediate: true },
+  )
+
+  const columnOptions = computed(() =>
+    columns.value
+      .filter((c) => {
+        const k = colKey(c)
+        return k && !frozen.value.has(k)
+      })
+      .map((c) => ({
+        value: colKey(c),
+        label: columnLabel(c),
+      })),
+  )
+
+  const displayColumns = computed(() =>
+    columns.value.filter((c) => {
+      const k = colKey(c)
+      if (!k) return true
+      if (frozen.value.has(k)) return true
+      return visibleKeys.value.includes(k)
+    }),
+  )
+
+  return { visibleKeys, displayColumns, columnOptions }
+}

+ 11 - 0
src/config/env.ts

@@ -0,0 +1,11 @@
+/** 当前 Vite 模式:development | test | production */
+export const viteMode = import.meta.env.MODE
+
+/** 是否开发(vite 内置) */
+export const isDev = import.meta.env.DEV
+
+/** 是否生产构建(vite 内置,test 模式打包时为 false) */
+export const isProd = import.meta.env.PROD
+
+/** 接口根路径(来自对应 .env.* 中的 VITE_API_BASE) */
+export const apiBase = import.meta.env.VITE_API_BASE as string

+ 281 - 0
src/layouts/AdminLayout.vue

@@ -0,0 +1,281 @@
+<script setup lang="ts">
+import type { MenuOption } from 'naive-ui'
+import { NIcon, useMessage } from 'naive-ui'
+import { BookOutline, LogoUsd, MenuOutline } from '@vicons/ionicons5'
+import RouteTabBar from '@/components/RouteTabBar.vue'
+import { setToken } from '@/api/request'
+import { useAppStore } from '@/stores/app'
+import { useTabStore } from '@/stores/tabs'
+
+const app = useAppStore()
+const tabStore = useTabStore()
+const { sidebarCollapsed } = storeToRefs(app)
+const route = useRoute()
+const router = useRouter()
+const message = useMessage()
+
+watch(
+  () => route.fullPath,
+  () => {
+    tabStore.syncFromRoute(route)
+  },
+  { immediate: true },
+)
+
+function renderIcon(icon: typeof BookOutline) {
+  return () => h(NIcon, { size: 18 }, { default: () => h(icon) })
+}
+
+const SUBMENU_COURSES = 'submenu-courses'
+const SUBMENU_FINANCE = 'submenu-finance'
+
+const menuOptions: MenuOption[] = [
+  {
+    label: '课程管理',
+    key: SUBMENU_COURSES,
+    icon: renderIcon(BookOutline),
+    children: [
+      { label: '常见问题', key: '/courses/common-questions' },
+      { label: '有奖问答', key: '/courses/reward-questions' },
+      { label: '教学内容', key: '/courses/goods' },
+    ],
+  },
+  {
+    label: '财务管理',
+    key: SUBMENU_FINANCE,
+    icon: renderIcon(LogoUsd),
+    children: [
+      { label: '取款申请', key: '/finance/withdraw-apply' },
+      { label: '订单列表', key: '/finance/order-list' },
+      { label: '客户列表', key: '/finance/customer-list' },
+    ],
+  },
+]
+
+const expandedKeys = ref<string[]>([])
+
+watch(
+  () => route.path,
+  (p) => {
+    if (p.startsWith('/courses')) {
+      expandedKeys.value = [SUBMENU_COURSES]
+      return
+    }
+    if (p.startsWith('/finance')) {
+      expandedKeys.value = [SUBMENU_FINANCE]
+      return
+    }
+    expandedKeys.value = []
+  },
+  { immediate: true },
+)
+
+const activeKey = computed(() => route.path)
+const pageTitle = computed(() => (route.meta.title as string) ?? '后台')
+
+const userOptions = [
+  { label: '修改密码', key: 'account' },
+  { type: 'divider', key: 'd' },
+  { label: '退出登录', key: 'logout' },
+]
+
+function handleUserSelect(key: string) {
+  if (key === 'logout') {
+    setToken(null)
+    message.success('已退出登录')
+    router.replace({ path: '/login' })
+  }
+}
+
+function goHome() {
+  router.push('/courses/common-questions')
+}
+
+/** NMenu 无内置 router 集成;受控 :value 时需自行跳转,否则点击不会改变路由 */
+function handleMenuSelect(key: string | number) {
+  const path = String(key)
+  if (path.startsWith('/')) void router.push(path)
+}
+</script>
+
+<template>
+  <NLayout has-sider class="admin-root" position="absolute">
+    <NLayoutSider
+      v-model:collapsed="sidebarCollapsed"
+      bordered
+      collapse-mode="width"
+      :collapsed-width="72"
+      :width="248"
+      :native-scrollbar="false"
+      show-trigger
+      trigger-style="right: -14px; border-radius: 0 8px 8px 0;"
+      class="admin-sider"
+    >
+      <div class="sider-inner" :class="{ 'is-collapsed': sidebarCollapsed }">
+        <div class="brand" @click="goHome">
+          <div class="brand-mark" aria-hidden="true" />
+          <span v-if="!sidebarCollapsed" class="admin-logo">课程后台</span>
+        </div>
+        <NScrollbar class="sider-menu-scroll">
+          <NMenu
+            v-model:expanded-keys="expandedKeys"
+            :value="activeKey"
+            :options="menuOptions"
+            :collapsed="sidebarCollapsed"
+            :collapsed-width="72"
+            :collapsed-icon-size="22"
+            :indent="22"
+            inverted
+            @update:value="handleMenuSelect"
+          />
+        </NScrollbar>
+      </div>
+    </NLayoutSider>
+
+    <!-- 使用独立列 + 内部滚动,避免顶栏与内容在同一 Layout 滚动容器内一起滚动 -->
+    <div class="admin-right">
+      <NLayoutHeader bordered class="admin-header">
+        <div class="header-left">
+          <NButton quaternary circle @click="app.toggleSidebar">
+            <template #icon>
+              <NIcon :component="MenuOutline" />
+            </template>
+          </NButton>
+          <NBreadcrumb separator="/">
+            <NBreadcrumbItem>首页</NBreadcrumbItem>
+            <NBreadcrumbItem>{{ pageTitle }}</NBreadcrumbItem>
+          </NBreadcrumb>
+        </div>
+        <div class="header-right">
+          <NDropdown :options="userOptions" @select="handleUserSelect">
+            <NAvatar
+              round
+              size="small"
+              :style="{ cursor: 'pointer', background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }"
+            >
+              管
+            </NAvatar>
+          </NDropdown>
+        </div>
+      </NLayoutHeader>
+
+      <div class="admin-route-tabs">
+        <RouteTabBar />
+      </div>
+
+      <div class="admin-main">
+        <div class="content-wrap page-enter">
+          <RouterView />
+        </div>
+      </div>
+    </div>
+  </NLayout>
+</template>
+
+<style scoped>
+.admin-root {
+  height: 100%;
+  min-height: 100vh;
+}
+
+/* 与 NMenu 的 collapsedWidth 对齐:收起时去掉水平内边距,否则菜单按 72px 居中图标会偏右 */
+.sider-inner {
+  padding: 20px 12px 24px;
+  min-height: 100vh;
+  box-sizing: border-box;
+}
+
+.sider-inner.is-collapsed {
+  padding-left: 0;
+  padding-right: 0;
+}
+
+.sider-menu-scroll {
+  max-height: calc(100vh - 88px);
+}
+
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 0 8px 24px;
+  cursor: pointer;
+  user-select: none;
+}
+
+.sider-inner.is-collapsed .brand {
+  justify-content: center;
+  padding-left: 0;
+  padding-right: 0;
+}
+
+.brand-mark {
+  width: 36px;
+  height: 36px;
+  border-radius: 10px;
+  flex-shrink: 0;
+  background: linear-gradient(135deg, #6366f1 0%, #a855f7 55%, #ec4899 100%);
+  box-shadow: 0 8px 24px rgba(99, 102, 241, 0.35);
+}
+
+.admin-right {
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: var(--admin-bg, #f4f5f8);
+}
+
+.admin-header {
+  flex-shrink: 0;
+  position: sticky;
+  top: 0;
+  z-index: 200;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 64px;
+  padding: 0 24px;
+  background: rgba(255, 255, 255, 0.92);
+  backdrop-filter: blur(12px);
+  box-shadow: 0 1px 0 var(--admin-border);
+}
+
+.admin-route-tabs {
+  flex-shrink: 0;
+  padding: 0 20px;
+  background: rgba(255, 255, 255, 0.96);
+  border-bottom: 1px solid var(--admin-border);
+  box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
+}
+
+.admin-main {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.header-left,
+.header-right {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.content-wrap {
+  box-sizing: border-box;
+  width: 100%;
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+  padding: 20px;
+  max-width: 100%;
+  display: flex;
+  flex-direction: column;
+}
+</style>

+ 8 - 0
src/main.ts

@@ -0,0 +1,8 @@
+import App from './App.vue'
+import router from './router'
+import './styles/global.css'
+
+const app = createApp(App)
+app.use(createPinia())
+app.use(router)
+app.mount('#app')

+ 115 - 0
src/router/index.ts

@@ -0,0 +1,115 @@
+import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
+import { getToken } from '@/api/request'
+import AdminLayout from '@/layouts/AdminLayout.vue'
+
+const useHashHistory = import.meta.env.VITE_USE_HASH_HISTORY === 'true'
+
+const router = createRouter({
+  history: useHashHistory
+    ? createWebHashHistory(import.meta.env.BASE_URL)
+    : createWebHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: '/login',
+      name: 'Login',
+      meta: { title: '登录', public: true },
+      component: () => import('@/views/LoginView.vue'),
+    },
+    {
+      path: '/',
+      component: AdminLayout,
+      redirect: '/courses/common-questions',
+      meta: { requiresAuth: true },
+      children: [
+        {
+          path: 'dashboard',
+          redirect: '/courses/common-questions',
+        },
+        {
+          path: 'courses',
+          redirect: '/courses/common-questions',
+        },
+        {
+          path: 'finance',
+          redirect: '/finance/withdraw-apply',
+        },
+        {
+          path: 'courses/common-questions',
+          name: 'CommonQuestions',
+          meta: { title: '常见问题', breadcrumb: ['课程管理', '常见问题'], requiresAuth: true },
+          component: () => import('@/views/courses/CommonQuestionView.vue'),
+        },
+        {
+          path: 'courses/reward-questions',
+          name: 'RewardQuestions',
+          meta: { title: '有奖问答', breadcrumb: ['课程管理', '有奖问答'], requiresAuth: true },
+          component: () => import('@/views/courses/RewardQuestionView.vue'),
+        },
+        {
+          path: 'courses/goods',
+          name: 'CourseGoods',
+          meta: { title: '教学内容', breadcrumb: ['课程管理', '教学内容'], requiresAuth: true },
+          component: () => import('@/views/courses/GoodsView.vue'),
+        },
+        {
+          path: 'finance/withdraw-apply',
+          name: 'FinanceWithdrawApply',
+          meta: {
+            title: '取款申请',
+            breadcrumb: ['财务管理', '取款申请'],
+            requiresAuth: true,
+          },
+          component: () => import('@/views/finance/WithdrawApplyView.vue'),
+        },
+        {
+          path: 'finance/order-list',
+          name: 'FinanceOrderList',
+          meta: {
+            title: '订单列表',
+            breadcrumb: ['财务管理', '订单列表'],
+            requiresAuth: true,
+          },
+          component: () => import('@/views/finance/OrderListView.vue'),
+        },
+        {
+          path: 'finance/customer-list',
+          name: 'FinanceCustomerList',
+          meta: {
+            title: '客户列表',
+            breadcrumb: ['财务管理', '客户列表'],
+            requiresAuth: true,
+          },
+          component: () => import('@/views/finance/CustomerListView.vue'),
+        },
+        {
+          path: 'courses/finance/withdraw-apply',
+          redirect: '/finance/withdraw-apply',
+        },
+        {
+          path: 'settings',
+          redirect: '/courses/common-questions',
+        },
+      ],
+    },
+  ],
+})
+
+router.beforeEach((to) => {
+  const token = getToken()
+  const isPublic = to.matched.some((r) => r.meta.public === true)
+
+  if (isPublic) {
+    if (token && to.name === 'Login') {
+      return { path: '/courses/common-questions' }
+    }
+    return true
+  }
+
+  if (!token) {
+    return { path: '/login', query: { redirect: to.fullPath } }
+  }
+
+  return true
+})
+
+export default router

+ 11 - 0
src/stores/app.ts

@@ -0,0 +1,11 @@
+import { useLocalStorage } from '@vueuse/core'
+
+export const useAppStore = defineStore('app', () => {
+  const sidebarCollapsed = useLocalStorage('kecheng-admin-sidebar', false)
+
+  function toggleSidebar() {
+    sidebarCollapsed.value = !sidebarCollapsed.value
+  }
+
+  return { sidebarCollapsed, toggleSidebar }
+})

+ 38 - 0
src/stores/tabs.ts

@@ -0,0 +1,38 @@
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+
+export interface TabItem {
+  path: string
+  title: string
+  /** 固定页签,不可关闭 */
+  affix?: boolean
+}
+
+export const useTabStore = defineStore('tabs', () => {
+  const tabs = ref<TabItem[]>([
+    { path: '/courses/common-questions', title: '常见问题', affix: true },
+  ])
+
+  function syncFromRoute(route: RouteLocationNormalizedLoaded) {
+    const title = route.meta?.title
+    if (typeof title !== 'string' || !title) return
+    const path = route.path
+    if (!path.startsWith('/') || path === '/') return
+    const hit = tabs.value.find((t) => t.path === path)
+    if (!hit) {
+      tabs.value.push({ path, title })
+    } else if (hit.title !== title) {
+      hit.title = title
+    }
+  }
+
+  /** 关闭指定路径页签;返回被移除的下标,未移除(不存在或固定)时为 -1 */
+  function removeTab(path: string) {
+    const i = tabs.value.findIndex((t) => t.path === path)
+    if (i === -1) return -1
+    if (tabs.value[i].affix) return -1
+    tabs.value.splice(i, 1)
+    return i
+  }
+
+  return { tabs, syncFromRoute, removeTab }
+})

+ 183 - 0
src/styles/global.css

@@ -0,0 +1,183 @@
+:root {
+  --admin-bg: #f4f5f8;
+  --admin-surface: #ffffff;
+  --admin-sider-from: #0e1118;
+  --admin-sider-to: #0a0c10;
+  --admin-border: rgba(15, 23, 42, 0.08);
+  --admin-text-muted: #64748b;
+  --admin-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+  height: 100%;
+  margin: 0;
+}
+
+#app {
+  overflow: hidden;
+}
+
+body {
+  font-family:
+    'Plus Jakarta Sans',
+    system-ui,
+    -apple-system,
+    BlinkMacSystemFont,
+    'Segoe UI',
+    sans-serif;
+  font-feature-settings: 'ss01' on, 'cv01' on;
+  -webkit-font-smoothing: antialiased;
+  background: var(--admin-bg);
+  color: #0f172a;
+}
+
+.admin-sider {
+  background: linear-gradient(165deg, var(--admin-sider-from) 0%, var(--admin-sider-to) 100%);
+  box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
+}
+
+.admin-sider .n-menu {
+  --n-item-color-hover: rgba(255, 255, 255, 0.06);
+  --n-item-color-active: rgba(99, 102, 241, 0.22);
+}
+
+.admin-logo {
+  letter-spacing: 0.04em;
+  font-weight: 650;
+  font-size: 0.95rem;
+  color: rgba(248, 250, 252, 0.95);
+}
+
+.admin-main {
+  min-height: 100%;
+  background: linear-gradient(180deg, #f4f5f8 0%, #eef0f5 100%);
+}
+
+.page-enter {
+  animation: page-in 0.35s ease both;
+}
+
+@keyframes page-in {
+  from {
+    opacity: 0;
+    transform: translateY(6px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* ----- 后台内容页通用:顶栏 / 标题 / 卡片 / 分页(与侧边栏、顶栏无关的页面结构) ----- */
+
+.page-head {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 24px;
+}
+
+.page .title,
+.page-head .title {
+  margin: 0;
+  font-size: 1.5rem;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  color: #0f172a;
+}
+
+.page .subtitle,
+.page-head .subtitle {
+  margin: 8px 0 0;
+  font-size: 0.95rem;
+  color: var(--admin-text-muted);
+}
+
+/* 白底容器:数据表、设置表单、统计卡、工作台分栏等 */
+.table-card,
+.settings-card,
+.stat-card,
+.panel {
+  background: var(--admin-surface);
+  box-shadow: var(--admin-shadow);
+  border: 1px solid var(--admin-border);
+}
+
+.pager-wrap {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 16px;
+  margin-top: auto;
+  flex-shrink: 0;
+}
+
+.pager-inline {
+  display: inline-flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+
+.pager-total {
+  font-size: 0.875rem;
+  color: var(--admin-text-muted);
+  white-space: nowrap;
+}
+
+/* 列表页:主内容区撑满剩余高度,白底卡片内表格可滚动,分页贴底 */
+.page:not(.page--table) {
+  flex: 0 0 auto;
+}
+
+.page--table {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.page--table .page-head {
+  flex-shrink: 0;
+}
+
+.table-card--fill {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.table-card--fill .n-card__content {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.table-card-inner {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-height: 0;
+  height: 100%;
+}
+
+.table-card__body {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.data-table-fill {
+  height: 100%;
+}

+ 143 - 0
src/views/DashboardView.vue

@@ -0,0 +1,143 @@
+<script setup lang="ts">
+import { ArrowUpOutline } from '@vicons/ionicons5'
+
+const trend = ref([
+  { label: '本周开课', value: '12', hint: '较上周 +18%', up: true },
+  { label: '在学人数', value: '2,480', hint: '较上周 +6%', up: true },
+  { label: '完课率', value: '86%', hint: '较上周 −2%', up: false },
+  { label: '待审课程', value: '5', hint: '需尽快处理', up: null },
+])
+</script>
+
+<template>
+  <div class="page">
+    <header class="page-head">
+      <div>
+        <h1 class="title">工作台</h1>
+        <p class="subtitle">欢迎回来,这是今日课程与运营概览。</p>
+      </div>
+      <NSpace>
+        <NButton secondary>导出报表</NButton>
+        <NButton type="primary">新建课程</NButton>
+      </NSpace>
+    </header>
+
+    <NGrid cols="1 520:2 960:4" :x-gap="20" :y-gap="20">
+      <NGridItem v-for="(item, i) in trend" :key="i">
+        <NCard class="stat-card" :bordered="false" size="medium">
+          <div class="stat-label">{{ item.label }}</div>
+          <div class="stat-value">{{ item.value }}</div>
+          <div class="stat-hint" :data-up="item.up">
+            <NIcon v-if="item.up !== null" :component="ArrowUpOutline" class="stat-arrow" />
+            {{ item.hint }}
+          </div>
+        </NCard>
+      </NGridItem>
+    </NGrid>
+
+    <NGrid cols="1 600:2" :x-gap="20" :y-gap="20" style="margin-top: 8px">
+      <NGridItem>
+        <NCard title="近期动态" :bordered="false" class="panel">
+          <NList>
+            <NListItem>
+              <template #prefix>
+                <NAvatar round size="small" color="#6366f1">A</NAvatar>
+              </template>
+              <div class="list-main">
+                <div class="list-title">新课程「数据分析入门」已提交审核</div>
+                <div class="list-meta">10 分钟前</div>
+              </div>
+            </NListItem>
+            <NListItem>
+              <template #prefix>
+                <NAvatar round size="small" color="#8b5cf6">B</NAvatar>
+              </template>
+              <div class="list-main">
+                <div class="list-title">128 名学员完成了「Python 基础」章节</div>
+                <div class="list-meta">昨天</div>
+              </div>
+            </NListItem>
+            <NListItem>
+              <template #prefix>
+                <NAvatar round size="small" color="#ec4899">C</NAvatar>
+              </template>
+              <div class="list-main">
+                <div class="list-title">系统已生成上周学习报告</div>
+                <div class="list-meta">周一</div>
+              </div>
+            </NListItem>
+          </NList>
+        </NCard>
+      </NGridItem>
+      <NGridItem>
+        <NCard title="快捷入口" :bordered="false" class="panel">
+          <NSpace vertical size="large">
+            <NButton block secondary size="large">管理课程章节</NButton>
+            <NButton block secondary size="large">查看学员反馈</NButton>
+            <NButton block quaternary size="large">系统公告</NButton>
+          </NSpace>
+        </NCard>
+      </NGridItem>
+    </NGrid>
+  </div>
+</template>
+
+<style scoped>
+.panel {
+  min-height: 280px;
+}
+
+.stat-label {
+  font-size: 0.85rem;
+  color: var(--admin-text-muted);
+  margin-bottom: 8px;
+}
+
+.stat-value {
+  font-size: 1.75rem;
+  font-weight: 700;
+  letter-spacing: -0.03em;
+  color: #0f172a;
+}
+
+.stat-hint {
+  margin-top: 12px;
+  font-size: 0.8rem;
+  color: var(--admin-text-muted);
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.stat-hint[data-up='true'] {
+  color: #059669;
+}
+
+.stat-hint[data-up='false'] {
+  color: #dc2626;
+}
+
+.stat-arrow {
+  font-size: 14px;
+}
+
+.stat-hint[data-up='false'] .stat-arrow {
+  transform: rotate(180deg);
+}
+
+.list-main {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.list-title {
+  font-size: 0.92rem;
+  color: #1e293b;
+}
+
+.list-meta {
+  font-size: 0.8rem;
+  color: var(--admin-text-muted);
+}
+</style>

+ 147 - 0
src/views/LoginView.vue

@@ -0,0 +1,147 @@
+<script setup lang="ts">
+import type { FormInst, FormRules } from 'naive-ui'
+import { useMessage } from 'naive-ui'
+import { login } from '@/api/modules/auth'
+
+const route = useRoute()
+const router = useRouter()
+const message = useMessage()
+
+const formRef = ref<FormInst | null>(null)
+const loading = ref(false)
+
+const form = reactive({
+  loginName: 'admin',
+  password: '123456',
+})
+
+const rules: FormRules = {
+  loginName: [{ required: true, message: '请输入账号', trigger: ['blur', 'input'] }],
+  password: [{ required: true, message: '请输入密码', trigger: ['blur', 'input'] }],
+}
+
+async function handleSubmit() {
+  await formRef.value?.validate()
+  loading.value = true
+  try {
+    const { token } = await login({
+      loginName: form.loginName.trim(),
+      password: form.password,
+    })
+    if (!token) {
+      message.warning('登录成功,但未返回 token,请检查 pickTokenFromLoginResponse 与后端字段')
+    } else {
+      message.success('登录成功')
+    }
+    const redirect = route.query.redirect
+    const path =
+      typeof redirect === 'string' && redirect.startsWith('/')
+        ? redirect
+        : '/courses/common-questions'
+    await router.replace(path)
+  } catch {
+    // 错误已在 axios 拦截器提示
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<template>
+  <div class="login-page">
+    <div class="login-bg" aria-hidden="true" />
+    <div class="login-panel">
+      <div class="login-brand">
+        <div class="login-mark" />
+        <h1 class="login-title">课程后台</h1>
+        <p class="login-sub">请使用账号登录管理系统</p>
+      </div>
+      <NForm ref="formRef" :model="form" :rules="rules" size="large" class="login-form">
+        <NFormItem path="loginName" label="账号">
+          <NInput v-model:value="form.loginName" placeholder="登录名" autocomplete="username" />
+        </NFormItem>
+        <NFormItem path="password" label="密码">
+          <NInput
+            v-model:value="form.password"
+            type="password"
+            show-password-on="click"
+            placeholder="密码"
+            autocomplete="current-password"
+            @keydown.enter.prevent="handleSubmit"
+          />
+        </NFormItem>
+        <NFormItem>
+          <NButton type="primary" block :loading="loading" attr-type="button" @click="handleSubmit">
+            登录
+          </NButton>
+        </NFormItem>
+      </NForm>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.login-page {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+  position: relative;
+  overflow: hidden;
+}
+
+.login-bg {
+  position: absolute;
+  inset: 0;
+  background:
+    radial-gradient(ellipse 80% 60% at 50% -30%, rgba(99, 102, 241, 0.35), transparent),
+    radial-gradient(ellipse 60% 50% at 100% 50%, rgba(168, 85, 247, 0.12), transparent),
+    linear-gradient(165deg, #0f1219 0%, #1a1f2e 45%, #0e1118 100%);
+}
+
+.login-panel {
+  position: relative;
+  width: 100%;
+  max-width: 400px;
+  padding: 40px 36px 36px;
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.96);
+  box-shadow:
+    0 1px 0 rgba(255, 255, 255, 0.6) inset,
+    0 24px 48px rgba(15, 23, 42, 0.18);
+  border: 1px solid rgba(255, 255, 255, 0.5);
+}
+
+.login-brand {
+  text-align: center;
+  margin-bottom: 28px;
+}
+
+.login-mark {
+  width: 48px;
+  height: 48px;
+  margin: 0 auto 16px;
+  border-radius: 14px;
+  background: linear-gradient(135deg, #6366f1 0%, #a855f7 55%, #ec4899 100%);
+  box-shadow: 0 12px 32px rgba(99, 102, 241, 0.35);
+}
+
+.login-title {
+  margin: 0;
+  font-size: 1.35rem;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  color: #0f172a;
+}
+
+.login-sub {
+  margin: 10px 0 0;
+  font-size: 0.9rem;
+  color: #64748b;
+}
+
+.login-form :deep(.n-form-item-feedback__line) {
+  min-height: 18px;
+}
+</style>

+ 42 - 0
src/views/SettingsView.vue

@@ -0,0 +1,42 @@
+<script setup lang="ts">
+const siteName = ref('课程学习中心')
+const contactEmail = ref('admin@example.com')
+const notify = ref(true)
+</script>
+
+<template>
+  <div class="page">
+    <header class="page-head">
+      <div>
+        <h1 class="title">系统设置</h1>
+        <p class="subtitle">站点信息与通知偏好,可按需扩展权限与集成。</p>
+      </div>
+    </header>
+
+    <NCard title="基本设置" :bordered="false" class="settings-card">
+      <NForm label-placement="left" label-width="auto" style="max-width: 520px">
+        <NFormItem label="站点名称">
+          <NInput v-model:value="siteName" placeholder="展示在浏览器标题等位置" />
+        </NFormItem>
+        <NFormItem label="联系邮箱">
+          <NInput v-model:value="contactEmail" placeholder="用于系统通知" />
+        </NFormItem>
+        <NFormItem label="邮件通知">
+          <NSwitch v-model:value="notify" />
+        </NFormItem>
+        <NFormItem>
+          <NSpace>
+            <NButton type="primary">保存</NButton>
+            <NButton secondary>重置</NButton>
+          </NSpace>
+        </NFormItem>
+      </NForm>
+    </NCard>
+  </div>
+</template>
+
+<style scoped>
+.settings-card {
+  max-width: 720px;
+}
+</style>

+ 226 - 0
src/views/courses/CommonQuestionView.vue

@@ -0,0 +1,226 @@
+<script setup lang="ts">
+import type { DataTableColumns, FormInst, FormRules } from "naive-ui";
+import {
+  NButton,
+  NFormItemGi,
+  NInput,
+  NModal,
+  NFormItem,
+  useDialog,
+  useMessage,
+} from "naive-ui";
+import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
+import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
+import {
+  useConfirmRowDelete,
+  usePagedKeywordList,
+  useTableColumnsControl,
+} from "@/composables";
+import type {
+  CommonQuestionItem,
+  CommonQuestionSearchParams,
+} from "@/api/modules/courses/commonQuestion";
+import * as commonQuestionApi from "@/api/modules/courses/commonQuestion";
+
+const message = useMessage();
+const dialog = useDialog();
+
+const { loading, list, total, page, pageSize, keyword, fetchList, onSearch } =
+  usePagedKeywordList<CommonQuestionItem, CommonQuestionSearchParams>({
+    fetcher: (q) => commonQuestionApi.searchCommonQuestionList(q),
+  });
+
+function resetSearch() {
+  keyword.value = "";
+  onSearch();
+}
+
+const { confirmDelete } = useConfirmRowDelete<CommonQuestionItem>({
+  dialog,
+  message,
+  title: "删除常见问题",
+  content: "确定删除该条问题吗?",
+  deleteRow: (row) => commonQuestionApi.deleteCommonQuestion({ ids: [row.id] }),
+  onAfterDelete: () => fetchList(),
+});
+
+const showModal = ref(false);
+const submitting = ref(false);
+const editingId = ref<number | null>(null);
+const formRef = ref<FormInst | null>(null);
+const form = ref({ question: "", answer: "" });
+
+const rules: FormRules = {
+  question: [{ required: true, message: "请输入问题", trigger: "blur" }],
+  answer: [{ required: true, message: "请输入答案", trigger: "blur" }],
+};
+
+function openAdd() {
+  editingId.value = null;
+  form.value = { question: "", answer: "" };
+  showModal.value = true;
+}
+
+function openEdit(row: CommonQuestionItem) {
+  editingId.value = row.id;
+  form.value = { question: row.question, answer: row.answer };
+  showModal.value = true;
+}
+
+async function submit() {
+  await formRef.value?.validate();
+  submitting.value = true;
+  try {
+    if (editingId.value != null) {
+      await commonQuestionApi.updateCommonQuestion({
+        id: editingId.value,
+        question: form.value.question.trim(),
+        answer: form.value.answer.trim(),
+      });
+      message.success("已更新");
+    } else {
+      await commonQuestionApi.addCommonQuestion({
+        question: form.value.question.trim(),
+        answer: form.value.answer.trim(),
+      });
+      message.success("已添加");
+    }
+    showModal.value = false;
+    await fetchList();
+  } finally {
+    submitting.value = false;
+  }
+}
+
+const allColumns = ref<DataTableColumns<CommonQuestionItem>>([
+  {
+    title: "问题",
+    key: "question",
+    ellipsis: { tooltip: true },
+    minWidth: 200,
+  },
+  { title: "答案", key: "answer", ellipsis: { tooltip: true }, minWidth: 240 },
+  {
+    title: "操作",
+    key: "actions",
+    width: 140,
+    fixed: "right",
+    render(row) {
+      return h("div", { style: "display:flex;gap:8px" }, [
+        h(
+          NButton,
+          {
+            size: "small",
+            quaternary: true,
+            type: "info",
+            onClick: () => openEdit(row),
+          },
+          { default: () => "编辑" },
+        ),
+        h(
+          NButton,
+          {
+            size: "small",
+            quaternary: true,
+            type: "error",
+            onClick: () => confirmDelete(row),
+          },
+          { default: () => "删除" },
+        ),
+      ]);
+    },
+  },
+]);
+
+const { visibleKeys, displayColumns, columnOptions } =
+  useTableColumnsControl(allColumns);
+</script>
+
+<template>
+  <div class="page page--table">
+    <AdminSearchPanel :field-count="1" @search="onSearch" @reset="resetSearch">
+      <NFormItemGi :span="1" label="问题">
+        <NInput
+          v-model:value="keyword"
+          placeholder="按问题关键词搜索"
+          clearable
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+    </AdminSearchPanel>
+
+    <NCard :bordered="false" class="table-card table-card--fill">
+      <div class="table-card-inner">
+        <AdminTablePageBar
+          title="常见问题"
+          v-model:visible-keys="visibleKeys"
+          :column-options="columnOptions"
+          :refresh-loading="loading"
+          @refresh="fetchList"
+        >
+          <NButton   @click="openAdd">新建</NButton>
+        </AdminTablePageBar>
+        <div class="table-card__body">
+          <NDataTable
+            :columns="displayColumns"
+            :data="list"
+            :loading="loading"
+            :bordered="false"
+            :single-line="false"
+            flex-height
+            class="data-table-fill"
+            :scroll-x="720"
+          />
+        </div>
+        <div class="pager-wrap">
+          <div class="pager-inline">
+            <span class="pager-total">共 {{ total }} 条</span>
+            <NPagination
+              v-model:page="page"
+              v-model:page-size="pageSize"
+              :item-count="total"
+              :page-sizes="[10, 20, 50]"
+              show-size-picker
+            />
+          </div>
+        </div>
+      </div>
+    </NCard>
+
+    <NModal
+      v-model:show="showModal"
+      preset="card"
+      :title="editingId != null ? '编辑常见问题' : '新建常见问题'"
+      style="width: min(520px, 92vw)"
+      :mask-closable="false"
+      @after-leave="() => formRef?.restoreValidation()"
+    >
+      <NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
+        <NFormItem label="问题" path="question">
+          <NInput
+            v-model:value="form.question"
+            type="textarea"
+            :autosize="{ minRows: 2, maxRows: 6 }"
+            placeholder="问题内容"
+          />
+        </NFormItem>
+        <NFormItem label="答案" path="answer">
+          <NInput
+            v-model:value="form.answer"
+            type="textarea"
+            :autosize="{ minRows: 3, maxRows: 10 }"
+            placeholder="答案内容"
+          />
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <NSpace justify="end">
+          <NButton @click="showModal = false">取消</NButton>
+          <NButton type="primary" :loading="submitting" @click="submit"
+            >保存</NButton
+          >
+        </NSpace>
+      </template>
+    </NModal>
+  </div>
+</template>

+ 691 - 0
src/views/courses/GoodsVideoManageModal.vue

@@ -0,0 +1,691 @@
+<script setup lang="ts">
+import type { DataTableColumns, FormInst, FormRules } from "naive-ui";
+import {
+  NButton,
+  NDataTable,
+  NForm,
+  NFormItemGi,
+  NFormItem,
+  NImage,
+  NInput,
+  NInputNumber,
+  NModal,
+  NRadioGroup,
+  NRadio,
+  NSpace,
+  NPagination,
+  useDialog,
+  useMessage,
+} from "naive-ui";
+import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
+import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
+import { apiBase } from "@/config/env";
+import { useTableColumnsControl } from "@/composables";
+import type { GoodsItem } from "@/api/modules/courses/goods";
+import type { GoodsVideoItem } from "@/api/modules/courses/goodsVideo";
+import * as goodsApi from "@/api/modules/courses/goods";
+import * as goodsVideoApi from "@/api/modules/courses/goodsVideo";
+
+const props = defineProps<{
+  show: boolean;
+  goods: GoodsItem | null;
+}>();
+
+const emit = defineEmits<{
+  "update:show": [boolean];
+}>();
+
+function updateShow(v: boolean) {
+  emit("update:show", v);
+}
+
+const message = useMessage();
+const dialog = useDialog();
+
+const videoLoading = ref(false);
+const videoList = ref<GoodsVideoItem[]>([]);
+const videoTotal = ref(0);
+const videoPage = ref(1);
+const videoPageSize = ref(10);
+const videoKeyword = ref("");
+
+const showVideoForm = ref(false);
+const videoSubmitting = ref(false);
+const videoCoverUploading = ref(false);
+const videoCoverFileInputRef = ref<HTMLInputElement | null>(null);
+const videoUploading = ref(false);
+const videoFileInputRef = ref<HTMLInputElement | null>(null);
+const videoEditingId = ref<number | null>(null);
+const videoFormRef = ref<FormInst | null>(null);
+
+interface VideoFormModel {
+  videoName: string;
+  title: string;
+  introduction: string;
+  frontUrl: string;
+  payType: 0 | 1;
+  fileType: 1 | 2;
+  fileUrl: string;
+  linkUrl: string;
+  sort: number | null;
+}
+
+const videoForm = ref<VideoFormModel>({
+  videoName: "",
+  title: "",
+  introduction: "",
+  frontUrl: "",
+  payType: 0,
+  fileType: 1,
+  fileUrl: "",
+  linkUrl: "",
+  sort: 0,
+});
+
+const videoRules: FormRules = {
+  videoName: [{ required: true, message: "请输入名称", trigger: "blur" }],
+  title: [{ required: true, message: "请输入标题", trigger: "blur" }],
+  introduction: [{ required: true, message: "请输入简介", trigger: "blur" }],
+  frontUrl: [{ required: true, message: "请输入封面地址", trigger: "blur" }],
+  payType: [
+    {
+      validator(_r, v: unknown) {
+        const n = Number(v);
+        if (n !== 0 && n !== 1) return new Error("请选择是否收费");
+        return true;
+      },
+      trigger: "change",
+    },
+  ],
+  fileType: [
+    {
+      validator(_r, v: unknown) {
+        const n = Number(v);
+        if (n !== 1 && n !== 2) return new Error("请选择资源类型");
+        return true;
+      },
+      trigger: "change",
+    },
+  ],
+  fileUrl: [
+    {
+      validator(_r, v: unknown) {
+        if (videoForm.value.fileType !== 1) return true;
+        const s = String(v ?? "").trim();
+        if (!s) return new Error("请先上传文件");
+        return true;
+      },
+      trigger: "blur",
+    },
+  ],
+  linkUrl: [
+    {
+      validator(_r, v: unknown) {
+        if (videoForm.value.fileType !== 2) return true;
+        const s = String(v ?? "").trim();
+        if (!s) return new Error("请填写外部链接");
+        return true;
+      },
+      trigger: "blur",
+    },
+  ],
+  sort: [
+    {
+      validator(_r, v: unknown) {
+        if (v === null || v === undefined || Number.isNaN(Number(v))) {
+          return new Error("请输入排序");
+        }
+        return true;
+      },
+      trigger: "blur",
+    },
+  ],
+};
+
+const videoModalTitle = computed(() => {
+  const g = props.goods;
+  if (!g) return "视频管理";
+  return Number(g.goodsType) === 5
+    ? `文件管理 — ${g.goodsName}`
+    : `视频管理 — ${g.goodsName}`;
+});
+
+const isReportGoods = computed(() => Number(props.goods?.goodsType) === 5);
+const mediaLabel = computed(() => (isReportGoods.value ? "文件" : "视频"));
+const mediaFileAccept = computed(() => (isReportGoods.value ? "*/*" : "video/*"));
+
+async function fetchVideoList() {
+  const g = props.goods;
+  if (!g) return;
+  videoLoading.value = true;
+  try {
+    const { list, total } = await goodsVideoApi.searchGoodsVideoList({
+      goodsId: g.id,
+      videoName: videoKeyword.value.trim() || undefined,
+      page: { current: videoPage.value, row: videoPageSize.value },
+    });
+    videoList.value = list;
+    videoTotal.value = total;
+  } finally {
+    videoLoading.value = false;
+  }
+}
+
+function onVideoSearch() {
+  videoPage.value = 1;
+  void fetchVideoList();
+}
+
+function resetVideoSearch() {
+  videoKeyword.value = "";
+  videoPage.value = 1;
+  void fetchVideoList();
+}
+
+watch([videoPage, videoPageSize], () => {
+  if (props.show && props.goods) {
+    void fetchVideoList();
+  }
+});
+
+watch(
+  () => [props.show, props.goods?.id] as const,
+  ([open]) => {
+    if (open && props.goods) {
+      videoKeyword.value = "";
+      videoPage.value = 1;
+      void fetchVideoList();
+    }
+  },
+);
+
+function onListModalLeave() {
+  showVideoForm.value = false;
+  videoKeyword.value = "";
+}
+
+function resetVideoForm() {
+  videoForm.value = {
+    videoName: "",
+    title: "",
+    introduction: "",
+    frontUrl: "",
+    payType: 0,
+    fileType: 2,
+    fileUrl: "",
+    linkUrl: "",
+    sort: 0,
+  };
+}
+
+function clearVideoFormValidation() {
+  nextTick(() => {
+    videoFormRef.value?.restoreValidation();
+  });
+}
+
+function openVideoAdd() {
+  videoEditingId.value = null;
+  resetVideoForm();
+  showVideoForm.value = true;
+  clearVideoFormValidation();
+}
+
+function normPay(v: unknown): 0 | 1 {
+  return Number(v) === 1 ? 1 : 0;
+}
+
+function normFile(v: unknown): 1 | 2 {
+  return Number(v) === 2 ? 2 : 1;
+}
+
+function resolveCoverUrl(url?: string | null): string {
+  const s = String(url ?? "").trim();
+  if (!s) return "";
+  if (/^(https?:)?\/\//i.test(s) || s.startsWith("data:") || s.startsWith("blob:")) {
+    return s;
+  }
+  const path = s.startsWith("/") ? s : `/${s}`;
+  try {
+    if (apiBase) return new URL(path, apiBase).toString();
+  } catch {
+    // ignore invalid env and fallback to current origin
+  }
+  if (typeof window === "undefined") return path;
+  return new URL(path, window.location.origin).toString();
+}
+
+function openVideoEdit(row: GoodsVideoItem) {
+  const intro = (row as GoodsVideoItem & { introduction?: string }).introduction;
+  videoEditingId.value = row.id;
+  videoForm.value = {
+    videoName: row.videoName,
+    title: row.title,
+    introduction: intro ?? "",
+    frontUrl: row.frontUrl,
+    payType: normPay(row.payType),
+    fileType: normFile(row.fileType),
+    fileUrl: row.fileUrl ?? "",
+    linkUrl: row.linkUrl ?? "",
+    sort: Number(row.sort) || 0,
+  };
+  showVideoForm.value = true;
+  clearVideoFormValidation();
+}
+
+function onVideoFileTypeChange() {
+  if (videoForm.value.fileType === 1) {
+    videoForm.value.linkUrl = "";
+  } else {
+    videoForm.value.fileUrl = "";
+  }
+}
+
+function triggerVideoCoverPick() {
+  videoCoverFileInputRef.value?.click();
+}
+
+async function onVideoCoverSelected(ev: Event) {
+  const input = ev.target as HTMLInputElement;
+  const file = input.files?.[0];
+  input.value = "";
+  if (!file) return;
+  videoCoverUploading.value = true;
+  try {
+    const url = await goodsApi.uploadGoodsCover(file);
+    videoForm.value.frontUrl = url;
+    message.success("封面上传成功");
+  } catch (e: unknown) {
+    if (e instanceof Error && e.message === "上传返回无有效地址") {
+      message.error(e.message);
+    }
+  } finally {
+    videoCoverUploading.value = false;
+  }
+}
+
+function triggerVideoFilePick() {
+  if (!props.goods) return;
+  videoFileInputRef.value?.click();
+}
+
+async function onVideoFileSelected(ev: Event) {
+  const input = ev.target as HTMLInputElement;
+  const file = input.files?.[0];
+  input.value = "";
+  const g = props.goods;
+  if (!file || !g) return;
+  videoUploading.value = true;
+  try {
+    const url = await goodsVideoApi.uploadGoodsVideoFile(g.id, file);
+    videoForm.value.fileUrl = url;
+    message.success("上传成功");
+  } catch (e: unknown) {
+    if (e instanceof Error && e.message === "上传返回无有效地址") {
+      message.error(e.message);
+    }
+  } finally {
+    videoUploading.value = false;
+  }
+}
+
+function buildVideoPayload() {
+  const g = props.goods!;
+  const f = videoForm.value;
+  const sort = f.sort === null ? 0 : Number(f.sort);
+  const base = {
+    goodsId: g.id,
+    videoName: f.videoName.trim(),
+    title: f.title.trim(),
+    introduction: f.introduction.trim(),
+    frontUrl: f.frontUrl.trim(),
+    payType: f.payType,
+    fileType: f.fileType,
+    sort,
+  };
+  if (f.fileType === 1) {
+    return { ...base, fileUrl: f.fileUrl.trim(), linkUrl: "" };
+  }
+  return { ...base, fileUrl: "", linkUrl: f.linkUrl.trim() };
+}
+
+async function submitVideo() {
+  await videoFormRef.value?.validate();
+  videoSubmitting.value = true;
+  try {
+    const payload = buildVideoPayload();
+    if (videoEditingId.value != null) {
+      await goodsVideoApi.updateGoodsVideo({
+        id: videoEditingId.value,
+        ...payload,
+      });
+      message.success(`${mediaLabel.value}已更新`);
+    } else {
+      await goodsVideoApi.addGoodsVideo(payload);
+      message.success(`${mediaLabel.value}已添加`);
+    }
+    showVideoForm.value = false;
+    await fetchVideoList();
+  } finally {
+    videoSubmitting.value = false;
+  }
+}
+
+function confirmDeleteVideo(row: GoodsVideoItem) {
+  dialog.warning({
+    title: `删除${mediaLabel.value}`,
+    content: `确定删除该${mediaLabel.value}吗?`,
+    positiveText: "删除",
+    negativeText: "取消",
+    onPositiveClick: async () => {
+      await goodsVideoApi.deleteGoodsVideo({ ids: [row.id] });
+      message.success("已删除");
+      await fetchVideoList();
+    },
+  });
+}
+
+const allVideoColumns = ref<DataTableColumns<GoodsVideoItem>>([
+  {
+    title: "封面",
+    key: "frontUrl",
+    width: 96,
+    fixed: "left",
+    render(row) {
+      const src = resolveCoverUrl(row.frontUrl);
+      if (!src) return "-";
+      return h(NImage, {
+        src,
+        width: 72,
+        height: 40,
+        objectFit: "cover",
+        class: "table-cover-image",
+        alt: "封面",
+      });
+    },
+  },
+  { title: "名称", key: "videoName", ellipsis: { tooltip: true }, width: 120 },
+  { title: "标题", key: "title", ellipsis: { tooltip: true }, minWidth: 120 },
+  {
+    title: "收费",
+    key: "payType",
+    render(row) {
+      return normPay(row.payType) === 1 ? "是" : "否";
+    },
+  },
+  {
+    title: "类型",
+    key: "fileType",
+    render(row) {
+      return normFile(row.fileType) === 1 ? "上传" : "外链";
+    },
+  },
+  {
+    title: "地址",
+    key: "addr",
+    ellipsis: { tooltip: true },
+    render(row) {
+      return normFile(row.fileType) === 1 ? row.fileUrl : row.linkUrl;
+    },
+  },
+  { title: "排序", key: "sort" },
+  {
+    title: "操作",
+    key: "vactions",
+    fixed: "right",
+    width: 120,
+    render(row) {
+      return h("div", { style: "display:flex;gap:8px;flex-wrap:wrap" }, [
+        h(
+          NButton,
+          {
+            size: "small",
+            quaternary: true,
+            type: "info",
+            onClick: () => openVideoEdit(row),
+          },
+          { default: () => "编辑" },
+        ),
+        h(
+          NButton,
+          {
+            size: "small",
+            quaternary: true,
+            type: "error",
+            onClick: () => confirmDeleteVideo(row),
+          },
+          { default: () => "删除" },
+        ),
+      ]);
+    },
+  },
+]);
+
+const { visibleKeys: videoVisibleKeys, displayColumns: videoDisplayColumns, columnOptions: videoColumnOptions } =
+  useTableColumnsControl(allVideoColumns, { frozenKeys: ['vactions'] });
+</script>
+
+<template>
+  <NModal
+    :show="show"
+    preset="card"
+    :title="videoModalTitle"
+    style="width: min(1200px, 96vw)"
+    :mask-closable="false"
+    @update:show="updateShow"
+    @after-leave="onListModalLeave"
+  >
+    <div v-if="goods" class="video-manage-panel">
+      <AdminSearchPanel :field-count="1" @search="onVideoSearch" @reset="resetVideoSearch">
+        <NFormItemGi :span="1" label="名称">
+          <NInput
+            v-model:value="videoKeyword"
+            :placeholder="`按${mediaLabel}名称搜索`"
+            clearable
+            @keyup.enter="onVideoSearch"
+          />
+        </NFormItemGi>
+      </AdminSearchPanel>
+      <AdminTablePageBar
+        :title="`${mediaLabel}列表`"
+        v-model:visible-keys="videoVisibleKeys"
+        :column-options="videoColumnOptions"
+        :refresh-loading="videoLoading"
+        @refresh="fetchVideoList"
+      >
+        <NButton type="primary" @click="openVideoAdd">新建{{ mediaLabel }}</NButton>
+      </AdminTablePageBar>
+      <div class="video-manage-table">
+        <NDataTable
+          :columns="videoDisplayColumns"
+          :data="videoList"
+          :row-key="(row: GoodsVideoItem) => row.id"
+          :loading="videoLoading"
+          :bordered="false"
+          :single-line="false"
+          class="data-table-fill"
+          :scroll-x="1200"
+        />
+      </div>
+      <div class="pager-wrap">
+        <div class="pager-inline">
+          <span class="pager-total">共 {{ videoTotal }} 条</span>
+          <NPagination
+            v-model:page="videoPage"
+            v-model:page-size="videoPageSize"
+            :item-count="videoTotal"
+            :page-sizes="[10, 20, 50]"
+            show-size-picker
+          />
+        </div>
+      </div>
+    </div>
+  </NModal>
+
+  <NModal
+    v-model:show="showVideoForm"
+    preset="card"
+    :title="videoEditingId != null ? `编辑${mediaLabel}` : `新建${mediaLabel}`"
+    style="width: min(560px, 94vw)"
+    :mask-closable="false"
+    @after-leave="() => videoFormRef?.restoreValidation()"
+  >
+    <NForm
+      ref="videoFormRef"
+      :model="videoForm"
+      :rules="videoRules"
+      label-placement="top"
+    >
+      <NFormItem label="封面" path="frontUrl">
+        <NSpace vertical style="width: 100%" :size="8">
+          <input
+            ref="videoCoverFileInputRef"
+            type="file"
+            accept="image/*"
+            style="display: none"
+            @change="onVideoCoverSelected"
+          />
+          <NSpace>
+            <NButton :loading="videoCoverUploading" @click="triggerVideoCoverPick">
+              {{ videoForm.frontUrl ? "重新上传封面" : "选择并上传封面" }}
+            </NButton>
+          </NSpace>
+          <img
+            v-if="videoForm.frontUrl"
+            :src="videoForm.frontUrl"
+            alt="封面预览"
+            class="video-cover-preview-image"
+          />
+          <span v-else class="video-upload-hint">请先选择图片文件,上传后会自动填入封面地址</span>
+        </NSpace>
+      </NFormItem>
+      <NFormItem label="名称" path="videoName">
+        <NInput v-model:value="videoForm.videoName" placeholder="请输入名称" />
+      </NFormItem>
+      <NFormItem label="标题" path="title">
+        <NInput v-model:value="videoForm.title" placeholder="请输入标题" />
+      </NFormItem>
+      <NFormItem :label="`${mediaLabel}简介`" path="introduction">
+        <NInput
+          v-model:value="videoForm.introduction"
+          type="textarea"
+          :autosize="{ minRows: 3, maxRows: 8 }"
+            :placeholder="`请输入${mediaLabel}简介`"
+        />
+      </NFormItem>
+      <NFormItem label="是否收费" path="payType">
+        <NRadioGroup v-model:value="videoForm.payType">
+          <NSpace>
+            <NRadio :value="0">否</NRadio>
+            <NRadio :value="1">是</NRadio>
+          </NSpace>
+        </NRadioGroup>
+      </NFormItem>
+      <NFormItem label="资源类型" path="fileType">
+        <NRadioGroup
+          v-model:value="videoForm.fileType"
+          @update:value="onVideoFileTypeChange"
+        >
+          <NSpace vertical :size="8">
+            <NRadio :value="2">外部链接(填写外部链接)</NRadio>
+            <NRadio :value="1">上传{{ mediaLabel }}(选择文件上传)</NRadio>
+          </NSpace>
+        </NRadioGroup>
+      </NFormItem>
+      <NFormItem
+        v-if="videoForm.fileType === 1"
+        label="资源文件"
+        path="fileUrl"
+      >
+        <NSpace vertical style="width: 100%" :size="8">
+          <input
+            ref="videoFileInputRef"
+            type="file"
+            :accept="mediaFileAccept"
+            style="display: none"
+            @change="onVideoFileSelected"
+          />
+          <NSpace>
+            <NButton
+              :disabled="!goods"
+              :loading="videoUploading"
+              @click="triggerVideoFilePick"
+            >
+              {{ videoForm.fileUrl ? "重新上传" : "选择并上传" }}
+            </NButton>
+          </NSpace>
+          <NInput
+            v-if="videoForm.fileUrl"
+            v-model:value="videoForm.fileUrl"
+            readonly
+            placeholder="上传后的地址"
+          />
+          <span v-else class="video-upload-hint">
+            请先选择{{ `${mediaLabel}文件` }},上传成功后地址会自动填入
+          </span>
+        </NSpace>
+      </NFormItem>
+      <NFormItem
+        v-if="videoForm.fileType === 2"
+        label="外部链接"
+        path="linkUrl"
+      >
+        <NInput v-model:value="videoForm.linkUrl" placeholder="请输入外部链接" />
+      </NFormItem>
+      <NFormItem label="排序" path="sort">
+        <NInputNumber
+          v-model:value="videoForm.sort"
+          :min="0"
+          :precision="0"
+          :show-button="false"
+          placeholder="请输入排序"
+          style="width: 100%"
+        />
+      </NFormItem>
+    </NForm>
+    <template #footer>
+      <NSpace justify="end">
+        <NButton @click="showVideoForm = false">取消</NButton>
+        <NButton type="primary" :loading="videoSubmitting" @click="submitVideo"
+          >保存</NButton
+        >
+      </NSpace>
+    </template>
+  </NModal>
+</template>
+
+<style scoped>
+.video-manage-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  width: 100%;
+  min-height: 420px;
+  max-height: min(72vh, 720px);
+}
+
+.video-manage-table {
+  flex: 1;
+  min-height: 200px;
+  overflow: hidden;
+}
+
+.video-upload-hint {
+  font-size: 12px;
+  color: var(--n-text-color-3);
+}
+
+.video-cover-preview-image {
+  width: 180px;
+  height: 100px;
+  border-radius: 6px;
+  border: 1px solid var(--n-border-color);
+  object-fit: cover;
+  background-color: #f5f5f5;
+}
+
+.table-cover-image {
+  border-radius: 4px;
+  border: 1px solid var(--n-border-color);
+}
+</style>

+ 577 - 0
src/views/courses/GoodsView.vue

@@ -0,0 +1,577 @@
+<script setup lang="ts">
+import type { DataTableColumns, FormInst, FormRules } from "naive-ui";
+import {
+  NButton,
+  NFormItemGi,
+  NImage,
+  NInput,
+  NInputNumber,
+  NModal,
+  NFormItem,
+  NSelect,
+  NTag,
+  useDialog,
+  useMessage,
+} from "naive-ui";
+import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
+import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
+import { apiBase } from "@/config/env";
+import {
+  useConfirmRowDelete,
+  usePagedKeywordList,
+  useTableColumnsControl,
+} from "@/composables";
+import type {
+  GoodsFormPayload,
+  GoodsItem,
+  GoodsSearchParams,
+  GoodsType,
+} from "@/api/modules/courses/goods";
+import * as goodsApi from "@/api/modules/courses/goods";
+import GoodsVideoManageModal from "@/views/courses/GoodsVideoManageModal.vue";
+
+const GOODS_TYPE_MAP: Record<number, string> = {
+  1: "免费试看课程",
+  2: "实战交易课程(50节)",
+  3: "拆出章节视频*12节",
+  4: "章节视频",
+  5: "专题报告",
+};
+
+const GOODS_TYPE_OPTIONS = [
+  { label: "免费试看课程", value: 1 },
+  { label: "实战交易课程(50节)", value: 2 },
+  { label: "拆出章节视频*12节", value: 3 },
+  { label: "章节视频", value: 4 },
+  { label: "专题报告", value: 5 },
+];
+
+const message = useMessage();
+const dialog = useDialog();
+
+const filterGoodsType = ref<number | null>(null);
+
+const { loading, list, total, page, pageSize, keyword, fetchList, onSearch } =
+  usePagedKeywordList<GoodsItem, GoodsSearchParams>({
+    fetcher: (q) => goodsApi.searchGoodsList(q),
+    buildQuery: ({ keywordTrimmed, page: p }) => ({
+      goodsName: keywordTrimmed || undefined,
+      goodsType: filterGoodsType.value ?? undefined,
+      page: p,
+    }),
+  });
+
+function resetSearch() {
+  keyword.value = "";
+  filterGoodsType.value = null;
+  onSearch();
+}
+
+const { confirmDelete } = useConfirmRowDelete<GoodsItem>({
+  dialog,
+  message,
+  title: "删除教学内容",
+  content: "确定删除该条记录吗?",
+  deleteRow: (row) => goodsApi.deleteGoods({ ids: [row.id] }),
+  onAfterDelete: () => fetchList(),
+});
+
+const showVideoModal = ref(false);
+const videoModalGoods = ref<GoodsItem | null>(null);
+
+function openVideoManage(row: GoodsItem) {
+  // snapshot to avoid reactive row mutation causing modal query mismatch
+  videoModalGoods.value = { ...row };
+  showVideoModal.value = true;
+}
+
+watch(showVideoModal, (open) => {
+  if (!open) {
+    videoModalGoods.value = null;
+  }
+});
+
+const showModal = ref(false);
+const submitting = ref(false);
+const coverUploading = ref(false);
+const coverFileInputRef = ref<HTMLInputElement | null>(null);
+const editingId = ref<number | null>(null);
+const formRef = ref<FormInst | null>(null);
+
+interface GoodsFormModel {
+  goodsName: string;
+  frontUrl: string;
+  title: string;
+  introduction: string;
+  goodsType: GoodsType | number;
+  goodsPrice: number | null;
+  download: string;
+}
+
+const form = ref<GoodsFormModel>({
+  goodsName: "",
+  frontUrl: "",
+  title: "",
+  introduction: "",
+  goodsType: 1,
+  goodsPrice: null,
+  download: "",
+});
+
+const isFreeGoodsType = computed(() => Number(form.value.goodsType) === 1);
+
+const rules: FormRules = {
+  frontUrl: [{ required: true, message: "请上传封面", trigger: ["change", "blur"] }],
+  goodsName: [{ required: true, message: "请输入名称", trigger: "blur" }],
+  title: [{ required: true, message: "请输入标题", trigger: "blur" }],
+  introduction: [{ required: true, message: "请输入简介", trigger: "blur" }],
+  /** NSelect + required 对数字/接口字符串容易误判,改为显式区间校验 */
+  goodsType: [
+    {
+      validator(_rule, value: unknown) {
+        const n = Number(value);
+        if (Number.isNaN(n) || n < 1 || n > 5) {
+          return new Error("请选择类型");
+        }
+        return true;
+      },
+      trigger: ["change", "blur"],
+    },
+  ],
+  goodsPrice: [
+    {
+      validator(_rule, value: number | null) {
+        if (isFreeGoodsType.value) return true;
+        if (
+          value === null ||
+          value === undefined ||
+          Number.isNaN(Number(value))
+        ) {
+          return new Error("请输入价格");
+        }
+        return true;
+      },
+      trigger: "blur",
+    },
+  ],
+  download: [{ required: true, message: "请输入下载地址", trigger: "blur" }],
+};
+
+function emptyForm() {
+  form.value = {
+    goodsName: "",
+    frontUrl: "",
+    title: "",
+    introduction: "",
+    goodsType: 1,
+    goodsPrice: null,
+    download: "",
+  };
+}
+
+watch(
+  () => form.value.goodsType,
+  () => {
+    if (isFreeGoodsType.value) {
+      form.value.goodsPrice = null;
+    }
+    formRef.value?.restoreValidation();
+  },
+);
+
+function clearFormValidation() {
+  nextTick(() => {
+    formRef.value?.restoreValidation();
+  });
+}
+
+function openAdd() {
+  editingId.value = null;
+  emptyForm();
+  showModal.value = true;
+  clearFormValidation();
+}
+
+function normalizeType(v: unknown): GoodsType {
+  const n = Number(v);
+  if (n >= 1 && n <= 5) return n as GoodsType;
+  return 1;
+}
+
+function openEdit(row: GoodsItem) {
+  editingId.value = row.id;
+  form.value = {
+    goodsName: row.goodsName,
+    frontUrl: row.frontUrl ?? "",
+    title: row.title,
+    introduction: row.introduction,
+    goodsType: normalizeType(row.goodsType),
+    goodsPrice:
+      row.goodsPrice === "" ||
+      row.goodsPrice === undefined ||
+      row.goodsPrice === null
+        ? null
+        : Number(row.goodsPrice),
+    download: row.download,
+  };
+  showModal.value = true;
+  clearFormValidation();
+}
+
+function triggerCoverFilePick() {
+  coverFileInputRef.value?.click();
+}
+
+async function onCoverFileSelected(ev: Event) {
+  const input = ev.target as HTMLInputElement;
+  const file = input.files?.[0];
+  input.value = "";
+  if (!file) return;
+  coverUploading.value = true;
+  try {
+    const url = await goodsApi.uploadGoodsCover(file);
+    form.value.frontUrl = url;
+    message.success("封面上传成功");
+  } catch (e: unknown) {
+    if (e instanceof Error && e.message === "上传返回无有效地址") {
+      message.error(e.message);
+    }
+  } finally {
+    coverUploading.value = false;
+  }
+}
+
+function buildSubmitPayload(): GoodsFormPayload {
+  const p = form.value.goodsPrice;
+  return {
+    goodsName: form.value.goodsName.trim(),
+    frontUrl: form.value.frontUrl.trim(),
+    title: form.value.title.trim(),
+    introduction: form.value.introduction.trim(),
+    goodsType: form.value.goodsType,
+    goodsPrice: p === null ? 0 : p,
+    download: form.value.download.trim(),
+  };
+}
+
+async function submit() {
+  await formRef.value?.validate();
+  submitting.value = true;
+  try {
+    const payload = buildSubmitPayload();
+    if (editingId.value != null) {
+      await goodsApi.updateGoods({ id: editingId.value, ...payload });
+      message.success("已更新");
+    } else {
+      await goodsApi.addGoods(payload);
+      message.success("已添加");
+    }
+    showModal.value = false;
+    await fetchList();
+  } finally {
+    submitting.value = false;
+  }
+}
+
+function typeLabel(t: number) {
+  return GOODS_TYPE_MAP[t] ?? `类型${t}`;
+}
+
+function resolveCoverUrl(url?: string | null): string {
+  const s = String(url ?? "").trim();
+  if (!s) return "";
+  if (/^(https?:)?\/\//i.test(s) || s.startsWith("data:") || s.startsWith("blob:")) {
+    return s;
+  }
+  const path = s.startsWith("/") ? s : `/${s}`;
+  try {
+    if (apiBase) return new URL(path, apiBase).toString();
+  } catch {
+    // ignore invalid env and fallback to current origin
+  }
+  if (typeof window === "undefined") return path;
+  return new URL(path, window.location.origin).toString();
+}
+
+const allColumns = ref<DataTableColumns<GoodsItem>>([
+  {
+    title: "封面",
+    key: "frontUrl",
+    width: 96,
+    fixed: "left",
+    render(row) {
+      const src = resolveCoverUrl(row.frontUrl);
+      if (!src) return "-";
+      return h(NImage, {
+        src,
+        width: 72,
+        height: 40,
+        objectFit: "cover",
+        class: "table-cover-image",
+        alt: "封面",
+      });
+    },
+  },
+  { title: "名称", key: "goodsName", ellipsis: { tooltip: true } },
+  { title: "标题", key: "title", ellipsis: { tooltip: true } },
+  { title: "简介", key: "introduction", ellipsis: { tooltip: true } },
+  {
+    title: "类型",
+    key: "goodsType",
+
+    render(row) {
+      const label = typeLabel(Number(row.goodsType));
+      return h(NTag, { size: "small", round: true }, { default: () => label });
+    },
+  },
+  {
+    title: "价格",
+    key: "goodsPrice",
+
+    render(row) {
+      return String(row.goodsPrice ?? "");
+    },
+  },
+  {
+    title: "下载地址",
+    key: "download",
+    ellipsis: { tooltip: true },
+  },
+  {
+    title: "操作",
+    key: "actions",
+    width: 220,
+    fixed: "right",
+    render(row) {
+      return h("div", { style: "display:flex;gap:8px;flex-wrap:wrap" }, [
+        h(
+          NButton,
+          {
+            size: "small",
+            quaternary: true,
+            type: "info",
+            onClick: () => openEdit(row),
+          },
+          { default: () => "编辑" },
+        ),
+        h(
+          NButton,
+          {
+            size: "small",
+            quaternary: true,
+            type: "success",
+            onClick: () => openVideoManage(row),
+          },
+          { default: () => (Number(row.goodsType) === 5 ? "添加文件" : "添加视频") },
+        ),
+        h(
+          NButton,
+          {
+            size: "small",
+            quaternary: true,
+            type: "error",
+            onClick: () => confirmDelete(row),
+          },
+          { default: () => "删除" },
+        ),
+      ]);
+    },
+  },
+]);
+
+const { visibleKeys, displayColumns, columnOptions } =
+  useTableColumnsControl(allColumns);
+</script>
+
+<template>
+  <div class="page page--table">
+    <AdminSearchPanel :field-count="2" @search="onSearch" @reset="resetSearch">
+      <NFormItemGi :span="1" label="名称">
+        <NInput
+          v-model:value="keyword"
+          placeholder="按名称搜索"
+          clearable
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="类型">
+        <NSelect
+          v-model:value="filterGoodsType"
+          placeholder="全部类型"
+          clearable
+          :options="GOODS_TYPE_OPTIONS"
+        />
+      </NFormItemGi>
+    </AdminSearchPanel>
+
+    <NCard :bordered="false" class="table-card table-card--fill">
+      <div class="table-card-inner">
+        <AdminTablePageBar
+          title="教学内容"
+          v-model:visible-keys="visibleKeys"
+          :column-options="columnOptions"
+          :refresh-loading="loading"
+          @refresh="fetchList"
+        >
+          <NButton @click="openAdd">新建</NButton>
+        </AdminTablePageBar>
+        <div class="table-card__body">
+          <NDataTable
+            :columns="displayColumns"
+            :data="list"
+            :loading="loading"
+            :bordered="false"
+            :single-line="false"
+            flex-height
+            class="data-table-fill"
+            :scroll-x="1280"
+          />
+        </div>
+        <div class="pager-wrap">
+          <div class="pager-inline">
+            <span class="pager-total">共 {{ total }} 条</span>
+            <NPagination
+              v-model:page="page"
+              v-model:page-size="pageSize"
+              :item-count="total"
+              :page-sizes="[10, 20, 50]"
+              show-size-picker
+            />
+          </div>
+        </div>
+      </div>
+    </NCard>
+
+    <NModal
+      v-model:show="showModal"
+      preset="card"
+      :title="editingId != null ? '编辑教学内容' : '新建教学内容'"
+      style="width: min(640px, 94vw)"
+      :mask-closable="false"
+      @after-leave="() => formRef?.restoreValidation()"
+    >
+      <NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
+        <NFormItem label="封面" path="frontUrl">
+          <NSpace vertical style="width: 100%" :size="8">
+            <input
+              ref="coverFileInputRef"
+              type="file"
+              accept="image/*"
+              style="display: none"
+              @change="onCoverFileSelected"
+            />
+            <NSpace>
+              <NButton :loading="coverUploading" @click="triggerCoverFilePick">
+                {{ form.frontUrl ? "重新上传封面" : "选择并上传封面" }}
+              </NButton>
+            </NSpace>
+            <img
+              v-if="form.frontUrl"
+              :src="form.frontUrl"
+              alt="封面预览"
+              class="cover-preview-image"
+            />
+            <span v-else class="cover-upload-hint">
+              请先选择图片文件,上传后会自动填入封面地址
+            </span>
+          </NSpace>
+        </NFormItem>
+        <NFormItem label="名称" path="goodsName">
+          <NInput v-model:value="form.goodsName" placeholder="请输入名称" />
+        </NFormItem>
+        <NFormItem label="标题" path="title">
+          <NInput v-model:value="form.title" placeholder="请输入标题" />
+        </NFormItem>
+        <NFormItem label="简介" path="introduction">
+          <NInput
+            v-model:value="form.introduction"
+            type="textarea"
+            :autosize="{ minRows: 3, maxRows: 8 }"
+            placeholder="请输入简介"
+          />
+        </NFormItem>
+        <NFormItem label="类型" path="goodsType">
+          <NSelect
+            v-model:value="form.goodsType"
+            :options="GOODS_TYPE_OPTIONS"
+            placeholder="选择类型"
+          />
+        </NFormItem>
+        <NFormItem v-if="!isFreeGoodsType" label="价格" path="goodsPrice">
+          <NInputNumber
+            v-model:value="form.goodsPrice"
+            class="goods-price-input"
+            :min="0"
+            :precision="2"
+            :show-button="false"
+            placeholder="请输入价格"
+            style="width: 100%"
+          />
+        </NFormItem>
+        <NFormItem label="下载地址" path="download">
+          <NInput v-model:value="form.download" placeholder="请输入下载地址" />
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <NSpace justify="end">
+          <NButton @click="showModal = false">取消</NButton>
+          <NButton type="primary" :loading="submitting" @click="submit"
+            >保存</NButton
+          >
+        </NSpace>
+      </template>
+    </NModal>
+
+    <GoodsVideoManageModal
+      v-model:show="showVideoModal"
+      :goods="videoModalGoods"
+    />
+  </div>
+</template>
+
+<style scoped>
+.goods-price-input :deep(.n-input) {
+  width: 100%;
+}
+
+.cover-upload-hint {
+  font-size: 12px;
+  color: var(--n-text-color-3);
+}
+
+.cover-preview-image {
+  width: 180px;
+  height: 100px;
+  border-radius: 6px;
+  border: 1px solid var(--n-border-color);
+  object-fit: cover;
+  background-color: #f5f5f5;
+}
+
+.report-file-list {
+  display: grid;
+  gap: 6px;
+}
+
+.report-file-list__item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 6px 8px;
+  border: 1px solid var(--n-border-color);
+  border-radius: 6px;
+}
+
+.report-file-list__item a {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.table-cover-image {
+  border-radius: 4px;
+  border: 1px solid var(--n-border-color);
+}
+</style>

+ 312 - 0
src/views/courses/RewardQuestionView.vue

@@ -0,0 +1,312 @@
+<script setup lang="ts">
+import type { FormInst, FormRules } from "naive-ui";
+import {
+  NButton,
+  NEmpty,
+  NFormItemGi,
+  NInput,
+  NModal,
+  NFormItem,
+  NRadioGroup,
+  NRadio,
+  NSpin,
+  NTag,
+  useDialog,
+  useMessage,
+} from "naive-ui";
+import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
+import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
+import { useConfirmRowDelete, usePagedKeywordList } from "@/composables";
+import type {
+  RewardQuestionItem,
+  RewardQuestionSearchParams,
+} from "@/api/modules/courses/rewardQuestion";
+import * as rewardQuestionApi from "@/api/modules/courses/rewardQuestion";
+
+const message = useMessage();
+const dialog = useDialog();
+
+const { loading, list, total, page, pageSize, keyword, fetchList, onSearch } =
+  usePagedKeywordList<RewardQuestionItem, RewardQuestionSearchParams>({
+    fetcher: (q) => rewardQuestionApi.searchRewardQuestionList(q),
+    mapList: (rows) =>
+      rows.map((r) => ({ ...r, answer: normalizeAnswer(r.answer) })),
+  });
+
+function resetSearch() {
+  keyword.value = "";
+  onSearch();
+}
+
+/** 无表格列时仅占位,用于隐藏工具栏「列设置」 */
+const tableVisibleKeys = ref<string[]>([]);
+
+const { confirmDelete } = useConfirmRowDelete<RewardQuestionItem>({
+  dialog,
+  message,
+  title: "删除有奖问答",
+  content: "确定删除该条题目吗?",
+  deleteRow: (row) => rewardQuestionApi.deleteRewardQuestion({ ids: [row.id] }),
+  onAfterDelete: () => fetchList(),
+});
+
+const showModal = ref(false);
+const submitting = ref(false);
+const editingId = ref<number | null>(null);
+const formRef = ref<FormInst | null>(null);
+const form = ref<{ question: string; answer: 0 | 1 }>({
+  question: "",
+  answer: 1,
+});
+
+const rules: FormRules = {
+  question: [{ required: true, message: "请输入问题", trigger: "blur" }],
+};
+
+function normalizeAnswer(v: unknown): 0 | 1 {
+  const n = Number(v);
+  return n === 0 ? 0 : 1;
+}
+
+function openAdd() {
+  editingId.value = null;
+  form.value = { question: "", answer: 1 };
+  showModal.value = true;
+}
+
+function openEdit(row: RewardQuestionItem) {
+  editingId.value = row.id;
+  form.value = { question: row.question, answer: normalizeAnswer(row.answer) };
+  showModal.value = true;
+}
+
+async function submit() {
+  await formRef.value?.validate();
+  submitting.value = true;
+  try {
+    if (editingId.value != null) {
+      await rewardQuestionApi.updateRewardQuestion({
+        id: editingId.value,
+        question: form.value.question.trim(),
+        answer: form.value.answer,
+      });
+      message.success("已更新");
+    } else {
+      await rewardQuestionApi.addRewardQuestion({
+        question: form.value.question.trim(),
+        answer: form.value.answer,
+      });
+      message.success("已添加");
+    }
+    showModal.value = false;
+    await fetchList();
+  } finally {
+    submitting.value = false;
+  }
+}
+
+function answerTagType(row: RewardQuestionItem): "success" | "error" {
+  return row.answer === 1 ? "success" : "error";
+}
+</script>
+
+<template>
+  <div class="page page--table">
+    <AdminSearchPanel :field-count="1" @search="onSearch" @reset="resetSearch">
+      <NFormItemGi :span="1" label="问题">
+        <NInput
+          v-model:value="keyword"
+          placeholder="按问题关键词搜索"
+          clearable
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+    </AdminSearchPanel>
+
+    <NCard :bordered="false" class="table-card table-card--fill">
+      <div class="table-card-inner">
+        <AdminTablePageBar
+          title="有奖问答"
+          v-model:visible-keys="tableVisibleKeys"
+          :column-options="[]"
+          :refresh-loading="loading"
+          @refresh="fetchList"
+        >
+          <NButton @click="openAdd">新建</NButton>
+        </AdminTablePageBar>
+        <div class="table-card__body reward-card-body">
+          <NSpin :show="loading">
+            <NEmpty
+              v-if="!loading && list.length === 0"
+              description="暂无题目"
+            />
+            <div
+              v-else
+              class="reward-grid"
+              :class="{ 'reward-grid--initial': loading && list.length === 0 }"
+            >
+              <article
+                v-for="row in list"
+                :key="row.id"
+                class="reward-card"
+              >
+                <div class="reward-card__body">
+                  <p class="reward-card__question">{{ row.question }}</p>
+                </div>
+                <div class="reward-card__meta">
+                  <NTag :type="answerTagType(row)" size="small" round>
+                    {{ row.answer === 1 ? "对" : "错" }}
+                  </NTag>
+                  <div class="reward-card__actions">
+                    <NButton
+                      size="small"
+                      quaternary
+                      type="info"
+                      @click="openEdit(row)"
+                    >
+                      编辑
+                    </NButton>
+                    <NButton
+                      size="small"
+                      quaternary
+                      type="error"
+                      @click="confirmDelete(row)"
+                    >
+                      删除
+                    </NButton>
+                  </div>
+                </div>
+              </article>
+            </div>
+          </NSpin>
+        </div>
+        <div class="pager-wrap">
+          <div class="pager-inline">
+            <span class="pager-total">共 {{ total }} 条</span>
+            <NPagination
+              v-model:page="page"
+              v-model:page-size="pageSize"
+              :item-count="total"
+              :page-sizes="[10, 20, 50]"
+              show-size-picker
+            />
+          </div>
+        </div>
+      </div>
+    </NCard>
+
+    <NModal
+      v-model:show="showModal"
+      preset="card"
+      :title="editingId != null ? '编辑有奖问答' : '新建有奖问答'"
+      style="width: min(520px, 92vw)"
+      :mask-closable="false"
+    >
+      <NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
+        <NFormItem label="问题" path="question">
+          <NInput
+            v-model:value="form.question"
+            type="textarea"
+            :autosize="{ minRows: 2 }"
+            placeholder="题目内容"
+          />
+        </NFormItem>
+        <NFormItem label="答案" path="answer">
+          <NRadioGroup v-model:value="form.answer">
+            <NSpace>
+              <NRadio :value="0">错</NRadio>
+              <NRadio :value="1">对</NRadio>
+            </NSpace>
+          </NRadioGroup>
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <NSpace justify="end">
+          <NButton @click="showModal = false">取消</NButton>
+          <NButton type="primary" :loading="submitting" @click="submit"
+            >保存</NButton
+          >
+        </NSpace>
+      </template>
+    </NModal>
+  </div>
+</template>
+
+<style scoped>
+.reward-card-body {
+  overflow: auto;
+}
+
+.reward-grid {
+  --reward-card-height: 300px;
+  --reward-grid-gap: 14px;
+  /** 一行最多 5 列:列最小宽度 = (容器宽 − 4 条间距) / 5,窄屏时单列占满 */
+  --reward-grid-col-min: min(
+    100%,
+    max(160px, calc((100% - 4 * var(--reward-grid-gap)) / 5))
+  );
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(var(--reward-grid-col-min), 1fr));
+  gap: var(--reward-grid-gap);
+  align-content: start;
+}
+
+.reward-grid--initial {
+  min-height: 200px;
+}
+
+.reward-card {
+  margin: 0;
+  padding: 16px 16px 14px;
+  border-radius: 12px;
+  border: 1px solid var(--admin-border);
+  background: linear-gradient(
+    180deg,
+    rgba(255, 255, 255, 1) 0%,
+    rgba(248, 250, 252, 0.92) 100%
+  );
+  box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+  height: var(--reward-card-height);
+  min-height: var(--reward-card-height);
+  max-height: var(--reward-card-height);
+}
+
+.reward-card__body {
+  flex: 1 1 0;
+  min-height: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+  -webkit-overflow-scrolling: touch;
+}
+
+.reward-card__question {
+  margin: 0;
+  font-size: 0.9375rem;
+  line-height: 1.55;
+  color: #0f172a;
+  white-space: pre-wrap;
+  word-break: break-word;
+  overflow-wrap: anywhere;
+}
+
+.reward-card__meta {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  flex: 0 0 auto;
+  padding-top: 12px;
+  border-top: 1px solid rgba(15, 23, 42, 0.06);
+}
+
+.reward-card__actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  margin-left: auto;
+}
+</style>

+ 202 - 0
src/views/finance/CustomerListView.vue

@@ -0,0 +1,202 @@
+<script setup lang="ts">
+import type { DataTableColumns } from 'naive-ui'
+import { NDataTable, NFormItemGi, NInput, NPagination } from 'naive-ui'
+import AdminSearchPanel from '@/components/AdminSearchPanel.vue'
+import AdminTablePageBar from '@/components/AdminTablePageBar.vue'
+import * as customerApi from '@/api/modules/finance/customer'
+import type { CustomerItem, CustomerSearchParams } from '@/api/modules/finance/customer'
+import { useTableColumnsControl } from '@/composables'
+
+const loading = ref(false)
+const list = ref<CustomerItem[]>([])
+const total = ref(0)
+const page = ref(1)
+const pageSize = ref(10)
+
+const search = ref<{
+  cId: string
+  email: string
+  name: string
+  phone: string
+}>({
+  cId: '',
+  email: '',
+  name: '',
+  phone: '',
+})
+
+function amountText(v: unknown) {
+  const n = Number(v ?? 0)
+  if (Number.isNaN(n)) return '0.00'
+  return n.toLocaleString(undefined, {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2,
+  })
+}
+
+function timeCell(v: unknown) {
+  if (v == null || v === '') return '--'
+  if (typeof v === 'number') {
+    const d = new Date(v)
+    return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString()
+  }
+  const s = String(v).trim()
+  if (!s) return '--'
+  const n = Number(s)
+  if (!Number.isNaN(n) && s === String(n) && s.length >= 10) {
+    const d = new Date(n)
+    if (!Number.isNaN(d.getTime())) return d.toLocaleString()
+  }
+  const d = new Date(s)
+  if (!Number.isNaN(d.getTime())) return d.toLocaleString()
+  return s
+}
+
+function buildSearchPayload(): CustomerSearchParams {
+  return {
+    cId: search.value.cId.trim() || undefined,
+    email: search.value.email.trim() || undefined,
+    name: search.value.name.trim() || undefined,
+    phone: search.value.phone.trim() || undefined,
+    page: { current: page.value, row: pageSize.value },
+  }
+}
+
+async function fetchList() {
+  loading.value = true
+  try {
+    const { list: rows, total: count } = await customerApi.searchCustomerPage(buildSearchPayload())
+    list.value = rows
+    total.value = count
+  } finally {
+    loading.value = false
+  }
+}
+
+function onSearch() {
+  page.value = 1
+  void fetchList()
+}
+
+function resetSearch() {
+  search.value = {
+    cId: '',
+    email: '',
+    name: '',
+    phone: '',
+  }
+  page.value = 1
+  void fetchList()
+}
+
+const columns = ref<DataTableColumns<CustomerItem>>([
+  { title: 'CID', key: 'cId', width: 120, ellipsis: { tooltip: true } },
+  { title: '邮箱', key: 'email', width: 220, ellipsis: { tooltip: true } },
+  { title: '姓名', key: 'name', width: 120, ellipsis: { tooltip: true } },
+  { title: '手机号', key: 'phone', width: 140, ellipsis: { tooltip: true } },
+  { title: '身份证号', key: 'identity', width: 220, ellipsis: { tooltip: true } },
+  {
+    title: '累计消费',
+    key: 'totalSpendingAmount',
+    width: 130,
+    render: (row) => amountText(row.totalSpendingAmount),
+  },
+  {
+    title: '注册时间',
+    key: 'addTime',
+    width: 180,
+    render: (row) => timeCell(row.addTime),
+  },
+])
+
+const { visibleKeys, displayColumns, columnOptions } = useTableColumnsControl(columns)
+
+watch([page, pageSize], () => {
+  void fetchList()
+})
+
+onMounted(() => {
+  void fetchList()
+})
+
+onActivated(() => {
+  void fetchList()
+})
+</script>
+
+<template>
+  <div class="page page--table">
+    <AdminSearchPanel :field-count="5" @search="onSearch" @reset="resetSearch">
+      <NFormItemGi :span="1" label="CID">
+        <NInput
+          v-model:value="search.cId"
+          clearable
+          placeholder="CID"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="邮箱">
+        <NInput
+          v-model:value="search.email"
+          clearable
+          placeholder="邮箱"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="姓名">
+        <NInput
+          v-model:value="search.name"
+          clearable
+          placeholder="姓名"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="手机号">
+        <NInput
+          v-model:value="search.phone"
+          clearable
+          placeholder="手机号"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+    </AdminSearchPanel>
+
+    <NCard :bordered="false" class="table-card table-card--fill">
+      <div class="table-card-inner">
+        <AdminTablePageBar
+          title="客户列表"
+          v-model:visible-keys="visibleKeys"
+          :column-options="columnOptions"
+          :refresh-loading="loading"
+          @refresh="fetchList"
+        />
+
+        <div class="table-card__body">
+          <NDataTable
+            :columns="displayColumns"
+            :data="list"
+            :loading="loading"
+            :bordered="false"
+            :single-line="false"
+            :row-key="(row: CustomerItem) => `${row.id ?? ''}-${row.cId ?? ''}-${row.email ?? ''}-${row.phone ?? ''}`"
+            class="data-table-fill"
+            :scroll-x="1110"
+          />
+        </div>
+
+        <div class="pager-wrap">
+          <div class="pager-inline">
+            <span class="pager-total">共 {{ total }} 条</span>
+            <NPagination
+              v-model:page="page"
+              v-model:page-size="pageSize"
+              :item-count="total"
+              :page-sizes="[10, 20, 50, 100]"
+              show-size-picker
+            />
+          </div>
+        </div>
+      </div>
+    </NCard>
+  </div>
+</template>

+ 457 - 0
src/views/finance/OrderListView.vue

@@ -0,0 +1,457 @@
+<script setup lang="ts">
+import type { DataTableColumns } from "naive-ui";
+import {
+  NButton,
+  NDataTable,
+  NDatePicker,
+  NImage,
+  NFormItemGi,
+  NInput,
+  NModal,
+  NPagination,
+  NSelect,
+  NSpace,
+  NTag,
+  useDialog,
+  useMessage,
+} from "naive-ui";
+import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
+import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
+import * as orderApi from "@/api/modules/finance/order";
+import type { OrderItem, OrderSearchParams } from "@/api/modules/finance/order";
+import { useTableColumnsControl } from "@/composables";
+
+const message = useMessage();
+const dialog = useDialog();
+const loading = ref(false);
+const completingId = ref<number | null>(null);
+
+const list = ref<OrderItem[]>([]);
+const total = ref(0);
+const page = ref(1);
+const pageSize = ref(10);
+const coursesModalVisible = ref(false);
+
+interface OrderCourseItem {
+  frontUrl?: string;
+  goodsName?: string;
+  title?: string;
+  goodsPrice?: number | string;
+}
+
+const activeCourses = ref<OrderCourseItem[]>([]);
+
+const statusOptions = [
+  { label: "全部", value: "" },
+  { label: "未支付", value: "1" },
+  { label: "已支付", value: "2" },
+  { label: "支付失败", value: "3" },
+  { label: "已过期", value: "4" },
+  { label: "已取消", value: "5" },
+];
+
+const search = ref<{
+  serial: string;
+  payName: string;
+  status: string;
+  dateRange: [number, number] | null;
+}>({
+  serial: "",
+  payName: "",
+  status: "",
+  dateRange: null,
+});
+
+function toDateText(ts: number) {
+  const d = new Date(ts);
+  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}`;
+}
+
+function buildSearchPayload(): OrderSearchParams {
+  const statusStr = search.value.status;
+  return {
+    serial: search.value.serial.trim() || undefined,
+    payName: search.value.payName.trim() || undefined,
+    status:
+      statusStr && /^\d+$/.test(statusStr) ? Number(statusStr) : undefined,
+    startDate: search.value.dateRange
+      ? toDateText(search.value.dateRange[0])
+      : undefined,
+    endDate: search.value.dateRange
+      ? toDateText(search.value.dateRange[1])
+      : undefined,
+    page: { current: page.value, row: pageSize.value },
+  };
+}
+
+async function fetchList() {
+  loading.value = true;
+  try {
+    const { list: rows, total: count } = await orderApi.searchOrderPage(
+      buildSearchPayload(),
+    );
+    list.value = rows;
+    total.value = count;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function onSearch() {
+  page.value = 1;
+  void fetchList();
+}
+
+function resetSearch() {
+  search.value = {
+    serial: "",
+    payName: "",
+    status: "",
+    dateRange: null,
+  };
+  page.value = 1;
+  void fetchList();
+}
+
+function amountText(v: unknown) {
+  const n = Number(v ?? 0);
+  if (Number.isNaN(n)) return "0.00";
+  return n.toLocaleString(undefined, {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2,
+  });
+}
+
+function orderStatusTag(status: number | undefined) {
+  const s = status ?? 0;
+  if (s === 1) return { label: "未支付", type: "warning" as const };
+  if (s === 2) return { label: "已支付", type: "success" as const };
+  if (s === 3) return { label: "支付失败", type: "error" as const };
+  if (s === 4) return { label: "已过期", type: "default" as const };
+  if (s === 5) return { label: "已取消", type: "error" as const };
+  return { label: String(s || "--"), type: "default" as const };
+}
+
+function timeCell(v: unknown) {
+  if (v == null || v === "") return "--";
+  if (typeof v === "number") {
+    const d = new Date(v);
+    return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString();
+  }
+  const s = String(v).trim();
+  if (!s) return "--";
+  const n = Number(s);
+  if (!Number.isNaN(n) && s === String(n) && s.length >= 10) {
+    const d = new Date(n);
+    if (!Number.isNaN(d.getTime())) return d.toLocaleString();
+  }
+  const d = new Date(s);
+  if (!Number.isNaN(d.getTime())) return d.toLocaleString();
+  return s;
+}
+
+function parseOrderCourses(details: unknown): OrderCourseItem[] {
+  if (details == null || details === "") return [];
+  const normalize = (input: unknown): OrderCourseItem[] => {
+    if (Array.isArray(input)) return input as OrderCourseItem[];
+    if (input && typeof input === "object") return [input as OrderCourseItem];
+    return [];
+  };
+  if (typeof details === "string") {
+    const text = details.trim();
+    if (!text) return [];
+    try {
+      return normalize(JSON.parse(text));
+    } catch {
+      return [];
+    }
+  }
+  return normalize(details);
+}
+
+function courseName(item: OrderCourseItem) {
+  return String(item.goodsName ?? item.title ?? "").trim() || "--";
+}
+
+function courseAmount(item: OrderCourseItem) {
+  const n = Number(item.goodsPrice ?? 0);
+  if (Number.isNaN(n)) return "--";
+  return amountText(n);
+}
+
+function openCoursesModal(row: OrderItem) {
+  activeCourses.value = parseOrderCourses(row.details);
+  coursesModalVisible.value = true;
+}
+
+function confirmOrder(row: OrderItem) {
+  dialog.warning({
+    title: "确认订单",
+    content: `确定将流水号 ${row.serial ?? row.id} 的订单标为已确认?`,
+    positiveText: "确定",
+    negativeText: "取消",
+    onPositiveClick: async () => {
+      completingId.value = row.id;
+      try {
+        await orderApi.completeOrder({ id: row.id });
+        message.success("确认成功");
+        await fetchList();
+      } finally {
+        completingId.value = null;
+      }
+    },
+  });
+}
+
+const columns = ref<DataTableColumns<OrderItem>>([
+  { title: "CID", key: "cId", width: 120, ellipsis: { tooltip: true } },
+  { title: "流水号", key: "serial", width: 180, ellipsis: { tooltip: true } },
+  { title: "付款人姓名", key: "payName", width: 100, ellipsis: { tooltip: true } },
+  { title: "付款人电话", key: "payPhone", width: 120, ellipsis: { tooltip: true } },
+  { title: "邮箱", key: "email", width: 200, ellipsis: { tooltip: true } },
+  {
+    title: "金额",
+    key: "amount",
+    width: 110,
+    render: (row) => amountText(row.amount),
+  },
+  { title: "货币", key: "currency", width: 80 },
+  {
+    title: "汇款金额",
+    key: "transformAmount",
+    width: 110,
+    render: (row) => amountText(row.transformAmount),
+  },
+  { title: "汇款货币", key: "transformCurrency", width: 96 },
+  { title: "支付通道", key: "channelCode", width: 120, ellipsis: { tooltip: true } },
+  {
+    title: "状态",
+    key: "status",
+    width: 100,
+    render: (row) => {
+      const t = orderStatusTag(row.status);
+      return h(NTag, { type: t.type, size: "small" }, { default: () => t.label });
+    },
+  },
+  {
+    title: "购买时间",
+    key: "addTime",
+    width: 170,
+    render: (row) => timeCell(row.addTime),
+  },
+  {
+    title: "支付时间",
+    key: "payTime",
+    width: 170,
+    render: (row) => timeCell(row.payTime),
+  },
+  {
+    title: "购买的课程",
+    key: "details",
+    width: 120,
+    render: (row) =>
+      h(
+        NButton,
+        { size: "small", onClick: () => openCoursesModal(row) },
+        { default: () => "查看" },
+      ),
+  },
+  {
+    title: "操作",
+    key: "actions",
+    width: 120,
+    fixed: "right",
+    render: (row) =>
+      h(
+        NButton,
+        {
+          size: "small",
+          type: row.status === 1 ? "primary" : "default",
+          disabled: row.status !== 1,
+          loading: completingId.value === row.id,
+          onClick: () => confirmOrder(row),
+        },
+        { default: () => "确认订单" },
+      ),
+  },
+]);
+
+const { visibleKeys, displayColumns, columnOptions } = useTableColumnsControl(
+  columns,
+  { frozenKeys: ["actions"] },
+);
+
+watch([page, pageSize], () => {
+  void fetchList();
+});
+
+onMounted(() => {
+  void fetchList();
+});
+
+onActivated(() => {
+  void fetchList();
+});
+</script>
+
+<template>
+  <div class="page page--table">
+    <AdminSearchPanel :field-count="5" @search="onSearch" @reset="resetSearch">
+      <NFormItemGi :span="1" label="流水号">
+        <NInput
+          v-model:value="search.serial"
+          clearable
+          placeholder="流水号"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="付款人姓名">
+        <NInput
+          v-model:value="search.payName"
+          clearable
+          placeholder="付款人姓名"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="状态">
+        <NSelect v-model:value="search.status" :options="statusOptions" />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="时间范围">
+        <NDatePicker
+          v-model:value="search.dateRange"
+          type="daterange"
+          clearable
+          style="width: 100%"
+        />
+      </NFormItemGi>
+    </AdminSearchPanel>
+
+    <NCard :bordered="false" class="table-card table-card--fill">
+      <div class="table-card-inner">
+        <AdminTablePageBar
+          title="订单列表"
+          v-model:visible-keys="visibleKeys"
+          :column-options="columnOptions"
+          :refresh-loading="loading"
+          @refresh="fetchList"
+        />
+
+        <div class="table-card__body">
+          <NDataTable
+            :columns="displayColumns"
+            :data="list"
+            :loading="loading"
+            :bordered="false"
+            :single-line="false"
+            :row-key="(row: OrderItem) => row.id"
+            class="data-table-fill"
+            :scroll-x="2000"
+          />
+        </div>
+
+        <div class="pager-wrap">
+          <div class="pager-inline">
+            <span class="pager-total">共 {{ total }} 条</span>
+            <NPagination
+              v-model:page="page"
+              v-model:page-size="pageSize"
+              :item-count="total"
+              :page-sizes="[10, 20, 50, 100]"
+              show-size-picker
+            />
+          </div>
+        </div>
+      </div>
+    </NCard>
+
+    <NModal
+      v-model:show="coursesModalVisible"
+      preset="card"
+      title="购买的课程"
+      style="width: min(860px, 92vw)"
+      :mask-closable="false"
+    >
+      <div v-if="activeCourses.length" class="order-course-list">
+        <div
+          v-for="(item, index) in activeCourses"
+          :key="`${courseName(item)}-${index}`"
+          class="order-course-item"
+        >
+          <NImage
+            class="order-course-item__cover"
+            :src="item.frontUrl"
+            object-fit="cover"
+            width="88"
+            height="88"
+            preview-disabled
+            fallback-src=""
+          />
+          <div class="order-course-item__main">
+            <div class="order-course-item__name">{{ courseName(item) }}</div>
+            <div class="order-course-item__amount">金额:{{ courseAmount(item) }}</div>
+          </div>
+        </div>
+      </div>
+      <div v-else class="order-course-empty">暂无课程数据</div>
+
+      <template #footer>
+        <NSpace justify="end">
+          <NButton @click="coursesModalVisible = false">关闭</NButton>
+        </NSpace>
+      </template>
+    </NModal>
+  </div>
+</template>
+
+<style scoped>
+.order-course-list {
+  display: grid;
+  gap: 12px;
+  max-height: 58vh;
+  overflow: auto;
+  padding-right: 2px;
+}
+
+.order-course-item {
+  display: flex;
+  gap: 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 10px;
+  padding: 10px;
+  align-items: center;
+}
+
+.order-course-item__cover {
+  flex: none;
+  border-radius: 8px;
+  overflow: hidden;
+  background: #f8fafc;
+}
+
+.order-course-item__main {
+  min-width: 0;
+}
+
+.order-course-item__name {
+  font-size: 14px;
+  color: #111827;
+  font-weight: 600;
+  line-height: 1.4;
+  word-break: break-all;
+}
+
+.order-course-item__amount {
+  margin-top: 8px;
+  color: #475569;
+  font-size: 13px;
+}
+
+.order-course-empty {
+  color: #64748b;
+  text-align: center;
+  padding: 24px 0;
+}
+</style>

+ 1730 - 0
src/views/finance/WithdrawApplyView.vue

@@ -0,0 +1,1730 @@
+<script setup lang="ts">
+import type { DataTableColumns, FormInst, FormRules } from "naive-ui";
+import { EllipsisVertical } from "@vicons/ionicons5";
+import { NButton, NDropdown, NFormItemGi, NIcon, NInput, NTag, useMessage } from "naive-ui";
+import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
+import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
+import type {
+  DepositItem,
+  RemitChannelItem,
+  WithdrawItem,
+  WithdrawSearchParams,
+} from "@/api/modules/finance/withdraw";
+import * as withdrawApi from "@/api/modules/finance/withdraw";
+import { useTableColumnsControl } from "@/composables";
+
+interface SearchModel {
+  cId: string;
+  serial: string;
+  name: string;
+  status: string;
+  submitStatus: string;
+  backstageStatus: string;
+  infoStatus: string;
+  channelCodes: string[];
+  dateRange: [number, number] | null;
+  submitDateRange: [number, number] | null;
+}
+
+type ActionType =
+  | "approve"
+  | "mt"
+  | "receipt"
+  | "submit"
+  | "backstage"
+  | "info"
+  | "remit"
+  | "detail";
+type BatchType = "submits" | "channels" | "infos";
+
+const message = useMessage();
+const loading = ref(false);
+const exporting = ref(false);
+
+const list = ref<WithdrawItem[]>([]);
+const total = ref(0);
+const page = ref(1);
+const pageSize = ref(10);
+
+const statusOptions = [
+  { label: "全部状态", value: "-1" },
+  { label: "待处理", value: "1" },
+  { label: "已通过", value: "2" },
+  { label: "已拒绝", value: "3" },
+];
+const statusOptions1 = [
+  { label: "全部状态", value: "-1" },
+  { label: "待处理", value: "0" },
+  { label: "处理中", value: "1" },
+  { label: "已通过", value: "2" },
+  { label: "已拒绝", value: "3" },
+  { label: "已取消", value: "4" },
+];
+const processOptions = [
+  { label: "通过", value: "2" },
+  { label: "拒绝", value: "3" },
+];
+const receiptOptions = [
+  { label: "成功", value: "1" },
+  { label: "失败", value: "2" },
+];
+const search = ref<SearchModel>({
+  cId: "",
+  serial: "",
+  name: "",
+  status: "-1",
+  submitStatus: "-1",
+  backstageStatus: "-1",
+  infoStatus: "-1",
+  channelCodes: [],
+  dateRange: null,
+  submitDateRange: null,
+});
+
+const channelOptions = ref<Array<{ label: string; value: string }>>([]);
+
+const checkedRowKeys = ref<Array<number | string>>([]);
+
+const selectedRows = computed(() => {
+  const set = new Set(checkedRowKeys.value.map((k) => Number(k)));
+  return list.value.filter((r) => set.has(r.id));
+});
+const selectedIds = computed(() => selectedRows.value.map((r) => r.id));
+const selectedAmountTotal = computed(() =>
+  selectedRows.value.reduce((sum, row) => sum + Number(row.amount ?? 0), 0),
+);
+
+const isBatchSubmitReady = computed(
+  () =>
+    selectedRows.value.length > 0 &&
+    selectedRows.value.every(
+      (item) =>
+        item.status === 2 &&
+        item.withdrawStatus === 2 &&
+        (item.callbackStatus === 0 || item.callbackStatus == null) &&
+        item.submitStatus === 1 &&
+        item.backstageStatus === 2 &&
+        item.infoStatus === 2,
+    ),
+);
+const isBatchChannelReady = computed(
+  () =>
+    selectedRows.value.length > 0 &&
+    selectedRows.value.every(
+      (item) =>
+        item.status === 2 &&
+        item.withdrawStatus === 2 &&
+        (item.callbackStatus === 0 || item.callbackStatus == null) &&
+        item.submitStatus === 2 &&
+        item.backstageStatus === 2,
+    ),
+);
+const isBatchInfoReady = computed(
+  () =>
+    selectedRows.value.length > 0 &&
+    selectedRows.value.every(
+      (item) =>
+        item.status === 2 &&
+        item.withdrawStatus === 2 &&
+        (item.callbackStatus === 0 || item.callbackStatus == null) &&
+        item.submitStatus === 1 &&
+        item.backstageStatus === 2 &&
+        item.infoStatus === 1,
+    ),
+);
+
+const actionModalVisible = ref(false);
+const actionFormRef = ref<FormInst | null>(null);
+const actionSubmitting = ref(false);
+const actionForm = ref({
+  id: 0,
+  type: "approve" as ActionType,
+  status: null as string | null,
+  withdrawStatus: null as string | null,
+  callbackStatus: null as string | null,
+  submitStatus: null as string | null,
+  backstageStatus: null as string | null,
+  infoStatus: null as string | null,
+  approveDesc: "",
+  withdrawTicket: "",
+});
+
+const batchModalVisible = ref(false);
+const batchFormRef = ref<FormInst | null>(null);
+const batchSubmitting = ref(false);
+const batchType = ref<BatchType>("submits");
+const batchForm = ref({
+  submitStatus: "2" as "2" | "3",
+  callbackStatus: "1" as "1" | "2",
+  infoStatus: "2" as "2" | "3",
+  approveDesc: "",
+});
+
+const detailModalVisible = ref(false);
+const detailRow = ref<WithdrawItem | null>(null);
+const remitModalVisible = ref(false);
+const remitSubmitting = ref(false);
+const remitChannelsLoading = ref(false);
+const remitFormLoading = ref(false);
+const remitRow = ref<WithdrawItem | null>(null);
+const remitChannels = ref<RemitChannelItem[]>([]);
+const remitTitle = ref("");
+const remitBase = ref<Record<string, unknown> | null>(null);
+const remitFields = ref<
+  Array<{
+    name: string;
+    label: string;
+    value: string;
+    options: Array<{ label: string; value: string }>;
+  }>
+>([]);
+const depositRecordVisible = ref(false);
+const withdrawalRecordVisible = ref(false);
+const recordLoading = ref(false);
+const depositRecords = ref<DepositItem[]>([]);
+const depositRecordTotal = ref(0);
+const depositRecordPage = ref(1);
+const depositRecordPageSize = ref(10);
+const withdrawalRecords = ref<WithdrawItem[]>([]);
+const withdrawalRecordTotal = ref(0);
+const withdrawalRecordPage = ref(1);
+const withdrawalRecordPageSize = ref(10);
+
+const actionRules: FormRules = {
+  status: [{ required: true, message: "请选择处理状态", trigger: "change" }],
+  withdrawStatus: [
+    { required: true, message: "请选择处理状态", trigger: "change" },
+  ],
+  callbackStatus: [
+    { required: true, message: "请选择处理状态", trigger: "change" },
+  ],
+  submitStatus: [
+    { required: true, message: "请选择处理状态", trigger: "change" },
+  ],
+  backstageStatus: [
+    { required: true, message: "请选择处理状态", trigger: "change" },
+  ],
+  infoStatus: [
+    { required: true, message: "请选择处理状态", trigger: "change" },
+  ],
+  approveDesc: [{ required: true, message: "请输入拒绝原因", trigger: "blur" }],
+  withdrawTicket: [
+    { required: true, message: "请输入 MT 订单号", trigger: "blur" },
+  ],
+};
+const batchRules: FormRules = {
+  submitStatus: [
+    { required: true, message: "请选择处理状态", trigger: "change" },
+  ],
+  callbackStatus: [
+    { required: true, message: "请选择处理状态", trigger: "change" },
+  ],
+  infoStatus: [
+    { required: true, message: "请选择处理状态", trigger: "change" },
+  ],
+  approveDesc: [{ required: true, message: "请输入拒绝原因", trigger: "blur" }],
+};
+const actionTitleMap: Record<ActionType, string> = {
+  approve: "取款审核",
+  mt: "MT 扣款",
+  receipt: "回执处理",
+  submit: "汇款提交",
+  backstage: "后台审核",
+  info: "资料审核",
+  remit: "出金通道",
+  detail: "详情",
+};
+const batchTitleMap: Record<BatchType, string> = {
+  submits: "批量汇款",
+  channels: "批量回执",
+  infos: "批量资料审核",
+};
+const actionModalTitle = computed(() => actionTitleMap[actionForm.value.type]);
+const batchModalTitle = computed(() => batchTitleMap[batchType.value]);
+
+function toDateText(ts: number | null | undefined) {
+  if (!ts) return "";
+  const d = new Date(ts);
+  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}`;
+}
+
+function amountText(v: unknown) {
+  const n = Number(v ?? 0);
+  if (Number.isNaN(n)) return "0.00";
+  return n.toLocaleString(undefined, {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2,
+  });
+}
+
+function resolveStatus(row: WithdrawItem) {
+  const statusLabelMap: Record<string, string> = {
+    "0": "待处理",
+    "1": "处理中",
+    "2": "已通过",
+    "3": "已拒绝",
+    "4": "已取消",
+  };
+  const statusTypeMap: Record<string, "warning" | "info" | "success" | "error"> = {
+    "0": "warning",
+    "1": "info",
+    "2": "success",
+    "3": "error",
+    "4": "error",
+  };
+  const toDisplayStatusValue = () => {
+    // 与查询条件状态保持一致:0-待处理 1-处理中 2-已通过 3-已拒绝 4-已取消
+    if (row.status === 5 || row.backstageStatus === 5) return "4";
+    if (row.status === 3 || row.withdrawStatus === 3 || row.callbackStatus === 2) return "3";
+    if (row.status === 2 && row.withdrawStatus === 2 && row.callbackStatus === 1) return "2";
+    if (row.status === 1) return "0";
+    return "1";
+  };
+  const value = toDisplayStatusValue();
+  return {
+    label: statusLabelMap[value] ?? "处理中",
+    type: statusTypeMap[value] ?? "info",
+  };
+}
+
+function feeReductionText(v?: number) {
+  if (v === 1) return "是";
+  if (v === 0) return "否";
+  return "--";
+}
+
+function salesLevelText(v?: number) {
+  if (v === 1) return "Level 1";
+  if (v === 2) return "Level 2";
+  if (v === 3) return "Level 3";
+  return "--";
+}
+
+function buildSearchPayload(): WithdrawSearchParams {
+  const toOptionalNumber = (v: string) => {
+    const n = Number(v);
+    return Number.isNaN(n) || n < 0 ? null : n;
+  };
+  return {
+    cId: search.value.cId.trim() || undefined,
+    serial: search.value.serial.trim() || undefined,
+    name: search.value.name.trim() || undefined,
+    status: toOptionalNumber(search.value.status),
+    submitStatus: toOptionalNumber(search.value.submitStatus),
+    backstageStatus: toOptionalNumber(search.value.backstageStatus),
+    infoStatus: toOptionalNumber(search.value.infoStatus),
+    channelCodes: search.value.channelCodes.length
+      ? search.value.channelCodes
+      : undefined,
+    startDate: search.value.dateRange
+      ? toDateText(search.value.dateRange[0])
+      : undefined,
+    endDate: search.value.dateRange
+      ? toDateText(search.value.dateRange[1])
+      : undefined,
+    startSubmitDate: search.value.submitDateRange
+      ? toDateText(search.value.submitDateRange[0])
+      : undefined,
+    endSubmitDate: search.value.submitDateRange
+      ? toDateText(search.value.submitDateRange[1])
+      : undefined,
+    page: { current: page.value, row: pageSize.value },
+  };
+}
+
+async function fetchList() {
+  loading.value = true;
+  try {
+    const { list: rows, total: count } =
+      await withdrawApi.searchWithdrawPage(buildSearchPayload());
+    list.value = rows;
+    total.value = count;
+    checkedRowKeys.value = [];
+  } finally {
+    loading.value = false;
+  }
+}
+
+function openActionModal(type: ActionType, row: WithdrawItem) {
+  actionForm.value = {
+    id: row.id,
+    type,
+    status: null,
+    withdrawStatus: null,
+    callbackStatus: null,
+    submitStatus: type === "submit" ? "2" : null,
+    backstageStatus: null,
+    infoStatus: null,
+    approveDesc: "",
+    withdrawTicket: "",
+  };
+  actionModalVisible.value = true;
+  nextTick(() => actionFormRef.value?.restoreValidation());
+}
+
+function openBatchModal(type: BatchType) {
+  batchType.value = type;
+  batchForm.value = {
+    submitStatus: "2",
+    callbackStatus: "1",
+    infoStatus: "2",
+    approveDesc: "",
+  };
+  batchModalVisible.value = true;
+  nextTick(() => batchFormRef.value?.restoreValidation());
+}
+
+function handleActionSelect(key: string | number, row: WithdrawItem) {
+  const action = String(key) as ActionType;
+  if (action === "detail") {
+    detailRow.value = row;
+    detailModalVisible.value = true;
+    return;
+  }
+  if (action === "remit") {
+    void openRemitModal(row);
+    return;
+  }
+  openActionModal(action, row);
+}
+
+function openActionFromDetail(action: ActionType) {
+  const row = detailRow.value;
+  if (!row || action === "detail") return;
+  detailModalVisible.value = false;
+  if (action === "remit") {
+    void openRemitModal(row);
+    return;
+  }
+  openActionModal(action, row);
+}
+
+async function fetchDepositRecords() {
+  const row = detailRow.value;
+  if (!row?.cId) return;
+  recordLoading.value = true;
+  try {
+    const { list, total } = await withdrawApi.searchDepositPage({
+      cId: row.cId,
+      page: { current: depositRecordPage.value, row: depositRecordPageSize.value },
+    });
+    depositRecords.value = list;
+    depositRecordTotal.value = total;
+  } finally {
+    recordLoading.value = false;
+  }
+}
+
+async function fetchWithdrawalRecords() {
+  const row = detailRow.value;
+  if (!row?.cId) return;
+  recordLoading.value = true;
+  try {
+    const { list, total } = await withdrawApi.searchWithdrawPage({
+      cId: row.cId,
+      page: { current: withdrawalRecordPage.value, row: withdrawalRecordPageSize.value },
+    });
+    withdrawalRecords.value = list;
+    withdrawalRecordTotal.value = total;
+  } finally {
+    recordLoading.value = false;
+  }
+}
+
+function channelTypeLabel(type?: string) {
+  if (type === "BANK_TELEGRAPHIC") return "国际电汇";
+  if (type === "BANK") return "银行卡";
+  if (type === "DIGITAL_CURRENCY") return "数字货币";
+  if (type === "CHANNEL_TYPE_WALLET") return "电子钱包";
+  if (type === "CHANNEL_TYPE_CARD") return "信用卡";
+  if (type === "CHANNEL_TYPE_ALI_WALLET") return "Ali Wallet";
+  if (type === "UCARD_WALLET") return "CWG Card";
+  return "其他通道";
+}
+
+const groupedRemitChannels = computed(() => {
+  const map = new Map<string, RemitChannelItem[]>();
+  remitChannels.value.forEach((item) => {
+    const key = item.type || "UNKNOWN";
+    const list = map.get(key) || [];
+    list.push(item);
+    map.set(key, list);
+  });
+  return Array.from(map.entries()).map(([type, list]) => ({
+    type,
+    label: channelTypeLabel(type),
+    list,
+  }));
+});
+
+function parseSelectOptions(selectObj: unknown) {
+  if (!selectObj || typeof selectObj !== "object") return [];
+  return Object.entries(selectObj as Record<string, unknown>).map(([label, value]) => ({
+    label,
+    value: String(value ?? ""),
+  }));
+}
+
+async function openRemitModal(row: WithdrawItem) {
+  remitRow.value = row;
+  remitModalVisible.value = true;
+  remitTitle.value = "";
+  remitBase.value = null;
+  remitFields.value = [];
+  remitChannelsLoading.value = true;
+  try {
+    remitChannels.value = (await withdrawApi.getWithdrawRemitChannels()) ?? [];
+  } finally {
+    remitChannelsLoading.value = false;
+  }
+}
+
+async function chooseRemitChannel(channel: RemitChannelItem) {
+  if (!remitRow.value) return;
+  remitFormLoading.value = true;
+  try {
+    const data = await withdrawApi.getWithdrawRemitChannelInfo({
+      id: remitRow.value.id,
+      withdrawInfoUrl: channel.withdrawInfoUrl,
+      payType: channel.code,
+    });
+    remitTitle.value = channel.name || channel.enName || channel.code;
+    remitBase.value = data as Record<string, unknown>;
+    const labels =
+      ((data?.params as Record<string, unknown>) ??
+        (data?.paramsEn as Record<string, unknown>) ??
+        {}) as Record<string, unknown>;
+    const blacklist = new Set(["params", "paramsEn", "id", "serial", "withdrawUrl"]);
+    const fields: Array<{
+      name: string;
+      label: string;
+      value: string;
+      options: Array<{ label: string; value: string }>;
+    }> = [];
+    Object.keys(data ?? {}).forEach((name) => {
+      if (blacklist.has(name)) return;
+      const label = String(labels[name] ?? "");
+      if (!label) return;
+      fields.push({
+        name,
+        label,
+        value: String((data as Record<string, unknown>)[name] ?? ""),
+        options: parseSelectOptions((data as Record<string, unknown>)[`${name}Select`]),
+      });
+    });
+    remitFields.value = fields;
+  } finally {
+    remitFormLoading.value = false;
+  }
+}
+
+async function submitRemit() {
+  if (!remitBase.value) return;
+  const payload: Record<string, unknown> = {
+    id: remitBase.value.id,
+    serial: remitBase.value.serial,
+    withdrawUrl: remitBase.value.withdrawUrl,
+    payType: remitBase.value.payType,
+  };
+  remitFields.value.forEach((field) => {
+    payload[field.name] = field.value;
+  });
+  remitSubmitting.value = true;
+  try {
+    const res = await withdrawApi.submitWithdrawApi(payload);
+    message.success("提交成功");
+    remitModalVisible.value = false;
+    await fetchList();
+    if (res?.result) window.open(res.result, "_blank");
+  } finally {
+    remitSubmitting.value = false;
+  }
+}
+
+function getActionOptions(row: WithdrawItem): Array<{ label: string; key: ActionType }> {
+  const callbackPending = row.callbackStatus === 0 || row.callbackStatus == null;
+  const options: Array<{ label: string; key: ActionType }> = [];
+
+  // 旧页逻辑:待处理时显示「审核」
+  if (row.status === 1) {
+    options.push({ label: "审核", key: "approve" });
+  }
+
+  // 旧页逻辑:待 MT 扣款时显示「MT」
+  if (row.withdrawStatus === 1) {
+    options.push({ label: "MT", key: "mt" });
+  }
+
+  // 旧页逻辑:通道回执
+  if (
+    row.withdrawStatus === 2 &&
+    callbackPending &&
+    row.submitStatus === 2 &&
+    row.backstageStatus === 2
+  ) {
+    options.push({ label: "回执", key: "receipt" });
+  }
+
+  // 旧页逻辑:汇款提交
+  if (
+    row.withdrawStatus === 2 &&
+    callbackPending &&
+    row.submitStatus === 1 &&
+    row.backstageStatus === 2 &&
+    row.infoStatus === 2
+  ) {
+    options.push({ label: "汇款提交", key: "submit" });
+  }
+
+  // 旧页逻辑:后台审核
+  if (row.withdrawStatus === 2 && row.backstageStatus === 1) {
+    options.push({ label: "后台审核", key: "backstage" });
+  }
+
+  // 旧页逻辑:资料审核
+  if (
+    row.withdrawStatus === 2 &&
+    callbackPending &&
+    row.submitStatus === 1 &&
+    row.backstageStatus === 2 &&
+    row.infoStatus === 1
+  ) {
+    options.push({ label: "资料审核", key: "info" });
+  }
+
+  // 旧页逻辑:出金通道(三方提交)
+  if (
+    row.status === 2 &&
+    row.withdrawStatus === 2 &&
+    row.submitStatus === 1 &&
+    row.infoStatus === 2
+  ) {
+    options.push({ label: "出金通道", key: "remit" });
+  }
+
+  // 旧页里详情始终可见(不带权限判断)
+  options.push({ label: "详情", key: "detail" });
+  return options;
+}
+
+function displayText(v: unknown) {
+  const s = String(v ?? "").trim();
+  return s || "--";
+}
+
+function loginTypeText(v: unknown) {
+  const n = Number(v);
+  if (n === 1) return "Classic";
+  if (n === 2) return "Senior";
+  if (n === 3) return "Institutions";
+  if (n === 5) return "Speed";
+  if (n === 6) return "NewSpeed";
+  if (n === 7) return "StandardAccount";
+  if (n === 8) return "CentAccount";
+  return "--";
+}
+
+function remitTypeText(v: unknown) {
+  const s = String(v ?? "");
+  if (s === "BANK_TELEGRAPHIC") return "国际电汇";
+  if (s === "BANK") return "银行卡";
+  if (s === "DIGITAL_CURRENCY") return "数字货币";
+  if (s === "CHANNEL_TYPE_WALLET") return "电子钱包";
+  if (s === "CHANNEL_TYPE_CARD") return "信用卡";
+  if (s === "CHANNEL_TYPE_ALI_WALLET") return "Ali Wallet";
+  if (s === "UCARD_WALLET") return "CWG Card";
+  return "--";
+}
+
+interface DetailFieldItem {
+  label: string;
+  value: string;
+}
+
+function hasDisplayValue(v: unknown) {
+  return String(v ?? "").trim() !== "";
+}
+
+function toDetailValue(v: unknown) {
+  return hasDisplayValue(v) ? String(v).trim() : "--";
+}
+
+function mapFields(
+  source: Record<string, unknown>,
+  defs: Array<{ label: string; key: string }>,
+) {
+  return defs
+    .filter((def) => hasDisplayValue(source[def.key]))
+    .map((def) => ({ label: def.label, value: toDetailValue(source[def.key]) }));
+}
+
+const basicDetailFields = computed<DetailFieldItem[]>(() => {
+  const row = detailRow.value;
+  if (!row) return [];
+  const rec = row as Record<string, unknown>;
+  return [
+    { label: "CID", value: toDetailValue(row.cId) },
+    { label: "归属编号", value: toDetailValue(row.pIbNo) },
+    { label: "姓名", value: toDetailValue(row.name) },
+    { label: "交易账户", value: toDetailValue(row.login) },
+    { label: "平台", value: toDetailValue(row.platform) },
+    { label: "登录类型", value: loginTypeText(rec.loginType) },
+    { label: "流水号", value: toDetailValue(row.serial) },
+    { label: "申请时间", value: toDetailValue(row.addTime) },
+  ];
+});
+
+const bankDetailFields = computed<DetailFieldItem[]>(() => {
+  const row = detailRow.value;
+  if (!row) return [];
+  const rec = row as Record<string, unknown>;
+  const remitType = String(rec.remitChannelType ?? "");
+  const fields: DetailFieldItem[] = [
+    { label: "出金通道类型", value: remitTypeText(rec.remitChannelType) },
+  ];
+
+  const bankTransferFields = [
+    { label: "银行名称", key: "bankName" },
+    { label: "开户名", key: "bankUname" },
+    { label: "银行账号", key: "bankCardNum" },
+    { label: "开户支行", key: "bankBranchName" },
+    { label: "Swift/BIC", key: "swiftCode" },
+    { label: "银行编码", key: "customBankCode" },
+    { label: "银行地址", key: "bankAddr" },
+  ];
+  const cardFields = [
+    { label: "Agency No", key: "agencyNo" },
+    { label: "CPF", key: "cpf" },
+    { label: "CVV", key: "cvv" },
+    { label: "Expiry Year", key: "expiryYear" },
+    { label: "Expiry Month", key: "expiryMonth" },
+    { label: "Account Name", key: "accountName" },
+    { label: "Account Number", key: "accountNumber" },
+    { label: "地址", key: "address" },
+  ];
+  const walletFields = [
+    { label: "账户名称", key: "accountName" },
+    { label: "账户号码", key: "accountNumber" },
+    { label: "地址", key: "address" },
+  ];
+
+  if (remitType === "BANK" || remitType === "BANK_TELEGRAPHIC") {
+    fields.push(...mapFields(rec, bankTransferFields));
+  } else if (remitType === "CHANNEL_TYPE_CARD" || remitType === "UCARD_WALLET") {
+    fields.push(...mapFields(rec, cardFields));
+  } else if (
+    remitType === "CHANNEL_TYPE_WALLET" ||
+    remitType === "CHANNEL_TYPE_ALI_WALLET" ||
+    remitType === "DIGITAL_CURRENCY"
+  ) {
+    fields.push(...mapFields(rec, walletFields));
+  } else {
+    fields.push(...mapFields(rec, [...bankTransferFields, ...cardFields]));
+  }
+  return fields;
+});
+
+const withdrawDetailFields = computed<DetailFieldItem[]>(() => {
+  const row = detailRow.value;
+  if (!row) return [];
+  return [
+    {
+      label: "申请金额",
+      value: `${amountText(row.amount)} (${displayText(row.currency)})`,
+    },
+    {
+      label: "平台出金",
+      value: `${amountText(row.withdrawAmount)} (${displayText(row.withdrawCurrency)})`,
+    },
+    {
+      label: "汇款金额",
+      value: `${amountText(row.transformAmount)} (${displayText(row.transformCurrency)})`,
+    },
+    { label: "取款方式", value: toDetailValue(row.remitChannelName) },
+    { label: "手续费减免", value: feeReductionText(row.feeReduction) },
+    { label: "手续费", value: amountText(row.feeAmount) },
+    { label: "手续费减免金额", value: amountText(row.feeReductionAmount) },
+    { label: "风险等级", value: salesLevelText(row.salesSettingLevel) },
+    { label: "汇款提交时间", value: toDetailValue(row.submitTime) },
+    { label: "状态", value: resolveStatus(row).label },
+    { label: "拒绝/备注", value: toDetailValue(row.approveDesc) },
+  ];
+});
+
+async function submitAction() {
+  await actionFormRef.value?.validate();
+  actionSubmitting.value = true;
+  try {
+    const f = actionForm.value;
+    if (f.type === "approve") {
+      await withdrawApi.approveWithdraw({
+        id: f.id,
+        status: (Number(f.status) as 2 | 3) ?? 2,
+        approveDesc: f.status === "3" ? f.approveDesc.trim() : "",
+      });
+    } else if (f.type === "mt") {
+      await withdrawApi.approveWithdrawMT({
+        id: f.id,
+        withdrawStatus: (Number(f.withdrawStatus) as 2 | 3) ?? 2,
+        approveDesc: f.withdrawStatus === "3" ? f.approveDesc.trim() : "",
+        withdrawTicket: f.withdrawStatus === "2" ? f.withdrawTicket.trim() : "",
+      });
+    } else if (f.type === "receipt") {
+      await withdrawApi.approveWithdrawReceipt({
+        id: f.id,
+        callbackStatus: (Number(f.callbackStatus) as 1 | 2) ?? 1,
+        approveDesc: f.callbackStatus === "2" ? f.approveDesc.trim() : "",
+      });
+    } else if (f.type === "submit") {
+      await withdrawApi.approveWithdrawSubmit({
+        id: f.id,
+        submitStatus: (Number(f.submitStatus) as 2 | 3) ?? 2,
+        approveDesc: f.submitStatus === "3" ? f.approveDesc.trim() : "",
+      });
+    } else if (f.type === "backstage") {
+      await withdrawApi.approveWithdrawBackstage({
+        id: f.id,
+        backstageStatus: (Number(f.backstageStatus) as 2 | 3) ?? 2,
+        approveDesc: f.backstageStatus === "3" ? f.approveDesc.trim() : "",
+      });
+    } else if (f.type === "info") {
+      await withdrawApi.approveWithdrawInfo({
+        id: f.id,
+        infoStatus: (Number(f.infoStatus) as 2 | 3) ?? 2,
+        approveDesc: f.infoStatus === "3" ? f.approveDesc.trim() : "",
+      });
+    }
+    message.success("处理成功");
+    actionModalVisible.value = false;
+    await fetchList();
+  } finally {
+    actionSubmitting.value = false;
+  }
+}
+
+async function submitBatch() {
+  await batchFormRef.value?.validate();
+  if (!selectedIds.value.length) {
+    message.warning("请先勾选记录");
+    return;
+  }
+  batchSubmitting.value = true;
+  try {
+    const f = batchForm.value;
+    if (batchType.value === "submits") {
+      await withdrawApi.batchApproveWithdrawSubmit({
+        ids: selectedIds.value,
+        submitStatus: Number(f.submitStatus) as 2 | 3,
+        approveDesc: f.submitStatus === "3" ? f.approveDesc.trim() : "",
+      });
+    } else if (batchType.value === "channels") {
+      await withdrawApi.batchApproveWithdrawReceipt({
+        ids: selectedIds.value,
+        callbackStatus: Number(f.callbackStatus) as 1 | 2,
+        approveDesc: f.callbackStatus === "2" ? f.approveDesc.trim() : "",
+      });
+    } else {
+      await withdrawApi.batchApproveWithdrawInfo({
+        ids: selectedIds.value,
+        infoStatus: Number(f.infoStatus) as 2 | 3,
+        approveDesc: f.infoStatus === "3" ? f.approveDesc.trim() : "",
+      });
+    }
+    message.success("批量处理成功");
+    batchModalVisible.value = false;
+    await fetchList();
+  } finally {
+    batchSubmitting.value = false;
+  }
+}
+
+async function exportCurrentList() {
+  exporting.value = true;
+  try {
+    const payload = buildSearchPayload();
+    delete payload.page;
+    const { blob, fileName } = await withdrawApi.exportWithdrawList(payload);
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = fileName || "withdraw.xlsx";
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+    message.success("导出成功");
+  } finally {
+    exporting.value = false;
+  }
+}
+
+function onSearch() {
+  page.value = 1;
+  void fetchList();
+}
+
+function resetSearch() {
+  search.value = {
+    cId: "",
+    serial: "",
+    name: "",
+    status: "-1",
+    submitStatus: "-1",
+    backstageStatus: "-1",
+    infoStatus: "-1",
+    channelCodes: [],
+    dateRange: null,
+    submitDateRange: null,
+  };
+  page.value = 1;
+  void fetchList();
+}
+
+const allColumns = ref<DataTableColumns<WithdrawItem>>([
+  { type: "selection", width: 44, fixed: "left" },
+  { title: "CID", key: "cId" },
+  { title: "姓名", key: "name" },
+  {
+    title: "流水号",
+    key: "serial",
+    width: 200,
+    render(row) {
+      return h(
+        NButton,
+        {
+          text: true,
+          type: "primary",
+          class: "serial-link-btn",
+          onClick: () => handleActionSelect("detail", row),
+        },
+        { default: () => displayText(row.serial) },
+      );
+    },
+  },
+  {
+    title: "申请金额",
+    key: "amount",
+    
+    render(row) {
+      return `${amountText(row.amount)} ${row.currency || ""}`.trim();
+    },
+  },
+  {
+    title: "平台出金",
+    key: "withdrawAmount",
+    
+    render(row) {
+      return `${amountText(row.withdrawAmount)} ${row.withdrawCurrency || ""}`.trim();
+    },
+  },
+  {
+    title: "汇款金额",
+    key: "transformAmount",
+    
+    render(row) {
+      return `${amountText(row.transformAmount)} ${row.transformCurrency || ""}`.trim();
+    },
+  },
+  {
+    title: "取款方式",
+    key: "remitChannelName",
+    
+    ellipsis: { tooltip: true },
+  },
+  { title: "申请时间", key: "addTime", },
+  { title: "放款时间", key: "submitTime", },
+  {
+    title: "手续费减免",
+    key: "feeReduction",
+    render(row) {
+      return feeReductionText(row.feeReduction);
+    },
+  },
+  {
+    title: "手续费",
+    key: "feeAmount",
+    render(row) {
+      return amountText(row.feeAmount);
+    },
+  },
+  {
+    title: "手续费减免金额",
+    key: "feeReductionAmount",
+    render(row) {
+      return amountText(row.feeReductionAmount);
+    },
+  },
+  {
+    title: "风险等级",
+    key: "salesSettingLevel",
+    render(row) {
+      return salesLevelText(row.salesSettingLevel);
+    },
+  },
+  {
+    title: "状态",
+    key: "statusTag",
+   
+    render(row) {
+      const s = resolveStatus(row);
+      return h(
+        NTag,
+        { type: s.type, size: "small" },
+        { default: () => s.label },
+      );
+    },
+  },
+  {
+    title: "操作",
+    key: "actions",
+    width: 110,
+    fixed: "right",
+    render(row) {
+      return h("div", { class: "action-cell" }, [
+        h(
+          NDropdown,
+          {
+            trigger: "click",
+            placement: "bottom-end",
+            options: getActionOptions(row),
+            onSelect: (key: string | number) => handleActionSelect(key, row),
+          },
+          {
+            default: () =>
+              h(
+                NButton,
+                { text: true, circle: true, class: "action-cell__more-btn" },
+                { icon: () => h(NIcon, { size: 16 }, { default: () => h(EllipsisVertical) }) },
+              ),
+          },
+        ),
+      ]);
+    },
+  },
+]);
+
+const depositRecordColumns: DataTableColumns<DepositItem> = [
+  { title: "流水号", key: "serial" },
+  { title: "CID", key: "cId" },
+  { title: "交易账户", key: "login" },
+  {
+    title: "金额",
+    key: "amount",
+    render: (row) => `${amountText(row.amount)} ${row.currency || ""}`.trim(),
+  },
+  { title: "支付方式", key: "payTypeName" },
+  { title: "状态", key: "status" },
+  { title: "时间", key: "addTime" },
+];
+
+const withdrawalRecordColumns: DataTableColumns<WithdrawItem> = [
+  { title: "流水号", key: "serial" },
+  { title: "CID", key: "cId" },
+  {
+    title: "申请金额",
+    key: "amount",
+    render: (row) => `${amountText(row.amount)} ${row.currency || ""}`.trim(),
+  },
+  {
+    title: "平台出金",
+    key: "withdrawAmount",
+    render: (row) =>
+      `${amountText(row.withdrawAmount)} ${row.withdrawCurrency || ""}`.trim(),
+  },
+  {
+    title: "汇款金额",
+    key: "transformAmount",
+    render: (row) =>
+      `${amountText(row.transformAmount)} ${row.transformCurrency || ""}`.trim(),
+  },
+  { title: "状态", key: "status", render: (row) => resolveStatus(row).label },
+  { title: "申请时间", key: "addTime" },
+  { title: "放款时间", key: "submitTime" },
+];
+
+const { visibleKeys, displayColumns, columnOptions } = useTableColumnsControl(
+  allColumns,
+  {
+    frozenKeys: ["selection", "actions"],
+  },
+);
+
+watch([page, pageSize], () => {
+  void fetchList();
+});
+
+onMounted(() => {
+  // 首次进入先拉列表,避免等待筛选项接口后才展示数据
+  void fetchList();
+  void (async () => {
+    const [channels] = await Promise.allSettled([
+      withdrawApi.getWithdrawChannelCodes(),
+    ]);
+    if (channels.status === "fulfilled") {
+      channelOptions.value = (channels.value ?? []).map((i) => ({
+        label: i.name || i.enName || i.code,
+        value: i.code,
+      }));
+    }
+  })();
+});
+
+onActivated(() => {
+  // 页签缓存场景下,重新激活时也刷新一次列表
+  void fetchList();
+});
+
+watch([depositRecordPage, depositRecordPageSize], () => {
+  if (depositRecordVisible.value) void fetchDepositRecords();
+});
+watch([withdrawalRecordPage, withdrawalRecordPageSize], () => {
+  if (withdrawalRecordVisible.value) void fetchWithdrawalRecords();
+});
+</script>
+
+<template>
+  <div class="page page--table">
+    <AdminSearchPanel :field-count="10" @search="onSearch" @reset="resetSearch">
+      <NFormItemGi :span="1" label="CID">
+        <NInput
+          v-model:value="search.cId"
+          clearable
+          placeholder="输入 CID"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="流水号">
+        <NInput
+          v-model:value="search.serial"
+          clearable
+          placeholder="输入流水号"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="姓名">
+        <NInput
+          v-model:value="search.name"
+          clearable
+          placeholder="输入姓名"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="状态">
+        <NSelect v-model:value="search.status" :options="statusOptions1" />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="资料审核状态">
+        <NSelect v-model:value="search.infoStatus" :options="statusOptions" />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="汇款状态">
+        <NSelect v-model:value="search.submitStatus" :options="statusOptions" />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="后台状态">
+        <NSelect
+          v-model:value="search.backstageStatus"
+          :options="statusOptions"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="通道">
+        <NSelect
+          v-model:value="search.channelCodes"
+          multiple
+          filterable
+          clearable
+          :options="channelOptions"
+          placeholder="选择通道"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="申请时间">
+        <NDatePicker
+          v-model:value="search.dateRange"
+          type="daterange"
+          clearable
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="提交时间">
+        <NDatePicker
+          v-model:value="search.submitDateRange"
+          type="daterange"
+          clearable
+        />
+      </NFormItemGi>
+    </AdminSearchPanel>
+
+    <NCard :bordered="false" class="table-card table-card--fill">
+      <div class="table-card-inner">
+        <AdminTablePageBar
+          title="取款申请"
+          v-model:visible-keys="visibleKeys"
+          :column-options="columnOptions"
+          :refresh-loading="loading"
+          @refresh="fetchList"
+        >
+          <NButton :loading="exporting" @click="exportCurrentList"
+            >导出</NButton
+          >
+          <NButton
+            :disabled="!isBatchInfoReady"
+            @click="openBatchModal('infos')"
+            >批量资料审核</NButton
+          >
+          <NButton
+            :disabled="!isBatchChannelReady"
+            @click="openBatchModal('channels')"
+            >批量回执</NButton
+          >
+          <NButton
+            type="primary"
+            :disabled="!isBatchSubmitReady"
+            @click="openBatchModal('submits')"
+          >
+            批量汇款
+          </NButton>
+        </AdminTablePageBar>
+
+        <div class="table-summary">
+          已选 {{ selectedRows.length }} 条,金额合计
+          {{ amountText(selectedAmountTotal) }}
+        </div>
+
+        <div class="table-card__body">
+          <NDataTable
+            :columns="displayColumns"
+            :data="list"
+            :loading="loading"
+            :bordered="false"
+            :single-line="false"
+            :row-key="(row: WithdrawItem) => row.id"
+            :checked-row-keys="checkedRowKeys"
+            class="data-table-fill"
+            :scroll-x="2900"
+            @update:checked-row-keys="(keys) => (checkedRowKeys = keys)"
+          />
+        </div>
+
+        <div class="pager-wrap">
+          <div class="pager-inline">
+            <span class="pager-total">共 {{ total }} 条</span>
+            <NPagination
+              v-model:page="page"
+              v-model:page-size="pageSize"
+              :item-count="total"
+              :page-sizes="[10, 20, 50, 100]"
+              show-size-picker
+            />
+          </div>
+        </div>
+      </div>
+    </NCard>
+
+    <NModal
+      v-model:show="detailModalVisible"
+      preset="card"
+      title="详情"
+      style="width: min(1200px, 96vw)"
+      :mask-closable="false"
+    >
+      <template v-if="detailRow">
+        <NSpace justify="end" style="margin-bottom: 12px">
+          <!-- <NButton size="small" @click="openDepositRecords">存款记录</NButton> -->
+          <!-- <NButton size="small" @click="openWithdrawalRecords">取款记录</NButton> -->
+          <NButton
+            v-for="item in getActionOptions(detailRow).filter((x) => x.key !== 'detail')"
+            :key="item.key"
+            size="small"
+            @click="openActionFromDetail(item.key)"
+          >
+            {{ item.label }}
+          </NButton>
+        </NSpace>
+
+        <div class="detail-grid">
+          <section class="detail-panel">
+            <header class="detail-panel__header">基础信息</header>
+            <div class="detail-panel__body">
+              <div class="detail-list">
+                <div
+                  v-for="item in basicDetailFields"
+                  :key="`basic-${item.label}`"
+                  class="detail-item"
+                >
+                  <span class="detail-item__label">{{ item.label }}</span>
+                  <span class="detail-item__value">{{ item.value }}</span>
+                </div>
+              </div>
+            </div>
+          </section>
+
+          <section class="detail-panel">
+            <header class="detail-panel__header">银行信息</header>
+            <div class="detail-panel__body">
+              <div class="detail-list">
+                <div
+                  v-for="item in bankDetailFields"
+                  :key="`bank-${item.label}`"
+                  class="detail-item"
+                >
+                  <span class="detail-item__label">{{ item.label }}</span>
+                  <span class="detail-item__value">{{ item.value }}</span>
+                </div>
+              </div>
+            </div>
+          </section>
+
+          <section class="detail-panel">
+            <header class="detail-panel__header">取款信息</header>
+            <div class="detail-panel__body">
+              <div class="detail-list">
+                <div
+                  v-for="item in withdrawDetailFields"
+                  :key="`withdraw-${item.label}`"
+                  class="detail-item"
+                >
+                  <span class="detail-item__label">{{ item.label }}</span>
+                  <span class="detail-item__value">{{ item.value }}</span>
+                </div>
+              </div>
+            </div>
+          </section>
+        </div>
+      </template>
+      <template #footer>
+        <NSpace justify="end">
+          <NButton @click="detailModalVisible = false">关闭</NButton>
+        </NSpace>
+      </template>
+    </NModal>
+
+    <NModal
+      v-model:show="remitModalVisible"
+      preset="card"
+      :title="remitTitle ? `出金通道 - ${remitTitle}` : '出金通道'"
+      style="width: min(900px, 96vw)"
+      :mask-closable="false"
+    >
+      <div v-if="!remitFields.length && !remitFormLoading">
+        <div v-if="remitChannelsLoading" class="remit-tip">正在加载通道...</div>
+        <div v-else-if="!groupedRemitChannels.length" class="remit-tip">暂无可用出金通道</div>
+        <div v-else class="remit-groups">
+          <section
+            v-for="group in groupedRemitChannels"
+            :key="group.type"
+            class="remit-group"
+          >
+            <div class="remit-group__title">{{ group.label }}</div>
+            <div class="remit-group__list">
+              <NButton
+                v-for="channel in group.list"
+                :key="channel.code"
+                size="small"
+                @click="chooseRemitChannel(channel)"
+              >
+                {{ channel.name || channel.enName || channel.code }}
+              </NButton>
+            </div>
+          </section>
+        </div>
+      </div>
+      <NForm v-else-if="!remitFormLoading" label-placement="top">
+        <NFormItem
+          v-for="field in remitFields"
+          :key="field.name"
+          :label="field.label"
+        >
+          <NSelect
+            v-if="field.options.length"
+            v-model:value="field.value"
+            :options="field.options"
+            clearable
+          />
+          <NInput v-else v-model:value="field.value" />
+        </NFormItem>
+      </NForm>
+      <div v-else class="remit-tip">正在加载通道参数...</div>
+      <template #footer>
+        <NSpace justify="space-between">
+          <NButton
+            v-if="remitFields.length"
+            @click="
+              remitTitle = '';
+              remitBase = null;
+              remitFields = [];
+            "
+          >
+            返回通道列表
+          </NButton>
+          <span />
+          <NSpace>
+            <NButton @click="remitModalVisible = false">取消</NButton>
+            <NButton
+              v-if="remitFields.length"
+              type="primary"
+              :loading="remitSubmitting"
+              @click="submitRemit"
+            >
+              提交
+            </NButton>
+          </NSpace>
+        </NSpace>
+      </template>
+    </NModal>
+
+    <NModal
+      v-model:show="depositRecordVisible"
+      preset="card"
+      title="存款记录"
+      style="width: min(1100px, 96vw)"
+      :mask-closable="false"
+    >
+      <NDataTable
+        :data="depositRecords"
+        :loading="recordLoading"
+        :bordered="false"
+        :single-line="false"
+        :columns="depositRecordColumns"
+      />
+      <template #footer>
+        <NSpace justify="space-between" align="center">
+          <span>共 {{ depositRecordTotal }} 条</span>
+          <NPagination
+            v-model:page="depositRecordPage"
+            v-model:page-size="depositRecordPageSize"
+            :item-count="depositRecordTotal"
+            :page-sizes="[10, 20, 50]"
+            show-size-picker
+          />
+        </NSpace>
+      </template>
+    </NModal>
+
+    <NModal
+      v-model:show="withdrawalRecordVisible"
+      preset="card"
+      title="取款记录"
+      style="width: min(1100px, 96vw)"
+      :mask-closable="false"
+    >
+      <NDataTable
+        :data="withdrawalRecords"
+        :loading="recordLoading"
+        :bordered="false"
+        :single-line="false"
+        :columns="withdrawalRecordColumns"
+      />
+      <template #footer>
+        <NSpace justify="space-between" align="center">
+          <span>共 {{ withdrawalRecordTotal }} 条</span>
+          <NPagination
+            v-model:page="withdrawalRecordPage"
+            v-model:page-size="withdrawalRecordPageSize"
+            :item-count="withdrawalRecordTotal"
+            :page-sizes="[10, 20, 50]"
+            show-size-picker
+          />
+        </NSpace>
+      </template>
+    </NModal>
+
+    <NModal
+      v-model:show="actionModalVisible"
+      preset="card"
+      :title="actionModalTitle"
+      style="width: min(560px, 92vw)"
+      :mask-closable="false"
+    >
+      <NForm
+        ref="actionFormRef"
+        :model="actionForm"
+        :rules="actionRules"
+        label-placement="top"
+      >
+        <NFormItem
+          v-if="actionForm.type === 'approve'"
+          label="处理状态"
+          path="status"
+        >
+          <NSelect
+            v-model:value="actionForm.status"
+            :options="processOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="actionForm.type === 'mt'"
+          label="处理状态"
+          path="withdrawStatus"
+        >
+          <NSelect
+            v-model:value="actionForm.withdrawStatus"
+            :options="processOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="actionForm.type === 'mt' && actionForm.withdrawStatus === '2'"
+          label="MT 订单号"
+          path="withdrawTicket"
+        >
+          <NInput
+            v-model:value="actionForm.withdrawTicket"
+            placeholder="请输入 MT 订单号"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="actionForm.type === 'receipt'"
+          label="回执状态"
+          path="callbackStatus"
+        >
+          <NSelect
+            v-model:value="actionForm.callbackStatus"
+            :options="receiptOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="actionForm.type === 'submit'"
+          label="汇款状态"
+          path="submitStatus"
+        >
+          <NSelect
+            v-model:value="actionForm.submitStatus"
+            :options="processOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="actionForm.type === 'backstage'"
+          label="后台状态"
+          path="backstageStatus"
+        >
+          <NSelect
+            v-model:value="actionForm.backstageStatus"
+            :options="processOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="actionForm.type === 'info'"
+          label="资料状态"
+          path="infoStatus"
+        >
+          <NSelect
+            v-model:value="actionForm.infoStatus"
+            :options="processOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="
+            (actionForm.type === 'approve' && actionForm.status === '3') ||
+            (actionForm.type === 'mt' && actionForm.withdrawStatus === '3') ||
+            (actionForm.type === 'receipt' &&
+              actionForm.callbackStatus === '2') ||
+            (actionForm.type === 'submit' && actionForm.submitStatus === '3') ||
+            (actionForm.type === 'backstage' &&
+              actionForm.backstageStatus === '3') ||
+            (actionForm.type === 'info' && actionForm.infoStatus === '3')
+          "
+          label="拒绝原因"
+          path="approveDesc"
+        >
+          <NInput
+            v-model:value="actionForm.approveDesc"
+            placeholder="请输入拒绝原因(支持输入自定义)"
+          />
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <NSpace justify="end">
+          <NButton @click="actionModalVisible = false">取消</NButton>
+          <NButton
+            type="primary"
+            :loading="actionSubmitting"
+            @click="submitAction"
+            >确认</NButton
+          >
+        </NSpace>
+      </template>
+    </NModal>
+
+    <NModal
+      v-model:show="batchModalVisible"
+      preset="card"
+      :title="batchModalTitle"
+      style="width: min(560px, 92vw)"
+      :mask-closable="false"
+    >
+      <NForm
+        ref="batchFormRef"
+        :model="batchForm"
+        :rules="batchRules"
+        label-placement="top"
+      >
+        <NAlert type="info" style="margin-bottom: 12px">
+          本次处理 {{ selectedRows.length }} 条,金额合计
+          {{ amountText(selectedAmountTotal) }}
+        </NAlert>
+        <NFormItem
+          v-if="batchType === 'submits'"
+          label="汇款状态"
+          path="submitStatus"
+        >
+          <NSelect
+            v-model:value="batchForm.submitStatus"
+            :options="processOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="batchType === 'channels'"
+          label="回执状态"
+          path="callbackStatus"
+        >
+          <NSelect
+            v-model:value="batchForm.callbackStatus"
+            :options="receiptOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="batchType === 'infos'"
+          label="资料状态"
+          path="infoStatus"
+        >
+          <NSelect
+            v-model:value="batchForm.infoStatus"
+            :options="processOptions"
+          />
+        </NFormItem>
+        <NFormItem
+          v-if="
+            (batchType === 'submits' && batchForm.submitStatus === '3') ||
+            (batchType === 'channels' && batchForm.callbackStatus === '2') ||
+            (batchType === 'infos' && batchForm.infoStatus === '3')
+          "
+          label="拒绝原因"
+          path="approveDesc"
+        >
+          <NInput
+            v-model:value="batchForm.approveDesc"
+            placeholder="请输入拒绝原因"
+          />
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <NSpace justify="end">
+          <NButton @click="batchModalVisible = false">取消</NButton>
+          <NButton
+            type="primary"
+            :loading="batchSubmitting"
+            @click="submitBatch"
+            >确认</NButton
+          >
+        </NSpace>
+      </template>
+    </NModal>
+  </div>
+</template>
+
+<style scoped>
+.table-summary {
+  margin: 8px 0 12px;
+  font-size: 13px;
+  color: var(--n-text-color-2);
+}
+
+.action-cell {
+  display: inline-flex;
+  align-items: center;
+}
+
+.detail-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.detail-panel {
+  overflow: hidden;
+  border: 1px solid #e5e7eb;
+  border-radius: 10px;
+  background: #fff;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+}
+
+.detail-panel__header {
+  padding: 10px 14px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #fff;
+  background: linear-gradient(90deg, #5b56d8 0%, #6f46c8 100%);
+}
+
+.detail-panel__body {
+  padding: 12px;
+  background: #f8fafc;
+}
+
+.detail-list {
+  display: grid;
+  gap: 10px;
+  font-size: 13px;
+}
+
+.detail-item {
+  display: grid;
+  grid-template-columns: 98px minmax(0, 1fr);
+  align-items: center;
+  column-gap: 10px;
+  line-height: 1.45;
+  min-height: 36px;
+  padding: 8px 10px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  background: #f9fafb;
+}
+
+.detail-item__label {
+  color: #6b7280;
+}
+
+.detail-item__value {
+  color: #111827;
+  font-weight: 500;
+  word-break: break-all;
+}
+
+.remit-tip {
+  padding: 24px 0;
+  text-align: center;
+  color: var(--n-text-color-3);
+}
+
+.remit-groups {
+  display: grid;
+  gap: 12px;
+}
+
+.remit-group {
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  padding: 10px;
+}
+
+.remit-group__title {
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.remit-group__list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+@media (max-width: 1200px) {
+  .detail-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 860px) {
+  .detail-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+</style>

+ 16 - 0
src/vite-env.d.ts

@@ -0,0 +1,16 @@
+/// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  /** 接口根路径,由 .env.development / .env.test / .env.production 注入 */
+  readonly VITE_API_BASE: string
+}
+
+interface ImportMeta {
+  readonly env: ImportMetaEnv
+}
+
+declare module '*.vue' {
+  import type { DefineComponent } from 'vue'
+  const component: DefineComponent<object, object, unknown>
+  export default component
+}

+ 17 - 0
tsconfig.app.json

@@ -0,0 +1,17 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+    "types": ["vite/client"],
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+
+    /* Linting */
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 7 - 0
tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ]
+}

+ 24 - 0
tsconfig.node.json

@@ -0,0 +1,24 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "target": "es2023",
+    "lib": ["ES2023"],
+    "module": "esnext",
+    "types": ["node"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 32 - 0
vite.config.ts

@@ -0,0 +1,32 @@
+import { fileURLToPath, URL } from 'node:url'
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import AutoImport from 'unplugin-auto-import/vite'
+import Components from 'unplugin-vue-components/vite'
+import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [
+    vue(),
+    AutoImport({
+      imports: ['vue', 'vue-router', 'pinia'],
+      dts: 'src/auto-imports.d.ts',
+      vueTemplate: true,
+    }),
+    Components({
+      resolvers: [NaiveUiResolver()],
+      dts: 'src/components.d.ts',
+    }),
+  ],
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url)),
+    },
+  },
+  // 允许通过局域网 IP 访问(默认仅 localhost,手机/其他电脑打不开)
+  server: {
+    host: true,
+    port: 5173,
+  },
+})

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác