feat: API层与认证流(client拦截/守卫/登录改密实装/域API骨架/vitest)
- 自建轻量 axios client(src/api/client.ts):Bearer 注入、401 清会话跳登录
(login 请求除外)、409 精确匹配"必须先修改初始密码"跳强制改密(区别于并发
冲突 409)、429/网络错误统一 ElMessage、ApiError 标准化、showErrorMessage
可opt-out——模板 utils/http 的 {code,msg,data} 信封与后端裸 HTTP 语义不符故弃用
- Pinia auth store(src/store/modules/auth.ts):token/role/tenant_slug/
must_change 落地并持久化(all-kefu-auth key),login/logout/changePassword/
会话过期/强制改密跳转(动态 import 断循环)
- 路由守卫:token 事实源判定、must_change 全站门(除改密页)、UserInfo 由登录
快照派生(后端无用户信息端点);user store 残留自愈
- 登录页实装(去除角色调试开关)、强制改密页实装;用户菜单/锁屏对接真实 logout
- 域 API 骨架:tenants/config/conversations/stats/settings(类型+端点常量)
- mask 哨兵助手:__SET__ ↔ 已设置 表单语义
- vitest 25 例(client 11/store 8/mask 6);登录页拖拽验证 reset 空引用修复
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"fix": "eslint --fix",
|
"fix": "eslint --fix",
|
||||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,vue,html,md}\"",
|
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,vue,html,md}\"",
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-vue": "^9.27.0",
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
|
"happy-dom": "^21.0.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.81.0",
|
||||||
@@ -79,6 +81,7 @@
|
|||||||
"vite": "^7.1.5",
|
"vite": "^7.1.5",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-vue-devtools": "^7.7.6",
|
"vite-plugin-vue-devtools": "^7.7.6",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
"vue-tsc": "~2.1.6"
|
"vue-tsc": "~2.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+355
@@ -99,6 +99,9 @@ importers:
|
|||||||
globals:
|
globals:
|
||||||
specifier: ^15.9.0
|
specifier: ^15.9.0
|
||||||
version: 15.15.0
|
version: 15.15.0
|
||||||
|
happy-dom:
|
||||||
|
specifier: ^21.0.1
|
||||||
|
version: 21.0.1
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.5.3
|
specifier: ^3.5.3
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
@@ -156,6 +159,9 @@ importers:
|
|||||||
vite-plugin-vue-devtools:
|
vite-plugin-vue-devtools:
|
||||||
specifier: ^7.7.6
|
specifier: ^7.7.6
|
||||||
version: 7.7.7(rollup@4.52.3)(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.6.3))
|
version: 7.7.7(rollup@4.52.3)(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.6.3))
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.2.4
|
||||||
|
version: 3.2.7(@types/node@24.8.1)(happy-dom@21.0.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
vue-tsc:
|
vue-tsc:
|
||||||
specifier: ~2.1.6
|
specifier: ~2.1.6
|
||||||
version: 2.1.10(typescript@5.6.3)
|
version: 2.1.10(typescript@5.6.3)
|
||||||
@@ -941,6 +947,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7
|
vite: ^5.2.0 || ^6 || ^7
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2':
|
||||||
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -965,6 +977,12 @@ packages:
|
|||||||
'@types/web-bluetooth@0.0.21':
|
'@types/web-bluetooth@0.0.21':
|
||||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||||
|
|
||||||
|
'@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.44.1':
|
'@typescript-eslint/eslint-plugin@8.44.1':
|
||||||
resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==}
|
resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1031,6 +1049,35 @@ packages:
|
|||||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0
|
vite: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
vue: ^3.2.25
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@vitest/expect@3.2.7':
|
||||||
|
resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
|
||||||
|
|
||||||
|
'@vitest/mocker@3.2.7':
|
||||||
|
resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/pretty-format@3.2.7':
|
||||||
|
resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
|
||||||
|
|
||||||
|
'@vitest/runner@3.2.7':
|
||||||
|
resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@3.2.7':
|
||||||
|
resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
|
||||||
|
|
||||||
|
'@vitest/spy@3.2.7':
|
||||||
|
resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
|
||||||
|
|
||||||
|
'@vitest/utils@3.2.7':
|
||||||
|
resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
|
||||||
|
|
||||||
'@volar/language-core@2.4.23':
|
'@volar/language-core@2.4.23':
|
||||||
resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==}
|
resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==}
|
||||||
|
|
||||||
@@ -1173,6 +1220,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
astral-regex@2.0.0:
|
astral-regex@2.0.0:
|
||||||
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
|
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1224,10 +1275,18 @@ packages:
|
|||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
buffer-image-size@0.6.4:
|
||||||
|
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
bundle-name@4.1.0:
|
bundle-name@4.1.0:
|
||||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cac@6.7.14:
|
||||||
|
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
cacheable@2.0.2:
|
cacheable@2.0.2:
|
||||||
resolution: {integrity: sha512-dWjhLx8RWnPsAWVKwW/wI6OJpQ/hSVb1qS0NUif8TR9vRiSwci7Gey8x04kRU9iAF+Rnbtex5Kjjfg/aB5w8Pg==}
|
resolution: {integrity: sha512-dWjhLx8RWnPsAWVKwW/wI6OJpQ/hSVb1qS0NUif8TR9vRiSwci7Gey8x04kRU9iAF+Rnbtex5Kjjfg/aB5w8Pg==}
|
||||||
|
|
||||||
@@ -1242,10 +1301,18 @@ packages:
|
|||||||
caniuse-lite@1.0.30001745:
|
caniuse-lite@1.0.30001745:
|
||||||
resolution: {integrity: sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==}
|
resolution: {integrity: sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
check-error@2.1.3:
|
||||||
|
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
@@ -1342,6 +1409,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
deep-eql@5.0.2:
|
||||||
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
@@ -1426,6 +1497,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
env-paths@2.2.1:
|
env-paths@2.2.1:
|
||||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1561,6 +1636,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==}
|
resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==}
|
||||||
engines: {node: ^18.19.0 || >=20.5.0}
|
engines: {node: ^18.19.0 || >=20.5.0}
|
||||||
|
|
||||||
|
expect-type@1.4.0:
|
||||||
|
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
exsolve@1.0.7:
|
exsolve@1.0.7:
|
||||||
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
|
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
|
||||||
|
|
||||||
@@ -1725,6 +1804,10 @@ packages:
|
|||||||
graphemer@1.4.0:
|
graphemer@1.4.0:
|
||||||
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
||||||
|
|
||||||
|
happy-dom@21.0.1:
|
||||||
|
resolution: {integrity: sha512-pRBs/u9fhrncyByOl58DMex0R2uAj6ZPKWIjV5j5yNGZBqLw3P6BTRhlc0DD5b/Ht4/6JYTMuE2LJhUWRC6SGw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
has-flag@4.0.0:
|
has-flag@4.0.0:
|
||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2012,6 +2095,9 @@ packages:
|
|||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
|
loupe@3.2.1:
|
||||||
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
@@ -2178,6 +2264,10 @@ packages:
|
|||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pathval@2.0.1:
|
||||||
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
|
engines: {node: '>= 14.16'}
|
||||||
|
|
||||||
perfect-debounce@1.0.0:
|
perfect-debounce@1.0.0:
|
||||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
@@ -2383,6 +2473,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
signal-exit@4.1.0:
|
signal-exit@4.1.0:
|
||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2418,6 +2511,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
std-env@3.10.0:
|
||||||
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2539,10 +2638,28 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
tinybench@2.9.0:
|
||||||
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
tinyexec@0.3.2:
|
||||||
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinypool@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
||||||
|
tinyrainbow@2.0.0:
|
||||||
|
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tinyspy@4.0.6:
|
||||||
|
resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
@@ -2661,6 +2778,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
|
vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
|
||||||
|
|
||||||
|
vite-node@3.2.4:
|
||||||
|
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vite-plugin-compression@0.5.1:
|
vite-plugin-compression@0.5.1:
|
||||||
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
|
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2727,6 +2849,34 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@3.2.7:
|
||||||
|
resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@types/debug': ^4.1.12
|
||||||
|
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
|
||||||
|
'@vitest/browser': 3.2.7
|
||||||
|
'@vitest/ui': 3.2.7
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@types/debug':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
@@ -2784,6 +2934,10 @@ packages:
|
|||||||
webpack-virtual-modules@0.6.2:
|
webpack-virtual-modules@0.6.2:
|
||||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||||
|
|
||||||
|
whatwg-mimetype@3.0.0:
|
||||||
|
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
which@1.3.1:
|
which@1.3.1:
|
||||||
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2793,6 +2947,11 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2805,6 +2964,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
|
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.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
|
||||||
|
|
||||||
wsl-utils@0.1.0:
|
wsl-utils@0.1.0:
|
||||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3507,6 +3678,13 @@ snapshots:
|
|||||||
tailwindcss: 4.1.14
|
tailwindcss: 4.1.14
|
||||||
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/deep-eql': 4.0.2
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -3527,6 +3705,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/web-bluetooth@0.0.21': {}
|
'@types/web-bluetooth@0.0.21': {}
|
||||||
|
|
||||||
|
'@types/whatwg-mimetype@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.2':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.8.1
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3))(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)':
|
'@typescript-eslint/eslint-plugin@8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3))(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.1
|
'@eslint-community/regexpp': 4.12.1
|
||||||
@@ -3626,6 +3810,48 @@ snapshots:
|
|||||||
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
vue: 3.5.22(typescript@5.6.3)
|
vue: 3.5.22(typescript@5.6.3)
|
||||||
|
|
||||||
|
'@vitest/expect@3.2.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/spy': 3.2.7
|
||||||
|
'@vitest/utils': 3.2.7
|
||||||
|
chai: 5.3.3
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/mocker@3.2.7(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 3.2.7
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.19
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
|
|
||||||
|
'@vitest/pretty-format@3.2.7':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/runner@3.2.7':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 3.2.7
|
||||||
|
pathe: 2.0.3
|
||||||
|
strip-literal: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/snapshot@3.2.7':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 3.2.7
|
||||||
|
magic-string: 0.30.19
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/spy@3.2.7':
|
||||||
|
dependencies:
|
||||||
|
tinyspy: 4.0.6
|
||||||
|
|
||||||
|
'@vitest/utils@3.2.7':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 3.2.7
|
||||||
|
loupe: 3.2.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@volar/language-core@2.4.23':
|
'@volar/language-core@2.4.23':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/source-map': 2.4.23
|
'@volar/source-map': 2.4.23
|
||||||
@@ -3840,6 +4066,8 @@ snapshots:
|
|||||||
|
|
||||||
array-union@2.1.0: {}
|
array-union@2.1.0: {}
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
astral-regex@2.0.0: {}
|
astral-regex@2.0.0: {}
|
||||||
|
|
||||||
async-validator@4.2.5: {}
|
async-validator@4.2.5: {}
|
||||||
@@ -3889,10 +4117,16 @@ snapshots:
|
|||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
|
buffer-image-size@0.6.4:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.8.1
|
||||||
|
|
||||||
bundle-name@4.1.0:
|
bundle-name@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
run-applescript: 7.1.0
|
run-applescript: 7.1.0
|
||||||
|
|
||||||
|
cac@6.7.14: {}
|
||||||
|
|
||||||
cacheable@2.0.2:
|
cacheable@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cacheable/memoize': 2.0.2
|
'@cacheable/memoize': 2.0.2
|
||||||
@@ -3910,11 +4144,21 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001745: {}
|
caniuse-lite@1.0.30001745: {}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
dependencies:
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
check-error: 2.1.3
|
||||||
|
deep-eql: 5.0.2
|
||||||
|
loupe: 3.2.1
|
||||||
|
pathval: 2.0.1
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
|
|
||||||
|
check-error@2.1.3: {}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
@@ -4001,6 +4245,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
deep-pick-omit@1.2.1: {}
|
deep-pick-omit@1.2.1: {}
|
||||||
@@ -4092,6 +4338,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
env-paths@2.2.1: {}
|
env-paths@2.2.1: {}
|
||||||
|
|
||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
@@ -4282,6 +4530,8 @@ snapshots:
|
|||||||
strip-final-newline: 4.0.0
|
strip-final-newline: 4.0.0
|
||||||
yoctocolors: 2.1.2
|
yoctocolors: 2.1.2
|
||||||
|
|
||||||
|
expect-type@1.4.0: {}
|
||||||
|
|
||||||
exsolve@1.0.7: {}
|
exsolve@1.0.7: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
@@ -4447,6 +4697,19 @@ snapshots:
|
|||||||
|
|
||||||
graphemer@1.4.0: {}
|
graphemer@1.4.0: {}
|
||||||
|
|
||||||
|
happy-dom@21.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.8.1
|
||||||
|
'@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
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
@@ -4657,6 +4920,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
@@ -4802,6 +5067,8 @@ snapshots:
|
|||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
pathval@2.0.1: {}
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@@ -4985,6 +5252,8 @@ snapshots:
|
|||||||
|
|
||||||
shebang-regex@3.0.0: {}
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
sirv@3.0.2:
|
sirv@3.0.2:
|
||||||
@@ -5014,6 +5283,10 @@ snapshots:
|
|||||||
|
|
||||||
speakingurl@14.0.1: {}
|
speakingurl@14.0.1: {}
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 8.0.0
|
emoji-regex: 8.0.0
|
||||||
@@ -5180,11 +5453,21 @@ snapshots:
|
|||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
tinypool@1.1.1: {}
|
||||||
|
|
||||||
|
tinyrainbow@2.0.0: {}
|
||||||
|
|
||||||
|
tinyspy@4.0.6: {}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
@@ -5315,6 +5598,27 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
|
|
||||||
|
vite-node@3.2.4(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1):
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
debug: 4.4.3
|
||||||
|
es-module-lexer: 1.7.0
|
||||||
|
pathe: 2.0.3
|
||||||
|
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
- jiti
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
- tsx
|
||||||
|
- yaml
|
||||||
|
|
||||||
vite-plugin-compression@0.5.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
|
vite-plugin-compression@0.5.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@@ -5389,6 +5693,48 @@ snapshots:
|
|||||||
tsx: 4.20.6
|
tsx: 4.20.6
|
||||||
yaml: 2.8.1
|
yaml: 2.8.1
|
||||||
|
|
||||||
|
vitest@3.2.7(@types/node@24.8.1)(happy-dom@21.0.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/expect': 3.2.7
|
||||||
|
'@vitest/mocker': 3.2.7(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
|
||||||
|
'@vitest/pretty-format': 3.2.7
|
||||||
|
'@vitest/runner': 3.2.7
|
||||||
|
'@vitest/snapshot': 3.2.7
|
||||||
|
'@vitest/spy': 3.2.7
|
||||||
|
'@vitest/utils': 3.2.7
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.4.3
|
||||||
|
expect-type: 1.4.0
|
||||||
|
magic-string: 0.30.19
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.3
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
|
vite-node: 3.2.4(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 24.8.1
|
||||||
|
happy-dom: 21.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- jiti
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
- tsx
|
||||||
|
- yaml
|
||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
vue-demi@0.14.10(vue@3.5.22(typescript@5.6.3)):
|
vue-demi@0.14.10(vue@3.5.22(typescript@5.6.3)):
|
||||||
@@ -5443,6 +5789,8 @@ snapshots:
|
|||||||
|
|
||||||
webpack-virtual-modules@0.6.2: {}
|
webpack-virtual-modules@0.6.2: {}
|
||||||
|
|
||||||
|
whatwg-mimetype@3.0.0: {}
|
||||||
|
|
||||||
which@1.3.1:
|
which@1.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -5451,6 +5799,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
@@ -5464,6 +5817,8 @@ snapshots:
|
|||||||
imurmurhash: 0.1.4
|
imurmurhash: 0.1.4
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
|
ws@8.22.0: {}
|
||||||
|
|
||||||
wsl-utils@0.1.0:
|
wsl-utils@0.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-wsl: 3.1.0
|
is-wsl: 3.1.0
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* auth store 单元测试(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* - login 成功存 token/role/tenant_slug/must_change_password/username(含模板侧同步)
|
||||||
|
* - logout:调 API + 清空状态 + 模板侧 logOut(API 失败也照常本地清)
|
||||||
|
* - localStorage 持久化(all-kefu 前缀键;新建 pinia 实例模拟刷新恢复)
|
||||||
|
* - handleSessionExpired / handleMustChangePassword / changePassword
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { createApp, defineComponent } from 'vue'
|
||||||
|
import { createPersistedState } from 'pinia-plugin-persistedstate'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
fetchLogin: vi.fn(),
|
||||||
|
fetchLogout: vi.fn(),
|
||||||
|
fetchChangePassword: vi.fn(),
|
||||||
|
userStore: {
|
||||||
|
logOut: vi.fn(),
|
||||||
|
setLoginStatus: vi.fn(),
|
||||||
|
setToken: vi.fn(),
|
||||||
|
setUserInfo: vi.fn(),
|
||||||
|
checkAndClearWorktabs: vi.fn(),
|
||||||
|
info: {}
|
||||||
|
},
|
||||||
|
routerPush: vi.fn(),
|
||||||
|
routerReplace: vi.fn(),
|
||||||
|
currentRoute: { value: { path: '/' } }
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/auth', () => ({
|
||||||
|
fetchLogin: mocks.fetchLogin,
|
||||||
|
fetchLogout: mocks.fetchLogout,
|
||||||
|
fetchChangePassword: mocks.fetchChangePassword
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/store/modules/user', () => ({
|
||||||
|
useUserStore: () => mocks.userStore
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
router: { push: mocks.routerPush, replace: mocks.routerReplace, currentRoute: mocks.currentRoute }
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
|
||||||
|
const LOGIN_RESPONSE = {
|
||||||
|
token: 'tok-abc',
|
||||||
|
role: 'super',
|
||||||
|
tenant_slug: null,
|
||||||
|
must_change_password: true
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等价应用的持久化 pinia:
|
||||||
|
* pinia.use() 在未安装 app 时(_a 为空)插件只进 toBeInstalled 队列,
|
||||||
|
* 必须 app.use(pinia) 才真正生效——真实应用经 initStore(main.ts)如此;
|
||||||
|
* 测试里用一次性 app 复刻同一装配,否则持久化插件静默不运行。
|
||||||
|
*/
|
||||||
|
function freshPersistedPinia() {
|
||||||
|
const pinia = createPinia()
|
||||||
|
pinia.use(createPersistedState())
|
||||||
|
createApp(defineComponent(() => () => null)).use(pinia)
|
||||||
|
setActivePinia(pinia)
|
||||||
|
return pinia
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth store(src/store/modules/auth.ts)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.currentRoute.value.path = '/'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('login 成功:存 token/role/tenantSlug/mustChangePassword/username 并同步模板侧', async () => {
|
||||||
|
freshPersistedPinia()
|
||||||
|
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await store.login({ username: 'admin', password: 'admin123' })
|
||||||
|
|
||||||
|
expect(store.token).toBe('tok-abc')
|
||||||
|
expect(store.role).toBe('super')
|
||||||
|
expect(store.tenantSlug).toBeNull()
|
||||||
|
expect(store.mustChangePassword).toBe(true)
|
||||||
|
expect(store.username).toBe('admin')
|
||||||
|
expect(store.isAuthenticated).toBe(true)
|
||||||
|
|
||||||
|
// 模板侧(user store)同步:token / 登录标记 / 用户信息(菜单过滤用 roles)
|
||||||
|
expect(mocks.userStore.setToken).toHaveBeenCalledWith('tok-abc', '')
|
||||||
|
expect(mocks.userStore.setLoginStatus).toHaveBeenCalledWith(true)
|
||||||
|
expect(mocks.userStore.setUserInfo).toHaveBeenCalled()
|
||||||
|
const info = mocks.userStore.setUserInfo.mock.calls[0][0]
|
||||||
|
expect(info.roles).toEqual(['super'])
|
||||||
|
expect(info.userName).toBe('admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('login 失败:状态保持空、错误向上抛', async () => {
|
||||||
|
freshPersistedPinia()
|
||||||
|
mocks.fetchLogin.mockRejectedValue(new Error('用户名或密码错误'))
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await expect(store.login({ username: 'a', password: 'b' })).rejects.toThrow('用户名或密码错误')
|
||||||
|
|
||||||
|
expect(store.token).toBe('')
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
expect(mocks.userStore.setLoginStatus).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout:调登出 API + 清空状态 + 模板侧 logOut', async () => {
|
||||||
|
freshPersistedPinia()
|
||||||
|
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||||||
|
mocks.fetchLogout.mockResolvedValue({ ok: true })
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await store.login({ username: 'admin', password: 'x' })
|
||||||
|
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
expect(mocks.fetchLogout).toHaveBeenCalledTimes(1)
|
||||||
|
expect(store.token).toBe('')
|
||||||
|
expect(store.role).toBe('')
|
||||||
|
expect(store.tenantSlug).toBeNull()
|
||||||
|
expect(store.username).toBe('')
|
||||||
|
expect(store.mustChangePassword).toBe(false)
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout 时后端已不可达:仍完成本地清理(best effort)', async () => {
|
||||||
|
freshPersistedPinia()
|
||||||
|
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||||||
|
mocks.fetchLogout.mockRejectedValue(new Error('network down'))
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await store.login({ username: 'admin', password: 'x' })
|
||||||
|
|
||||||
|
await expect(store.logout()).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
expect(store.token).toBe('')
|
||||||
|
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('localStorage 持久化:all-kefu 前缀键;新 pinia 实例(模拟刷新)恢复会话', async () => {
|
||||||
|
freshPersistedPinia()
|
||||||
|
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE, role: 'tenant', tenant_slug: 'jiancai', must_change_password: false })
|
||||||
|
|
||||||
|
await useAuthStore().login({ username: 'jcadmin', password: 'x' })
|
||||||
|
|
||||||
|
// 持久化键带 all-kefu 前缀
|
||||||
|
expect(localStorage.getItem('all-kefu-auth')).toBeTruthy()
|
||||||
|
|
||||||
|
// 模拟页面刷新:全新 pinia + 插件,从 localStorage 恢复
|
||||||
|
freshPersistedPinia()
|
||||||
|
const restored = useAuthStore()
|
||||||
|
expect(restored.token).toBe('tok-abc')
|
||||||
|
expect(restored.role).toBe('tenant')
|
||||||
|
expect(restored.tenantSlug).toBe('jiancai')
|
||||||
|
expect(restored.username).toBe('jcadmin')
|
||||||
|
expect(restored.mustChangePassword).toBe(false)
|
||||||
|
expect(restored.isAuthenticated).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleSessionExpired:清空会话并委托模板侧 logOut(401 全局处理路径)', () => {
|
||||||
|
freshPersistedPinia()
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.token = 'tok'
|
||||||
|
store.role = 'super'
|
||||||
|
store.username = 'admin'
|
||||||
|
|
||||||
|
store.handleSessionExpired()
|
||||||
|
|
||||||
|
expect(store.token).toBe('')
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleMustChangePassword:置标志并跳强制改密页;已在改密页不重复跳', async () => {
|
||||||
|
freshPersistedPinia()
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await store.handleMustChangePassword()
|
||||||
|
|
||||||
|
expect(store.mustChangePassword).toBe(true)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mocks.routerReplace).toHaveBeenCalledWith('/auth/force-change-password')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 已在改密页:不再跳
|
||||||
|
mocks.routerReplace.mockClear()
|
||||||
|
mocks.currentRoute.value.path = '/auth/force-change-password'
|
||||||
|
await store.handleMustChangePassword()
|
||||||
|
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changePassword 成功:清 must_change 标志', async () => {
|
||||||
|
freshPersistedPinia()
|
||||||
|
mocks.fetchChangePassword.mockResolvedValue({ ok: true, must_change_password: false })
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.mustChangePassword = true
|
||||||
|
|
||||||
|
await store.changePassword({ old_password: 'old', new_password: 'new-pass-123' })
|
||||||
|
|
||||||
|
expect(mocks.fetchChangePassword).toHaveBeenCalledWith({
|
||||||
|
old_password: 'old',
|
||||||
|
new_password: 'new-pass-123'
|
||||||
|
})
|
||||||
|
expect(store.mustChangePassword).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* HTTP client 单元测试(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 契约对齐后端 app/platform/auth.py 与 auth_routes.py:
|
||||||
|
* - Authorization: Bearer <token> 注入
|
||||||
|
* - 401(非登录请求)→ 清会话 + 跳登录(登录请求自身的 401 = 凭据错误,只提示不跳转)
|
||||||
|
* - 409 detail === "必须先修改初始密码" → 跳强制改密页;
|
||||||
|
* 其余 409(并发冲突等)为普通业务错误,不跳转
|
||||||
|
* - 其余错误 / 网络错误 → ElMessage 出 detail / 统一文案
|
||||||
|
* - 成功透传 response.data
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
userStore: {
|
||||||
|
logOut: vi.fn(),
|
||||||
|
setLoginStatus: vi.fn(),
|
||||||
|
setToken: vi.fn(),
|
||||||
|
setUserInfo: vi.fn(),
|
||||||
|
checkAndClearWorktabs: vi.fn(),
|
||||||
|
info: {}
|
||||||
|
},
|
||||||
|
routerPush: vi.fn(),
|
||||||
|
routerReplace: vi.fn(),
|
||||||
|
currentRoute: { value: { path: '/' } },
|
||||||
|
elMessageError: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('element-plus', () => ({
|
||||||
|
ElMessage: { error: mocks.elMessageError, success: vi.fn() }
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/store/modules/user', () => ({
|
||||||
|
useUserStore: () => mocks.userStore
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
router: { push: mocks.routerPush, replace: mocks.routerReplace, currentRoute: mocks.currentRoute }
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { http } from '@/api/client'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
|
||||||
|
/** 自定义 axios adapter:按计划响应回放,并捕获请求头 */
|
||||||
|
function makeAdapter(plan: { status: number; body: unknown } | { networkError: true }) {
|
||||||
|
let captured: InternalAxiosRequestConfig | null = null
|
||||||
|
const adapter = (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
|
||||||
|
captured = config
|
||||||
|
if ('networkError' in plan) {
|
||||||
|
// 模拟断网:无 response 的 AxiosError
|
||||||
|
return Promise.reject(
|
||||||
|
new AxiosError('Network Error', 'ECONNABORTED', config, undefined, undefined)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const response: AxiosResponse = {
|
||||||
|
status: plan.status,
|
||||||
|
statusText: '',
|
||||||
|
headers: {},
|
||||||
|
config,
|
||||||
|
data: plan.body
|
||||||
|
}
|
||||||
|
if (plan.status >= 200 && plan.status < 300) return Promise.resolve(response)
|
||||||
|
return Promise.reject(
|
||||||
|
new AxiosError(
|
||||||
|
`Request failed with status code ${plan.status}`,
|
||||||
|
String(plan.status),
|
||||||
|
config,
|
||||||
|
undefined,
|
||||||
|
response
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
adapter,
|
||||||
|
capturedHeaders: () => (captured as InternalAxiosRequestConfig | null)?.headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HTTP client(src/api/client.ts)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.currentRoute.value.path = '/'
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('token 注入', () => {
|
||||||
|
it('有 token 时自动注入 Authorization: Bearer <token>', async () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.token = 'tok-123'
|
||||||
|
|
||||||
|
const { adapter, capturedHeaders } = makeAdapter({ status: 200, body: { ok: true } })
|
||||||
|
await http.post<{ ok: boolean }>('/api/platform/auth/logout', null, { adapter })
|
||||||
|
|
||||||
|
expect(capturedHeaders()?.get('Authorization')).toBe('Bearer tok-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无 token 时不注入 Authorization 头', async () => {
|
||||||
|
const { adapter, capturedHeaders } = makeAdapter({ status: 200, body: { ok: true } })
|
||||||
|
await http.post('/api/platform/auth/logout', null, { adapter })
|
||||||
|
|
||||||
|
expect(capturedHeaders()?.get('Authorization')).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('401 处理', () => {
|
||||||
|
it('非登录请求 401:清空会话并触发登出清理(跳登录)', async () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.token = 'expired-token'
|
||||||
|
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
status: 401,
|
||||||
|
body: { detail: '未认证或凭证已失效' }
|
||||||
|
})
|
||||||
|
await expect(http.get('/api/platform/tenants', { adapter })).rejects.toThrow(
|
||||||
|
'未认证或凭证已失效'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(authStore.token).toBe('')
|
||||||
|
expect(authStore.isAuthenticated).toBe(false)
|
||||||
|
// 模板侧全套清理(含跳登录页)由 userStore.logOut 承担
|
||||||
|
expect(mocks.userStore.logOut).toHaveBeenCalled()
|
||||||
|
expect(ElMessage.error).toHaveBeenCalledWith('未认证或凭证已失效')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('登录请求自身 401(凭据错误):只提示 detail,不触发登出/跳转', async () => {
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
status: 401,
|
||||||
|
body: { detail: '用户名或密码错误' }
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
http.post('/api/platform/auth/login', { username: 'a', password: 'b' }, { adapter })
|
||||||
|
).rejects.toThrow('用户名或密码错误')
|
||||||
|
|
||||||
|
expect(mocks.userStore.logOut).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.routerReplace).not.toHaveBeenCalledWith('/auth/force-change-password')
|
||||||
|
expect(ElMessage.error).toHaveBeenCalledWith('用户名或密码错误')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('409 处理', () => {
|
||||||
|
it('409 detail 为必须先修改初始密码:置 must_change 并跳强制改密页', async () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.token = 'tok'
|
||||||
|
authStore.mustChangePassword = false
|
||||||
|
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
status: 409,
|
||||||
|
body: { detail: '必须先修改初始密码' }
|
||||||
|
})
|
||||||
|
await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow(
|
||||||
|
'必须先修改初始密码'
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mocks.routerReplace).toHaveBeenCalledWith('/auth/force-change-password')
|
||||||
|
})
|
||||||
|
expect(authStore.mustChangePassword).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已在强制改密页时收到 409:不再重复跳转', async () => {
|
||||||
|
mocks.currentRoute.value.path = '/auth/force-change-password'
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
status: 409,
|
||||||
|
body: { detail: '必须先修改初始密码' }
|
||||||
|
})
|
||||||
|
await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(ElMessage.error).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('其他 409(并发冲突):普通业务错误,不跳改密页', async () => {
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
status: 409,
|
||||||
|
body: { detail: '并发冲突: 域名已被占用' }
|
||||||
|
})
|
||||||
|
await expect(http.post('/api/platform/tenants', {}, { adapter })).rejects.toThrow(
|
||||||
|
'并发冲突: 域名已被占用'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||||||
|
expect(ElMessage.error).toHaveBeenCalledWith('并发冲突: 域名已被占用')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('其他错误与成功', () => {
|
||||||
|
it('登录 429 限流:弹出后端 detail 提示,不触发跳转', async () => {
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
status: 429,
|
||||||
|
body: { detail: '登录尝试过于频繁,请15分钟后再试' }
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
http.post('/api/platform/auth/login', { username: 'a', password: 'b' }, { adapter })
|
||||||
|
).rejects.toThrow('登录尝试过于频繁,请15分钟后再试')
|
||||||
|
|
||||||
|
expect(ElMessage.error).toHaveBeenCalledWith('登录尝试过于频繁,请15分钟后再试')
|
||||||
|
expect(mocks.userStore.logOut).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('网络错误(无响应):统一文案提示', async () => {
|
||||||
|
const { adapter } = makeAdapter({ networkError: true })
|
||||||
|
await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow()
|
||||||
|
|
||||||
|
expect(ElMessage.error).toHaveBeenCalledTimes(1)
|
||||||
|
const message = vi.mocked(ElMessage.error).mock.calls[0][0] as string
|
||||||
|
expect(message).toContain('网络')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showErrorMessage: false 时不弹全局错误提示', async () => {
|
||||||
|
const { adapter } = makeAdapter({ status: 400, body: { detail: '原密码不正确' } })
|
||||||
|
await expect(
|
||||||
|
http.post('/api/platform/auth/change-password', {}, { adapter, showErrorMessage: false })
|
||||||
|
).rejects.toThrow('原密码不正确')
|
||||||
|
|
||||||
|
expect(ElMessage.error).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('成功:直接透传 response.data(非 axios 包装)', async () => {
|
||||||
|
const body = {
|
||||||
|
token: 't',
|
||||||
|
role: 'super',
|
||||||
|
tenant_slug: null,
|
||||||
|
must_change_password: true
|
||||||
|
}
|
||||||
|
const { adapter } = makeAdapter({ status: 200, body })
|
||||||
|
const res = await http.post<typeof body>('/api/platform/auth/login', {}, { adapter })
|
||||||
|
|
||||||
|
expect(res).toEqual(body)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 掩码哨兵辅助纯函数测试(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 契约对齐后端 app/platform/routes/config_routes.py:
|
||||||
|
* 密钥字段 GET 响应非空时回 `__SET__`(MASKED 哨兵),PUT 提交 `__SET__`
|
||||||
|
* 表示"保持库值"。本模块负责 `__SET__` ↔ 表单"已设置"语义的相互转换,
|
||||||
|
* 配置中心 / 平台设置页(Task 7/9)消费。
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
MASKED_DISPLAY,
|
||||||
|
MASKED_SENTINEL,
|
||||||
|
isMaskedSentinel,
|
||||||
|
secretPlaceholder,
|
||||||
|
secretToSubmit
|
||||||
|
} from '@/api/mask'
|
||||||
|
|
||||||
|
describe('掩码哨兵辅助(src/api/mask.ts)', () => {
|
||||||
|
it('哨兵识别:__SET__ 为哨兵,其他值不是', () => {
|
||||||
|
expect(isMaskedSentinel('__SET__')).toBe(true)
|
||||||
|
expect(isMaskedSentinel('sk-real-key')).toBe(false)
|
||||||
|
expect(isMaskedSentinel('')).toBe(false)
|
||||||
|
expect(isMaskedSentinel(null)).toBe(false)
|
||||||
|
expect(isMaskedSentinel(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET 掩码值 → 表单占位语义:已设置提示可覆盖,未设置提示待输入', () => {
|
||||||
|
expect(MASKED_SENTINEL).toBe('__SET__')
|
||||||
|
expect(MASKED_DISPLAY).toBe('已设置')
|
||||||
|
// 后端下发的密钥字段只可能是哨兵(已设置)或空串/缺省(未设置)
|
||||||
|
expect(secretPlaceholder('__SET__')).toBe('已设置,输入新值覆盖')
|
||||||
|
expect(secretPlaceholder('')).toBe('未设置,输入新值')
|
||||||
|
expect(secretPlaceholder(null)).toBe('未设置,输入新值')
|
||||||
|
expect(secretPlaceholder(undefined)).toBe('未设置,输入新值')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('表单值 → 提交值:未改动(空输入 + 原本已设置)回传哨兵保持库值', () => {
|
||||||
|
// 用户没有输入新值,且库里有值 → 回 __SET__,后端保持原值不落哨兵字面量
|
||||||
|
expect(secretToSubmit('', '__SET__')).toBe('__SET__')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('表单值 → 提交值:输入了新值则提交新值(覆盖)', () => {
|
||||||
|
expect(secretToSubmit('sk-new-key', '__SET__')).toBe('sk-new-key')
|
||||||
|
expect(secretToSubmit('sk-first-key', '')).toBe('sk-first-key')
|
||||||
|
expect(secretToSubmit('sk-first-key', null)).toBe('sk-first-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('表单值 → 提交值:未设置且留空 → 提交空串(保持未设置)', () => {
|
||||||
|
expect(secretToSubmit('', '')).toBe('')
|
||||||
|
expect(secretToSubmit('', null)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('往返一致性:掩码字段未改动时,提交值仍是哨兵(后端可识别为"保持")', () => {
|
||||||
|
const fromServer = '__SET__'
|
||||||
|
const displayed = secretPlaceholder(fromServer)
|
||||||
|
expect(displayed).toContain('已设置')
|
||||||
|
|
||||||
|
const submitted = secretToSubmit('', fromServer)
|
||||||
|
expect(isMaskedSentinel(submitted)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
+28
-50
@@ -1,60 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* 认证 API——脚手架阶段(Phase 3 Task 2)的开发桩。
|
* 认证 API(Phase 3 Task 3 实装,替换 Task 2 开发桩)
|
||||||
*
|
*
|
||||||
* 后端真实端点为 /api/platform/auth/*(登录 / 用户信息 / 改密),
|
* 对接后端 app/platform/routes/auth_routes.py:
|
||||||
* Task 3 对接真实登录时将本文件替换为真实请求。
|
* - POST /api/platform/auth/login {username, password}
|
||||||
|
* → {token, role: "super"|"tenant", tenant_slug, must_change_password}
|
||||||
|
* - POST /api/platform/auth/logout(Bearer;撤销当前 token)
|
||||||
|
* - POST /api/platform/auth/change-password {old_password, new_password}
|
||||||
|
* → {ok, must_change_password}
|
||||||
*
|
*
|
||||||
* 桩行为:
|
* 错误语义(HTTP client 统一处理,见 src/api/client.ts):
|
||||||
* - fetchLogin:开发态直接发放本地会话令牌,不发起网络请求;
|
* - 401 凭据错误/会话过期(登录请求自身 401 只提示不跳转)
|
||||||
* - fetchGetUserInfo:按登录页的角色调试开关(sessionStorage
|
* - 429 登录限流(5 次/15 分钟)
|
||||||
* `all-kefu-dev-role`:super | tenant)返回用户信息,用于演示
|
* - 400 原密码不正确
|
||||||
* 纯前端菜单权限控制——super 全量菜单,tenant 隐藏
|
|
||||||
* 租户管理 / 平台设置(见 router/modules/*.ts 的 meta.roles);
|
|
||||||
* - 生产构建(非 DEV)下调用将抛出显式错误,避免带桩上线。
|
|
||||||
*/
|
*/
|
||||||
const DEV_ROLE_KEY = 'all-kefu-dev-role'
|
import { http } from './client'
|
||||||
export type DevStubRole = 'super' | 'tenant'
|
import type { LoginParams, LoginResponse, ChangePasswordParams } from '@/store/modules/auth'
|
||||||
|
|
||||||
export function setDevStubRole(role: DevStubRole) {
|
/** 登录 */
|
||||||
sessionStorage.setItem(DEV_ROLE_KEY, role)
|
export function fetchLogin(params: LoginParams): Promise<LoginResponse> {
|
||||||
|
return http.post<LoginResponse>('/api/platform/auth/login', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDevStubRole(): DevStubRole {
|
/** 登出(撤销当前 token;需 Bearer) */
|
||||||
return sessionStorage.getItem(DEV_ROLE_KEY) === 'tenant' ? 'tenant' : 'super'
|
export function fetchLogout(): Promise<{ ok: boolean }> {
|
||||||
|
return http.post<{ ok: boolean }>('/api/platform/auth/logout')
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertStubAllowed(): void {
|
/** 修改密码(验旧密 → 写新 hash;改完当前会话保持有效) */
|
||||||
if (!import.meta.env.DEV) {
|
export function fetchChangePassword(params: ChangePasswordParams): Promise<{
|
||||||
throw new Error('认证 API 尚未对接真实后端(Task 3 实现),生产构建不可用')
|
ok: boolean
|
||||||
}
|
must_change_password: boolean
|
||||||
}
|
}> {
|
||||||
|
return http.post<{ ok: boolean; must_change_password: boolean }>(
|
||||||
/**
|
'/api/platform/auth/change-password',
|
||||||
* 登录(开发桩:任意账号密码直接获得开发会话)
|
params
|
||||||
* @param params 登录参数
|
)
|
||||||
* @returns 登录响应
|
|
||||||
*/
|
|
||||||
export function fetchLogin(params: Api.Auth.LoginParams) {
|
|
||||||
assertStubAllowed()
|
|
||||||
void params
|
|
||||||
return Promise.resolve<Api.Auth.LoginResponse>({
|
|
||||||
token: 'dev-stub-token',
|
|
||||||
refreshToken: ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户信息(开发桩:角色取自登录页调试开关)
|
|
||||||
* @returns 用户信息
|
|
||||||
*/
|
|
||||||
export function fetchGetUserInfo() {
|
|
||||||
assertStubAllowed()
|
|
||||||
const role = getDevStubRole()
|
|
||||||
return Promise.resolve<Api.Auth.UserInfo>({
|
|
||||||
buttons: [],
|
|
||||||
roles: [role],
|
|
||||||
userId: 1,
|
|
||||||
userName: role === 'super' ? '平台超管(调试)' : '租户管理员(调试)',
|
|
||||||
email: 'dev@all-kefu.local'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* 平台 API HTTP client(Phase 3 Task 3,自建薄封装)
|
||||||
|
*
|
||||||
|
* ## 为什么不用模板的 src/utils/http
|
||||||
|
*
|
||||||
|
* 模板封装与后端约定对不上,改造成本高于自建:
|
||||||
|
* - 响应:模板要求 `{code, msg, data}` 业务信封;后端(FastAPI)业务语义
|
||||||
|
* 直用 HTTP 状态码,成功响应就是数据本身
|
||||||
|
* - 错误:模板读 `data.msg`;FastAPI 错误体是 `{detail: 中文}`
|
||||||
|
* - 凭据:模板裸塞 `Authorization: <token>`;后端要求 `Bearer <token>`
|
||||||
|
* - 模板无 409 强制改密语义
|
||||||
|
*
|
||||||
|
* ## 本 client 约定
|
||||||
|
*
|
||||||
|
* - 请求拦截:auth store 有 token 时自动注入 `Authorization: Bearer <token>`
|
||||||
|
* - 401(非登录请求):清会话 + 跳登录(登录请求自身的 401 = 凭据错误,
|
||||||
|
* 只弹 detail,不触发登出/跳转——否则登录页死循环)
|
||||||
|
* - 409 且 detail 为强制改密文案:跳强制改密页;其余 409(并发冲突等)
|
||||||
|
* 是普通业务错误,只弹 detail(后端 409 双语义:enforce_password_changed
|
||||||
|
* 与租户/域名并发冲突,见 app/platform/routes/)
|
||||||
|
* - 其余错误/网络错误:ElMessage 出后端 detail / 统一网络文案
|
||||||
|
* - 成功:直接透传 response.data
|
||||||
|
* - 请求配置 showErrorMessage: false 可关闭全局错误提示(调用方自行处理)
|
||||||
|
*/
|
||||||
|
import axios, {
|
||||||
|
AxiosError,
|
||||||
|
type AxiosRequestConfig,
|
||||||
|
type AxiosResponse,
|
||||||
|
type InternalAxiosRequestConfig
|
||||||
|
} from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制改密 409 的 detail 契约(app/platform/auth.py enforce_password_changed
|
||||||
|
* 的静态文案)。只有命中该文案的 409 才触发改密跳转。
|
||||||
|
*/
|
||||||
|
export const MUST_CHANGE_DETAIL = '必须先修改初始密码'
|
||||||
|
|
||||||
|
/** 网络错误统一文案(无响应时后端不可能给 detail) */
|
||||||
|
export const NETWORK_ERROR_MESSAGE = '网络连接异常,请检查网络后重试'
|
||||||
|
|
||||||
|
/** 登录端点路径(其 401/429 是凭据/限流业务错误,不走会话过期处理) */
|
||||||
|
const LOGIN_URL = '/api/platform/auth/login'
|
||||||
|
|
||||||
|
/** 统一 API 错误(供调用方按需识别状态码) */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status?: number
|
||||||
|
readonly url?: string
|
||||||
|
|
||||||
|
constructor(message: string, status?: number, url?: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.url = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端错误体形态(FastAPI HTTPException:{detail: string}) */
|
||||||
|
interface ApiErrorBody {
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扩展请求配置:允许关闭全局错误提示 */
|
||||||
|
export interface RequestOptions extends AxiosRequestConfig {
|
||||||
|
showErrorMessage?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
timeout: 15000,
|
||||||
|
// 开发走 vite 代理(/),生产同源反代;两环境 VITE_API_URL 均为 '/'
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || '/',
|
||||||
|
// 后端业务语义直用 HTTP 状态码,非 2xx 一律进错误拦截器
|
||||||
|
validateStatus: (status) => status >= 200 && status < 300
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 请求拦截:Bearer token 注入 */
|
||||||
|
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const { token } = useAuthStore()
|
||||||
|
if (token) {
|
||||||
|
config.headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 提取错误提示文案:优先后端 detail,其次统一网络文案 */
|
||||||
|
function extractDetail(error: AxiosError<ApiErrorBody>): string {
|
||||||
|
if (error.response?.data?.detail) {
|
||||||
|
return error.response.data.detail
|
||||||
|
}
|
||||||
|
return error.response ? `请求失败(HTTP ${error.response.status})` : NETWORK_ERROR_MESSAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 响应拦截:401/409 全局处理 + 统一错误提示 */
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => response,
|
||||||
|
async (error: AxiosError<ApiErrorBody>) => {
|
||||||
|
const status = error.response?.status
|
||||||
|
const detail = extractDetail(error)
|
||||||
|
const url = error.config?.url || ''
|
||||||
|
const config = error.config as (RequestOptions & InternalAxiosRequestConfig) | undefined
|
||||||
|
const isLoginRequest = url.includes(LOGIN_URL)
|
||||||
|
|
||||||
|
if (status === 401 && !isLoginRequest) {
|
||||||
|
// 会话过期:清空状态 + 模板侧全套清理(含跳登录,登录页不重复跳)
|
||||||
|
await useAuthStore().handleSessionExpired()
|
||||||
|
} else if (status === 409 && detail === MUST_CHANGE_DETAIL) {
|
||||||
|
// 强制改密:置标志 + 跳改密页(已在改密页不重复跳)
|
||||||
|
await useAuthStore().handleMustChangePassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.showErrorMessage !== false) {
|
||||||
|
ElMessage.error(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(detail, status, url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 发起请求,成功透传 response.data */
|
||||||
|
async function request<T>(config: RequestOptions): Promise<T> {
|
||||||
|
const response = await instance.request<T>(config)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 方法集合 */
|
||||||
|
export const http = {
|
||||||
|
get<T = unknown>(url: string, config?: RequestOptions): Promise<T> {
|
||||||
|
return request<T>({ ...config, url, method: 'GET' })
|
||||||
|
},
|
||||||
|
post<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
|
||||||
|
return request<T>({ ...config, url, method: 'POST', data })
|
||||||
|
},
|
||||||
|
put<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
|
||||||
|
return request<T>({ ...config, url, method: 'PUT', data })
|
||||||
|
},
|
||||||
|
patch<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
|
||||||
|
return request<T>({ ...config, url, method: 'PATCH', data })
|
||||||
|
},
|
||||||
|
del<T = unknown>(url: string, config?: RequestOptions): Promise<T> {
|
||||||
|
return request<T>({ ...config, url, method: 'DELETE' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 租户配置中心 API 骨架(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 端点事实源:app/platform/routes/config_routes.py
|
||||||
|
* (配置快照 / 分组编辑 / 版本历史 / 回滚 / 重置 / 试金石)。
|
||||||
|
* 本文件只建类型与端点常量——调用函数在 Task 7(配置中心四件)实装。
|
||||||
|
*
|
||||||
|
* 密钥掩码哨兵:GET 的密钥字段非空时为 '__SET__'(见 src/api/mask.ts
|
||||||
|
* 的表单语义转换);PUT 提交 '__SET__' = 保持库值。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 配置组名(快照顶层键) */
|
||||||
|
export type ConfigGroupKey = 'brand' | 'prompts' | 'wall' | 'push' | 'email' | 'ai'
|
||||||
|
|
||||||
|
/** 当前生效配置响应(GET /api/platform/tenants/{slug}/config) */
|
||||||
|
export interface TenantConfigResponse {
|
||||||
|
slug: string
|
||||||
|
version: number
|
||||||
|
/** 掩码视图的整份快照(密钥字段可能为 '__SET__') */
|
||||||
|
config: Record<string, Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置版本行(GET .../config/versions) */
|
||||||
|
export interface ConfigVersionItem {
|
||||||
|
version: number
|
||||||
|
created_at: string
|
||||||
|
created_by: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本列表响应 */
|
||||||
|
export interface ConfigVersionListResponse {
|
||||||
|
slug: string
|
||||||
|
items: ConfigVersionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本详情响应(含整份快照) */
|
||||||
|
export interface ConfigVersionDetailResponse extends ConfigVersionItem {
|
||||||
|
slug: string
|
||||||
|
config: Record<string, Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回滚请求(POST .../config/rollback,产生新版本) */
|
||||||
|
export interface ConfigRollbackRequest {
|
||||||
|
version: number
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分组保存请求(PUT .../config/{group}) */
|
||||||
|
export interface PutConfigGroupRequest {
|
||||||
|
note: string
|
||||||
|
value: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 词表试金石请求(POST .../wall/test) */
|
||||||
|
export interface WallTestRequest {
|
||||||
|
sentences: string[]
|
||||||
|
has_contact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 词表试金石响应(逐句判定) */
|
||||||
|
export interface WallTestResponse {
|
||||||
|
results: Array<{ sentence: string; blocked: boolean; rule?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置中心端点 */
|
||||||
|
export const CONFIG_ENDPOINTS = {
|
||||||
|
current: (slug: string) => `/api/platform/tenants/${slug}/config`,
|
||||||
|
putGroup: (slug: string, group: ConfigGroupKey | string) =>
|
||||||
|
`/api/platform/tenants/${slug}/config/${group}`,
|
||||||
|
resetGroup: (slug: string, group: ConfigGroupKey | string) =>
|
||||||
|
`/api/platform/tenants/${slug}/config/${group}/reset`,
|
||||||
|
versions: (slug: string) => `/api/platform/tenants/${slug}/config/versions`,
|
||||||
|
versionDetail: (slug: string, version: number) =>
|
||||||
|
`/api/platform/tenants/${slug}/config/versions/${version}`,
|
||||||
|
rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`,
|
||||||
|
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 会话与线索 API 骨架(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 端点事实源:app/platform/routes/data_routes.py
|
||||||
|
* (会话列表/详情/删除、推送日志)。本文件只建类型与端点常量——
|
||||||
|
* 调用函数在 Task 5(会话与线索)实装。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 意向层级(Evaluation.intent_level) */
|
||||||
|
export type IntentLevel = 'high' | 'medium' | 'low' | 'none' | null
|
||||||
|
|
||||||
|
/** 联系方式(评估产出) */
|
||||||
|
export interface ContactInfo {
|
||||||
|
email?: string | null
|
||||||
|
whatsapp?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话列表行(GET /api/platform/tenants/{slug}/conversations) */
|
||||||
|
export interface ConversationListItem {
|
||||||
|
id: string
|
||||||
|
session_id: string
|
||||||
|
status: string
|
||||||
|
source: string
|
||||||
|
site: string | null
|
||||||
|
ip: string
|
||||||
|
device: string
|
||||||
|
country: string | null
|
||||||
|
intent_level: IntentLevel
|
||||||
|
reasoning: string | null
|
||||||
|
contact_info: ContactInfo | null
|
||||||
|
email_sent: boolean
|
||||||
|
message_count: number
|
||||||
|
preview: string
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话列表查询参数(intent 白名单校验在后端;from/to 为闭区间日期) */
|
||||||
|
export interface ConversationListParams {
|
||||||
|
intent?: string
|
||||||
|
site?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话列表响应 */
|
||||||
|
export interface ConversationListResponse {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
items: ConversationListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话详情消息行(时间升序) */
|
||||||
|
export interface ConversationMessage {
|
||||||
|
role: 'user' | 'assistant' | 'system' | string
|
||||||
|
content: string
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话详情响应(GET .../conversations/{convId}) */
|
||||||
|
export interface ConversationDetailResponse extends ConversationListItem {
|
||||||
|
client_info: Record<string, unknown>
|
||||||
|
messages: ConversationMessage[]
|
||||||
|
profile: {
|
||||||
|
chips: Record<string, unknown>
|
||||||
|
summary: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推送日志行(GET .../push-logs) */
|
||||||
|
export interface PushLogItem {
|
||||||
|
id: number
|
||||||
|
conversation_id: string
|
||||||
|
status: string
|
||||||
|
error: string | null
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推送日志响应 */
|
||||||
|
export interface PushLogResponse {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
items: PushLogItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话与线索端点 */
|
||||||
|
export const CONVERSATION_ENDPOINTS = {
|
||||||
|
list: (slug: string) => `/api/platform/tenants/${slug}/conversations`,
|
||||||
|
detail: (slug: string, convId: string) =>
|
||||||
|
`/api/platform/tenants/${slug}/conversations/${convId}`,
|
||||||
|
remove: (slug: string, convId: string) =>
|
||||||
|
`/api/platform/tenants/${slug}/conversations/${convId}`,
|
||||||
|
pushLogs: (slug: string) => `/api/platform/tenants/${slug}/push-logs`
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 掩码哨兵辅助(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 契约对齐后端 app/platform/routes/config_routes.py:
|
||||||
|
* - 密钥字段 GET 响应:库中非空 → `__SET__`(MASKED 哨兵);空/缺省 → 原样
|
||||||
|
* - PUT 提交:密钥字段传 `__SET__` = 保持库值(后端还原,哨兵字面量绝不落库)
|
||||||
|
*
|
||||||
|
* 本模块提供哨兵 ↔ 表单语义的纯函数转换,配置中心(Task 7)与
|
||||||
|
* 平台设置(Task 9)的凭据表单消费:
|
||||||
|
* - secretPlaceholder:GET 掩码值 → 输入框占位文案("已设置,输入新值覆盖")
|
||||||
|
* - secretToSubmit:用户输入 + 原掩码状态 → 提交值(未改动回哨兵保持库值)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 后端密钥掩码哨兵(config_routes.MASKED) */
|
||||||
|
export const MASKED_SENTINEL = '__SET__'
|
||||||
|
|
||||||
|
/** 表单侧"已设置"展示语义 */
|
||||||
|
export const MASKED_DISPLAY = '已设置'
|
||||||
|
|
||||||
|
/** 值是否为掩码哨兵 */
|
||||||
|
export function isMaskedSentinel(value: unknown): value is typeof MASKED_SENTINEL {
|
||||||
|
return value === MASKED_SENTINEL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 掩码值 → 表单占位文案
|
||||||
|
*
|
||||||
|
* @param maskedValue 后端下发的密钥字段值(哨兵=已设置;空串/null/undefined=未设置)
|
||||||
|
*/
|
||||||
|
export function secretPlaceholder(maskedValue: string | null | undefined): string {
|
||||||
|
return isMaskedSentinel(maskedValue) ? '已设置,输入新值覆盖' : '未设置,输入新值'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表单输入 → 提交值
|
||||||
|
*
|
||||||
|
* @param input 用户输入(空串=未改动/清空)
|
||||||
|
* @param originalMasked GET 时的掩码值(哨兵=库中已有值;否则=未设置)
|
||||||
|
* @returns 提交值:输入非空 → 新值;输入空且已设置 → 哨兵(保持库值);输入空且未设置 → 空串
|
||||||
|
*/
|
||||||
|
export function secretToSubmit(input: string, originalMasked: string | null | undefined): string {
|
||||||
|
if (input !== '') return input
|
||||||
|
return isMaskedSentinel(originalMasked) ? MASKED_SENTINEL : ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 平台设置 API 骨架(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 端点事实源:app/platform/routes/config_routes.py 的 settings_router
|
||||||
|
* (/api/platform/settings,仅超管:集团 AI 凭据 / 默认 SMTP 等)。
|
||||||
|
* 本文件只建类型与端点常量——调用函数在 Task 9(平台设置)实装。
|
||||||
|
*
|
||||||
|
* 密钥掩码:key 名含 key/password/secret 的非空字符串值在列表响应中
|
||||||
|
* 掩码为 '__SET__'(嵌套 dict 递归两层);PUT 提交 '__SET__' = 保持库值。
|
||||||
|
* 表单语义转换见 src/api/mask.ts。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 平台设置项(列表响应行;value 可能是掩码哨兵或任意 JSON) */
|
||||||
|
export interface PlatformSettingItem {
|
||||||
|
key: string
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台设置列表响应 */
|
||||||
|
export interface PlatformSettingsResponse {
|
||||||
|
items: PlatformSettingItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** upsert 单个设置项请求(同 key 再写 = 整值替换) */
|
||||||
|
export interface PutPlatformSettingRequest {
|
||||||
|
key: string
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台设置端点 */
|
||||||
|
export const SETTINGS_ENDPOINTS = {
|
||||||
|
list: '/api/platform/settings',
|
||||||
|
put: '/api/platform/settings'
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 统计分析 API 骨架(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 端点事实源:app/platform/routes/stats_routes.py + app/platform/stats.py
|
||||||
|
* (平台合并统计 / 单租户统计,?from/&to= 闭区间)。本文件只建类型与
|
||||||
|
* 端点常量——调用函数在 Task 4(仪表盘)与 Task 8(统计分析)实装。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 指标卡(None 项为 null——如无推送时成功率无定义) */
|
||||||
|
export interface StatsCards {
|
||||||
|
conversations: number
|
||||||
|
leads: number
|
||||||
|
high_intent: number
|
||||||
|
push_success_rate: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 漏斗计数 */
|
||||||
|
export interface StatsFunnel {
|
||||||
|
conversations: number
|
||||||
|
leads: number
|
||||||
|
evaluated: number
|
||||||
|
push_succeeded: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按日趋势行 */
|
||||||
|
export interface StatsTrendPoint {
|
||||||
|
date: string
|
||||||
|
conversations: number
|
||||||
|
leads: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分布视图(intent 按枚举键、source/site 计数表、country Top 数组) */
|
||||||
|
export interface StatsDistributions {
|
||||||
|
intent: Record<string, number>
|
||||||
|
source: Record<string, number>
|
||||||
|
site: Record<string, number>
|
||||||
|
country: Array<{ country: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推送健康计数 */
|
||||||
|
export interface PushHealth {
|
||||||
|
pending: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
skipped: number
|
||||||
|
duplicated: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计响应(平台合并与单租户同构) */
|
||||||
|
export interface StatsResponse {
|
||||||
|
cards: StatsCards
|
||||||
|
funnel: StatsFunnel
|
||||||
|
trend: StatsTrendPoint[]
|
||||||
|
distributions: StatsDistributions
|
||||||
|
push_health: PushHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计查询参数(from/to 闭区间,ISO 日期) */
|
||||||
|
export interface StatsParams {
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计端点(platform 合并版仅超管;单租户版超管或本租户管理员) */
|
||||||
|
export const STATS_ENDPOINTS = {
|
||||||
|
platform: '/api/platform/stats',
|
||||||
|
tenant: (slug: string) => `/api/platform/tenants/${slug}/stats`
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 租户与站点管理 API 骨架(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 端点事实源:app/platform/routes/tenant_routes.py
|
||||||
|
* (/api/platform/tenants,require_super / require_tenant(slug) 鉴权)。
|
||||||
|
* 本文件只建类型与端点常量——调用函数在 Task 6(租户管理与站点管理)实装。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 租户状态(Tenant.status) */
|
||||||
|
export type TenantStatus = 'active' | 'disabled'
|
||||||
|
|
||||||
|
/** 站点概要(_site_summary:slug/name/status/domains/brand_overlay) */
|
||||||
|
export interface SiteSummary {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
domains: string[]
|
||||||
|
brand_overlay: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员概要(_admin_summary:不含任何凭据字段) */
|
||||||
|
export interface TenantAdminSummary {
|
||||||
|
username: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 租户列表行(GET /api/platform/tenants) */
|
||||||
|
export interface TenantListItem {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
status: TenantStatus
|
||||||
|
domain_count: number
|
||||||
|
current_version: number | null
|
||||||
|
admins: TenantAdminSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 租户列表响应 */
|
||||||
|
export interface TenantListResponse {
|
||||||
|
items: TenantListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 租户详情响应(GET /api/platform/tenants/{slug}) */
|
||||||
|
export interface TenantDetailResponse {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
status: TenantStatus
|
||||||
|
sites: SiteSummary[]
|
||||||
|
admins: TenantAdminSummary[]
|
||||||
|
current_version: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建租户请求(POST /api/platform/tenants,201) */
|
||||||
|
export interface CreateTenantRequest {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
/** 出厂档案:auto | jiancai | blank */
|
||||||
|
profile: string
|
||||||
|
admin_username: string
|
||||||
|
admin_password: string
|
||||||
|
domains: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改名/改状态请求(PATCH /api/platform/tenants/{slug}) */
|
||||||
|
export interface PatchTenantRequest {
|
||||||
|
name?: string
|
||||||
|
status?: TenantStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建站点请求(POST /api/platform/tenants/{slug}/sites,201) */
|
||||||
|
export interface CreateSiteRequest {
|
||||||
|
slug: string
|
||||||
|
name?: string
|
||||||
|
brand_overlay?: Record<string, unknown>
|
||||||
|
domains?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定域名请求(POST .../sites/{siteSlug}/domains,201;409=域名已被占用) */
|
||||||
|
export interface AddDomainRequest {
|
||||||
|
domain: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 租户管理端点(鉴权与 409 并发冲突语义见 tenant_routes.py) */
|
||||||
|
export const TENANT_ENDPOINTS = {
|
||||||
|
list: '/api/platform/tenants',
|
||||||
|
create: '/api/platform/tenants',
|
||||||
|
detail: (slug: string) => `/api/platform/tenants/${slug}`,
|
||||||
|
patch: (slug: string) => `/api/platform/tenants/${slug}`,
|
||||||
|
createSite: (slug: string) => `/api/platform/tenants/${slug}/sites`,
|
||||||
|
patchSite: (slug: string, siteSlug: string) =>
|
||||||
|
`/api/platform/tenants/${slug}/sites/${siteSlug}`,
|
||||||
|
addDomain: (slug: string, siteSlug: string) =>
|
||||||
|
`/api/platform/tenants/${slug}/sites/${siteSlug}/domains`,
|
||||||
|
deleteDomain: (slug: string, siteSlug: string, domain: string) =>
|
||||||
|
`/api/platform/tenants/${slug}/sites/${siteSlug}/domains/${domain}`
|
||||||
|
} as const
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtUserMenu' })
|
defineOptions({ name: 'ArtUserMenu' })
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const { getUserInfo: userInfo } = storeToRefs(userStore)
|
const { getUserInfo: userInfo } = storeToRefs(userStore)
|
||||||
const userMenuPopover = ref()
|
const userMenuPopover = ref()
|
||||||
@@ -93,7 +95,8 @@
|
|||||||
cancelButtonText: t('common.cancel'),
|
cancelButtonText: t('common.cancel'),
|
||||||
customClass: 'login-out-dialog'
|
customClass: 'login-out-dialog'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
userStore.logOut()
|
// 先调后端撤销 token(best effort),再走模板全套本地清理 + 跳登录
|
||||||
|
void authStore.logout()
|
||||||
})
|
})
|
||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import CryptoJS from 'crypto-js'
|
import CryptoJS from 'crypto-js'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -122,6 +123,7 @@
|
|||||||
|
|
||||||
// Store
|
// Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
|
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
@@ -400,7 +402,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toLogin = () => {
|
const toLogin = () => {
|
||||||
userStore.logOut()
|
// 锁屏页"返回登录":先撤销后端 token,再走本地清理 + 跳登录
|
||||||
|
void authStore.logout()
|
||||||
}
|
}
|
||||||
|
|
||||||
const openLockScreen = () => {
|
const openLockScreen = () => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { nextTick } from 'vue'
|
|||||||
import NProgress from 'nprogress'
|
import NProgress from 'nprogress'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
import { useMenuStore } from '@/store/modules/menu'
|
import { useMenuStore } from '@/store/modules/menu'
|
||||||
import { setWorktab } from '@/utils/navigation'
|
import { setWorktab } from '@/utils/navigation'
|
||||||
import { setPageTitle } from '@/utils/router'
|
import { setPageTitle } from '@/utils/router'
|
||||||
@@ -48,7 +49,6 @@ import { staticRoutes } from '../routes/staticRoutes'
|
|||||||
import { loadingService } from '@/utils/ui'
|
import { loadingService } from '@/utils/ui'
|
||||||
import { useCommon } from '@/hooks/core/useCommon'
|
import { useCommon } from '@/hooks/core/useCommon'
|
||||||
import { useWorktabStore } from '@/store/modules/worktab'
|
import { useWorktabStore } from '@/store/modules/worktab'
|
||||||
import { fetchGetUserInfo } from '@/api/auth'
|
|
||||||
import { ApiStatus } from '@/utils/http/status'
|
import { ApiStatus } from '@/utils/http/status'
|
||||||
import { isHttpError } from '@/utils/http/error'
|
import { isHttpError } from '@/utils/http/error'
|
||||||
import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core'
|
import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core'
|
||||||
@@ -151,12 +151,18 @@ async function handleRouteGuard(
|
|||||||
NProgress.start()
|
NProgress.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 检查登录状态
|
// 1. 检查登录状态(会话事实源 = auth store 的 token)
|
||||||
if (!handleLoginStatus(to, userStore, next)) {
|
if (!handleLoginStatus(to, userStore, next)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查路由初始化是否已失败(防止死循环)
|
// 2. 强制改密门:会话有效但 must_change → 除改密页外一律重定向
|
||||||
|
// (含登录页——已登录的引导流程不回登录;后端 409 语义的前端镜像)
|
||||||
|
if (!handleMustChangePassword(to, next)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查路由初始化是否已失败(防止死循环)
|
||||||
if (routeInitFailed) {
|
if (routeInitFailed) {
|
||||||
// 已经失败过,直接放行到错误页面,不再重试
|
// 已经失败过,直接放行到错误页面,不再重试
|
||||||
if (to.matched.length > 0) {
|
if (to.matched.length > 0) {
|
||||||
@@ -168,7 +174,7 @@ async function handleRouteGuard(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 处理动态路由注册
|
// 4. 处理动态路由注册
|
||||||
if (!routeRegistry?.isRegistered() && userStore.isLogin) {
|
if (!routeRegistry?.isRegistered() && userStore.isLogin) {
|
||||||
// 防止并发请求(快速连续导航场景)
|
// 防止并发请求(快速连续导航场景)
|
||||||
if (routeInitInProgress) {
|
if (routeInitInProgress) {
|
||||||
@@ -180,12 +186,12 @@ async function handleRouteGuard(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 处理根路径重定向
|
// 5. 处理根路径重定向
|
||||||
if (handleRootPathRedirect(to, next)) {
|
if (handleRootPathRedirect(to, next)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 处理已匹配的路由
|
// 6. 处理已匹配的路由
|
||||||
if (to.matched.length > 0) {
|
if (to.matched.length > 0) {
|
||||||
setWorktab(to)
|
setWorktab(to)
|
||||||
setPageTitle(to)
|
setPageTitle(to)
|
||||||
@@ -193,12 +199,40 @@ async function handleRouteGuard(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 未匹配到路由,跳转到 404
|
// 7. 未匹配到路由,跳转到 404
|
||||||
next({ name: 'Exception404' })
|
next({ name: 'Exception404' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制改密门
|
||||||
|
*
|
||||||
|
* 会话有效且 must_change_password=true 时,除强制改密页本身外
|
||||||
|
* 一律重定向(含登录页与静态异常页——改完初始密码前不进后台)。
|
||||||
|
* 后端同语义:受保护端点 409(HTTP client 全局跳转兜底)。
|
||||||
|
*
|
||||||
|
* @returns true 表示可以继续,false 表示已处理跳转
|
||||||
|
*/
|
||||||
|
function handleMustChangePassword(
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
next: NavigationGuardNext
|
||||||
|
): boolean {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.isAuthenticated || !authStore.mustChangePassword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (to.path === RoutesAlias.ForceChangePassword) {
|
||||||
|
next()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
next({ path: RoutesAlias.ForceChangePassword, replace: true })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理登录状态
|
* 处理登录状态
|
||||||
|
*
|
||||||
|
* 会话事实源为 auth store 的 token(localStorage 持久化,刷新恢复);
|
||||||
|
* 模板 user store 的 isLogin 由登录/登出同步维护(此处对遗留残留自愈)。
|
||||||
* @returns true 表示可以继续,false 表示已处理跳转
|
* @returns true 表示可以继续,false 表示已处理跳转
|
||||||
*/
|
*/
|
||||||
function handleLoginStatus(
|
function handleLoginStatus(
|
||||||
@@ -206,13 +240,26 @@ function handleLoginStatus(
|
|||||||
userStore: ReturnType<typeof useUserStore>,
|
userStore: ReturnType<typeof useUserStore>,
|
||||||
next: NavigationGuardNext
|
next: NavigationGuardNext
|
||||||
): boolean {
|
): boolean {
|
||||||
// 已登录或访问登录页或静态路由,直接放行
|
const authStore = useAuthStore()
|
||||||
if (userStore.isLogin || to.path === RoutesAlias.Login || isStaticRoute(to.path)) {
|
|
||||||
|
// 已认证:放行(自愈模板侧登录标记——如 Task 2 桩时代的残留状态)
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
if (!userStore.isLogin) {
|
||||||
|
userStore.setLoginStatus(true)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未认证:登录页/静态路由放行,但强制改密页需要会话——回登录页
|
||||||
|
if (to.path === RoutesAlias.Login || isStaticRoute(to.path)) {
|
||||||
|
if (to.path === RoutesAlias.ForceChangePassword) {
|
||||||
|
next({ name: 'Login', replace: true })
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数
|
// 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数
|
||||||
userStore.logOut()
|
|
||||||
next({
|
next({
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
query: { redirect: to.fullPath }
|
query: { redirect: to.fullPath }
|
||||||
@@ -266,8 +313,8 @@ async function handleDynamicRoutes(
|
|||||||
loadingService.showLoading()
|
loadingService.showLoading()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 获取用户信息
|
// 1. 派生用户信息(auth store 登录快照 → 模板 UserInfo)
|
||||||
await fetchUserInfo()
|
deriveUserInfo()
|
||||||
|
|
||||||
// 2. 获取菜单数据
|
// 2. 获取菜单数据
|
||||||
const menuList = await menuProcessor.getMenuList()
|
const menuList = await menuProcessor.getMenuList()
|
||||||
@@ -365,12 +412,15 @@ async function handleDynamicRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户信息
|
* 派生用户信息
|
||||||
|
*
|
||||||
|
* 后端无用户信息端点——登录响应(role/username)已随 auth store 持久化,
|
||||||
|
* 此处映射为模板消费形态(roles 供菜单角色过滤,见 MenuProcessor)。
|
||||||
*/
|
*/
|
||||||
async function fetchUserInfo(): Promise<void> {
|
function deriveUserInfo(): void {
|
||||||
|
const authStore = useAuthStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const data = await fetchGetUserInfo()
|
userStore.setUserInfo(authStore.userInfo)
|
||||||
userStore.setUserInfo(data)
|
|
||||||
// 检查并清理工作台标签页(如果是不同用户登录)
|
// 检查并清理工作台标签页(如果是不同用户登录)
|
||||||
userStore.checkAndClearWorktabs()
|
userStore.checkAndClearWorktabs()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* 认证状态管理(Phase 3 Task 3)
|
||||||
|
*
|
||||||
|
* 会话事实源:token / role / tenantSlug / username / mustChangePassword。
|
||||||
|
* 持久化 localStorage(键 all-kefu-auth,all-kefu 前缀与模板 sys-v* 键区隔),
|
||||||
|
* 刷新页面恢复会话;路由守卫与 HTTP client(401/409 全局处理)消费。
|
||||||
|
*
|
||||||
|
* 模板侧同步:登录成功后把 token / 登录标记 / 用户信息(roles 供菜单过滤)
|
||||||
|
* 同步进模板的 user store(isLogin/accessToken/info 是模板路由初始化与
|
||||||
|
* 顶栏展示的既有依赖);登出委托 userStore.logOut() 完成模板全套清理
|
||||||
|
* (动态路由注销、工作台标签、跳登录页)。
|
||||||
|
*
|
||||||
|
* 与 @/api/client 的模块环(client → 本模块 → @/api/auth → client):
|
||||||
|
* 双方均只在函数体内访问对方导出(ESM 活绑定),模块求值期无交叉读取。
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { fetchChangePassword, fetchLogin, fetchLogout } from '@/api/auth'
|
||||||
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
import { RoutesAlias } from '@/router/routesAlias'
|
||||||
|
|
||||||
|
/** 平台角色(与后端 token 快照一致) */
|
||||||
|
export type AdminRole = 'super' | 'tenant'
|
||||||
|
|
||||||
|
/** 登录参数(POST /api/platform/auth/login) */
|
||||||
|
export interface LoginParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改密参数(POST /api/platform/auth/change-password) */
|
||||||
|
export interface ChangePasswordParams {
|
||||||
|
old_password: string
|
||||||
|
new_password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录响应(后端 auth_routes.login) */
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
role: AdminRole
|
||||||
|
tenant_slug: string | null
|
||||||
|
must_change_password: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 持久化键(all-kefu 前缀;user store 等模板键为 sys-v* 体系) */
|
||||||
|
const AUTH_STORAGE_KEY = 'all-kefu-auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名 → 稳定伪 ID(djb2 哈希)
|
||||||
|
*
|
||||||
|
* 后端不下发 user_id;模板 user store 的工作台跨用户清理
|
||||||
|
* (checkAndClearWorktabs)按 userId 比对,用确定性哈希保持该语义。
|
||||||
|
*/
|
||||||
|
function stableUserId(username: string): number {
|
||||||
|
let hash = 5381
|
||||||
|
for (let i = 0; i < username.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + username.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
return Math.abs(hash) || 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore(
|
||||||
|
'authStore',
|
||||||
|
() => {
|
||||||
|
// ---- 状态 ------------------------------------------------------------------
|
||||||
|
const token = ref('')
|
||||||
|
const role = ref<AdminRole | ''>('')
|
||||||
|
const tenantSlug = ref<string | null>(null)
|
||||||
|
const username = ref('')
|
||||||
|
const mustChangePassword = ref(false)
|
||||||
|
|
||||||
|
// ---- 计算属性 ----------------------------------------------------------------
|
||||||
|
const isAuthenticated = computed(() => token.value !== '')
|
||||||
|
|
||||||
|
/** 模板 Api.Auth.UserInfo 映射(守卫初始化动态路由/菜单角色过滤用) */
|
||||||
|
const userInfo = computed<Api.Auth.UserInfo>(() => ({
|
||||||
|
buttons: [],
|
||||||
|
roles: role.value ? [role.value] : [],
|
||||||
|
userId: stableUserId(username.value),
|
||||||
|
userName: username.value,
|
||||||
|
email: ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---- 内部工具 ----------------------------------------------------------------
|
||||||
|
/** 登录成功后的状态落地(不调 API) */
|
||||||
|
function applyLogin(loginUsername: string, res: LoginResponse): void {
|
||||||
|
token.value = res.token
|
||||||
|
role.value = res.role
|
||||||
|
tenantSlug.value = res.tenant_slug
|
||||||
|
mustChangePassword.value = res.must_change_password
|
||||||
|
username.value = loginUsername
|
||||||
|
syncUserStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步模板 user store(模板路由初始化/顶栏依赖其状态) */
|
||||||
|
function syncUserStore(): void {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
userStore.setToken(token.value, '')
|
||||||
|
userStore.setLoginStatus(true)
|
||||||
|
userStore.setUserInfo(userInfo.value)
|
||||||
|
userStore.checkAndClearWorktabs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空本 store 全部会话状态 */
|
||||||
|
function clearSession(): void {
|
||||||
|
token.value = ''
|
||||||
|
role.value = ''
|
||||||
|
tenantSlug.value = null
|
||||||
|
username.value = ''
|
||||||
|
mustChangePassword.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面跳转(动态 import 断开与 router 模块的静态环;
|
||||||
|
* 已在目标页则不重复跳——防 401/409 拦截死循环)
|
||||||
|
*/
|
||||||
|
async function goTo(path: string): Promise<void> {
|
||||||
|
const { router } = await import('@/router')
|
||||||
|
if (router.currentRoute.value.path === path) return
|
||||||
|
await router.replace(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Actions -----------------------------------------------------------------
|
||||||
|
/** 登录:调 API 成功后落地会话状态(失败向上抛,由调用方/全局拦截提示) */
|
||||||
|
async function login(params: LoginParams): Promise<LoginResponse> {
|
||||||
|
const res = await fetchLogin(params)
|
||||||
|
applyLogin(params.username, res)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出:调后端撤销 token(best effort,失败也照常本地清理),
|
||||||
|
* 清空会话后委托模板 userStore.logOut()(动态路由注销 + 跳登录页)
|
||||||
|
*/
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetchLogout()
|
||||||
|
} catch {
|
||||||
|
// token 已失效/网络不可达:本地清理照常进行
|
||||||
|
}
|
||||||
|
clearSession()
|
||||||
|
useUserStore().logOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改密:成功后清 must_change 标志(后端已就地清当前 token 的标志,
|
||||||
|
* 会话保持,改完无需重登)
|
||||||
|
*/
|
||||||
|
async function changePassword(params: ChangePasswordParams): Promise<void> {
|
||||||
|
await fetchChangePassword(params)
|
||||||
|
mustChangePassword.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 401 全局处理:清会话 + 模板侧全套清理(userStore.logOut 含跳登录) */
|
||||||
|
function handleSessionExpired(): void {
|
||||||
|
clearSession()
|
||||||
|
useUserStore().logOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 409 强制改密全局处理:置标志 + 跳改密页(已在改密页不重复跳) */
|
||||||
|
async function handleMustChangePassword(): Promise<void> {
|
||||||
|
mustChangePassword.value = true
|
||||||
|
await goTo(RoutesAlias.ForceChangePassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
role,
|
||||||
|
tenantSlug,
|
||||||
|
username,
|
||||||
|
mustChangePassword,
|
||||||
|
isAuthenticated,
|
||||||
|
userInfo,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
changePassword,
|
||||||
|
clearSession,
|
||||||
|
handleSessionExpired,
|
||||||
|
handleMustChangePassword
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
persist: {
|
||||||
|
key: AUTH_STORAGE_KEY,
|
||||||
|
storage: localStorage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -41,6 +41,7 @@ import { AppRouteRecord } from '@/types/router'
|
|||||||
import { setPageTitle } from '@/utils/router'
|
import { setPageTitle } from '@/utils/router'
|
||||||
import { resetRouterState } from '@/router/guards/beforeEach'
|
import { resetRouterState } from '@/router/guards/beforeEach'
|
||||||
import { useMenuStore } from './menu'
|
import { useMenuStore } from './menu'
|
||||||
|
import { RoutesAlias } from '@/router/routesAlias'
|
||||||
import { StorageConfig } from '@/utils/storage/storage-config'
|
import { StorageConfig } from '@/utils/storage/storage-config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,8 +168,9 @@ export const useUserStore = defineStore(
|
|||||||
// 重置路由状态
|
// 重置路由状态
|
||||||
resetRouterState(500)
|
resetRouterState(500)
|
||||||
// 跳转到登录页,携带当前路由作为 redirect 参数
|
// 跳转到登录页,携带当前路由作为 redirect 参数
|
||||||
|
// (已在登录页则不带——否则登录成功后会跳回登录页自身)
|
||||||
const currentRoute = router.currentRoute.value
|
const currentRoute = router.currentRoute.value
|
||||||
const redirect = currentRoute.path !== '/login' ? currentRoute.fullPath : undefined
|
const redirect = currentRoute.path !== RoutesAlias.Login ? currentRoute.fullPath : undefined
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
query: redirect ? { redirect } : undefined
|
query: redirect ? { redirect } : undefined
|
||||||
|
|||||||
Vendored
+7
-16
@@ -24,8 +24,7 @@
|
|||||||
* ## 使用方式
|
* ## 使用方式
|
||||||
*
|
*
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* const params: Api.Auth.LoginParams = { userName: 'admin', password: '123456' }
|
* const info: Api.Auth.UserInfo = { buttons: [], roles: ['super'], userId: 1, userName: 'admin', email: '' }
|
||||||
* const response: Api.Auth.UserInfo = await fetchUserInfo()
|
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @module types/api/api
|
* @module types/api/api
|
||||||
@@ -60,21 +59,13 @@ declare namespace Api {
|
|||||||
type EnableStatus = '1' | '2'
|
type EnableStatus = '1' | '2'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 认证类型 */
|
/** 认证类型(登录/改密参数与响应见 src/store/modules/auth.ts,与后端字段一一对应) */
|
||||||
namespace Auth {
|
namespace Auth {
|
||||||
/** 登录参数 */
|
/**
|
||||||
interface LoginParams {
|
* 用户信息(模板消费形态:路由初始化/菜单角色过滤/顶栏展示)。
|
||||||
userName: string
|
* 后端不下发用户信息端点——由 auth store 从登录快照派生
|
||||||
password: string
|
* (roles=[role]、userName=登录名、userId=用户名稳定哈希)。
|
||||||
}
|
*/
|
||||||
|
|
||||||
/** 登录响应 */
|
|
||||||
interface LoginResponse {
|
|
||||||
token: string
|
|
||||||
refreshToken: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户信息 */
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
buttons: string[]
|
buttons: string[]
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
|||||||
-2
@@ -85,8 +85,6 @@ declare module 'vue' {
|
|||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
|
||||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- 强制修改密码(骨架占位页;登录对接与提交逻辑在后续任务实现) -->
|
<!-- 强制修改密码(首登引导 / 管理员重置后的 must_change 语义) -->
|
||||||
<template>
|
<template>
|
||||||
<div class="force-change-password flex-cc w-full h-screen">
|
<div class="force-change-password flex-cc w-full h-screen">
|
||||||
<ElCard shadow="never" class="card">
|
<ElCard shadow="never" class="card">
|
||||||
@@ -6,11 +6,20 @@
|
|||||||
<span class="card-title">首次登录请修改密码</span>
|
<span class="card-title">首次登录请修改密码</span>
|
||||||
</template>
|
</template>
|
||||||
<ElForm ref="formRef" :model="formData" :rules="rules" label-position="top">
|
<ElForm ref="formRef" :model="formData" :rules="rules" label-position="top">
|
||||||
|
<ElFormItem prop="oldPassword" label="原密码">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.oldPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入原密码"
|
||||||
|
show-password
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
<ElFormItem prop="newPassword" label="新密码">
|
<ElFormItem prop="newPassword" label="新密码">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model.trim="formData.newPassword"
|
v-model.trim="formData.newPassword"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入新密码"
|
placeholder="请输入新密码(不少于 8 位)"
|
||||||
show-password
|
show-password
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
@@ -28,25 +37,31 @@
|
|||||||
提交修改
|
提交修改
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
<p class="tip">密码修改成功后方可进入后台(对接后端 API 在后续任务实现)</p>
|
<p class="tip">密码修改成功后方可进入后台;修改后当前会话保持有效,无需重新登录</p>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
|
||||||
defineOptions({ name: 'ForceChangePassword' })
|
defineOptions({ name: 'ForceChangePassword' })
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
|
oldPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
|
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||||
newPassword: [
|
newPassword: [
|
||||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
{ min: 8, message: '密码长度不少于 8 位', trigger: 'blur' }
|
{ min: 8, message: '密码长度不少于 8 位', trigger: 'blur' }
|
||||||
@@ -66,15 +81,27 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 骨架阶段仅做表单校验,不发起请求(Task 3 对接 /api/platform/auth/*)
|
// 提交改密(POST /api/platform/auth/change-password):
|
||||||
|
// 成功 → must_change 清零 → 进仪表盘;400 原密码不正确 / 401 会话过期
|
||||||
|
// 由 HTTP client 统一提示
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
const valid = await formRef.value.validate().catch(() => false)
|
const valid = await formRef.value.validate().catch(() => false)
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
setTimeout(() => {
|
try {
|
||||||
|
await authStore.changePassword({
|
||||||
|
old_password: formData.oldPassword,
|
||||||
|
new_password: formData.newPassword
|
||||||
|
})
|
||||||
|
ElMessage.success('密码修改成功,即将进入后台')
|
||||||
|
await router.push('/')
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由 HTTP client 统一弹出(如 400 原密码不正确)
|
||||||
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}, 600)
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -78,15 +78,6 @@
|
|||||||
{{ $t('login.btnText') }}
|
{{ $t('login.btnText') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 角色调试开关:脚手架阶段演示菜单权限控制,Task 3 接入真实登录后移除 -->
|
|
||||||
<div class="mt-5 text-sm text-gray-600 flex-cb">
|
|
||||||
<span>角色(开发调试)</span>
|
|
||||||
<ElRadioGroup v-model="devRole" size="small">
|
|
||||||
<ElRadioButton value="super">超管</ElRadioButton>
|
|
||||||
<ElRadioButton value="tenant">租户管理员</ElRadioButton>
|
|
||||||
</ElRadioGroup>
|
|
||||||
</div>
|
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,9 +87,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppConfig from '@/config'
|
import AppConfig from '@/config'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { fetchLogin, setDevStubRole, type DevStubRole } from '@/api/auth'
|
import { RoutesAlias } from '@/router/routesAlias'
|
||||||
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
|
|
||||||
@@ -116,7 +107,7 @@
|
|||||||
|
|
||||||
const dragVerify = ref()
|
const dragVerify = ref()
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const isPassing = ref(false)
|
const isPassing = ref(false)
|
||||||
@@ -125,8 +116,6 @@
|
|||||||
const systemName = AppConfig.systemInfo.name
|
const systemName = AppConfig.systemInfo.name
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
const devRole = ref<DevStubRole>('super')
|
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -157,43 +146,31 @@
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 记录角色调试开关(开发桩读取;Task 3 移除)
|
// 登录请求(token/role/tenant_slug/must_change 由 auth store 落地并持久化)
|
||||||
setDevStubRole(devRole.value)
|
|
||||||
|
|
||||||
// 登录请求
|
|
||||||
const { username, password } = formData
|
const { username, password } = formData
|
||||||
|
await authStore.login({ username, password })
|
||||||
const { token, refreshToken } = await fetchLogin({
|
|
||||||
userName: username,
|
|
||||||
password
|
|
||||||
})
|
|
||||||
|
|
||||||
// 验证token
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('Login failed - no token received')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存储 token 和登录状态
|
|
||||||
userStore.setToken(token, refreshToken)
|
|
||||||
userStore.setLoginStatus(true)
|
|
||||||
|
|
||||||
// 登录成功处理
|
// 登录成功处理
|
||||||
showLoginSuccessNotice()
|
showLoginSuccessNotice()
|
||||||
|
|
||||||
// 获取 redirect 参数,如果存在则跳转到指定页面,否则跳转到首页
|
// 首登引导(must_change)直接进强制改密页,免一次中转;
|
||||||
|
// 正常登录按 redirect 回跳或进首页
|
||||||
const redirect = route.query.redirect as string
|
const redirect = route.query.redirect as string
|
||||||
router.push(redirect || '/')
|
await router.push(
|
||||||
} catch (error) {
|
authStore.mustChangePassword ? RoutesAlias.ForceChangePassword : redirect || '/'
|
||||||
console.error('[Login] Unexpected error:', error)
|
)
|
||||||
|
} catch {
|
||||||
|
// 错误提示已由 HTTP client 统一弹出:
|
||||||
|
// 401 凭据错误 / 429 登录限流(后端 detail 中文文案)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
resetDragVerify()
|
resetDragVerify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置拖拽验证
|
// 重置拖拽验证(登录成功跳转后组件已卸载,ref 为 null——需可选链)
|
||||||
const resetDragVerify = () => {
|
const resetDragVerify = () => {
|
||||||
dragVerify.value.reset()
|
dragVerify.value?.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录成功提示
|
// 登录成功提示
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Vitest 配置(Phase 3 Task 3 引入)
|
||||||
|
*
|
||||||
|
* 仅覆盖单元测试(src 下各 __tests__ 目录中的 *.test.ts)——与
|
||||||
|
* vite.config.ts 分离,避免测试环境加载 devtools/compression 等构建插件。
|
||||||
|
* 路径别名与 vite.config.ts / tsconfig.json 保持一致(@ @views @utils @stores 等)。
|
||||||
|
*
|
||||||
|
* CI 语义:`pnpm test` = `vitest run`(一次性跑完退出,非 watch)。
|
||||||
|
*/
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
'@views': fileURLToPath(new URL('./src/views', import.meta.url)),
|
||||||
|
'@imgs': fileURLToPath(new URL('./src/assets/images', import.meta.url)),
|
||||||
|
'@icons': fileURLToPath(new URL('./src/assets/icons', import.meta.url)),
|
||||||
|
'@utils': fileURLToPath(new URL('./src/utils', import.meta.url)),
|
||||||
|
'@stores': fileURLToPath(new URL('./src/store', import.meta.url)),
|
||||||
|
'@plugins': fileURLToPath(new URL('./src/plugins', import.meta.url)),
|
||||||
|
'@styles': fileURLToPath(new URL('./src/assets/styles', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
include: ['src/**/__tests__/**/*.test.ts'],
|
||||||
|
// 测试文件显式 import { describe, it, expect, vi } from 'vitest',不开 globals
|
||||||
|
globals: false
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user