feat: 完成 Passkey 能力与前后台加载优化

更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
2026-02-15 23:51:12 +08:00
parent ebfac7266b
commit 7007f5f6f5
129 changed files with 3747 additions and 432 deletions

13
app-frontend/login.html Normal file
View 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>

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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">

View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import LoginPage from './pages/LoginPage.vue'
import './style.css'
createApp(LoginPage).mount('#app')

View File

@@ -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')

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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')

View 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)
}

View File

@@ -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
},
},
},