feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
13
app-frontend/login.html
Normal file
13
app-frontend/login.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>知识管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>该页面需要启用 JavaScript 才能使用。</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/login-main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
389
app-frontend/package-lock.json
generated
389
app-frontend/package-lock.json
generated
@@ -18,6 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
},
|
||||
@@ -552,12 +554,55 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.7",
|
||||
@@ -1157,6 +1202,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
@@ -1202,6 +1260,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1214,6 +1288,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/copy-anything": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
@@ -1427,12 +1508,32 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1617,6 +1718,31 @@
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -1693,6 +1819,38 @@
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
|
||||
"integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1723,6 +1881,24 @@
|
||||
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
@@ -1741,7 +1917,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1770,6 +1945,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
"exsolve": "^1.0.7",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -1804,6 +1991,37 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
@@ -1852,6 +2070,13 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
@@ -1898,6 +2123,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
@@ -1927,6 +2165,148 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unimport": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/unimport/-/unimport-5.6.0.tgz",
|
||||
"integrity": "sha512-8rqAmtJV8o60x46kBAJKtHpJDJWkA2xcBqWKPI14MgUb05o1pnpnCnXSxedUXyeq7p8fR5g3pTo2BaswZ9lD9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"pkg-types": "^2.3.0",
|
||||
"scule": "^1.3.0",
|
||||
"strip-literal": "^3.1.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unimport/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-auto-import": {
|
||||
"version": "21.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz",
|
||||
"integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"picomatch": "^4.0.3",
|
||||
"unimport": "^5.6.0",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^4.0.0",
|
||||
"@vueuse/core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
|
||||
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components": {
|
||||
"version": "31.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.0.0.tgz",
|
||||
"integrity": "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"obug": "^2.1.1",
|
||||
"picomatch": "^4.0.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^3.2.2 || ^4.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
|
||||
@@ -2046,6 +2426,13 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ export async function login(payload) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function passkeyLoginOptions(payload) {
|
||||
const { data } = await publicApi.post('/passkeys/login/options', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function passkeyLoginVerify(payload) {
|
||||
const { data } = await publicApi.post('/passkeys/login/verify', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function register(payload) {
|
||||
const { data } = await publicApi.post('/register', payload)
|
||||
return data
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
let lastToastKey = ''
|
||||
let lastToastAt = 0
|
||||
@@ -7,13 +6,76 @@ let lastToastAt = 0
|
||||
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
|
||||
const MAX_RETRY_COUNT = 1
|
||||
const RETRY_BASE_DELAY_MS = 300
|
||||
const TOAST_STYLE_ID = 'zsglpt-lite-toast-style'
|
||||
|
||||
function ensureToastStyle() {
|
||||
if (typeof document === 'undefined') return
|
||||
if (document.getElementById(TOAST_STYLE_ID)) return
|
||||
const style = document.createElement('style')
|
||||
style.id = TOAST_STYLE_ID
|
||||
style.textContent = `
|
||||
.zsglpt-lite-toast-wrap {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.zsglpt-lite-toast {
|
||||
max-width: min(88vw, 420px);
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.24);
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transition: all .18s ease;
|
||||
}
|
||||
.zsglpt-lite-toast.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.zsglpt-lite-toast.is-error {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
function ensureToastWrap() {
|
||||
if (typeof document === 'undefined') return null
|
||||
ensureToastStyle()
|
||||
let wrap = document.querySelector('.zsglpt-lite-toast-wrap')
|
||||
if (wrap) return wrap
|
||||
wrap = document.createElement('div')
|
||||
wrap.className = 'zsglpt-lite-toast-wrap'
|
||||
document.body.appendChild(wrap)
|
||||
return wrap
|
||||
}
|
||||
|
||||
function showLiteToast(message) {
|
||||
const wrap = ensureToastWrap()
|
||||
if (!wrap) return
|
||||
const node = document.createElement('div')
|
||||
node.className = 'zsglpt-lite-toast is-error'
|
||||
node.textContent = String(message || '请求失败')
|
||||
wrap.appendChild(node)
|
||||
requestAnimationFrame(() => node.classList.add('is-visible'))
|
||||
window.setTimeout(() => node.classList.remove('is-visible'), 2300)
|
||||
window.setTimeout(() => node.remove(), 2600)
|
||||
}
|
||||
|
||||
function toastErrorOnce(key, message, minIntervalMs = 1500) {
|
||||
const now = Date.now()
|
||||
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
|
||||
lastToastKey = key
|
||||
lastToastAt = now
|
||||
ElMessage.error(message)
|
||||
showLiteToast(message)
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { publicApi } from './http'
|
||||
|
||||
export async function fetchSchedules() {
|
||||
const { data } = await publicApi.get('/schedules')
|
||||
export async function fetchSchedules(params = {}) {
|
||||
const { data } = await publicApi.get('/schedules', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -39,4 +39,3 @@ export async function clearScheduleLogs(scheduleId) {
|
||||
const { data } = await publicApi.delete(`/schedules/${scheduleId}/logs`)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { publicApi } from './http'
|
||||
|
||||
export async function fetchScreenshots() {
|
||||
const { data } = await publicApi.get('/screenshots')
|
||||
export async function fetchScreenshots(params = {}) {
|
||||
const { data } = await publicApi.get('/screenshots', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -14,4 +14,3 @@ export async function clearScreenshots() {
|
||||
const { data } = await publicApi.post('/screenshots/clear', {})
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -44,3 +44,28 @@ export async function fetchKdocsStatus() {
|
||||
const { data } = await publicApi.get('/kdocs/status')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchUserPasskeys() {
|
||||
const { data } = await publicApi.get('/user/passkeys')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUserPasskeyOptions(payload) {
|
||||
const { data } = await publicApi.post('/user/passkeys/register/options', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUserPasskeyVerify(payload) {
|
||||
const { data } = await publicApi.post('/user/passkeys/register/verify', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteUserPasskey(passkeyId) {
|
||||
const { data } = await publicApi.delete(`/user/passkeys/${passkeyId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function reportUserPasskeyClientError(payload) {
|
||||
const { data } = await publicApi.post('/user/passkeys/client-error', payload || {})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -9,14 +9,20 @@ import { fetchMyFeedbacks, submitFeedback } from '../api/feedback'
|
||||
import {
|
||||
bindEmail,
|
||||
changePassword,
|
||||
createUserPasskeyOptions,
|
||||
createUserPasskeyVerify,
|
||||
deleteUserPasskey,
|
||||
fetchEmailNotify,
|
||||
fetchUserPasskeys,
|
||||
fetchUserEmail,
|
||||
fetchKdocsSettings,
|
||||
reportUserPasskeyClientError,
|
||||
unbindEmail,
|
||||
updateKdocsSettings,
|
||||
updateEmailNotify,
|
||||
} from '../api/settings'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { createPasskey, isPasskeyAvailable } from '../utils/passkey'
|
||||
import { validateStrongPassword } from '../utils/password'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -116,6 +122,13 @@ const passwordForm = reactive({
|
||||
const kdocsLoading = ref(false)
|
||||
const kdocsSaving = ref(false)
|
||||
const kdocsUnitValue = ref('')
|
||||
const passkeyLoading = ref(false)
|
||||
const passkeyAddLoading = ref(false)
|
||||
const passkeyDeviceName = ref('')
|
||||
const passkeyItems = ref([])
|
||||
const passkeyRegisterOptions = ref(null)
|
||||
const passkeyRegisterOptionsAt = ref(0)
|
||||
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
|
||||
|
||||
function syncIsMobile() {
|
||||
isMobile.value = Boolean(mediaQuery?.matches)
|
||||
@@ -237,7 +250,7 @@ async function openSettings() {
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings()])
|
||||
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings(), loadPasskeys()])
|
||||
}
|
||||
|
||||
async function loadEmailInfo() {
|
||||
@@ -292,6 +305,116 @@ async function saveKdocsSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPasskeys() {
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
const data = await fetchUserPasskeys()
|
||||
passkeyItems.value = Array.isArray(data?.items) ? data.items : []
|
||||
if (passkeyItems.value.length < 3) {
|
||||
await prefetchPasskeyRegisterOptions()
|
||||
} else {
|
||||
passkeyRegisterOptions.value = null
|
||||
passkeyRegisterOptionsAt.value = 0
|
||||
}
|
||||
} catch {
|
||||
passkeyItems.value = []
|
||||
passkeyRegisterOptions.value = null
|
||||
passkeyRegisterOptionsAt.value = 0
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedPasskeyRegisterOptions() {
|
||||
if (!passkeyRegisterOptions.value) return null
|
||||
if (Date.now() - Number(passkeyRegisterOptionsAt.value || 0) > PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS) return null
|
||||
return passkeyRegisterOptions.value
|
||||
}
|
||||
|
||||
async function prefetchPasskeyRegisterOptions() {
|
||||
try {
|
||||
const res = await createUserPasskeyOptions({})
|
||||
passkeyRegisterOptions.value = res
|
||||
passkeyRegisterOptionsAt.value = Date.now()
|
||||
} catch {
|
||||
passkeyRegisterOptions.value = null
|
||||
passkeyRegisterOptionsAt.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddPasskey() {
|
||||
if (!isPasskeyAvailable()) {
|
||||
ElMessage.error('当前浏览器或环境不支持Passkey(需 HTTPS)')
|
||||
return
|
||||
}
|
||||
if (passkeyItems.value.length >= 3) {
|
||||
ElMessage.error('最多可绑定3台设备')
|
||||
return
|
||||
}
|
||||
|
||||
passkeyAddLoading.value = true
|
||||
try {
|
||||
let optionsRes = getCachedPasskeyRegisterOptions()
|
||||
if (!optionsRes) {
|
||||
optionsRes = await createUserPasskeyOptions({})
|
||||
}
|
||||
const credential = await createPasskey(optionsRes?.publicKey || {})
|
||||
await createUserPasskeyVerify({ credential, device_name: passkeyDeviceName.value.trim() })
|
||||
passkeyRegisterOptions.value = null
|
||||
passkeyRegisterOptionsAt.value = 0
|
||||
passkeyDeviceName.value = ''
|
||||
ElMessage.success('Passkey设备添加成功')
|
||||
await loadPasskeys()
|
||||
} catch (e) {
|
||||
try {
|
||||
await reportUserPasskeyClientError({
|
||||
stage: 'register',
|
||||
source: 'user-settings',
|
||||
name: e?.name || '',
|
||||
message: e?.message || '',
|
||||
code: e?.code || '',
|
||||
user_agent: navigator.userAgent || '',
|
||||
})
|
||||
} catch {
|
||||
// ignore report failure
|
||||
}
|
||||
passkeyRegisterOptions.value = null
|
||||
passkeyRegisterOptionsAt.value = 0
|
||||
await prefetchPasskeyRegisterOptions()
|
||||
const data = e?.response?.data
|
||||
const clientMessage = e?.message ? String(e.message) : ''
|
||||
const message =
|
||||
data?.error ||
|
||||
(e?.name === 'NotAllowedError'
|
||||
? `Passkey注册未完成(浏览器返回:${clientMessage || '未提供详细原因'})`
|
||||
: clientMessage || 'Passkey添加失败')
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
passkeyAddLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeletePasskey(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除设备「${item?.device_name || '未命名设备'}」吗?`, '删除Passkey设备', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteUserPasskey(item.id)
|
||||
ElMessage.success('设备已删除')
|
||||
await loadPasskeys()
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onBindEmail() {
|
||||
const email = bindEmailValue.value.trim().toLowerCase()
|
||||
if (!email) {
|
||||
@@ -665,6 +788,47 @@ async function dismissAnnouncementPermanently() {
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Passkey设备" name="passkeys">
|
||||
<div class="settings-section" v-loading="passkeyLoading">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="最多可绑定3台设备,用于无密码登录。"
|
||||
show-icon
|
||||
class="settings-alert"
|
||||
/>
|
||||
|
||||
<el-form inline>
|
||||
<el-form-item label="设备备注">
|
||||
<el-input
|
||||
v-model="passkeyDeviceName"
|
||||
placeholder="例如:我的iPhone / 办公Mac"
|
||||
maxlength="40"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="passkeyAddLoading" @click="onAddPasskey">
|
||||
添加Passkey设备
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-empty v-if="passkeyItems.length === 0" description="暂无Passkey设备" />
|
||||
<el-table v-else :data="passkeyItems" size="small" style="width: 100%">
|
||||
<el-table-column prop="device_name" label="设备备注" min-width="160" />
|
||||
<el-table-column prop="credential_id_preview" label="凭据ID" min-width="180" />
|
||||
<el-table-column prop="last_used_at" label="最近使用" min-width="140" />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="140" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" text @click="onDeletePasskey(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="表格上传" name="kdocs">
|
||||
<div v-loading="kdocsLoading" class="settings-section">
|
||||
<el-form label-position="top">
|
||||
|
||||
6
app-frontend/src/login-main.js
Normal file
6
app-frontend/src/login-main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import LoginPage from './pages/LoginPage.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(LoginPage).mount('#app')
|
||||
@@ -5,11 +5,6 @@ import router from './router'
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
fetchEmailVerifyStatus,
|
||||
forgotPassword,
|
||||
generateCaptcha,
|
||||
login,
|
||||
resendVerifyEmail,
|
||||
} from '../api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
@@ -23,10 +11,14 @@ const needCaptcha = ref(false)
|
||||
const captchaImage = ref('')
|
||||
const captchaSession = ref('')
|
||||
const loading = ref(false)
|
||||
const passkeyLoading = ref(false)
|
||||
|
||||
const emailEnabled = ref(false)
|
||||
const registerVerifyEnabled = ref(false)
|
||||
|
||||
const noticeType = ref('')
|
||||
const noticeText = ref('')
|
||||
|
||||
const forgotOpen = ref(false)
|
||||
const resendOpen = ref(false)
|
||||
|
||||
@@ -38,6 +30,7 @@ const forgotCaptchaImage = ref('')
|
||||
const forgotCaptchaSession = ref('')
|
||||
const forgotLoading = ref(false)
|
||||
const forgotHint = ref('')
|
||||
const forgotError = ref('')
|
||||
|
||||
const resendForm = reactive({
|
||||
email: '',
|
||||
@@ -46,8 +39,172 @@ const resendForm = reactive({
|
||||
const resendCaptchaImage = ref('')
|
||||
const resendCaptchaSession = ref('')
|
||||
const resendLoading = ref(false)
|
||||
const resendError = ref('')
|
||||
|
||||
const showResendLink = computed(() => Boolean(registerVerifyEnabled.value))
|
||||
const showResendLink = computed(() => true)
|
||||
const verifyStatusLoaded = ref(false)
|
||||
|
||||
function getCookie(name) {
|
||||
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
|
||||
return match ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status, data) {
|
||||
super(message || '请求失败')
|
||||
this.name = 'ApiError'
|
||||
this.response = {
|
||||
status: Number(status || 0),
|
||||
data: data || {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function apiRequest(path, options = {}) {
|
||||
const method = String(options.method || 'GET').toUpperCase()
|
||||
const headers = {
|
||||
...(options.headers || {}),
|
||||
}
|
||||
const hasBody = Object.prototype.hasOwnProperty.call(options, 'body')
|
||||
if (hasBody && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
const token = getCookie('csrf_token')
|
||||
if (token) {
|
||||
headers['X-CSRF-Token'] = token
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: hasBody ? JSON.stringify(options.body ?? {}) : undefined,
|
||||
})
|
||||
|
||||
let data = {}
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(data?.error || data?.message || `请求失败 (${response.status})`, response.status, data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const fetchEmailVerifyStatus = () => apiRequest('/email/verify-status')
|
||||
const generateCaptcha = () => apiRequest('/generate_captcha', { method: 'POST', body: {} })
|
||||
const loginRequest = (payload) => apiRequest('/login', { method: 'POST', body: payload || {} })
|
||||
const passkeyLoginOptions = (payload) => apiRequest('/passkeys/login/options', { method: 'POST', body: payload || {} })
|
||||
const passkeyLoginVerify = (payload) => apiRequest('/passkeys/login/verify', { method: 'POST', body: payload || {} })
|
||||
const resendVerifyEmail = (payload) => apiRequest('/resend-verify-email', { method: 'POST', body: payload || {} })
|
||||
const forgotPassword = (payload) => apiRequest('/forgot-password', { method: 'POST', body: payload || {} })
|
||||
|
||||
function base64UrlToUint8Array(base64url) {
|
||||
const value = String(base64url || '')
|
||||
const padding = '='.repeat((4 - (value.length % 4)) % 4)
|
||||
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = window.atob(base64)
|
||||
const bytes = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
bytes[i] = raw.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64Url(input) {
|
||||
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return window
|
||||
.btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function normalizePublicKeyOptions(options) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new Error('Passkey参数无效')
|
||||
}
|
||||
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
|
||||
}
|
||||
|
||||
function toRequestOptions(rawOptions) {
|
||||
const options = normalizePublicKeyOptions(rawOptions)
|
||||
const normalized = {
|
||||
...options,
|
||||
challenge: base64UrlToUint8Array(options.challenge),
|
||||
}
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
normalized.allowCredentials = options.allowCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToUint8Array(item.id),
|
||||
}))
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function serializeCredential(credential) {
|
||||
const response = credential?.response || {}
|
||||
const output = {
|
||||
id: credential?.id,
|
||||
rawId: uint8ArrayToBase64Url(credential?.rawId),
|
||||
type: credential?.type,
|
||||
authenticatorAttachment: credential?.authenticatorAttachment || undefined,
|
||||
response: {},
|
||||
}
|
||||
if (response.clientDataJSON) output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
|
||||
if (response.authenticatorData) output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
|
||||
if (response.signature) output.response.signature = uint8ArrayToBase64Url(response.signature)
|
||||
if (response.userHandle) {
|
||||
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
|
||||
} else {
|
||||
output.response.userHandle = null
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function isPasskeyAvailable() {
|
||||
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
|
||||
}
|
||||
|
||||
async function authenticateWithPasskey(rawOptions) {
|
||||
const publicKey = toRequestOptions(rawOptions)
|
||||
const credential = await navigator.credentials.get({ publicKey })
|
||||
return serializeCredential(credential)
|
||||
}
|
||||
|
||||
async function loadVerifyStatus() {
|
||||
if (verifyStatusLoaded.value) return
|
||||
try {
|
||||
const status = await fetchEmailVerifyStatus()
|
||||
emailEnabled.value = Boolean(status?.email_enabled)
|
||||
registerVerifyEnabled.value = Boolean(status?.register_verify_enabled)
|
||||
} catch {
|
||||
emailEnabled.value = false
|
||||
registerVerifyEnabled.value = false
|
||||
} finally {
|
||||
verifyStatusLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function setNotice(type, text) {
|
||||
noticeType.value = String(type || '')
|
||||
noticeText.value = String(text || '')
|
||||
}
|
||||
|
||||
function clearNotice() {
|
||||
noticeType.value = ''
|
||||
noticeText.value = ''
|
||||
}
|
||||
|
||||
async function refreshLoginCaptcha() {
|
||||
try {
|
||||
@@ -85,41 +242,45 @@ async function refreshResendCaptcha() {
|
||||
}
|
||||
}
|
||||
|
||||
function redirectAfterLogin() {
|
||||
const urlParams = new URLSearchParams(window.location.search || '')
|
||||
const next = String(urlParams.get('next') || '').trim()
|
||||
const safeNext = next && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : ''
|
||||
window.setTimeout(() => {
|
||||
window.location.href = safeNext || '/app'
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
clearNotice()
|
||||
|
||||
if (!form.username.trim() || !form.password.trim()) {
|
||||
ElMessage.error('用户名和密码不能为空')
|
||||
setNotice('error', '用户名和密码不能为空')
|
||||
return
|
||||
}
|
||||
if (needCaptcha.value && !form.captcha.trim()) {
|
||||
ElMessage.error('请输入验证码')
|
||||
setNotice('error', '请输入验证码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await login({
|
||||
username: form.username.trim(),
|
||||
const username = form.username.trim()
|
||||
await loginRequest({
|
||||
username,
|
||||
password: form.password,
|
||||
captcha_session: captchaSession.value,
|
||||
captcha: form.captcha.trim(),
|
||||
need_captcha: needCaptcha.value,
|
||||
})
|
||||
ElMessage.success('登录成功,正在跳转...')
|
||||
const urlParams = new URLSearchParams(window.location.search || '')
|
||||
const next = String(urlParams.get('next') || '').trim()
|
||||
const safeNext = next && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : ''
|
||||
setTimeout(() => {
|
||||
const target = safeNext || '/app'
|
||||
router.push(target).catch(() => {
|
||||
window.location.href = target
|
||||
})
|
||||
}, 300)
|
||||
setNotice('success', '登录成功,正在跳转...')
|
||||
redirectAfterLogin()
|
||||
} catch (e) {
|
||||
const status = e?.response?.status
|
||||
const data = e?.response?.data
|
||||
const message = data?.error || data?.message || '登录失败'
|
||||
|
||||
ElMessage.error(message)
|
||||
setNotice('error', message)
|
||||
|
||||
if (data?.need_captcha) {
|
||||
needCaptcha.value = true
|
||||
@@ -132,29 +293,59 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onPasskeyLogin() {
|
||||
clearNotice()
|
||||
|
||||
const username = form.username.trim()
|
||||
if (!isPasskeyAvailable()) {
|
||||
setNotice('error', '当前浏览器或环境不支持Passkey(需 HTTPS)')
|
||||
return
|
||||
}
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
const optionsRes = await passkeyLoginOptions(username ? { username } : {})
|
||||
const credential = await authenticateWithPasskey(optionsRes?.publicKey || {})
|
||||
await passkeyLoginVerify(username ? { username, credential } : { credential })
|
||||
setNotice('success', 'Passkey 登录成功,正在跳转...')
|
||||
redirectAfterLogin()
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
const message =
|
||||
data?.error ||
|
||||
(e?.name === 'NotAllowedError' ? 'Passkey验证未完成(可能取消、超时或设备未响应)' : e?.message || 'Passkey登录失败')
|
||||
setNotice('error', message)
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openForgot() {
|
||||
await loadVerifyStatus()
|
||||
forgotOpen.value = true
|
||||
forgotHint.value = ''
|
||||
forgotError.value = ''
|
||||
forgotForm.username = ''
|
||||
forgotForm.captcha = ''
|
||||
await refreshEmailResetCaptcha()
|
||||
}
|
||||
|
||||
async function submitForgot() {
|
||||
forgotError.value = ''
|
||||
forgotHint.value = ''
|
||||
|
||||
if (!emailEnabled.value) {
|
||||
ElMessage.warning('邮件功能未启用,请联系管理员重置密码。')
|
||||
forgotError.value = '邮件功能未启用,请联系管理员重置密码。'
|
||||
return
|
||||
}
|
||||
|
||||
const username = forgotForm.username.trim()
|
||||
if (!username) {
|
||||
ElMessage.error('请输入用户名')
|
||||
forgotError.value = '请输入用户名'
|
||||
return
|
||||
}
|
||||
if (!forgotForm.captcha.trim()) {
|
||||
ElMessage.error('请输入验证码')
|
||||
forgotError.value = '请输入验证码'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -165,17 +356,15 @@ async function submitForgot() {
|
||||
captcha_session: forgotCaptchaSession.value,
|
||||
captcha: forgotForm.captcha.trim(),
|
||||
})
|
||||
ElMessage.success(res?.message || '已发送重置邮件')
|
||||
setTimeout(() => {
|
||||
forgotOpen.value = false
|
||||
}, 800)
|
||||
setNotice('success', res?.message || '已发送重置邮件')
|
||||
forgotOpen.value = false
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
const message = data?.error || '发送失败'
|
||||
if (data?.code === 'email_not_bound') {
|
||||
forgotHint.value = message
|
||||
} else {
|
||||
ElMessage.error(message)
|
||||
forgotError.value = message
|
||||
}
|
||||
await refreshEmailResetCaptcha()
|
||||
} finally {
|
||||
@@ -184,20 +373,28 @@ async function submitForgot() {
|
||||
}
|
||||
|
||||
async function openResend() {
|
||||
await loadVerifyStatus()
|
||||
if (!registerVerifyEnabled.value) {
|
||||
setNotice('error', '当前未启用注册邮箱验证,无需重发验证邮件。')
|
||||
return
|
||||
}
|
||||
resendOpen.value = true
|
||||
resendForm.email = ''
|
||||
resendForm.captcha = ''
|
||||
resendError.value = ''
|
||||
await refreshResendCaptcha()
|
||||
}
|
||||
|
||||
async function submitResend() {
|
||||
resendError.value = ''
|
||||
|
||||
const email = resendForm.email.trim()
|
||||
if (!email) {
|
||||
ElMessage.error('请输入邮箱')
|
||||
resendError.value = '请输入邮箱'
|
||||
return
|
||||
}
|
||||
if (!resendForm.captcha.trim()) {
|
||||
ElMessage.error('请输入验证码')
|
||||
resendError.value = '请输入验证码'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,13 +405,11 @@ async function submitResend() {
|
||||
captcha_session: resendCaptchaSession.value,
|
||||
captcha: resendForm.captcha.trim(),
|
||||
})
|
||||
ElMessage.success(res?.message || '验证邮件已发送,请查收')
|
||||
setTimeout(() => {
|
||||
resendOpen.value = false
|
||||
}, 800)
|
||||
setNotice('success', res?.message || '验证邮件已发送,请查收')
|
||||
resendOpen.value = false
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '发送失败')
|
||||
resendError.value = data?.error || '发送失败'
|
||||
await refreshResendCaptcha()
|
||||
} finally {
|
||||
resendLoading.value = false
|
||||
@@ -222,17 +417,12 @@ async function submitResend() {
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
router.push('/register')
|
||||
window.location.href = '/register'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const status = await fetchEmailVerifyStatus()
|
||||
emailEnabled.value = Boolean(status?.email_enabled)
|
||||
registerVerifyEnabled.value = Boolean(status?.register_verify_enabled)
|
||||
} catch {
|
||||
emailEnabled.value = false
|
||||
registerVerifyEnabled.value = false
|
||||
if (needCaptcha.value) {
|
||||
await refreshLoginCaptcha()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -246,12 +436,16 @@ onMounted(async () => {
|
||||
<p>知识管理平台</p>
|
||||
</div>
|
||||
|
||||
<div v-if="noticeText" class="notice" :class="noticeType === 'success' ? 'is-success' : 'is-error'">
|
||||
{{ noticeText }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">用户账号</label>
|
||||
<el-input
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
class="login-input"
|
||||
class="text-input"
|
||||
placeholder="请输入用户名"
|
||||
autocomplete="username"
|
||||
/>
|
||||
@@ -259,12 +453,11 @@ onMounted(async () => {
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<el-input
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
class="login-input"
|
||||
class="text-input"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="onSubmit"
|
||||
@@ -274,10 +467,10 @@ onMounted(async () => {
|
||||
<div v-if="needCaptcha" class="form-group">
|
||||
<label for="captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<el-input
|
||||
<input
|
||||
id="captcha"
|
||||
v-model="form.captcha"
|
||||
class="login-input captcha-input"
|
||||
class="text-input captcha-input"
|
||||
placeholder="请输入验证码"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
@@ -296,6 +489,9 @@ onMounted(async () => {
|
||||
<button type="button" class="btn-login" :disabled="loading" @click="onSubmit">
|
||||
{{ loading ? '登录中...' : '登录系统' }}
|
||||
</button>
|
||||
<button type="button" class="btn-passkey" :disabled="passkeyLoading" @click="onPasskeyLogin">
|
||||
{{ passkeyLoading ? 'Passkey验证中...' : '使用 Passkey 登录' }}
|
||||
</button>
|
||||
|
||||
<div class="action-links">
|
||||
<button type="button" class="link-btn" @click="openForgot">忘记密码?</button>
|
||||
@@ -308,39 +504,38 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="forgotOpen" title="找回密码" width="min(560px, 92vw)">
|
||||
<el-alert
|
||||
v-if="!emailEnabled"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="邮件功能未启用"
|
||||
description="无法通过邮箱找回密码,请联系管理员重置密码。"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="通过邮箱找回密码"
|
||||
description="输入用户名并完成验证码,我们将向该账号绑定的邮箱发送重置链接。"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-if="forgotHint"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="无法通过邮箱找回密码"
|
||||
:description="forgotHint"
|
||||
show-icon
|
||||
class="alert"
|
||||
/>
|
||||
<el-form label-position="top" class="dialog-form">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="forgotForm.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div v-if="forgotOpen" class="modal-mask" @click.self="forgotOpen = false">
|
||||
<section class="modal-card">
|
||||
<div class="modal-head">
|
||||
<h3>找回密码</h3>
|
||||
<button type="button" class="modal-close" @click="forgotOpen = false">关闭</button>
|
||||
</div>
|
||||
|
||||
<p class="modal-tip" :class="{ warn: !emailEnabled }">
|
||||
{{
|
||||
emailEnabled
|
||||
? '输入用户名并完成验证码,我们将向该账号绑定的邮箱发送重置链接。'
|
||||
: '邮件功能未启用,无法通过邮箱找回密码。'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<p v-if="forgotHint" class="modal-tip warn">{{ forgotHint }}</p>
|
||||
<p v-if="forgotError" class="modal-tip error">{{ forgotError }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="forgot-username">用户名</label>
|
||||
<input id="forgot-username" v-model="forgotForm.username" class="text-input" placeholder="请输入用户名" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="forgot-captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<el-input v-model="forgotForm.captcha" placeholder="请输入验证码" />
|
||||
<input
|
||||
id="forgot-captcha"
|
||||
v-model="forgotForm.captcha"
|
||||
class="text-input captcha-input"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<img
|
||||
v-if="forgotCaptchaImage"
|
||||
class="captcha-img"
|
||||
@@ -349,28 +544,43 @@ onMounted(async () => {
|
||||
title="点击刷新"
|
||||
@click="refreshEmailResetCaptcha"
|
||||
/>
|
||||
<el-button @click="refreshEmailResetCaptcha">刷新</el-button>
|
||||
<button type="button" class="captcha-refresh" @click="refreshEmailResetCaptcha">刷新</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="forgotOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="forgotLoading" :disabled="!emailEnabled" @click="submitForgot">
|
||||
发送重置邮件
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-ghost" @click="forgotOpen = false">取消</button>
|
||||
<button type="button" class="btn-login" :disabled="forgotLoading || !emailEnabled" @click="submitForgot">
|
||||
{{ forgotLoading ? '发送中...' : '发送重置邮件' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="resendOpen" title="重发验证邮件" width="min(520px, 92vw)">
|
||||
<el-alert type="info" :closable="false" title="用于注册邮箱验证:请输入邮箱并完成验证码。" show-icon />
|
||||
<el-form label-position="top" class="dialog-form">
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="resendForm.email" placeholder="name@example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div v-if="resendOpen" class="modal-mask" @click.self="resendOpen = false">
|
||||
<section class="modal-card">
|
||||
<div class="modal-head">
|
||||
<h3>重发验证邮件</h3>
|
||||
<button type="button" class="modal-close" @click="resendOpen = false">关闭</button>
|
||||
</div>
|
||||
|
||||
<p class="modal-tip">用于注册邮箱验证:请输入邮箱并完成验证码。</p>
|
||||
<p v-if="resendError" class="modal-tip error">{{ resendError }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="resend-email">邮箱</label>
|
||||
<input id="resend-email" v-model="resendForm.email" class="text-input" placeholder="name@example.com" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="resend-captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<el-input v-model="resendForm.captcha" placeholder="请输入验证码" />
|
||||
<input
|
||||
id="resend-captcha"
|
||||
v-model="resendForm.captcha"
|
||||
class="text-input captcha-input"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<img
|
||||
v-if="resendCaptchaImage"
|
||||
class="captcha-img"
|
||||
@@ -379,16 +589,18 @@ onMounted(async () => {
|
||||
title="点击刷新"
|
||||
@click="refreshResendCaptcha"
|
||||
/>
|
||||
<el-button @click="refreshResendCaptcha">刷新</el-button>
|
||||
<button type="button" class="captcha-refresh" @click="refreshResendCaptcha">刷新</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="resendOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="resendLoading" @click="submitResend">发送</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-ghost" @click="resendOpen = false">取消</button>
|
||||
<button type="button" class="btn-login" :disabled="resendLoading" @click="submitResend">
|
||||
{{ resendLoading ? '发送中...' : '发送' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -420,14 +632,14 @@ onMounted(async () => {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 60px rgba(17, 24, 39, 0.15);
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
padding: 38px 34px;
|
||||
padding: 36px 30px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.login-badge {
|
||||
@@ -454,8 +666,28 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notice.is-error {
|
||||
color: #b91c1c;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.notice.is-success {
|
||||
color: #065f46;
|
||||
background: #d1fae5;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
@@ -466,47 +698,66 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-input :deep(.el-input__wrapper) {
|
||||
.text-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
min-height: 44px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 0 0 1px rgba(17, 24, 39, 0.14) inset;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-input :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.7) inset, 0 0 0 4px rgba(59, 130, 246, 0.16);
|
||||
}
|
||||
|
||||
.login-input :deep(.el-input__inner) {
|
||||
border: 1px solid rgba(17, 24, 39, 0.18);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: rgba(59, 130, 246, 0.8);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.02);
|
||||
.btn-passkey {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
margin-top: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-login:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
.btn-passkey:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
.btn-passkey:disabled,
|
||||
.btn-login:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.captcha-refresh:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.action-links {
|
||||
@@ -542,15 +793,6 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.dialog-form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -564,7 +806,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
height: 46px;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -572,8 +814,8 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.captcha-refresh {
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
@@ -586,15 +828,100 @@ onMounted(async () => {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(560px, 96vw);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.28);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-head h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.16);
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-tip {
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-tip.warn {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.modal-tip.error {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
min-width: 86px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.2);
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-page {
|
||||
align-items: flex-start;
|
||||
padding: 20px 12px 12px;
|
||||
padding: 16px 10px 10px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
max-width: 100%;
|
||||
padding: 28px 20px;
|
||||
padding: 26px 18px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
@@ -602,18 +929,17 @@ onMounted(async () => {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
padding: 13px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.captcha-refresh {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,9 @@ const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const schedules = ref([])
|
||||
const schedulePage = ref(1)
|
||||
const scheduleTotal = ref(0)
|
||||
const schedulePageSize = 12
|
||||
|
||||
const accountsLoading = ref(false)
|
||||
const accountOptions = ref([])
|
||||
@@ -65,6 +68,7 @@ const weekdayOptions = [
|
||||
]
|
||||
|
||||
const canUseSchedule = computed(() => userStore.isVip)
|
||||
const scheduleTotalPages = computed(() => Math.max(1, Math.ceil((scheduleTotal.value || 0) / schedulePageSize)))
|
||||
|
||||
function normalizeTime(value) {
|
||||
const match = String(value || '').match(/^(\d{1,2}):(\d{2})$/)
|
||||
@@ -94,17 +98,37 @@ async function loadAccounts() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadSchedulesAfterMutate() {
|
||||
if (schedulePage.value > 1 && schedules.value.length <= 1) {
|
||||
schedulePage.value -= 1
|
||||
}
|
||||
await loadSchedules()
|
||||
}
|
||||
|
||||
async function onSchedulePageChange(page) {
|
||||
schedulePage.value = page
|
||||
await loadSchedules()
|
||||
}
|
||||
|
||||
async function loadSchedules() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await fetchSchedules()
|
||||
schedules.value = (Array.isArray(list) ? list : []).map((s) => ({
|
||||
const params = {
|
||||
limit: schedulePageSize,
|
||||
offset: (schedulePage.value - 1) * schedulePageSize,
|
||||
}
|
||||
const payload = await fetchSchedules(params)
|
||||
const rawItems = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
|
||||
const rawTotal = Array.isArray(payload) ? rawItems.length : Number(payload?.total ?? rawItems.length)
|
||||
schedules.value = rawItems.map((s) => ({
|
||||
...s,
|
||||
browse_type: normalizeBrowseType(s?.browse_type),
|
||||
}))
|
||||
scheduleTotal.value = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : rawItems.length
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) window.location.href = '/login'
|
||||
schedules.value = []
|
||||
scheduleTotal.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -172,6 +196,7 @@ async function saveSchedule() {
|
||||
} else {
|
||||
await createSchedule(payload)
|
||||
ElMessage.success('创建成功')
|
||||
schedulePage.value = 1
|
||||
}
|
||||
editorOpen.value = false
|
||||
await loadSchedules()
|
||||
@@ -198,7 +223,7 @@ async function onDelete(schedule) {
|
||||
const res = await deleteSchedule(schedule.id)
|
||||
if (res?.success) {
|
||||
ElMessage.success('已删除')
|
||||
await loadSchedules()
|
||||
await reloadSchedulesAfterMutate()
|
||||
} else {
|
||||
ElMessage.error(res?.error || '删除失败')
|
||||
}
|
||||
@@ -375,6 +400,17 @@ onMounted(async () => {
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="scheduleTotal > schedulePageSize" class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="schedulePage"
|
||||
:page-size="schedulePageSize"
|
||||
:total="scheduleTotal"
|
||||
layout="prev, pager, next, jumper, ->, total"
|
||||
@current-change="onSchedulePageChange"
|
||||
/>
|
||||
<div class="page-hint app-muted">第 {{ schedulePage }} / {{ scheduleTotalPages }} 页</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
@@ -593,6 +629,19 @@ onMounted(async () => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { clearScreenshots, deleteScreenshot, fetchScreenshots } from '../api/screenshots'
|
||||
|
||||
const loading = ref(false)
|
||||
const screenshots = ref([])
|
||||
const currentPage = ref(1)
|
||||
const total = ref(0)
|
||||
const pageSize = 24
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / pageSize)))
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
@@ -22,16 +26,30 @@ function buildThumbUrl(filename) {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchScreenshots()
|
||||
screenshots.value = Array.isArray(data) ? data : []
|
||||
const params = {
|
||||
limit: pageSize,
|
||||
offset: (currentPage.value - 1) * pageSize,
|
||||
}
|
||||
const payload = await fetchScreenshots(params)
|
||||
const items = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
|
||||
const payloadTotal = Array.isArray(payload) ? items.length : Number(payload?.total ?? items.length)
|
||||
|
||||
screenshots.value = items
|
||||
total.value = Number.isFinite(payloadTotal) ? Math.max(0, payloadTotal) : items.length
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) window.location.href = '/login'
|
||||
screenshots.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPageChange(page) {
|
||||
currentPage.value = page
|
||||
await load()
|
||||
}
|
||||
|
||||
function openPreview(item) {
|
||||
previewTitle.value = item.display_name || item.filename || '截图预览'
|
||||
previewUrl.value = buildUrl(item.filename)
|
||||
@@ -126,6 +144,8 @@ async function onClearAll() {
|
||||
if (res?.success) {
|
||||
ElMessage.success(`已清空(删除 ${res?.deleted || 0} 张)`)
|
||||
screenshots.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
previewOpen.value = false
|
||||
return
|
||||
}
|
||||
@@ -150,8 +170,9 @@ async function onDelete(item) {
|
||||
try {
|
||||
const res = await deleteScreenshot(item.filename)
|
||||
if (res?.success) {
|
||||
screenshots.value = screenshots.value.filter((s) => s.filename !== item.filename)
|
||||
if (previewUrl.value.includes(encodeURIComponent(item.filename))) previewOpen.value = false
|
||||
if (currentPage.value > 1 && screenshots.value.length <= 1) currentPage.value -= 1
|
||||
await load()
|
||||
ElMessage.success('已删除')
|
||||
return
|
||||
}
|
||||
@@ -186,7 +207,7 @@ async function copyImage(item) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
}
|
||||
ElMessage.success('图片已复制到剪贴板')
|
||||
} catch (e) {
|
||||
} catch {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${url}`)
|
||||
@@ -218,13 +239,13 @@ onMounted(load)
|
||||
<div class="panel-title">截图管理</div>
|
||||
<div class="panel-actions">
|
||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||
<el-button type="danger" plain :disabled="screenshots.length === 0" @click="onClearAll">清空全部</el-button>
|
||||
<el-button type="danger" plain :disabled="total === 0" @click="onClearAll">清空全部</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
<template v-else>
|
||||
<el-empty v-if="screenshots.length === 0" description="暂无截图" />
|
||||
<el-empty v-if="total === 0" description="暂无截图" />
|
||||
|
||||
<div v-else class="grid">
|
||||
<el-card v-for="item in screenshots" :key="item.filename" shadow="never" class="shot-card" :body-style="{ padding: '0' }">
|
||||
@@ -247,6 +268,17 @@ onMounted(load)
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, jumper, ->, total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
<div class="page-hint app-muted">第 {{ currentPage }} / {{ totalPages }} 页</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="previewTitle" width="min(920px, 94vw)">
|
||||
@@ -294,6 +326,19 @@ onMounted(load)
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shot-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--app-border);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import AppLayout from '../layouts/AppLayout.vue'
|
||||
|
||||
const LoginPage = () => import('../pages/LoginPage.vue')
|
||||
const RegisterPage = () => import('../pages/RegisterPage.vue')
|
||||
const ResetPasswordPage = () => import('../pages/ResetPasswordPage.vue')
|
||||
const VerifyResultPage = () => import('../pages/VerifyResultPage.vue')
|
||||
const AppLayout = () => import('../layouts/AppLayout.vue')
|
||||
|
||||
const AccountsPage = () => import('../pages/AccountsPage.vue')
|
||||
const SchedulesPage = () => import('../pages/SchedulesPage.vue')
|
||||
|
||||
123
app-frontend/src/utils/passkey.js
Normal file
123
app-frontend/src/utils/passkey.js
Normal file
@@ -0,0 +1,123 @@
|
||||
function ensurePublicKeyOptions(options) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new Error('Passkey参数无效')
|
||||
}
|
||||
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
|
||||
}
|
||||
|
||||
function base64UrlToUint8Array(base64url) {
|
||||
const value = String(base64url || '')
|
||||
const padding = '='.repeat((4 - (value.length % 4)) % 4)
|
||||
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = window.atob(base64)
|
||||
const bytes = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
bytes[i] = raw.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64Url(input) {
|
||||
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return window
|
||||
.btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function toCreationOptions(rawOptions) {
|
||||
const options = ensurePublicKeyOptions(rawOptions)
|
||||
const normalized = {
|
||||
...options,
|
||||
challenge: base64UrlToUint8Array(options.challenge),
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64UrlToUint8Array(options.user?.id),
|
||||
},
|
||||
}
|
||||
|
||||
if (Array.isArray(options.excludeCredentials)) {
|
||||
normalized.excludeCredentials = options.excludeCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToUint8Array(item.id),
|
||||
}))
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function toRequestOptions(rawOptions) {
|
||||
const options = ensurePublicKeyOptions(rawOptions)
|
||||
const normalized = {
|
||||
...options,
|
||||
challenge: base64UrlToUint8Array(options.challenge),
|
||||
}
|
||||
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
normalized.allowCredentials = options.allowCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToUint8Array(item.id),
|
||||
}))
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function serializeCredential(credential) {
|
||||
if (!credential) return null
|
||||
|
||||
const response = credential.response || {}
|
||||
const output = {
|
||||
id: credential.id,
|
||||
rawId: uint8ArrayToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment || undefined,
|
||||
response: {},
|
||||
}
|
||||
|
||||
if (response.clientDataJSON) {
|
||||
output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
|
||||
}
|
||||
if (response.attestationObject) {
|
||||
output.response.attestationObject = uint8ArrayToBase64Url(response.attestationObject)
|
||||
}
|
||||
if (response.authenticatorData) {
|
||||
output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
|
||||
}
|
||||
if (response.signature) {
|
||||
output.response.signature = uint8ArrayToBase64Url(response.signature)
|
||||
}
|
||||
|
||||
if (response.userHandle) {
|
||||
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
|
||||
} else {
|
||||
output.response.userHandle = null
|
||||
}
|
||||
|
||||
if (typeof response.getTransports === 'function') {
|
||||
output.response.transports = response.getTransports() || []
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function isPasskeyAvailable() {
|
||||
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
|
||||
}
|
||||
|
||||
export async function createPasskey(rawOptions) {
|
||||
const publicKey = toCreationOptions(rawOptions)
|
||||
const credential = await navigator.credentials.create({ publicKey })
|
||||
return serializeCredential(credential)
|
||||
}
|
||||
|
||||
export async function authenticateWithPasskey(rawOptions) {
|
||||
const publicKey = toRequestOptions(rawOptions)
|
||||
const credential = await navigator.credentials.get({ publicKey })
|
||||
return serializeCredential(credential)
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||
dts: false,
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||
dts: false,
|
||||
}),
|
||||
],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: '../static/app',
|
||||
@@ -11,6 +25,10 @@ export default defineConfig({
|
||||
cssCodeSplit: true,
|
||||
chunkSizeWarningLimit: 800,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||
login: fileURLToPath(new URL('./login.html', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
@@ -24,10 +42,6 @@ export default defineConfig({
|
||||
return 'vendor-vue'
|
||||
}
|
||||
|
||||
if (id.includes('/node_modules/element-plus/') || id.includes('/node_modules/@element-plus/')) {
|
||||
return 'vendor-element'
|
||||
}
|
||||
|
||||
if (id.includes('/node_modules/axios/')) {
|
||||
return 'vendor-axios'
|
||||
}
|
||||
@@ -40,7 +54,7 @@ export default defineConfig({
|
||||
return 'vendor-realtime'
|
||||
}
|
||||
|
||||
return 'vendor-misc'
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user