diff --git a/admin/package.json b/admin/package.json index f3b544b..a46c6c0 100644 --- a/admin/package.json +++ b/admin/package.json @@ -21,6 +21,7 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build", "serve": "vite preview", + "test": "vitest run", "lint": "eslint", "fix": "eslint --fix", "lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,vue,html,md}\"", @@ -60,6 +61,7 @@ "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-vue": "^9.27.0", "globals": "^15.9.0", + "happy-dom": "^21.0.1", "prettier": "^3.5.3", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.81.0", @@ -79,6 +81,7 @@ "vite": "^7.1.5", "vite-plugin-compression": "^0.5.1", "vite-plugin-vue-devtools": "^7.7.6", + "vitest": "^3.2.4", "vue-tsc": "~2.1.6" } } diff --git a/admin/pnpm-lock.yaml b/admin/pnpm-lock.yaml index b88b1d6..71e1870 100644 --- a/admin/pnpm-lock.yaml +++ b/admin/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: globals: specifier: ^15.9.0 version: 15.15.0 + happy-dom: + specifier: ^21.0.1 + version: 21.0.1 prettier: specifier: ^3.5.3 version: 3.6.2 @@ -156,6 +159,9 @@ importers: vite-plugin-vue-devtools: 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)) + 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: specifier: ~2.1.6 version: 2.1.10(typescript@5.6.3) @@ -941,6 +947,12 @@ packages: peerDependencies: 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': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -965,6 +977,12 @@ packages: '@types/web-bluetooth@0.0.21': 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': resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==} 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 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': resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==} @@ -1173,6 +1220,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -1224,10 +1275,18 @@ packages: buffer-from@1.1.2: 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: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacheable@2.0.2: resolution: {integrity: sha512-dWjhLx8RWnPsAWVKwW/wI6OJpQ/hSVb1qS0NUif8TR9vRiSwci7Gey8x04kRU9iAF+Rnbtex5Kjjfg/aB5w8Pg==} @@ -1242,10 +1301,18 @@ packages: caniuse-lite@1.0.30001745: 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: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1342,6 +1409,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1426,6 +1497,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1561,6 +1636,10 @@ packages: resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} 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: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -1725,6 +1804,10 @@ packages: graphemer@1.4.0: 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: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2012,6 +2095,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2178,6 +2264,10 @@ packages: pathe@2.0.3: 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: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -2383,6 +2473,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2418,6 +2511,12 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} 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: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2539,10 +2638,28 @@ packages: engines: {node: '>=10'} 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: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 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: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2661,6 +2778,11 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==} peerDependencies: @@ -2727,6 +2849,34 @@ packages: yaml: 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: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -2784,6 +2934,10 @@ packages: webpack-virtual-modules@0.6.2: 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: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -2793,6 +2947,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2805,6 +2964,18 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} 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: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -3507,6 +3678,13 @@ snapshots: 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) + '@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/json-schema@7.0.15': {} @@ -3527,6 +3705,12 @@ snapshots: '@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)': dependencies: '@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) 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': dependencies: '@volar/source-map': 2.4.23 @@ -3840,6 +4066,8 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} async-validator@4.2.5: {} @@ -3889,10 +4117,16 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.8.1 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 + cac@6.7.14: {} + cacheable@2.0.2: dependencies: '@cacheable/memoize': 2.0.2 @@ -3910,11 +4144,21 @@ snapshots: 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: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -4001,6 +4245,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} deep-pick-omit@1.2.1: {} @@ -4092,6 +4338,8 @@ snapshots: entities@4.5.0: {} + entities@7.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -4282,6 +4530,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.4.0: {} + exsolve@1.0.7: {} fast-deep-equal@3.1.3: {} @@ -4447,6 +4697,19 @@ snapshots: 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-symbols@1.1.0: {} @@ -4657,6 +4920,8 @@ snapshots: lodash@4.17.21: {} + loupe@3.2.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -4802,6 +5067,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -4985,6 +5252,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} sirv@3.0.2: @@ -5014,6 +5283,10 @@ snapshots: speakingurl@14.0.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5180,11 +5453,21 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(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: dependencies: is-number: 7.0.0 @@ -5315,6 +5598,27 @@ snapshots: 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-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)): dependencies: chalk: 4.1.2 @@ -5389,6 +5693,48 @@ snapshots: tsx: 4.20.6 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: {} vue-demi@0.14.10(vue@3.5.22(typescript@5.6.3)): @@ -5443,6 +5789,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@3.0.0: {} + which@1.3.1: dependencies: isexe: 2.0.0 @@ -5451,6 +5799,11 @@ snapshots: dependencies: 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: {} wrap-ansi@7.0.0: @@ -5464,6 +5817,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + ws@8.22.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 diff --git a/admin/src/api/__tests__/auth-store.test.ts b/admin/src/api/__tests__/auth-store.test.ts new file mode 100644 index 0000000..8bb2a38 --- /dev/null +++ b/admin/src/api/__tests__/auth-store.test.ts @@ -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) + }) +}) diff --git a/admin/src/api/__tests__/client.test.ts b/admin/src/api/__tests__/client.test.ts new file mode 100644 index 0000000..262c80e --- /dev/null +++ b/admin/src/api/__tests__/client.test.ts @@ -0,0 +1,238 @@ +/** + * HTTP client 单元测试(Phase 3 Task 3) + * + * 契约对齐后端 app/platform/auth.py 与 auth_routes.py: + * - Authorization: Bearer 注入 + * - 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 => { + 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 ', 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('/api/platform/auth/login', {}, { adapter }) + + expect(res).toEqual(body) + }) + }) +}) diff --git a/admin/src/api/__tests__/mask.test.ts b/admin/src/api/__tests__/mask.test.ts new file mode 100644 index 0000000..5564d5c --- /dev/null +++ b/admin/src/api/__tests__/mask.test.ts @@ -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) + }) +}) diff --git a/admin/src/api/auth.ts b/admin/src/api/auth.ts index fc35461..3c08637 100644 --- a/admin/src/api/auth.ts +++ b/admin/src/api/auth.ts @@ -1,60 +1,38 @@ /** - * 认证 API——脚手架阶段(Phase 3 Task 2)的开发桩。 + * 认证 API(Phase 3 Task 3 实装,替换 Task 2 开发桩) * - * 后端真实端点为 /api/platform/auth/*(登录 / 用户信息 / 改密), - * Task 3 对接真实登录时将本文件替换为真实请求。 + * 对接后端 app/platform/routes/auth_routes.py: + * - 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} * - * 桩行为: - * - fetchLogin:开发态直接发放本地会话令牌,不发起网络请求; - * - fetchGetUserInfo:按登录页的角色调试开关(sessionStorage - * `all-kefu-dev-role`:super | tenant)返回用户信息,用于演示 - * 纯前端菜单权限控制——super 全量菜单,tenant 隐藏 - * 租户管理 / 平台设置(见 router/modules/*.ts 的 meta.roles); - * - 生产构建(非 DEV)下调用将抛出显式错误,避免带桩上线。 + * 错误语义(HTTP client 统一处理,见 src/api/client.ts): + * - 401 凭据错误/会话过期(登录请求自身 401 只提示不跳转) + * - 429 登录限流(5 次/15 分钟) + * - 400 原密码不正确 */ -const DEV_ROLE_KEY = 'all-kefu-dev-role' -export type DevStubRole = 'super' | 'tenant' +import { http } from './client' +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 { + return http.post('/api/platform/auth/login', params) } -export function getDevStubRole(): DevStubRole { - return sessionStorage.getItem(DEV_ROLE_KEY) === 'tenant' ? 'tenant' : 'super' +/** 登出(撤销当前 token;需 Bearer) */ +export function fetchLogout(): Promise<{ ok: boolean }> { + return http.post<{ ok: boolean }>('/api/platform/auth/logout') } -function assertStubAllowed(): void { - if (!import.meta.env.DEV) { - throw new Error('认证 API 尚未对接真实后端(Task 3 实现),生产构建不可用') - } -} - -/** - * 登录(开发桩:任意账号密码直接获得开发会话) - * @param params 登录参数 - * @returns 登录响应 - */ -export function fetchLogin(params: Api.Auth.LoginParams) { - assertStubAllowed() - void params - return Promise.resolve({ - token: 'dev-stub-token', - refreshToken: '' - }) -} - -/** - * 获取用户信息(开发桩:角色取自登录页调试开关) - * @returns 用户信息 - */ -export function fetchGetUserInfo() { - assertStubAllowed() - const role = getDevStubRole() - return Promise.resolve({ - buttons: [], - roles: [role], - userId: 1, - userName: role === 'super' ? '平台超管(调试)' : '租户管理员(调试)', - email: 'dev@all-kefu.local' - }) +/** 修改密码(验旧密 → 写新 hash;改完当前会话保持有效) */ +export function fetchChangePassword(params: ChangePasswordParams): Promise<{ + ok: boolean + must_change_password: boolean +}> { + return http.post<{ ok: boolean; must_change_password: boolean }>( + '/api/platform/auth/change-password', + params + ) } diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts new file mode 100644 index 0000000..f0a497a --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,143 @@ +/** + * 平台 API HTTP client(Phase 3 Task 3,自建薄封装) + * + * ## 为什么不用模板的 src/utils/http + * + * 模板封装与后端约定对不上,改造成本高于自建: + * - 响应:模板要求 `{code, msg, data}` 业务信封;后端(FastAPI)业务语义 + * 直用 HTTP 状态码,成功响应就是数据本身 + * - 错误:模板读 `data.msg`;FastAPI 错误体是 `{detail: 中文}` + * - 凭据:模板裸塞 `Authorization: `;后端要求 `Bearer ` + * - 模板无 409 强制改密语义 + * + * ## 本 client 约定 + * + * - 请求拦截:auth store 有 token 时自动注入 `Authorization: Bearer ` + * - 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): 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) => { + 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(config: RequestOptions): Promise { + const response = await instance.request(config) + return response.data +} + +/** API 方法集合 */ +export const http = { + get(url: string, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'GET' }) + }, + post(url: string, data?: unknown, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'POST', data }) + }, + put(url: string, data?: unknown, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'PUT', data }) + }, + patch(url: string, data?: unknown, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'PATCH', data }) + }, + del(url: string, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'DELETE' }) + } +} diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts new file mode 100644 index 0000000..f47ec6d --- /dev/null +++ b/admin/src/api/config.ts @@ -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> +} + +/** 配置版本行(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> +} + +/** 回滚请求(POST .../config/rollback,产生新版本) */ +export interface ConfigRollbackRequest { + version: number + note: string +} + +/** 分组保存请求(PUT .../config/{group}) */ +export interface PutConfigGroupRequest { + note: string + value: Record +} + +/** 词表试金石请求(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 diff --git a/admin/src/api/conversations.ts b/admin/src/api/conversations.ts new file mode 100644 index 0000000..5c96762 --- /dev/null +++ b/admin/src/api/conversations.ts @@ -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 + messages: ConversationMessage[] + profile: { + chips: Record + 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 diff --git a/admin/src/api/mask.ts b/admin/src/api/mask.ts new file mode 100644 index 0000000..60efdfc --- /dev/null +++ b/admin/src/api/mask.ts @@ -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 : '' +} diff --git a/admin/src/api/settings.ts b/admin/src/api/settings.ts new file mode 100644 index 0000000..b6f76dd --- /dev/null +++ b/admin/src/api/settings.ts @@ -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 diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts new file mode 100644 index 0000000..0b57ddb --- /dev/null +++ b/admin/src/api/stats.ts @@ -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 + source: Record + site: Record + 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 diff --git a/admin/src/api/tenants.ts b/admin/src/api/tenants.ts new file mode 100644 index 0000000..599f6c9 --- /dev/null +++ b/admin/src/api/tenants.ts @@ -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 +} + +/** 管理员概要(_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 + 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 diff --git a/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue b/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue index 7b9a370..c8a4a11 100644 --- a/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue +++ b/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue @@ -56,6 +56,7 @@ import { useRouter } from 'vue-router' import { ElMessageBox } from 'element-plus' import { useUserStore } from '@/store/modules/user' + import { useAuthStore } from '@/store/modules/auth' import { mittBus } from '@/utils/sys' defineOptions({ name: 'ArtUserMenu' }) @@ -63,6 +64,7 @@ const router = useRouter() const { t } = useI18n() const userStore = useUserStore() + const authStore = useAuthStore() const { getUserInfo: userInfo } = storeToRefs(userStore) const userMenuPopover = ref() @@ -93,7 +95,8 @@ cancelButtonText: t('common.cancel'), customClass: 'login-out-dialog' }).then(() => { - userStore.logOut() + // 先调后端撤销 token(best effort),再走模板全套本地清理 + 跳登录 + void authStore.logout() }) }, 200) } diff --git a/admin/src/components/core/layouts/art-screen-lock/index.vue b/admin/src/components/core/layouts/art-screen-lock/index.vue index 5bf5248..d7002a9 100644 --- a/admin/src/components/core/layouts/art-screen-lock/index.vue +++ b/admin/src/components/core/layouts/art-screen-lock/index.vue @@ -112,6 +112,7 @@ import { useI18n } from 'vue-i18n' import CryptoJS from 'crypto-js' import { useUserStore } from '@/store/modules/user' + import { useAuthStore } from '@/store/modules/auth' import { mittBus } from '@/utils/sys' // 国际化 @@ -122,6 +123,7 @@ // Store const userStore = useUserStore() + const authStore = useAuthStore() const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore) // 响应式数据 @@ -400,7 +402,8 @@ } const toLogin = () => { - userStore.logOut() + // 锁屏页"返回登录":先撤销后端 token,再走本地清理 + 跳登录 + void authStore.logout() } const openLockScreen = () => { diff --git a/admin/src/router/guards/beforeEach.ts b/admin/src/router/guards/beforeEach.ts index bc97e09..d66cbc8 100644 --- a/admin/src/router/guards/beforeEach.ts +++ b/admin/src/router/guards/beforeEach.ts @@ -40,6 +40,7 @@ import { nextTick } from 'vue' import NProgress from 'nprogress' import { useSettingStore } from '@/store/modules/setting' import { useUserStore } from '@/store/modules/user' +import { useAuthStore } from '@/store/modules/auth' import { useMenuStore } from '@/store/modules/menu' import { setWorktab } from '@/utils/navigation' import { setPageTitle } from '@/utils/router' @@ -48,7 +49,6 @@ import { staticRoutes } from '../routes/staticRoutes' import { loadingService } from '@/utils/ui' import { useCommon } from '@/hooks/core/useCommon' import { useWorktabStore } from '@/store/modules/worktab' -import { fetchGetUserInfo } from '@/api/auth' import { ApiStatus } from '@/utils/http/status' import { isHttpError } from '@/utils/http/error' import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core' @@ -151,12 +151,18 @@ async function handleRouteGuard( NProgress.start() } - // 1. 检查登录状态 + // 1. 检查登录状态(会话事实源 = auth store 的 token) if (!handleLoginStatus(to, userStore, next)) { return } - // 2. 检查路由初始化是否已失败(防止死循环) + // 2. 强制改密门:会话有效但 must_change → 除改密页外一律重定向 + // (含登录页——已登录的引导流程不回登录;后端 409 语义的前端镜像) + if (!handleMustChangePassword(to, next)) { + return + } + + // 3. 检查路由初始化是否已失败(防止死循环) if (routeInitFailed) { // 已经失败过,直接放行到错误页面,不再重试 if (to.matched.length > 0) { @@ -168,7 +174,7 @@ async function handleRouteGuard( return } - // 3. 处理动态路由注册 + // 4. 处理动态路由注册 if (!routeRegistry?.isRegistered() && userStore.isLogin) { // 防止并发请求(快速连续导航场景) if (routeInitInProgress) { @@ -180,12 +186,12 @@ async function handleRouteGuard( return } - // 4. 处理根路径重定向 + // 5. 处理根路径重定向 if (handleRootPathRedirect(to, next)) { return } - // 5. 处理已匹配的路由 + // 6. 处理已匹配的路由 if (to.matched.length > 0) { setWorktab(to) setPageTitle(to) @@ -193,12 +199,40 @@ async function handleRouteGuard( return } - // 6. 未匹配到路由,跳转到 404 + // 7. 未匹配到路由,跳转到 404 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 表示已处理跳转 */ function handleLoginStatus( @@ -206,13 +240,26 @@ function handleLoginStatus( userStore: ReturnType, next: NavigationGuardNext ): boolean { - // 已登录或访问登录页或静态路由,直接放行 - if (userStore.isLogin || to.path === RoutesAlias.Login || isStaticRoute(to.path)) { + const authStore = useAuthStore() + + // 已认证:放行(自愈模板侧登录标记——如 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 } // 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数 - userStore.logOut() next({ name: 'Login', query: { redirect: to.fullPath } @@ -266,8 +313,8 @@ async function handleDynamicRoutes( loadingService.showLoading() try { - // 1. 获取用户信息 - await fetchUserInfo() + // 1. 派生用户信息(auth store 登录快照 → 模板 UserInfo) + deriveUserInfo() // 2. 获取菜单数据 const menuList = await menuProcessor.getMenuList() @@ -365,12 +412,15 @@ async function handleDynamicRoutes( } /** - * 获取用户信息 + * 派生用户信息 + * + * 后端无用户信息端点——登录响应(role/username)已随 auth store 持久化, + * 此处映射为模板消费形态(roles 供菜单角色过滤,见 MenuProcessor)。 */ -async function fetchUserInfo(): Promise { +function deriveUserInfo(): void { + const authStore = useAuthStore() const userStore = useUserStore() - const data = await fetchGetUserInfo() - userStore.setUserInfo(data) + userStore.setUserInfo(authStore.userInfo) // 检查并清理工作台标签页(如果是不同用户登录) userStore.checkAndClearWorktabs() } diff --git a/admin/src/store/modules/auth.ts b/admin/src/store/modules/auth.ts new file mode 100644 index 0000000..9b2563c --- /dev/null +++ b/admin/src/store/modules/auth.ts @@ -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('') + const tenantSlug = ref(null) + const username = ref('') + const mustChangePassword = ref(false) + + // ---- 计算属性 ---------------------------------------------------------------- + const isAuthenticated = computed(() => token.value !== '') + + /** 模板 Api.Auth.UserInfo 映射(守卫初始化动态路由/菜单角色过滤用) */ + const userInfo = computed(() => ({ + 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 { + const { router } = await import('@/router') + if (router.currentRoute.value.path === path) return + await router.replace(path) + } + + // ---- Actions ----------------------------------------------------------------- + /** 登录:调 API 成功后落地会话状态(失败向上抛,由调用方/全局拦截提示) */ + async function login(params: LoginParams): Promise { + const res = await fetchLogin(params) + applyLogin(params.username, res) + return res + } + + /** + * 登出:调后端撤销 token(best effort,失败也照常本地清理), + * 清空会话后委托模板 userStore.logOut()(动态路由注销 + 跳登录页) + */ + async function logout(): Promise { + try { + await fetchLogout() + } catch { + // token 已失效/网络不可达:本地清理照常进行 + } + clearSession() + useUserStore().logOut() + } + + /** + * 改密:成功后清 must_change 标志(后端已就地清当前 token 的标志, + * 会话保持,改完无需重登) + */ + async function changePassword(params: ChangePasswordParams): Promise { + await fetchChangePassword(params) + mustChangePassword.value = false + } + + /** 401 全局处理:清会话 + 模板侧全套清理(userStore.logOut 含跳登录) */ + function handleSessionExpired(): void { + clearSession() + useUserStore().logOut() + } + + /** 409 强制改密全局处理:置标志 + 跳改密页(已在改密页不重复跳) */ + async function handleMustChangePassword(): Promise { + 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 + } + } +) diff --git a/admin/src/store/modules/user.ts b/admin/src/store/modules/user.ts index 08f7684..6ae2fff 100644 --- a/admin/src/store/modules/user.ts +++ b/admin/src/store/modules/user.ts @@ -41,6 +41,7 @@ import { AppRouteRecord } from '@/types/router' import { setPageTitle } from '@/utils/router' import { resetRouterState } from '@/router/guards/beforeEach' import { useMenuStore } from './menu' +import { RoutesAlias } from '@/router/routesAlias' import { StorageConfig } from '@/utils/storage/storage-config' /** @@ -167,8 +168,9 @@ export const useUserStore = defineStore( // 重置路由状态 resetRouterState(500) // 跳转到登录页,携带当前路由作为 redirect 参数 + // (已在登录页则不带——否则登录成功后会跳回登录页自身) const currentRoute = router.currentRoute.value - const redirect = currentRoute.path !== '/login' ? currentRoute.fullPath : undefined + const redirect = currentRoute.path !== RoutesAlias.Login ? currentRoute.fullPath : undefined router.push({ name: 'Login', query: redirect ? { redirect } : undefined diff --git a/admin/src/types/api/api.d.ts b/admin/src/types/api/api.d.ts index ad09c30..45e3a40 100644 --- a/admin/src/types/api/api.d.ts +++ b/admin/src/types/api/api.d.ts @@ -24,8 +24,7 @@ * ## 使用方式 * * ```typescript - * const params: Api.Auth.LoginParams = { userName: 'admin', password: '123456' } - * const response: Api.Auth.UserInfo = await fetchUserInfo() + * const info: Api.Auth.UserInfo = { buttons: [], roles: ['super'], userId: 1, userName: 'admin', email: '' } * ``` * * @module types/api/api @@ -60,21 +59,13 @@ declare namespace Api { type EnableStatus = '1' | '2' } - /** 认证类型 */ + /** 认证类型(登录/改密参数与响应见 src/store/modules/auth.ts,与后端字段一一对应) */ namespace Auth { - /** 登录参数 */ - interface LoginParams { - userName: string - password: string - } - - /** 登录响应 */ - interface LoginResponse { - token: string - refreshToken: string - } - - /** 用户信息 */ + /** + * 用户信息(模板消费形态:路由初始化/菜单角色过滤/顶栏展示)。 + * 后端不下发用户信息端点——由 auth store 从登录快照派生 + * (roles=[role]、userName=登录名、userId=用户名稳定哈希)。 + */ interface UserInfo { buttons: string[] roles: string[] diff --git a/admin/src/types/import/components.d.ts b/admin/src/types/import/components.d.ts index a1af2ef..447520a 100644 --- a/admin/src/types/import/components.d.ts +++ b/admin/src/types/import/components.d.ts @@ -85,8 +85,6 @@ declare module 'vue' { ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] 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'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] diff --git a/admin/src/views/auth/force-change-password/index.vue b/admin/src/views/auth/force-change-password/index.vue index aa9910d..c6b590c 100644 --- a/admin/src/views/auth/force-change-password/index.vue +++ b/admin/src/views/auth/force-change-password/index.vue @@ -1,4 +1,4 @@ - + + + + @@ -28,25 +37,31 @@ 提交修改 -

密码修改成功后方可进入后台(对接后端 API 在后续任务实现)

+

密码修改成功后方可进入后台;修改后当前会话保持有效,无需重新登录

diff --git a/admin/src/views/auth/login/index.vue b/admin/src/views/auth/login/index.vue index f0eaa62..77f8bce 100644 --- a/admin/src/views/auth/login/index.vue +++ b/admin/src/views/auth/login/index.vue @@ -78,15 +78,6 @@ {{ $t('login.btnText') }} - - -
- 角色(开发调试) - - 超管 - 租户管理员 - -
@@ -96,9 +87,9 @@