From 1cff321f2ae69f18dfd7be48350fb3c00f962efe Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 06:56:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=20W1-W3=20=E5=81=87=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 31 +- web/eslint.config.js | 35 + web/package.json | 19 +- web/pnpm-lock.yaml | 1299 ++++++++++++++++++++++++ web/src/App.vue | 9 + web/src/api/admin.ts | 191 ++++ web/src/api/http.ts | 85 ++ web/src/api/mock.ts | 768 ++++++++++++++ web/src/api/types.ts | 223 ++++ web/src/components/HelpTip.vue | 30 + web/src/components/PageHeader.vue | 37 + web/src/components/SecretOnceAlert.vue | 41 + web/src/layouts/AdminLayout.vue | 71 ++ web/src/main.ts | 2 - web/src/router/index.ts | 38 +- web/src/stores/app.ts | 7 - web/src/stores/auth.ts | 36 + web/src/utils/notify.ts | 10 + web/src/utils/time.spec.ts | 16 + web/src/utils/time.ts | 15 + web/src/views/EndpointsView.spec.ts | 70 ++ web/src/views/EndpointsView.vue | 505 +++++++++ web/src/views/GroupsView.vue | 332 ++++++ web/src/views/HomeView.vue | 15 - web/src/views/LoginView.spec.ts | 100 ++ web/src/views/LoginView.vue | 70 ++ web/src/views/MessagesView.vue | 204 ++++ web/src/views/OverviewView.vue | 59 ++ web/src/views/RegistrationView.vue | 104 ++ web/src/views/SettingsView.vue | 119 +++ web/src/views/TokensView.spec.ts | 71 ++ web/src/views/TokensView.vue | 186 ++++ web/vite.config.ts | 6 +- 33 files changed, 4773 insertions(+), 31 deletions(-) create mode 100644 web/eslint.config.js create mode 100644 web/src/api/admin.ts create mode 100644 web/src/api/http.ts create mode 100644 web/src/api/mock.ts create mode 100644 web/src/api/types.ts create mode 100644 web/src/components/HelpTip.vue create mode 100644 web/src/components/PageHeader.vue create mode 100644 web/src/components/SecretOnceAlert.vue create mode 100644 web/src/layouts/AdminLayout.vue delete mode 100644 web/src/stores/app.ts create mode 100644 web/src/stores/auth.ts create mode 100644 web/src/utils/notify.ts create mode 100644 web/src/utils/time.spec.ts create mode 100644 web/src/utils/time.ts create mode 100644 web/src/views/EndpointsView.spec.ts create mode 100644 web/src/views/EndpointsView.vue create mode 100644 web/src/views/GroupsView.vue delete mode 100644 web/src/views/HomeView.vue create mode 100644 web/src/views/LoginView.spec.ts create mode 100644 web/src/views/LoginView.vue create mode 100644 web/src/views/MessagesView.vue create mode 100644 web/src/views/OverviewView.vue create mode 100644 web/src/views/RegistrationView.vue create mode 100644 web/src/views/SettingsView.vue create mode 100644 web/src/views/TokensView.spec.ts create mode 100644 web/src/views/TokensView.vue diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 3ff3a96..4b0c6f1 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -389,7 +389,36 @@ ## 后台网页 W -暂无。 +1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`** + - 原条款:TASKS W1–W3「先按契约用假数据」;admin-api.md 第 11 节。 + - 实际做法:`web/src/api/admin.ts` 统一导出接口函数,内部调用 `mock.ts`;`http.ts` 已实现带 `X-Nixmsg-Request: 1` 的真实请求封装,供 W4 切换。 + - 原因:A 线管理接口尚未合入,页面与契约可并行开发。 + - 备选:用 MSW 拦截 fetch;当前集中换 `admin.ts` 更简单。 + - 假登录口令:`admin` / `adminpassword`(仅本地 mock,不进后端)。 + +2. **列表分页用页码映射 cursor 偏移** + - 原条款:admin-api 使用 `cursor`/`limit` 游标分页。 + - 实际做法:假数据把 `page` 编成数字偏移 cursor(`String((page-1)*limit)`),`n-data-table` remote 分页照常。 + - 原因:Naive UI 表格以页码交互;契约游标对前端透明即可。 + - 备选:W4 若后端 cursor 非偏移编码,在 `admin.ts` 内适配,页面仍用页码。 + +3. **帮助图标不用独立图标库** + - 原条款:DEVELOPMENT 2.4 用 `n-tooltip`;协作规则要求只用 Naive UI。 + - 实际做法:`HelpTip` 用带边框的 `?` 文字触发 tooltip,不引入 `@vicons/*`。 + - 原因:避免第二套依赖;视觉已够用。 + - 备选:若设计要求统一图标,可只加 `@vicons/ionicons5` 作图标资源(仍非第二套组件库)。 + +4. **操作日志页本期未做** + - 原条款:PRD F17 含管理操作日志;admin-api 写明改状态请求记日志,但未单列日志查询路由。 + - 实际做法:W3 菜单不含操作日志页。 + - 原因:契约无列表接口,无法按契约做假数据页。 + - 备选:A 线补查询接口后 W4/后续补页。 + +5. **端「踢下线」放在行内操作,未单独成页** + - 原条款:PRD F17 含踢下线。 + - 实际做法:端列表行操作调用 `POST .../kick`。 + - 原因:与重置密码、解锁同级的行内动作更贴桌面工作流。 + - 备选:无。 ## SDK 一 S1 diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..461c448 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,35 @@ +import js from "@eslint/js"; +import pluginVue from "eslint-plugin-vue"; +import tseslint from "typescript-eslint"; +import globals from "globals"; +import eslintConfigPrettier from "eslint-config-prettier"; + +export default tseslint.config( + { ignores: ["dist/**", "node_modules/**", "stub/**"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs["flat/recommended"], + { + files: ["**/*.{ts,vue}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { ...globals.browser, ...globals.node }, + parserOptions: { + parser: tseslint.parser, + extraFileExtensions: [".vue"], + }, + }, + rules: { + "vue/multi-word-component-names": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "vue/max-attributes-per-line": "off", + "vue/singleline-html-element-content-newline": "off", + "vue/html-self-closing": "off", + }, + }, + eslintConfigPrettier, +); diff --git a/web/package.json b/web/package.json index 3b43ee5..e8c6000 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview", - "typecheck": "vue-tsc -b --noEmit" + "typecheck": "vue-tsc -b --noEmit", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "naive-ui": "^2.43.1", @@ -16,14 +19,26 @@ "vue-router": "^4.5.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^26.6.3", "@vitejs/plugin-vue": "^6.0.1", + "@vue/test-utils": "^2.5.1", + "eslint": "^10.11.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-vue": "^10.11.1", + "globals": "^17.12.0", + "happy-dom": "^20.14.5", "typescript": "~5.9.2", + "typescript-eslint": "^8.71.0", "vite": "^7.1.7", + "vitest": "^5.0.2", + "vue-eslint-parser": "^10.4.1", "vue-tsc": "^3.0.8" }, "packageManager": "pnpm@10.17.1", "pnpm": { - "onlyBuiltDependencies": ["esbuild"] + "onlyBuiltDependencies": [ + "esbuild" + ] } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d544189..4914935 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -21,18 +21,48 @@ importers: specifier: ^4.5.1 version: 4.6.4(vue@3.5.43(typescript@5.9.3)) devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.11.0) '@types/node': specifier: ^26.6.3 version: 26.6.3 '@vitejs/plugin-vue': specifier: ^6.0.1 version: 6.0.9(vite@7.3.6(@types/node@26.6.3))(vue@3.5.43(typescript@5.9.3)) + '@vue/test-utils': + specifier: ^2.5.1 + version: 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3)) + eslint: + specifier: ^10.11.0 + version: 10.11.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.11.0) + eslint-plugin-vue: + specifier: ^10.11.1 + version: 10.11.1(@typescript-eslint/parser@8.71.0(eslint@10.11.0)(typescript@5.9.3))(eslint@10.11.0)(vue-eslint-parser@10.4.1(eslint@10.11.0)) + globals: + specifier: ^17.12.0 + version: 17.12.0 + happy-dom: + specifier: ^20.14.5 + version: 20.14.5 typescript: specifier: ~5.9.2 version: 5.9.3 + typescript-eslint: + specifier: ^8.71.0 + version: 8.71.0(eslint@10.11.0)(typescript@5.9.3) vite: specifier: ^7.1.7 version: 7.3.6(@types/node@26.6.3) + vitest: + specifier: ^5.0.2 + version: 5.0.2(@types/node@26.6.3)(happy-dom@20.14.5)(vite@7.3.6(@types/node@26.6.3)) + vue-eslint-parser: + specifier: ^10.4.1 + version: 10.4.1(eslint@10.11.0) vue-tsc: specifier: ^3.0.8 version: 3.3.11(typescript@5.9.3) @@ -56,6 +86,12 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@css-render/plugin-bem@0.15.14': resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} peerDependencies: @@ -225,18 +261,96 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] + '@one-ini/wasm@0.2.1': + resolution: {integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==} + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -365,9 +479,21 @@ packages: cpu: [x64] os: [win32] + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} @@ -377,6 +503,71 @@ packages: '@types/node@26.6.3': resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.71.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.71.0': + resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-vue@6.0.9': resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -384,6 +575,20 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/mocker@5.0.2': + resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -435,22 +640,91 @@ packages: '@vue/shared@3.5.43': resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + '@vue/test-utils@2.5.1': + resolution: {integrity: sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + abbrev@5.0.0: + resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + copy-anything@4.1.4: resolution: {integrity: sha512-/LLeW0CtsoSybVkEpYjifB2Pis8DUSuwY7QD9FiR98fWkdbdrACw3ilckfnAHMecQ+RFEdZlRt6eCXhYHJbl/g==} engines: {node: '>=18'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-render@0.15.14: resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + csstype@3.0.11: resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} @@ -465,21 +739,123 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + editorconfig@3.0.2: + resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==} + engines: {node: '>=20'} + hasBin: true + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-vue@10.11.1: + resolution: {integrity: sha512-pasVXhl7wUmIvhujoqs/m0mJ9r59QW1IR9XXYKGR0FETzSM+Hhlf4qKIYgWiDDP+9YKv4YfbwskD7LqIdVcgQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.3.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.11.0: + resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + evtd@0.2.4: resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -489,11 +865,44 @@ packages: picomatch: optional: true + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} + engines: {node: '>=18'} + + happy-dom@20.14.5: + resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==} + engines: {node: '>=20.0.0'} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + highlight.js@11.12.0: resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} @@ -501,18 +910,93 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-beautify@2.0.3: + resolution: {integrity: sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -527,9 +1011,48 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + nopt@10.0.1: + resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -549,10 +1072,29 @@ packages: typescript: optional: true + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} + engines: {node: '>=4'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -564,6 +1106,19 @@ packages: seemly@0.3.10: resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -572,10 +1127,21 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -583,6 +1149,23 @@ packages: treemate@0.3.11: resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.71.0: + resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -591,6 +1174,12 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vdirs@0.1.8: resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==} peerDependencies: @@ -636,6 +1225,47 @@ packages: yaml: optional: true + vitest@5.0.2: + resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.2 + '@vitest/browser-preview': 5.0.2 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.2 + '@vitest/coverage-v8': 5.0.2 + '@vitest/ui': 5.0.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vooks@0.2.12: resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} peerDependencies: @@ -644,6 +1274,15 @@ packages: vscode-uri@3.2.0: resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + vue-component-type-helpers@3.3.11: + resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==} + + vue-eslint-parser@10.4.1: + resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-router@4.6.4: resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: @@ -668,6 +1307,44 @@ packages: peerDependencies: vue: ^3.0.11 + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.2: + resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + engines: {node: '>=20.11'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: '@babel/helper-string-parser@7.29.7': {} @@ -683,6 +1360,18 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': dependencies: css-render: 0.15.14 @@ -771,13 +1460,80 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0)': + dependencies: + eslint: 10.11.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.11.0)': + optionalDependencies: + eslint: 10.11.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@juggle/resize-observer@3.4.0': {} + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@one-ini/wasm@0.2.1': {} + '@rolldown/pluginutils@1.0.1': {} '@rollup/rollup-android-arm-eabi@4.63.5': @@ -855,8 +1611,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.5': optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.25 @@ -867,12 +1634,120 @@ snapshots: dependencies: undici-types: 8.9.0 + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.2': + dependencies: + '@types/node': 26.6.3 + + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.11.0)(typescript@5.9.3))(eslint@10.11.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.71.0(eslint@10.11.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 10.11.0 + ignore: 7.0.10 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.71.0(eslint@10.11.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + eslint: 10.11.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.11.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.71.0': {} + + '@typescript-eslint/typescript-estree@8.71.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.0(eslint@10.11.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + eslint: 10.11.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-vue@6.0.9(vite@7.3.6(@types/node@26.6.3))(vue@3.5.43(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 7.3.6(@types/node@26.6.3) vue: 3.5.43(typescript@5.9.3) + '@vitest/mocker@5.0.2(vite@7.3.6(@types/node@26.6.3))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.2 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 7.3.6(@types/node@26.6.3) + + '@vitest/spy@5.0.2': {} + '@volar/language-core@2.4.28': dependencies: '@volar/source-map': 2.4.28 @@ -969,19 +1844,82 @@ snapshots: '@vue/shared@3.5.43': {} + '@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@vue/compiler-dom': 3.5.43 + js-beautify: 2.0.3 + vue: 3.5.43(typescript@5.9.3) + vue-component-type-helpers: 3.3.11 + optionalDependencies: + '@vue/server-renderer': 3.5.43 + + abbrev@5.0.0: {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + alien-signals@3.2.1: {} + assertion-error@2.0.1: {} + async-validator@4.2.5: {} + balanced-match@4.0.4: {} + birpc@2.9.0: {} + boolbase@1.0.0: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.6.3 + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + chai@6.2.2: {} + + commander@14.0.3: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + copy-anything@4.1.4: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-render@0.15.14: dependencies: '@emotion/hash': 0.8.0 csstype: 3.0.11 + cssesc@3.0.0: {} + csstype@3.0.11: {} csstype@3.2.3: {} @@ -992,8 +1930,23 @@ snapshots: date-fns@4.4.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + editorconfig@3.0.2: + dependencies: + '@one-ini/wasm': 0.2.1 + commander: 14.0.3 + minimatch: 10.2.6 + semver: 7.8.5 + entities@7.0.1: {} + es-module-lexer@2.3.2: {} + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -1023,31 +1976,233 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.11.0): + dependencies: + eslint: 10.11.0 + + eslint-plugin-vue@10.11.1(@typescript-eslint/parser@8.71.0(eslint@10.11.0)(typescript@5.9.3))(eslint@10.11.0)(vue-eslint-parser@10.4.1(eslint@10.11.0)): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0) + eslint: 10.11.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.6 + semver: 7.8.5 + vue-eslint-parser: 10.4.1(eslint@10.11.0) + xml-name-validator: 5.0.0 + optionalDependencies: + '@typescript-eslint/parser': 8.71.0(eslint@10.11.0)(typescript@5.9.3) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.11.0: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 11.1.5 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + evtd@0.2.4: {} + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + fsevents@2.3.3: optional: true + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@17.12.0: {} + + happy-dom@20.14.5: + dependencies: + '@types/node': 26.6.3 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + highlight.js@11.12.0: {} hookable@5.5.3: {} + hookified@1.15.1: {} + + hookified@2.2.0: {} + + ignore@5.3.2: {} + + ignore@7.0.10: {} + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-beautify@2.0.3: + dependencies: + config-chain: 1.1.13 + editorconfig: 3.0.2 + glob: 13.0.6 + js-cookie: 3.0.8 + nopt: 10.0.1 + + js-cookie@3.0.8: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash-es@4.18.1: {} lodash@4.18.1: {} + lru-cache@11.5.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minipass@7.1.3: {} + mitt@3.0.1: {} + ms@2.1.3: {} + muggle-string@0.4.1: {} naive-ui@2.45.3(vue@3.5.43(typescript@5.9.3)): @@ -1074,8 +2229,46 @@ snapshots: nanoid@3.3.19: {} + natural-compare@1.4.0: {} + + nopt@10.0.1: + dependencies: + abbrev: 5.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.2.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + path-browserify@1.0.1: {} + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -1089,12 +2282,27 @@ snapshots: optionalDependencies: typescript: 5.9.3 + postcss-selector-parser@7.1.6: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.28: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + + proto-list@1.2.4: {} + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + rfdc@1.4.1: {} rollup@4.63.5: @@ -1131,14 +2339,28 @@ snapshots: seemly@0.3.10: {} + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + source-map-js@1.2.1: {} speakingurl@14.0.1: {} + std-env@4.3.0: {} + superjson@2.2.6: dependencies: copy-anything: 4.1.4 + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -1146,10 +2368,35 @@ snapshots: treemate@0.3.11: {} + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.71.0(eslint@10.11.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.11.0)(typescript@5.9.3))(eslint@10.11.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@10.11.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0)(typescript@5.9.3) + eslint: 10.11.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} undici-types@8.9.0: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + vdirs@0.1.8(vue@3.5.43(typescript@5.9.3)): dependencies: evtd: 0.2.4 @@ -1167,6 +2414,28 @@ snapshots: '@types/node': 26.6.3 fsevents: 2.3.3 + vitest@5.0.2(@types/node@26.6.3)(happy-dom@20.14.5)(vite@7.3.6(@types/node@26.6.3)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.2(vite@7.3.6(@types/node@26.6.3)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 7.3.6(@types/node@26.6.3) + why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 26.6.3 + happy-dom: 20.14.5 + transitivePeerDependencies: + - msw + vooks@0.2.12(vue@3.5.43(typescript@5.9.3)): dependencies: evtd: 0.2.4 @@ -1174,6 +2443,20 @@ snapshots: vscode-uri@3.2.0: {} + vue-component-type-helpers@3.3.11: {} + + vue-eslint-parser@10.4.1(eslint@10.11.0): + dependencies: + debug: 4.4.3 + eslint: 10.11.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)): dependencies: '@vue/devtools-api': 6.6.4 @@ -1205,3 +2488,19 @@ snapshots: vdirs: 0.1.8(vue@3.5.43(typescript@5.9.3)) vooks: 0.2.12(vue@3.5.43(typescript@5.9.3)) vue: 3.5.43(typescript@5.9.3) + + whatwg-mimetype@3.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.2: {} + + word-wrap@1.2.5: {} + + ws@8.22.0: {} + + xml-name-validator@5.0.0: {} + + yocto-queue@0.1.0: {} diff --git a/web/src/App.vue b/web/src/App.vue index 2f1f3ac..d0a5b0d 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -12,3 +12,12 @@ import { RouterView } from "vue-router"; + + diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts new file mode 100644 index 0000000..faa973e --- /dev/null +++ b/web/src/api/admin.ts @@ -0,0 +1,191 @@ +/** + * 管理接口函数集中处。当前走假数据(mock);W4 改为 requestAdmin 即可,页面不用改。 + */ +import { ApiError } from "./http"; +import { mockApi } from "./mock"; +import { message } from "@/utils/notify"; +import type { + ApiToken, + ApiTokenCreateResult, + BatchResult, + Endpoint, + EndpointCreateRequest, + EndpointCreateResult, + EndpointListQuery, + EndpointPatchRequest, + GroupDetail, + GroupFailed, + GroupSummary, + ImportItem, + MeInfo, + MessageDetail, + MessageSummary, + Overview, + PageResult, + RegistrationSettings, + RegistrationUpdateRequest, + RuntimeSettings, +} from "./types"; + +export { ApiError }; +export type * from "./types"; + +async function run(fn: () => Promise, silent = false): Promise { + try { + return await fn(); + } catch (e) { + if (!silent && e instanceof ApiError) { + message.error(e.message); + } else if (!silent && e instanceof Error) { + message.error(e.message); + } + throw e; + } +} + +export function login(username: string, password: string) { + // 登录页自行展示错误,避免与 message 重复 + return run(() => mockApi.login(username, password), true); +} + +export function logout() { + return run(() => mockApi.logout()); +} + +export function fetchMe(silent = false) { + return run(() => mockApi.me(), silent); +} + +export function changePassword(oldPassword: string, newPassword: string) { + return run(() => mockApi.changePassword(oldPassword, newPassword)); +} + +export function fetchOverview(): Promise { + return run(() => mockApi.overview()); +} + +export function listEndpoints(q?: EndpointListQuery): Promise> { + return run(() => mockApi.listEndpoints(q)); +} + +export function createEndpoint(body: EndpointCreateRequest): Promise { + return run(() => mockApi.createEndpoint(body)); +} + +export function importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> { + return run(() => mockApi.importEndpoints(csvText), true); +} + +export function batchEndpoints(ids: string[], action: "disable" | "enable" | "delete"): Promise { + return run(() => mockApi.batchEndpoints(ids, action)); +} + +export function getEndpoint(id: string): Promise { + return run(() => mockApi.getEndpoint(id)); +} + +export function patchEndpoint(id: string, body: EndpointPatchRequest): Promise { + return run(() => mockApi.patchEndpoint(id, body)); +} + +export function deleteEndpoint(id: string) { + return run(() => mockApi.deleteEndpoint(id)); +} + +export function kickEndpoint(id: string) { + return run(() => mockApi.kickEndpoint(id)); +} + +export function resetLoginPassword(id: string, loginPassword?: string) { + return run(() => mockApi.resetLoginPassword(id, loginPassword)); +} + +export function setTalkPassword(id: string, talkPassword: string) { + return run(() => mockApi.setTalkPassword(id, talkPassword)); +} + +export function unlockEndpoint(id: string) { + return run(() => mockApi.unlockEndpoint(id)); +} + +export function getRegistration(): Promise { + return run(() => mockApi.getRegistration()); +} + +export function updateRegistration(body: RegistrationUpdateRequest): Promise { + return run(() => mockApi.updateRegistration(body)); +} + +export function listTokens(): Promise> { + return run(() => mockApi.listTokens()); +} + +export function createToken(name: string): Promise { + return run(() => mockApi.createToken(name)); +} + +export function patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise { + return run(() => mockApi.patchToken(id, body)); +} + +export function deleteToken(id: number) { + return run(() => mockApi.deleteToken(id)); +} + +export function listGroups(query?: string, cursor?: string, limit?: number): Promise> { + return run(() => mockApi.listGroups(query, cursor, limit)); +} + +export function createGroup(body: { + id?: string; + name: string; + owner_id: string; + member_ids?: string[]; +}): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> { + return run(() => mockApi.createGroup(body)); +} + +export function getGroup(id: string, cursor?: string, limit?: number): Promise { + return run(() => mockApi.getGroup(id, cursor, limit)); +} + +export function renameGroup(id: string, name: string): Promise { + return run(() => mockApi.renameGroup(id, name)); +} + +export function deleteGroup(id: string) { + return run(() => mockApi.deleteGroup(id)); +} + +export function addGroupMembers(id: string, memberIds: string[]) { + return run(() => mockApi.addGroupMembers(id, memberIds)); +} + +export function removeGroupMember(id: string, endpointId: string) { + return run(() => mockApi.removeGroupMember(id, endpointId)); +} + +export function transferGroup(id: string, endpointId: string) { + return run(() => mockApi.transferGroup(id, endpointId)); +} + +export function listMessages(q: { + cursor?: string; + limit?: number; + sender_id?: string; + endpoint_id?: string; + group_id?: string; + state?: string; +}): Promise> { + return run(() => mockApi.listMessages(q)); +} + +export function getMessage(seq: number): Promise { + return run(() => mockApi.getMessage(seq)); +} + +export function getSettings(): Promise { + return run(() => mockApi.getSettings()); +} + +export type { MeInfo }; diff --git a/web/src/api/http.ts b/web/src/api/http.ts new file mode 100644 index 0000000..b92c1a4 --- /dev/null +++ b/web/src/api/http.ts @@ -0,0 +1,85 @@ +import { message } from "@/utils/notify"; +import type { ApiEnvelope, ApiErrorBody } from "./types"; + +export class ApiError extends Error { + readonly code: string; + readonly httpStatus: number; + readonly data?: unknown; + + constructor(code: string, msg: string, httpStatus = 400, data?: unknown) { + super(msg); + this.name = "ApiError"; + this.code = code; + this.httpStatus = httpStatus; + this.data = data; + } +} + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface RequestOptions { + method?: HttpMethod; + body?: unknown; + headers?: Record; + /** 为 true 时不弹出错误提示(由调用方处理) */ + silent?: boolean; + rawBody?: BodyInit | null; + contentType?: string | null; +} + +/** + * 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1,错误直接 message 显示。 + * W4 接真实后端时页面不必改,只需让 admin 模块走本函数。 + */ +export async function requestAdmin(path: string, opts: RequestOptions = {}): Promise { + const method = opts.method ?? (opts.body != null || opts.rawBody != null ? "POST" : "GET"); + const headers: Record = { + "X-Nixmsg-Request": "1", + ...opts.headers, + }; + + let body: BodyInit | undefined | null = opts.rawBody; + if (opts.rawBody === undefined && opts.body !== undefined) { + headers["Content-Type"] = opts.contentType ?? "application/json"; + body = JSON.stringify(opts.body); + } else if (opts.contentType) { + headers["Content-Type"] = opts.contentType; + } + + const res = await fetch(path, { + method, + credentials: "include", + headers, + body: body ?? undefined, + }); + + let envelope: ApiEnvelope; + try { + envelope = (await res.json()) as ApiEnvelope; + } catch { + const err = new ApiError("internal", `响应不是 JSON(HTTP ${res.status})`, res.status); + if (!opts.silent) { + message.error(err.message); + } + throw err; + } + + if (typeof envelope !== "object" || envelope === null) { + const err = new ApiError("internal", "响应格式无效", res.status); + if (!opts.silent) { + message.error(err.message); + } + throw err; + } + + if (envelope.ok) { + return envelope.data; + } + + const errBody: ApiErrorBody = envelope.error ?? { code: "internal", message: "未知错误" }; + const err = new ApiError(errBody.code, errBody.message, res.status, "data" in envelope ? envelope.data : undefined); + if (!opts.silent) { + message.error(err.message); + } + throw err; +} diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts new file mode 100644 index 0000000..123b3a5 --- /dev/null +++ b/web/src/api/mock.ts @@ -0,0 +1,768 @@ +import type { + ApiToken, + ApiTokenCreateResult, + BatchResult, + Endpoint, + EndpointCreateRequest, + EndpointCreateResult, + EndpointListQuery, + EndpointPatchRequest, + GroupDetail, + GroupFailed, + GroupSummary, + ImportItem, + MeInfo, + MessageDetail, + MessageSummary, + Overview, + PageResult, + RegistrationSettings, + RegistrationUpdateRequest, + RuntimeSettings, +} from "./types"; +import { ApiError } from "./http"; + +const SESSION_KEY = "nixmsg_admin_mock_session"; +const now = () => Date.now(); + +function randomId(prefix: string): string { + const hex = Math.random().toString(16).slice(2, 10); + return `${prefix}_${hex}`; +} + +function randomPassword(len = 16): string { + const chars = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let out = ""; + for (let i = 0; i < len; i++) { + out += chars[Math.floor(Math.random() * chars.length)]; + } + return out; +} + +function clone(v: T): T { + return JSON.parse(JSON.stringify(v)) as T; +} + +const endpoints: Endpoint[] = [ + { + id: "device-1", + name: "gate", + remark: "lobby tablet", + source: "admin", + enabled: true, + online: true, + online_since_ms: now() - 3_600_000, + offline_since_ms: null, + talk_password_set: false, + default_delay_ms: 0, + created_at_ms: now() - 86_400_000, + login_locked: false, + session_issued_at_ms: now() - 3_600_000, + session_used_at_ms: now() - 60_000, + }, + { + id: "device-2", + name: "warehouse", + remark: "", + source: "self", + enabled: true, + online: false, + online_since_ms: null, + offline_since_ms: now() - 7200_000, + talk_password_set: true, + default_delay_ms: 5000, + created_at_ms: now() - 172_800_000, + login_locked: true, + }, + { + id: "ops-bot", + name: "ops-bot", + remark: "admin-provisioned", + source: "admin", + enabled: false, + online: false, + online_since_ms: null, + offline_since_ms: now() - 10_000_000, + talk_password_set: false, + default_delay_ms: 0, + created_at_ms: now() - 259_200_000, + login_locked: false, + }, +]; + +const registration: RegistrationSettings = { + enabled: false, + code: "demo-reg-code-01", + updated_at_ms: now() - 86_400_000, +}; + +const tokens: ApiToken[] = [ + { + id: 1, + name: "ops", + enabled: true, + created_at_ms: now() - 86_400_000, + last_used_at_ms: now() - 3_600_000, + }, +]; +let nextTokenId = 2; + +interface GroupRow { + id: string; + name: string; + owner_id: string; + created_at_ms: number; + members: { id: string; joined_at_ms: number }[]; +} + +const groups: GroupRow[] = [ + { + id: "g_demo0001", + name: "team-a", + owner_id: "device-1", + created_at_ms: now() - 50_000_000, + members: [ + { id: "device-1", joined_at_ms: now() - 50_000_000 }, + { id: "device-2", joined_at_ms: now() - 40_000_000 }, + { id: "ops-bot", joined_at_ms: now() - 30_000_000 }, + ], + }, +]; + +const messages: MessageDetail[] = [ + { + seq: 1, + id: "018f000000000000000000000001", + sender_id: "device-1", + dest_kind: "endpoint", + dest_id: "device-2", + state: "completed", + reason: "", + send_at_ms: now() - 3_600_000, + created_at_ms: now() - 3_601_000, + meta: {}, + content_type: "text/plain", + deliveries: [ + { + endpoint_id: "device-2", + state: "accepted", + reason: "", + attempts: 1, + pushed_at_ms: now() - 3_599_000, + updated_at_ms: now() - 3_598_000, + }, + ], + next_cursor: "", + }, + { + seq: 2, + id: "018f000000000000000000000002", + sender_id: "ops-bot", + dest_kind: "group", + dest_id: "g_demo0001", + state: "dispatched", + reason: "", + send_at_ms: now() - 600_000, + created_at_ms: now() - 601_000, + meta: {}, + content_type: "application/json", + deliveries: [ + { + endpoint_id: "device-1", + state: "accepted", + reason: "", + attempts: 1, + pushed_at_ms: now() - 599_000, + updated_at_ms: now() - 598_000, + }, + { + endpoint_id: "device-2", + state: "pending", + reason: "", + attempts: 2, + pushed_at_ms: now() - 590_000, + updated_at_ms: now() - 580_000, + }, + ], + next_cursor: "", + }, + { + seq: 3, + id: "018f000000000000000000000003", + sender_id: "device-2", + dest_kind: "endpoint", + dest_id: "device-1", + state: "scheduled", + reason: "", + send_at_ms: now() + 3_600_000, + created_at_ms: now() - 100_000, + meta: {}, + content_type: "text/plain", + deliveries: [], + next_cursor: "", + }, +]; + +const settings: RuntimeSettings = { + listen: ":7443", + admin_listen: "", + session_idle_days: 30, + record_retention_days: 7, + idempotency_hours: 24, + receipt_retention_days: 7, + sqlite_synchronous: "FULL", + limits: { + max_body_bytes: 262144, + max_meta_bytes: 4096, + max_frame_bytes: 786432, + max_ttl_seconds: 2592000, + max_schedule_seconds: 31536000, + max_group_members: 1000, + grace_seconds: 60, + ack_timeout_seconds: 300, + delivery_window: 32, + receipt_window: 64, + requests_per_second: 50, + max_pending_per_sender: 10000, + max_pending_per_receiver: 10000, + }, +}; + +const startedAt = now(); + +function requireSession(): void { + if (sessionStorage.getItem(SESSION_KEY) !== "1") { + throw new ApiError("unauthorized", "未登录", 401); + } +} + +function paginate(items: T[], cursor?: string, limit = 50): PageResult { + const offset = cursor ? Number.parseInt(cursor, 10) || 0 : 0; + const size = Math.min(Math.max(limit, 1), 200); + const slice = items.slice(offset, offset + size); + const next = offset + size < items.length ? String(offset + size) : ""; + return { items: clone(slice), next_cursor: next, total: items.length }; +} + +function endpointName(id: string): string { + return endpoints.find((e) => e.id === id)?.name ?? ""; +} + +function toGroupSummary(g: GroupRow): GroupSummary { + return { + id: g.id, + name: g.name, + owner_id: g.owner_id, + member_count: g.members.length, + created_at_ms: g.created_at_ms, + }; +} + +function toMessageSummary(m: MessageDetail): MessageSummary { + const counts = { pending: 0, accepted: 0, recalled: 0, expired: 0, dropped: 0, rejected: 0 }; + for (const d of m.deliveries) { + const key = d.state as keyof typeof counts; + if (key in counts) { + counts[key] += 1; + } + } + return { + seq: m.seq, + id: m.id, + sender_id: m.sender_id, + dest_kind: m.dest_kind, + dest_id: m.dest_id, + state: m.state as MessageSummary["state"], + reason: m.reason, + send_at_ms: m.send_at_ms, + created_at_ms: m.created_at_ms, + keep: false, + receipt: true, + content_type: m.content_type, + delivery_counts: counts, + }; +} + +export const mockApi = { + async login(username: string, password: string): Promise<{ username: string }> { + if (username !== "admin" || password !== "adminpassword") { + throw new ApiError("unauthorized", "用户名或密码错误", 401); + } + sessionStorage.setItem(SESSION_KEY, "1"); + return { username: "admin" }; + }, + + async logout(): Promise> { + sessionStorage.removeItem(SESSION_KEY); + return {}; + }, + + async me(): Promise { + requireSession(); + return { username: "admin", auth: "cookie" }; + }, + + async changePassword(oldPassword: string, newPassword: string): Promise> { + requireSession(); + if (oldPassword !== "adminpassword") { + throw new ApiError("unauthorized", "原密码错误", 401); + } + if (newPassword.length < 12) { + throw new ApiError("bad_request", "新密码至少 12 位", 400); + } + return {}; + }, + + async overview(): Promise { + requireSession(); + return { + version: "0.1.0-mock", + endpoints_total: endpoints.length, + endpoints_online: endpoints.filter((e) => e.online).length, + endpoints_disabled: endpoints.filter((e) => !e.enabled).length, + groups_total: groups.length, + messages_pending: messages.filter((m) => m.state === "dispatched").length, + messages_scheduled: messages.filter((m) => m.state === "scheduled").length, + uptime_ms: now() - startedAt, + }; + }, + + async listEndpoints(q: EndpointListQuery = {}): Promise> { + requireSession(); + let list = [...endpoints]; + if (q.source) { + list = list.filter((e) => e.source === q.source); + } + if (q.online === true || q.online === false) { + list = list.filter((e) => e.online === q.online); + } + if (q.enabled === true || q.enabled === false) { + list = list.filter((e) => e.enabled === q.enabled); + } + if (q.query) { + const needle = q.query.toLowerCase(); + list = list.filter( + (e) => e.id.toLowerCase().includes(needle) || e.name.toLowerCase().includes(needle), + ); + } + return paginate(list, q.cursor, q.limit); + }, + + async createEndpoint(body: EndpointCreateRequest): Promise { + requireSession(); + const id = (body.id || "").trim() || randomId("e"); + if (endpoints.some((e) => e.id === id)) { + throw new ApiError("id_taken", "编号已占用", 409); + } + const loginPassword = (body.login_password || "").trim() || randomPassword(); + endpoints.unshift({ + id, + name: body.name, + remark: body.remark ?? "", + source: "admin", + enabled: true, + online: false, + online_since_ms: null, + offline_since_ms: now(), + talk_password_set: Boolean(body.talk_password), + default_delay_ms: (body.default_delay_seconds ?? 0) * 1000, + created_at_ms: now(), + login_locked: false, + }); + return { id, login_password: loginPassword }; + }, + + async importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> { + requireSession(); + const lines = csvText.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim()); + if (lines.length < 2) { + throw new ApiError("bad_request", "CSV 校验失败", 400, { + errors: [{ line: 1, reason: "表头不正确" }], + }); + } + const header = lines[0].split(",").map((s) => s.trim()); + const expected = ["id", "name", "login_password", "talk_password", "default_delay_seconds", "remark"]; + if (expected.some((h, i) => header[i] !== h)) { + throw new ApiError("bad_request", "CSV 校验失败", 400, { + errors: [{ line: 1, reason: "?????" }], + }); + } + const errors: { line: number; reason: string }[] = []; + const rows: ImportItem[] = []; + for (let i = 1; i < lines.length; i++) { + const cols = lines[i].split(","); + const id = (cols[0] || "").trim() || randomId("e"); + const name = (cols[1] || "").trim(); + if (!name) { + errors.push({ line: i + 1, reason: "名称不能为空" }); + continue; + } + if (endpoints.some((e) => e.id === id) || rows.some((r) => r.id === id)) { + errors.push({ line: i + 1, reason: "编号占用" }); + continue; + } + rows.push({ + id, + name, + login_password: (cols[2] || "").trim() || randomPassword(), + }); + } + if (errors.length) { + throw new ApiError("bad_request", "CSV 校验失败", 400, { errors }); + } + for (const r of rows) { + endpoints.unshift({ + id: r.id, + name: r.name, + remark: "", + source: "admin", + enabled: true, + online: false, + online_since_ms: null, + offline_since_ms: now(), + talk_password_set: false, + default_delay_ms: 0, + created_at_ms: now(), + login_locked: false, + }); + } + return { items: rows }; + }, + + async batchEndpoints(ids: string[], action: "disable" | "enable" | "delete"): Promise { + requireSession(); + const ok_ids: string[] = []; + const failed: { id: string; code: string }[] = []; + for (const id of ids) { + const idx = endpoints.findIndex((e) => e.id === id); + if (idx < 0) { + failed.push({ id, code: "not_found" }); + continue; + } + if (action === "delete") { + endpoints.splice(idx, 1); + } else if (action === "disable") { + endpoints[idx].enabled = false; + } else { + endpoints[idx].enabled = true; + } + ok_ids.push(id); + } + return { ok_ids, failed }; + }, + + async getEndpoint(id: string): Promise { + requireSession(); + const e = endpoints.find((x) => x.id === id); + if (!e) { + throw new ApiError("not_found", "端不存在", 404); + } + return clone(e); + }, + + async patchEndpoint(id: string, body: EndpointPatchRequest): Promise { + requireSession(); + const e = endpoints.find((x) => x.id === id); + if (!e) { + throw new ApiError("not_found", "端不存在", 404); + } + if (body.name !== undefined) e.name = body.name; + if (body.remark !== undefined) e.remark = body.remark; + if (body.enabled !== undefined) e.enabled = body.enabled; + if (body.default_delay_seconds !== undefined) { + e.default_delay_ms = body.default_delay_seconds * 1000; + } + return clone(e); + }, + + async deleteEndpoint(id: string): Promise> { + requireSession(); + const idx = endpoints.findIndex((e) => e.id === id); + if (idx < 0) { + throw new ApiError("not_found", "端不存在", 404); + } + endpoints.splice(idx, 1); + return {}; + }, + + async kickEndpoint(id: string): Promise<{ kicked: boolean }> { + requireSession(); + const e = endpoints.find((x) => x.id === id); + if (!e) { + throw new ApiError("not_found", "端不存在", 404); + } + const kicked = e.online; + if (kicked) { + e.online = false; + e.online_since_ms = null; + e.offline_since_ms = now(); + } + return { kicked }; + }, + + async resetLoginPassword(id: string, loginPassword?: string): Promise<{ login_password: string }> { + requireSession(); + const e = endpoints.find((x) => x.id === id); + if (!e) { + throw new ApiError("not_found", "端不存在", 404); + } + return { login_password: (loginPassword || "").trim() || randomPassword() }; + }, + + async setTalkPassword(id: string, talkPassword: string): Promise<{ talk_password_set: boolean }> { + requireSession(); + const e = endpoints.find((x) => x.id === id); + if (!e) { + throw new ApiError("not_found", "端不存在", 404); + } + e.talk_password_set = talkPassword.length > 0; + return { talk_password_set: e.talk_password_set }; + }, + + async unlockEndpoint(id: string): Promise> { + requireSession(); + const e = endpoints.find((x) => x.id === id); + if (!e) { + throw new ApiError("not_found", "端不存在", 404); + } + e.login_locked = false; + return {}; + }, + + async getRegistration(): Promise { + requireSession(); + return clone(registration); + }, + + async updateRegistration(body: RegistrationUpdateRequest): Promise { + requireSession(); + if (body.enabled !== undefined) registration.enabled = body.enabled; + if (body.generate) { + registration.code = randomPassword(16); + } else if (body.code !== undefined) { + if (body.code.length < 8 || body.code.length > 64) { + throw new ApiError("bad_request", "安全码须为 8–64 字符", 400); + } + registration.code = body.code; + } + registration.updated_at_ms = now(); + return clone(registration); + }, + + async listTokens(): Promise> { + requireSession(); + return { items: clone(tokens), next_cursor: "", total: tokens.length }; + }, + + async createToken(name: string): Promise { + requireSession(); + const item: ApiToken = { + id: nextTokenId++, + name, + enabled: true, + created_at_ms: now(), + last_used_at_ms: null, + }; + tokens.unshift(item); + return { + id: item.id, + name: item.name, + token: `nxm_${randomPassword(32)}`, + created_at_ms: item.created_at_ms, + }; + }, + + async patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise { + requireSession(); + const t = tokens.find((x) => x.id === id); + if (!t) { + throw new ApiError("not_found", "令牌不存在", 404); + } + if (body.name !== undefined) t.name = body.name; + if (body.enabled !== undefined) t.enabled = body.enabled; + return clone(t); + }, + + async deleteToken(id: number): Promise> { + requireSession(); + const idx = tokens.findIndex((x) => x.id === id); + if (idx < 0) { + throw new ApiError("not_found", "令牌不存在", 404); + } + tokens.splice(idx, 1); + return {}; + }, + + async listGroups(query?: string, cursor?: string, limit?: number): Promise> { + requireSession(); + let list = groups.map(toGroupSummary); + if (query) { + const n = query.toLowerCase(); + list = list.filter((g) => g.name.toLowerCase().includes(n) || g.id.toLowerCase().includes(n)); + } + return paginate(list, cursor, limit); + }, + + async createGroup(body: { + id?: string; + name: string; + owner_id: string; + member_ids?: string[]; + }): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> { + requireSession(); + const id = (body.id || "").trim() || randomId("g"); + const failed: GroupFailed[] = []; + const memberIds = new Set([body.owner_id, ...(body.member_ids ?? [])]); + const members: GroupRow["members"] = []; + for (const mid of memberIds) { + if (!endpoints.some((e) => e.id === mid)) { + failed.push({ id: mid, code: "not_found" }); + continue; + } + members.push({ id: mid, joined_at_ms: now() }); + } + if (!members.some((m) => m.id === body.owner_id)) { + throw new ApiError("bad_request", "群主必须存在", 400); + } + groups.unshift({ + id, + name: body.name, + owner_id: body.owner_id, + created_at_ms: now(), + members, + }); + return { id, name: body.name, owner_id: body.owner_id, failed }; + }, + + async getGroup(id: string, cursor?: string, limit?: number): Promise { + requireSession(); + const g = groups.find((x) => x.id === id); + if (!g) { + throw new ApiError("not_found", "群不存在", 404); + } + const all = g.members.map((m) => { + const ep = endpoints.find((e) => e.id === m.id); + return { + id: m.id, + name: ep?.name ?? endpointName(m.id), + online: ep?.online ?? false, + joined_at_ms: m.joined_at_ms, + }; + }); + const page = paginate(all, cursor, limit); + return { + id: g.id, + name: g.name, + owner_id: g.owner_id, + created_at_ms: g.created_at_ms, + members: page.items, + next_cursor: page.next_cursor, + }; + }, + + async renameGroup(id: string, name: string): Promise { + requireSession(); + const g = groups.find((x) => x.id === id); + if (!g) { + throw new ApiError("not_found", "群不存在", 404); + } + g.name = name; + return toGroupSummary(g); + }, + + async deleteGroup(id: string): Promise> { + requireSession(); + const idx = groups.findIndex((x) => x.id === id); + if (idx < 0) { + throw new ApiError("not_found", "群不存在", 404); + } + groups.splice(idx, 1); + return {}; + }, + + async addGroupMembers(id: string, memberIds: string[]): Promise<{ failed: GroupFailed[] }> { + requireSession(); + const g = groups.find((x) => x.id === id); + if (!g) { + throw new ApiError("not_found", "群不存在", 404); + } + const failed: GroupFailed[] = []; + for (const mid of memberIds) { + if (!endpoints.some((e) => e.id === mid)) { + failed.push({ id: mid, code: "not_found" }); + continue; + } + if (!g.members.some((m) => m.id === mid)) { + g.members.push({ id: mid, joined_at_ms: now() }); + } + } + return { failed }; + }, + + async removeGroupMember(id: string, endpointId: string): Promise> { + requireSession(); + const g = groups.find((x) => x.id === id); + if (!g) { + throw new ApiError("not_found", "群不存在", 404); + } + g.members = g.members.filter((m) => m.id !== endpointId); + return {}; + }, + + async transferGroup(id: string, endpointId: string): Promise<{ owner_id: string }> { + requireSession(); + const g = groups.find((x) => x.id === id); + if (!g) { + throw new ApiError("not_found", "群不存在", 404); + } + if (!g.members.some((m) => m.id === endpointId)) { + throw new ApiError("bad_request", "新群主须为成员", 400); + } + g.owner_id = endpointId; + return { owner_id: endpointId }; + }, + + async listMessages(q: { + cursor?: string; + limit?: number; + sender_id?: string; + endpoint_id?: string; + group_id?: string; + state?: string; + }): Promise> { + requireSession(); + let list = messages.map(toMessageSummary); + if (q.sender_id) list = list.filter((m) => m.sender_id === q.sender_id); + if (q.group_id) list = list.filter((m) => m.dest_kind === "group" && m.dest_id === q.group_id); + if (q.endpoint_id) { + list = list.filter((m) => { + const detail = messages.find((x) => x.seq === m.seq); + return detail?.deliveries.some((d) => d.endpoint_id === q.endpoint_id); + }); + } + if (q.state) list = list.filter((m) => m.state === q.state); + return paginate(list, q.cursor, q.limit); + }, + + async getMessage(seq: number): Promise { + requireSession(); + const m = messages.find((x) => x.seq === seq); + if (!m) { + throw new ApiError("not_found", "消息不存在", 404); + } + return clone(m); + }, + + async getSettings(): Promise { + requireSession(); + return clone(settings); + }, + + _resetSession(): void { + sessionStorage.removeItem(SESSION_KEY); + }, + + _setSession(): void { + sessionStorage.setItem(SESSION_KEY, "1"); + }, +}; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..def2c1d --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,223 @@ +/** 与 docs/api/admin-api.md 对齐的类型(蛇形字段)。 */ + +export interface ApiErrorBody { + code: string; + message: string; +} + +export interface ApiSuccess { + ok: true; + data: T; +} + +export interface ApiFailure { + ok: false; + error: ApiErrorBody; + data?: unknown; +} + +export type ApiEnvelope = ApiSuccess | ApiFailure; + +export interface PageResult { + items: T[]; + next_cursor: string; + total: number; +} + +export interface MeInfo { + username: string; + auth: "cookie" | "token"; +} + +export interface Overview { + version: string; + endpoints_total: number; + endpoints_online: number; + endpoints_disabled: number; + groups_total: number; + messages_pending: number; + messages_scheduled: number; + uptime_ms: number; +} + +export type EndpointSource = "admin" | "self"; + +export interface Endpoint { + id: string; + name: string; + remark: string; + source: EndpointSource; + enabled: boolean; + online: boolean; + online_since_ms: number | null; + offline_since_ms: number | null; + talk_password_set: boolean; + default_delay_ms: number; + created_at_ms: number; + login_locked: boolean; + session_issued_at_ms?: number | null; + session_used_at_ms?: number | null; +} + +export interface EndpointCreateRequest { + id?: string; + name: string; + remark?: string; + login_password?: string; + talk_password?: string; + default_delay_seconds?: number; +} + +export interface EndpointCreateResult { + id: string; + login_password: string; +} + +export interface EndpointPatchRequest { + name?: string; + remark?: string; + default_delay_seconds?: number; + enabled?: boolean; +} + +export interface EndpointListQuery { + cursor?: string; + limit?: number; + source?: EndpointSource | ""; + online?: boolean | ""; + enabled?: boolean | ""; + query?: string; +} + +export interface ImportError { + line: number; + reason: string; +} + +export interface ImportItem { + id: string; + login_password: string; + name: string; +} + +export interface BatchResult { + ok_ids: string[]; + failed: { id: string; code: string }[]; +} + +export interface RegistrationSettings { + enabled: boolean; + code: string; + updated_at_ms: number; +} + +export interface RegistrationUpdateRequest { + enabled?: boolean; + code?: string; + generate?: boolean; +} + +export interface ApiToken { + id: number; + name: string; + enabled: boolean; + created_at_ms: number; + last_used_at_ms: number | null; +} + +export interface ApiTokenCreateResult { + id: number; + name: string; + token: string; + created_at_ms: number; +} + +export interface GroupSummary { + id: string; + name: string; + owner_id: string; + member_count: number; + created_at_ms: number; +} + +export interface GroupMember { + id: string; + name: string; + online: boolean; + joined_at_ms: number; +} + +export interface GroupDetail { + id: string; + name: string; + owner_id: string; + created_at_ms: number; + members: GroupMember[]; + next_cursor: string; +} + +export interface GroupFailed { + id: string; + code: string; +} + +export interface MessageDeliveryCounts { + pending: number; + accepted: number; + recalled: number; + expired: number; + dropped: number; + rejected: number; +} + +export interface MessageSummary { + seq: number; + id: string; + sender_id: string; + dest_kind: "endpoint" | "group"; + dest_id: string; + state: "scheduled" | "dispatched" | "completed"; + reason: string; + send_at_ms: number; + created_at_ms: number; + keep: boolean; + receipt: boolean; + content_type: string; + delivery_counts: MessageDeliveryCounts; +} + +export interface MessageDelivery { + endpoint_id: string; + state: string; + reason: string; + attempts: number; + pushed_at_ms: number | null; + updated_at_ms: number; +} + +export interface MessageDetail { + seq: number; + id: string; + sender_id: string; + dest_kind: "endpoint" | "group"; + dest_id: string; + state: string; + reason: string; + send_at_ms: number; + created_at_ms: number; + meta: Record; + content_type: string; + deliveries: MessageDelivery[]; + next_cursor: string; +} + +export interface RuntimeSettings { + listen: string; + admin_listen: string; + session_idle_days: number; + record_retention_days: number; + idempotency_hours: number; + receipt_retention_days: number; + sqlite_synchronous: string; + limits: Record; +} diff --git a/web/src/components/HelpTip.vue b/web/src/components/HelpTip.vue new file mode 100644 index 0000000..3f50527 --- /dev/null +++ b/web/src/components/HelpTip.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/web/src/components/PageHeader.vue b/web/src/components/PageHeader.vue new file mode 100644 index 0000000..a2a9868 --- /dev/null +++ b/web/src/components/PageHeader.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/web/src/components/SecretOnceAlert.vue b/web/src/components/SecretOnceAlert.vue new file mode 100644 index 0000000..437a726 --- /dev/null +++ b/web/src/components/SecretOnceAlert.vue @@ -0,0 +1,41 @@ + + + diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..5f64773 --- /dev/null +++ b/web/src/layouts/AdminLayout.vue @@ -0,0 +1,71 @@ + + + diff --git a/web/src/main.ts b/web/src/main.ts index 646ddfe..8c01e94 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,11 +1,9 @@ import { createApp } from "vue"; import { createPinia } from "pinia"; -import naive from "naive-ui"; import App from "./App.vue"; import { router } from "./router"; const app = createApp(App); app.use(createPinia()); app.use(router); -app.use(naive); app.mount("#app"); diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 95559a8..30945c9 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,13 +1,45 @@ import { createRouter, createWebHistory } from "vue-router"; -import HomeView from "../views/HomeView.vue"; +import { useAuthStore } from "@/stores/auth"; export const router = createRouter({ history: createWebHistory(), routes: [ + { + path: "/login", + name: "login", + component: () => import("../views/LoginView.vue"), + meta: { public: true }, + }, { path: "/", - name: "home", - component: HomeView, + component: () => import("../layouts/AdminLayout.vue"), + children: [ + { path: "", redirect: "/overview" }, + { path: "overview", name: "overview", component: () => import("../views/OverviewView.vue") }, + { path: "endpoints", name: "endpoints", component: () => import("../views/EndpointsView.vue") }, + { path: "registration", name: "registration", component: () => import("../views/RegistrationView.vue") }, + { path: "groups", name: "groups", component: () => import("../views/GroupsView.vue") }, + { path: "messages", name: "messages", component: () => import("../views/MessagesView.vue") }, + { path: "tokens", name: "tokens", component: () => import("../views/TokensView.vue") }, + { path: "settings", name: "settings", component: () => import("../views/SettingsView.vue") }, + ], }, ], }); + +router.beforeEach(async (to) => { + const auth = useAuthStore(); + if (!auth.ready) { + await auth.hydrate(); + } + if (to.meta.public) { + if (auth.isLoggedIn && to.name === "login") { + return { name: "overview" }; + } + return true; + } + if (!auth.isLoggedIn) { + return { name: "login", query: { redirect: to.fullPath } }; + } + return true; +}); diff --git a/web/src/stores/app.ts b/web/src/stores/app.ts deleted file mode 100644 index 8182eae..0000000 --- a/web/src/stores/app.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineStore } from "pinia"; -import { ref } from "vue"; - -export const useAppStore = defineStore("app", () => { - const title = ref("NixMsg"); - return { title }; -}); diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts new file mode 100644 index 0000000..7d35d41 --- /dev/null +++ b/web/src/stores/auth.ts @@ -0,0 +1,36 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { fetchMe, login as apiLogin, logout as apiLogout } from "@/api/admin"; + +export const useAuthStore = defineStore("auth", () => { + const username = ref(null); + const ready = ref(false); + + const isLoggedIn = computed(() => username.value != null); + + async function hydrate() { + try { + const me = await fetchMe(true); + username.value = me.username; + } catch { + username.value = null; + } finally { + ready.value = true; + } + } + + async function login(user: string, password: string) { + const res = await apiLogin(user, password); + username.value = res.username; + } + + async function logout() { + try { + await apiLogout(); + } finally { + username.value = null; + } + } + + return { username, ready, isLoggedIn, hydrate, login, logout }; +}); diff --git a/web/src/utils/notify.ts b/web/src/utils/notify.ts new file mode 100644 index 0000000..6d7387a --- /dev/null +++ b/web/src/utils/notify.ts @@ -0,0 +1,10 @@ +import { createDiscreteApi, dateZhCN, zhCN } from "naive-ui"; + +const { message, dialog } = createDiscreteApi(["message", "dialog"], { + configProviderProps: { + locale: zhCN, + dateLocale: dateZhCN, + }, +}); + +export { message, dialog }; diff --git a/web/src/utils/time.spec.ts b/web/src/utils/time.spec.ts new file mode 100644 index 0000000..b556af7 --- /dev/null +++ b/web/src/utils/time.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { formatLocalMs } from "./time"; + +describe("formatLocalMs", () => { + it("格式化为 YYYY-MM-DD HH:mm:ss", () => { + const ms = Date.UTC(2026, 0, 2, 3, 4, 5); + // 结果随本地时区变化,只校验形态 + const s = formatLocalMs(ms); + expect(s).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + }); + + it("空值显示破折号", () => { + expect(formatLocalMs(null)).toBe("—"); + expect(formatLocalMs(undefined)).toBe("—"); + }); +}); diff --git a/web/src/utils/time.ts b/web/src/utils/time.ts new file mode 100644 index 0000000..af1fc60 --- /dev/null +++ b/web/src/utils/time.ts @@ -0,0 +1,15 @@ +/** 将 Unix 毫秒格式化为浏览器本地时区 YYYY-MM-DD HH:mm:ss。 */ +export function formatLocalMs(ms: number | null | undefined): string { + if (ms == null || !Number.isFinite(ms)) { + return "—"; + } + const d = new Date(ms); + if (Number.isNaN(d.getTime())) { + return "—"; + } + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + ); +} diff --git a/web/src/views/EndpointsView.spec.ts b/web/src/views/EndpointsView.spec.ts new file mode 100644 index 0000000..5a9dd79 --- /dev/null +++ b/web/src/views/EndpointsView.spec.ts @@ -0,0 +1,70 @@ +import { config, mount, flushPromises } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + NConfigProvider, + NDialogProvider, + NMessageProvider, + dateZhCN, + zhCN, +} from "naive-ui"; +import { defineComponent, h, nextTick } from "vue"; +import EndpointsView from "./EndpointsView.vue"; +import { mockApi } from "@/api/mock"; + +config.global.stubs = { teleport: true }; + +function wrap(Comp: object) { + return defineComponent({ + setup() { + return () => + h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, { + default: () => + h(NMessageProvider, null, { + default: () => + h(NDialogProvider, null, { + default: () => h(Comp), + }), + }), + }); + }, + }); +} + +describe("EndpointsView 开通端", () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockApi._setSession(); + }); + + it("开通后密码只显示一次,关闭后消失", async () => { + const pinia = createPinia(); + const w = mount(wrap(EndpointsView), { + global: { plugins: [pinia] }, + attachTo: document.body, + }); + await flushPromises(); + + const openBtn = w.findAll("button").find((b) => b.text().trim() === "开通"); + expect(openBtn).toBeTruthy(); + await openBtn!.trigger("click"); + await nextTick(); + await flushPromises(); + + const nameRoot = w.find('[data-testid="create-name"]'); + expect(nameRoot.exists()).toBe(true); + await nameRoot.find("input").setValue("测试端"); + await w.find('[data-testid="create-submit"]').trigger("click"); + await flushPromises(); + + expect(w.text()).toContain("只显示一次"); + + const dismiss = w.findAll("button").find((b) => b.text().includes("我已保存")); + expect(dismiss).toBeTruthy(); + await dismiss!.trigger("click"); + await nextTick(); + + expect(w.text()).not.toContain("只显示一次"); + w.unmount(); + }); +}); diff --git a/web/src/views/EndpointsView.vue b/web/src/views/EndpointsView.vue new file mode 100644 index 0000000..da4394e --- /dev/null +++ b/web/src/views/EndpointsView.vue @@ -0,0 +1,505 @@ + + + + + diff --git a/web/src/views/GroupsView.vue b/web/src/views/GroupsView.vue new file mode 100644 index 0000000..d0d9642 --- /dev/null +++ b/web/src/views/GroupsView.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue deleted file mode 100644 index bf23435..0000000 --- a/web/src/views/HomeView.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/web/src/views/LoginView.spec.ts b/web/src/views/LoginView.spec.ts new file mode 100644 index 0000000..fcddc04 --- /dev/null +++ b/web/src/views/LoginView.spec.ts @@ -0,0 +1,100 @@ +import { config, mount, flushPromises } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { createMemoryHistory, createRouter } from "vue-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + NConfigProvider, + NDialogProvider, + NMessageProvider, + dateZhCN, + zhCN, +} from "naive-ui"; +import { defineComponent, h } from "vue"; +import LoginView from "./LoginView.vue"; +import { mockApi } from "@/api/mock"; +import { useAuthStore } from "@/stores/auth"; + +config.global.stubs = { teleport: true }; + +function wrap(child: object) { + return defineComponent({ + setup() { + return () => + h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, { + default: () => + h(NMessageProvider, null, { + default: () => + h(NDialogProvider, null, { + default: () => h(child), + }), + }), + }); + }, + }); +} + +describe("LoginView", () => { + beforeEach(() => { + mockApi._resetSession(); + sessionStorage.clear(); + }); + + it("登录成功后写入会话", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/login", component: LoginView }, + { path: "/overview", component: { template: "
ok
" } }, + ], + }); + await router.push("/login"); + await router.isReady(); + + const w = mount(wrap(LoginView), { + global: { plugins: [pinia, router] }, + attachTo: document.body, + }); + + const inputs = w.findAll("input"); + expect(inputs.length).toBeGreaterThanOrEqual(2); + await inputs[0].setValue("admin"); + await inputs[1].setValue("adminpassword"); + await w.find('[data-testid="login-submit"]').trigger("click"); + await flushPromises(); + + expect(useAuthStore().isLoggedIn).toBe(true); + expect(useAuthStore().username).toBe("admin"); + w.unmount(); + }); + + it("密码错误时显示错误且未登录", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/", component: LoginView }, + { path: "/overview", component: { template: "
" } }, + ], + }); + await router.push("/"); + await router.isReady(); + + const w = mount(wrap(LoginView), { + global: { plugins: [pinia, router] }, + attachTo: document.body, + }); + + const inputs = w.findAll("input"); + await inputs[0].setValue("admin"); + await inputs[1].setValue("wrong"); + await w.find('[data-testid="login-submit"]').trigger("click"); + await flushPromises(); + + expect(useAuthStore().isLoggedIn).toBe(false); + expect(w.text()).toContain("用户名或密码错误"); + w.unmount(); + }); +}); diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue new file mode 100644 index 0000000..6c7ff78 --- /dev/null +++ b/web/src/views/LoginView.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/web/src/views/MessagesView.vue b/web/src/views/MessagesView.vue new file mode 100644 index 0000000..aca82d4 --- /dev/null +++ b/web/src/views/MessagesView.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/web/src/views/OverviewView.vue b/web/src/views/OverviewView.vue new file mode 100644 index 0000000..4773d3c --- /dev/null +++ b/web/src/views/OverviewView.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/web/src/views/RegistrationView.vue b/web/src/views/RegistrationView.vue new file mode 100644 index 0000000..35cba73 --- /dev/null +++ b/web/src/views/RegistrationView.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue new file mode 100644 index 0000000..091d3be --- /dev/null +++ b/web/src/views/SettingsView.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/web/src/views/TokensView.spec.ts b/web/src/views/TokensView.spec.ts new file mode 100644 index 0000000..74ddc27 --- /dev/null +++ b/web/src/views/TokensView.spec.ts @@ -0,0 +1,71 @@ +import { config, mount, flushPromises } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + NConfigProvider, + NDialogProvider, + NMessageProvider, + dateZhCN, + zhCN, +} from "naive-ui"; +import { defineComponent, h, nextTick } from "vue"; +import TokensView from "./TokensView.vue"; +import { mockApi } from "@/api/mock"; + +config.global.stubs = { teleport: true }; + +function wrap(Comp: object) { + return defineComponent({ + setup() { + return () => + h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, { + default: () => + h(NMessageProvider, null, { + default: () => + h(NDialogProvider, null, { + default: () => h(Comp), + }), + }), + }); + }, + }); +} + +describe("TokensView 令牌只显示一次", () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockApi._setSession(); + }); + + it("创建后显示令牌,关闭后列表不再含明文", async () => { + const pinia = createPinia(); + const w = mount(wrap(TokensView), { + global: { plugins: [pinia] }, + attachTo: document.body, + }); + await flushPromises(); + + await w.find('[data-testid="token-create-open"]').trigger("click"); + await nextTick(); + await flushPromises(); + + await w.find('[data-testid="token-name"]').find("input").setValue("ci-test"); + await w.find('[data-testid="token-create-submit"]').trigger("click"); + await flushPromises(); + + expect(w.text()).toContain("令牌只显示一次"); + expect(w.text()).toMatch(/nxm_/); + + const shown = w.text().match(/nxm_[A-Za-z0-9]+/)?.[0]; + expect(shown).toBeTruthy(); + + const dismiss = w.findAll("button").find((b) => b.text().includes("我已保存")); + await dismiss!.trigger("click"); + await nextTick(); + + expect(w.text()).not.toContain("令牌只显示一次"); + expect(w.text()).not.toContain(shown!); + expect(w.text()).toContain("ci-test"); + w.unmount(); + }); +}); diff --git a/web/src/views/TokensView.vue b/web/src/views/TokensView.vue new file mode 100644 index 0000000..c6ca66f --- /dev/null +++ b/web/src/views/TokensView.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/web/vite.config.ts b/web/vite.config.ts index 381a82d..f1525a5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import vue from "@vitejs/plugin-vue"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,4 +18,8 @@ export default defineConfig({ "/healthz": "http://127.0.0.1:7443", }, }, + test: { + environment: "happy-dom", + include: ["src/**/*.{spec,test}.ts"], + }, });